From 23749184a273c13a1ccdcb5ad2f2f802e304d4d6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 4 May 2026 16:26:16 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20High-fidelity=20PPTX=20prev?= =?UTF-8?q?iew=20via=20pptx-preview=20in=20iframe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/api/src/files/documents/html.spec.ts | 115 ++++++++++ packages/api/src/files/documents/html.ts | 212 ++++++++++++++++-- 2 files changed, 308 insertions(+), 19 deletions(-) diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index 441c6e1fa3..5b460b2e27 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -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 => { + const zip = new JSZip(); + const slideXml = (title: string, body: string[] = []) => { + const titleP = `${title}`; + const bodyPs = body.map((b) => `${b}`).join(''); + return ` + + ${titleP}${bodyPs} +`; + }; + slides.forEach((s, i) => { + zip.file(`ppt/slides/slide${i + 1}.xml`, slideXml(s.title, s.body)); + }); + zip.file('docProps/core.xml', ''); + 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(/^/); + 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
    ). + 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( + / + + +
    Loading preview…
    + + + + +`; +} + +async function pptxToHtmlViaCdn(buffer: Buffer): Promise { + 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 { + 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 { + 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; }