mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
✨ 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:
parent
1804d26281
commit
23749184a2
2 changed files with 308 additions and 19 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue