✨ feat: High-fidelity PPTX preview via pptx-preview in iframe

Mirrors the DOCX CDN architecture for PPTX: small files (≤350 KB
binary) embed as base64 and render via `pptx-preview` loaded from
jsdelivr inside the Sandpack iframe. Larger files and air-gapped
deployments fall back to the existing slide-list extraction.

Why
---
PPTX is the format where the gap between LibreChat's preview and
Claude.ai-style previews was most visible (slide-list of bullet
points vs. rendered slide layouts). LibreOffice → PDF → PDF.js is
still the eventual gold-standard answer for PPTX fidelity, but
client-side rendering inside the Sandpack iframe gets us a
meaningful intermediate step (~1.5/5 → ~3.5/5) without a sidecar.

What
----
- `pptx-preview@1.0.7` (ISC license, ~1.36 MB UMD bundle that
  includes its echarts/lodash/uuid/jszip/tslib deps inline). Pinned
  to a specific version on jsdelivr with SHA-384 SRI and
  `crossorigin="anonymous"`.
- `buildPptxCdnDocument` mirrors the DOCX wrapper: same CSP locks
  (`default-src 'none'`, `connect-src 'none'`, no eval, no base/form
  tampering), same `id="lc-doc-data"` base64 slot, same fallback
  message wiring (`typeof pptxPreview === 'undefined'` →
  "Preview unavailable").
- New public `pptxToHtml(buffer)` dispatcher; `bufferToOfficeHtml`
  switches its `'pptx'` case to call it. `pptxToSlideListHtml` stays
  exported as the slide-list-only path (still hit by tests directly
  and by the dispatcher fallback).
- `OFFICE_PREVIEW_DISABLE_CDN=true` env-var hatch applies to PPTX
  too — air-gapped operators get the slide-list path. Same env-var
  read at call time, same matrix of truthy values (`1` / `true` /
  `yes` / case-insensitive / whitespace-trimmed).
- `_internal` re-exports moved to after the PPTX section since the
  PPTX internals live further down in the file. Adds
  `pptxToHtmlViaCdn`, `MAX_PPTX_CDN_BINARY_BYTES`,
  `PPTX_PREVIEW_CDN`.

Honest caveats
--------------
- The 1.36 MB UMD bundle has `require("stream"/"events"/"buffer"/
  "util")` references in its outer wrapper. Those are bundled-dep
  artifacts (likely from `tslib` / Node-shim transforms) and don't
  appear to execute on the browser code paths, but I haven't done
  manual e2e on a wide range of decks. If a class of files turns up
  that breaks rendering, the iframe-side fallback message catches it
  and operators have `OFFICE_PREVIEW_DISABLE_CDN=true` as the bail.
- First-render CDN fetch is ~1.36 MB (browser-cached after).
- PPTX with embedded media easily exceeds the 350 KB binary cap;
  those files take the slide-list path. Lifting the cap is a
  follow-up (tied to the broader self-hosting work).

Tests
-----
11 new in two new describe blocks:
- `pptxToHtml dispatcher`: routing predicate (small → CDN, env-set
  → slide-list).
- `CDN-rendered path`: base64 round-trip, SRI integrity +
  crossorigin, CSP locks (connect/eval/base/form), fallback message,
  size-threshold lock at 350 KB.
- `OFFICE_PREVIEW_DISABLE_CDN escape hatch`: env-var matrix for
  truthy values.

packages/api/src/files: 457/457 ✅ (was 446, +11).
This commit is contained in:
Danny Avila 2026-05-04 16:26:16 -04:00
parent 1804d26281
commit 23749184a2
2 changed files with 308 additions and 19 deletions

View file

@ -8,6 +8,7 @@ import {
csvToHtml,
excelSheetToHtml,
officeHtmlBucket,
pptxToHtml,
pptxToSlideListHtml,
sanitizeOfficeHtml,
wordDocToHtml,
@ -306,6 +307,120 @@ describe('Office HTML producers', () => {
});
});
describe('pptxToHtml dispatcher', () => {
/** Mirror the buildPptx helper from the slide-list block. */
const buildPptx = async (
slides: Array<{ title: string; body?: string[] }>,
): Promise<Buffer> => {
const zip = new JSZip();
const slideXml = (title: string, body: string[] = []) => {
const titleP = `<a:p><a:r><a:t>${title}</a:t></a:r></a:p>`;
const bodyPs = body.map((b) => `<a:p><a:r><a:t>${b}</a:t></a:r></a:p>`).join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:sp><p:txBody>${titleP}${bodyPs}</p:txBody></p:sp></p:spTree></p:cSld>
</p:sld>`;
};
slides.forEach((s, i) => {
zip.file(`ppt/slides/slide${i + 1}.xml`, slideXml(s.title, s.body));
});
zip.file('docProps/core.xml', '<core/>');
return zip.generateAsync({ type: 'nodebuffer' });
};
test('routes a small pptx (≤ cap) through the CDN-rendered path', async () => {
const pptx = await buildPptx([{ title: 'Hello', body: ['First slide'] }]);
const html = await pptxToHtml(pptx);
expect(html).toMatch(/^<!DOCTYPE html>/);
expect(html).toContain('id="lc-doc-data"');
expect(html).toContain('pptxPreview.init');
expect(html).toContain('cdn.jsdelivr.net/npm/pptx-preview@');
// Slide-list signature must NOT appear (those are <ol class="lc-pptx-list">).
expect(html).not.toContain('class="lc-pptx-list"');
});
describe('CDN-rendered path', () => {
test('embeds the binary as base64 that round-trips to the original bytes', async () => {
const original = await buildPptx([{ title: 'Test' }]);
const html = await _internal.pptxToHtmlViaCdn(original);
const match = html.match(
/<script id="lc-doc-data" type="application\/octet-stream;base64">([^<]*)<\/script>/,
);
expect(match).not.toBeNull();
const decoded = Buffer.from(match![1], 'base64');
expect(decoded.equals(original)).toBe(true);
});
test('pins pptx-preview to a specific version with SRI integrity', async () => {
const pptx = await buildPptx([{ title: 'X' }]);
const html = await _internal.pptxToHtmlViaCdn(pptx);
expect(html).toContain('https://cdn.jsdelivr.net/npm/pptx-preview@1.0.7/');
expect(html).toMatch(/integrity="sha384-[A-Za-z0-9+/=]+"/);
expect(html).toContain('crossorigin="anonymous"');
});
test('CSP locks the iframe down: connect-src none, no eval, no base/form tampering', async () => {
const pptx = await buildPptx([{ title: 'X' }]);
const html = await _internal.pptxToHtmlViaCdn(pptx);
const cspMatch = html.match(
/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/,
);
expect(cspMatch).not.toBeNull();
const csp = cspMatch![1];
expect(csp).toMatch(/connect-src 'none'/);
expect(csp).toMatch(/base-uri 'none'/);
expect(csp).toMatch(/form-action 'none'/);
expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/);
expect(csp).not.toMatch(/unsafe-eval/);
});
test('exposes a fallback message that surfaces if the renderer fails to load', async () => {
const pptx = await buildPptx([{ title: 'X' }]);
const html = await _internal.pptxToHtmlViaCdn(pptx);
expect(html).toContain('Loading preview…');
expect(html).toContain('Preview unavailable');
expect(html).toContain("typeof pptxPreview === 'undefined'");
});
test('size-fallback threshold is the documented 350 KB', () => {
expect(_internal.MAX_PPTX_CDN_BINARY_BYTES).toBe(350 * 1024);
});
});
describe('OFFICE_PREVIEW_DISABLE_CDN escape hatch', () => {
const ORIGINAL = process.env.OFFICE_PREVIEW_DISABLE_CDN;
afterEach(() => {
if (ORIGINAL === undefined) {
delete process.env.OFFICE_PREVIEW_DISABLE_CDN;
} else {
process.env.OFFICE_PREVIEW_DISABLE_CDN = ORIGINAL;
}
});
test('default behavior (env unset): small pptx → CDN path', async () => {
delete process.env.OFFICE_PREVIEW_DISABLE_CDN;
const pptx = await buildPptx([{ title: 'Hello' }]);
const html = await pptxToHtml(pptx);
expect(html).toContain('id="lc-doc-data"');
});
it.each([['true'], ['1'], ['yes'], ['TRUE']])(
'env=%s forces the slide-list fallback even for small files',
async (value) => {
process.env.OFFICE_PREVIEW_DISABLE_CDN = value;
const pptx = await buildPptx([{ title: 'Hello', body: ['Line 1'] }]);
const html = await pptxToHtml(pptx);
// Slide-list signature.
expect(html).toContain('class="lc-pptx-list"');
expect(html).toContain('Hello');
expect(html).not.toContain('id="lc-doc-data"');
expect(html).not.toContain('cdn.jsdelivr.net');
},
);
});
});
describe('bufferToOfficeHtml dispatcher', () => {
test('routes by extension when MIME is generic', async () => {
const html = await bufferToOfficeHtml(

View file

@ -518,20 +518,6 @@ export async function wordDocToHtml(buffer: Buffer): Promise<string> {
return wordDocToHtmlViaMammoth(buffer);
}
/**
* Test-only re-exports. Each path has distinct visual-fidelity and
* size characteristics that warrant direct testing rather than
* round-tripping through the size-based dispatcher with synthetically
* padded fixtures. Not part of the public API — callers in production
* code should always go through `wordDocToHtml`.
*/
export const _internal = {
wordDocToHtmlViaCdn,
wordDocToHtmlViaMammoth,
MAX_DOCX_CDN_BINARY_BYTES,
DOCX_PREVIEW_CDN,
};
/* =============================================================================
* XLSX / XLS / ODS → HTML (multi-sheet with pure-CSS tab strip)
* ============================================================================= */
@ -891,10 +877,12 @@ function renderPptxSlidesHtml(slides: PptxSlide[]): string {
}
/**
* Convert a `.pptx` buffer to a sandboxed HTML document rendering each slide
* as a card (slide number, title, body bullets). Honest about being a
* text-only preview — complex visual layouts, charts, and embedded media are
* not represented. Higher-fidelity rendering is a deferred follow-up.
* Convert a `.pptx` buffer to a slide-list HTML document — text-only
* preview rendering each slide as a card (slide number, title, body
* bullets). Honest about NOT preserving complex visual layouts,
* charts, theming, or embedded media. Used as the safe fallback path
* when the CDN renderer is disabled, the binary exceeds the embed
* size cap, or the high-fidelity path can't be reached.
*
* Pre-flights through `assertSafeZipSize` so a zip-bomb PPTX can't blow
* up the slide-XML extraction pass.
@ -909,6 +897,192 @@ export async function pptxToSlideListHtml(buffer: Buffer): Promise<string> {
return renderPptxSlidesHtml(slides);
}
/* =============================================================================
* PPTX CDN renderer (high-fidelity path)
*
* Mirrors the DOCX CDN architecture: embeds the binary as base64 and
* lets `pptx-preview` (loaded from a pinned jsdelivr URL with SRI) do
* the rendering inside the Sandpack iframe. The library is ISC-licensed
* (npm package permits commercial use) and ships a UMD bundle that
* exposes a global `pptxPreview.init(container, options)` →
* `previewer.preview(arrayBuffer)` API per the package README.
*
* Trade-offs vs the slide-list path:
* + Far higher visual fidelity — preserves slide layouts, theme
* colors, fonts, basic shape positioning, embedded images.
* + No server-side rendering cost; iframe sandbox isolates parser
* bugs from the API process.
* − UMD bundle is ~1.36 MB (contains echarts, tslib, lodash, uuid,
* jszip). Loaded once per iframe lifetime; browser-cached after.
* − Renderer hasn't been broadly browser-tested by us across the
* full spectrum of decks. See PR #12934 commit notes — fallback
* paths cover the cases where we hit a class of files that don't
* render: client-side "Preview unavailable" UX (CDN unreachable
* or `typeof pptxPreview === 'undefined'`) and the
* `OFFICE_PREVIEW_DISABLE_CDN` env-var hatch (forces the slide-
* list server-side).
*/
const PPTX_PREVIEW_CDN = {
pptxPreview: {
src: 'https://cdn.jsdelivr.net/npm/pptx-preview@1.0.7/dist/pptx-preview.umd.js',
integrity: 'sha384-CwntHHT2FbwZXuCmbf6K93YaEB9xRVVLaqFJ7pdMykeQABb/3MA0sbt2lGbgi1Mr',
},
} as const;
/**
* Same 350 KB binary cap as DOCX — keeps the base64-inflated wrapped
* HTML under `MAX_TEXT_CACHE_BYTES` (512 KB) on `attachment.text`.
* PPTX files often exceed this once embedded media is involved; the
* dispatcher's slide-list fallback handles the larger cases.
*/
const MAX_PPTX_CDN_BINARY_BYTES = 350 * 1024;
/**
* Build the CDN-rendered HTML document for a PPTX. Same wrapper shape
* and CSP as the DOCX equivalent — see `buildDocxCdnDocument` for the
* security-relevant rationale on script-src/connect-src/base-uri/etc.
*
* Slides need a fixed-size container for `pptx-preview` to render into
* — it computes per-slide layouts against the configured width/height
* rather than against the iframe's variable size. We use 960×540
* (16:9 standard) and let CSS scale the rendered output to fit the
* iframe via `transform: scale(...)`. The slides scroll vertically
* once the renderer paints them.
*/
function buildPptxCdnDocument(base64: string): string {
const csp = [
"default-src 'none'",
"script-src https://cdn.jsdelivr.net 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src 'self' data: blob:",
'font-src data:',
"connect-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join('; ');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<title>Preview</title>
<style>
:root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2937; --muted: #6b7280; }
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --fg: #e5e7eb; --muted: #9ca3af; } }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#lc-render { padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
#lc-render > * { box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); border-radius: 4px; max-width: 100%; }
#lc-fallback { padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; }
.lc-pptx-loading { display: flex; align-items: center; justify-content: center; height: 60vh; color: var(--muted); font-size: 14px; }
</style>
<script src="${PPTX_PREVIEW_CDN.pptxPreview.src}" integrity="${PPTX_PREVIEW_CDN.pptxPreview.integrity}" crossorigin="anonymous"></script>
</head>
<body>
<div id="lc-render"><div class="lc-pptx-loading">Loading preview…</div></div>
<div id="lc-fallback" hidden>Preview unavailable. Please download the file to view it.</div>
<script id="lc-doc-data" type="application/octet-stream;base64">${base64}</script>
<script>
(function () {
function showFallback(reason) {
var loading = document.querySelector('.lc-pptx-loading');
if (loading) { loading.remove(); }
var fallback = document.getElementById('lc-fallback');
if (fallback) {
fallback.hidden = false;
if (reason) { fallback.title = String(reason).slice(0, 200); }
}
}
if (typeof pptxPreview === 'undefined' || typeof pptxPreview.init !== 'function') {
showFallback('renderer-not-loaded');
return;
}
try {
var b64 = document.getElementById('lc-doc-data').textContent.trim();
var bytes = Uint8Array.from(atob(b64), function (c) { return c.charCodeAt(0); });
var container = document.getElementById('lc-render');
var loading = document.querySelector('.lc-pptx-loading');
if (loading) { loading.remove(); }
var previewer = pptxPreview.init(container, { width: 960, height: 540 });
var result = previewer.preview(bytes.buffer);
if (result && typeof result.catch === 'function') {
result.catch(function (err) { showFallback(err && err.message); });
}
} catch (err) {
showFallback(err && err.message);
}
})();
</script>
</body>
</html>`;
}
async function pptxToHtmlViaCdn(buffer: Buffer): Promise<string> {
return buildPptxCdnDocument(buffer.toString('base64'));
}
/**
* Convert a `.pptx` buffer to a sandboxed HTML document. Two render
* paths, chosen by file size and the `OFFICE_PREVIEW_DISABLE_CDN`
* escape hatch:
*
* 1. **CDN-rendered (default for files ≤ 350 KB binary)**: embeds
* the binary as base64 and lets `pptx-preview` render it inside
* the Sandpack iframe. High visual fidelity.
* 2. **Slide-list (fallback)**: server-side text-only extraction
* (each slide → card with title + bullets). Lower fidelity but
* compact, network-independent, and proven against arbitrary
* decks. Triggered when the binary exceeds the embed cap, when
* `OFFICE_PREVIEW_DISABLE_CDN=true` is set (air-gapped /
* filtered networks), or when the CDN script fails to load
* (handled iframe-side).
*
* Pre-flights through `assertSafeZipSize` so a zip-bomb PPTX is
* rejected before either renderer touches it.
*/
export async function pptxToHtml(buffer: Buffer): Promise<string> {
await assertSafeZipSize(buffer, { name: 'pptx' });
if (isOfficePreviewCdnDisabled()) {
return pptxToSlideListHtmlInternal(buffer);
}
if (buffer.length <= MAX_PPTX_CDN_BINARY_BYTES) {
return pptxToHtmlViaCdn(buffer);
}
return pptxToSlideListHtmlInternal(buffer);
}
/**
* Slide-list path that skips its own `assertSafeZipSize` call — used
* by `pptxToHtml` after the dispatcher has already validated the
* buffer. `pptxToSlideListHtml` (public, validating) stays the entry
* point for tests and direct callers.
*/
async function pptxToSlideListHtmlInternal(buffer: Buffer): Promise<string> {
const rawSlides = await extractPptxSlideXml(buffer);
const slides: PptxSlide[] = rawSlides.map(({ number, xml }) => {
const { title, body } = extractSlideText(xml);
return { number, title, body };
});
return renderPptxSlidesHtml(slides);
}
/**
* Test-only re-exports. Each path has distinct visual-fidelity and
* size characteristics that warrant direct testing rather than
* round-tripping through the size-based dispatcher with synthetically
* padded fixtures. Not part of the public API — callers in production
* code should always go through `wordDocToHtml` / `pptxToHtml`.
*/
export const _internal = {
wordDocToHtmlViaCdn,
wordDocToHtmlViaMammoth,
MAX_DOCX_CDN_BINARY_BYTES,
DOCX_PREVIEW_CDN,
pptxToHtmlViaCdn,
MAX_PPTX_CDN_BINARY_BYTES,
PPTX_PREVIEW_CDN,
};
/* =============================================================================
* Dispatcher
* ============================================================================= */
@ -1026,7 +1200,7 @@ export async function bufferToOfficeHtml(
case 'spreadsheet':
return excelSheetToHtml(buffer);
case 'pptx':
return pptxToSlideListHtml(buffer);
return pptxToHtml(buffer);
default:
return null;
}