From 8fac18e2e15bfe6b196394bc726008309ebc64f6 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Fri, 2 Oct 2026 13:34:46 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20fix:=20Show=20EMF/WMF?= =?UTF-8?q?=20Pictures=20in=20PowerPoint=20Previews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pptx-preview turns EMF/WMF media into data:image/x-emf URIs, which no browser can decode, so template logos stored as metafiles render as broken images. Convert them to SVG on the server with emf-converter (pinned 4.8.7, Apache-2.0, no deps), embed the SVGs keyed by a hash of the media's base64, and swap them into the rendered elements once the slides are drawn. Bounded by count, per-file, total-input, output and time budgets; a deck whose preview fits today never loses it to the added map. --- api/package.json | 1 + package-lock.json | 30 ++++ packages/api/package.json | 1 + packages/api/src/files/documents/html.spec.ts | 88 ++++++++++++ packages/api/src/files/documents/html.ts | 38 ++++- .../api/src/files/documents/metafiles.spec.ts | 106 ++++++++++++++ packages/api/src/files/documents/metafiles.ts | 132 ++++++++++++++++++ 7 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/files/documents/metafiles.spec.ts create mode 100644 packages/api/src/files/documents/metafiles.ts diff --git a/api/package.json b/api/package.json index 3d672cba2e7..e58f2df9f5a 100644 --- a/api/package.json +++ b/api/package.json @@ -76,6 +76,7 @@ "dedent": "^1.5.3", "dompurify": "^3.4.12", "dotenv": "^16.0.3", + "emf-converter": "4.8.7", "eventsource": "^3.0.2", "express": "^5.2.1", "express-mongo-sanitize": "^2.2.0", diff --git a/package-lock.json b/package-lock.json index 190c93550c2..1f751841261 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,7 @@ "dedent": "^1.5.3", "dompurify": "^3.4.12", "dotenv": "^16.0.3", + "emf-converter": "4.8.7", "eventsource": "^3.0.2", "express": "^5.2.1", "express-mongo-sanitize": "^2.2.0", @@ -760,6 +761,20 @@ "node": ">= 0.8.0" } }, + "api/node_modules/emf-converter": { + "version": "4.8.7", + "resolved": "https://registry.npmjs.org/emf-converter/-/emf-converter-4.8.7.tgz", + "integrity": "sha512-oxnd2LVALXdVsIDlwInIH+kSYBB4+qSJsg0pPF5JqArSnH7G/FdbMwbdL4lHlYIWsX+VAOdt1bYlXLyZcuAqRw==", + "license": "Apache-2.0", + "peerDependencies": { + "@napi-rs/canvas": "^1.0.9" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "api/node_modules/file-type": { "version": "21.3.2", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", @@ -42391,6 +42406,7 @@ "@langchain/langgraph-checkpoint-mongodb": "^1.4.0", "cluster-key-slot": "^1.1.2", "croner": "^10.0.1", + "emf-converter": "4.8.7", "express-rate-limit": "^8.5.1", "helmet": "^8.3.0", "proxy-from-env": "^2.1.0", @@ -42878,6 +42894,20 @@ "node": "^20.19.0 || >=22.12.0" } }, + "packages/api/node_modules/emf-converter": { + "version": "4.8.7", + "resolved": "https://registry.npmjs.org/emf-converter/-/emf-converter-4.8.7.tgz", + "integrity": "sha512-oxnd2LVALXdVsIDlwInIH+kSYBB4+qSJsg0pPF5JqArSnH7G/FdbMwbdL4lHlYIWsX+VAOdt1bYlXLyZcuAqRw==", + "license": "Apache-2.0", + "peerDependencies": { + "@napi-rs/canvas": "^1.0.9" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "packages/api/node_modules/get-tsconfig": { "version": "5.0.0-beta.5", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", diff --git a/packages/api/package.json b/packages/api/package.json index 7c12e936328..e057bc20bcf 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -181,6 +181,7 @@ "@langchain/langgraph-checkpoint-mongodb": "^1.4.0", "cluster-key-slot": "^1.1.2", "croner": "^10.0.1", + "emf-converter": "4.8.7", "express-rate-limit": "^8.5.1", "helmet": "^8.3.0", "proxy-from-env": "^2.1.0", diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index a46a6682788..a9915aa28ee 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -21,6 +21,51 @@ import { wordDocToHtml, } from './html'; import { ZipBombError } from './zipSafety'; +import * as metafiles from './metafiles'; + +/** Minimal valid little-endian EMF: header, brush, select, rectangle, EOF. */ +function buildEmf(): Buffer { + const parts: Buffer[] = []; + const rec = (type: number, size: number, ...ints: number[]): Buffer => { + const b = Buffer.alloc(size); + b.writeUInt32LE(type, 0); + b.writeUInt32LE(size, 4); + ints.forEach((v, i) => b.writeInt32LE(v | 0, 8 + i * 4)); + return b; + }; + const header = rec( + 1, + 108, + 0, + 0, + 99, + 49, // bounds + 0, + 0, + 2645, + 1322, // frame + 0x464d4520, // signature + 0x10000, // version + 0, // bytes (patched below) + 5, // records + 2, // handles (u32 + reserved u16 packed) + 0, // nDescription + 0, // offDescription + 0, // nPalEntries + 1920, + 1080, // device + 508, + 286, // millimeters + ); + parts.push(header); + parts.push(rec(39, 24, 1, 0, 0x00ff0000, 0)); + parts.push(rec(37, 12, 1)); + parts.push(rec(43, 24, 0, 0, 99, 49)); + parts.push(rec(14, 20, 0, 16, 20)); + const out = Buffer.concat(parts); + out.writeUInt32LE(out.length, 48); + return out; +} const fixturesDir = __dirname; const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixturesDir, name)); @@ -419,6 +464,49 @@ describe('Office HTML producers', () => { return zip.generateAsync({ type: 'nodebuffer' }); }; + describe('EMF/WMF metafile swap', () => { + const withEmf = async (): Promise => { + const zip = await JSZip.loadAsync(await buildPptx([{ title: 'T' }])); + zip.file('ppt/media/image1.emf', buildEmf()); + return zip.generateAsync({ type: 'nodebuffer' }); + }; + + afterEach(() => jest.restoreAllMocks()); + + test('embeds converted SVGs for pptx metafiles', async () => { + const html = await pptxToHtml(await withEmf()); + expect(html).toContain('id="lc-metafiles"'); + expect(html).toContain(metafiles.metafileKey(buildEmf().toString('base64'))); + expect(html).toContain('swapMetafiles'); + }); + + test('escapes < in the JSON block so cannot break out', async () => { + const html = await _internal.pptxToHtmlViaCdn( + await buildPptx([{ title: 'X' }]), + '', + false, + { k: 'data:x' }, + ); + const block = html.split('id="lc-metafiles"')[1].split('')[0]; + expect(block).toContain('\\u003c/script>'); + expect(block).not.toContain(''); + expect(html).toContain('\\u003c/script>'); + }); + + test('omits the block when there are no metafiles', async () => { + const html = await pptxToHtml(await buildPptx([{ title: 'T' }])); + expect(html).not.toContain('id="lc-metafiles"'); + }); + + test('drops the map, keeping the CDN doc, when it would exceed the output cap', async () => { + const huge = { k: 'a'.repeat(_internal.OFFICE_HTML_OUTPUT_CAP) }; + jest.spyOn(metafiles, 'extractPptxMetafileSvgs').mockResolvedValue(huge); + const html = await pptxToHtml(await buildPptx([{ title: 'T' }])); + expect(html).toContain('cdn.jsdelivr.net/npm/pptx-preview@'); + expect(html).not.toContain('id="lc-metafiles"'); + }); + }); + 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); diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index ff23d957065..b0009e6e435 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -6,6 +6,7 @@ import { OFFICE_FILE_SHELL_MARKER, } from 'librechat-data-provider'; import { tryLibreOfficePreview } from './libreoffice'; +import { METAFILE_KEY_JS, extractPptxMetafileSvgs } from './metafiles'; import { assertSafeZipSize } from './zipSafety'; /** @@ -1115,7 +1116,14 @@ function buildPptxCdnDocument( base64: string, slideListFallbackBody: string, fileShell = false, + metafileSvgs: Record = {}, ): string { + /* Server-converted EMF/WMF → SVG map. `<` is escaped so a value can + * never close the script element. */ + const metafileBlock = + Object.keys(metafileSvgs).length > 0 + ? `\n` + : ''; /* PPTX-specific CSP relaxations vs DOCX: * - `worker-src blob:` — pptx-preview's bundled echarts dep spins up * Web Workers via blob: URLs for chart rendering. Without this, @@ -1229,9 +1237,10 @@ ${PPTX_SLIDE_LIST_CSS} ${fileShell ? OFFICE_DOC_DATA_SLOT : ``} -