From 6b076b2d42a746881b32a23b29e336450ec846a1 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Fri, 2 Oct 2026 14:01:23 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20fix:=20Show=20EMF=20and?= =?UTF-8?q?=20WMF=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 and data:image/x-wmf URIs, which no browser decodes, so template logos stored as Windows metafiles render as broken images in the artifact panel. Convert them to SVG on the server with emf-converter (pinned 4.8.7, Apache-2.0, no dependencies), embed the SVGs keyed by a hash of each entry's base64, and swap them into the rendered elements when the bootstrap finalizes. Conversion is bounded per entry (512 KB, 10,000 records) and per deck (64 entries, 8 MB input, 1 s, 384 KB of SVG). If the map would push a deck past the preview cap, the deck is rendered without it, so no deck that previews today changes path. --- api/package.json | 1 + package-lock.json | 30 ++++ packages/api/package.json | 1 + .../files/documents/__tests__/emf.helper.ts | 82 +++++++++ packages/api/src/files/documents/html.spec.ts | 42 +++++ packages/api/src/files/documents/html.ts | 45 ++++- .../files/documents/metafiles.runtime.spec.ts | 86 +++++++++ .../api/src/files/documents/metafiles.spec.ts | 97 +++++++++++ packages/api/src/files/documents/metafiles.ts | 164 ++++++++++++++++++ 9 files changed, 543 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/files/documents/__tests__/emf.helper.ts create mode 100644 packages/api/src/files/documents/metafiles.runtime.spec.ts 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 6e9c002a699..d0e65fc8ad0 100644 --- a/api/package.json +++ b/api/package.json @@ -76,6 +76,7 @@ "dedent": "^1.5.3", "dompurify": "^3.4.16", "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 acf939a340f..1b1e53b3b57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "dedent": "^1.5.3", "dompurify": "^3.4.16", "dotenv": "^16.0.3", + "emf-converter": "4.8.7", "eventsource": "^3.0.2", "express": "^5.2.1", "express-mongo-sanitize": "^2.2.0", @@ -730,6 +731,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", @@ -40763,6 +40778,7 @@ "@simplewebauthn/server": "^13.3.2", "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", @@ -41250,6 +41266,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 39924f4ba15..7466c26761b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -182,6 +182,7 @@ "@simplewebauthn/server": "^13.3.2", "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/__tests__/emf.helper.ts b/packages/api/src/files/documents/__tests__/emf.helper.ts new file mode 100644 index 00000000000..8a849077452 --- /dev/null +++ b/packages/api/src/files/documents/__tests__/emf.helper.ts @@ -0,0 +1,82 @@ +/** Minimal valid little-endian EMF: header, brush, select, rectangle, EOF. */ +export 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; +} + +/** Placeable WMF: header, SETWINDOWEXT, RECTANGLE, EOF. */ +export function buildWmf(): Buffer { + const rec = (func: number, ...words: number[]): Buffer => { + const b = Buffer.alloc(6 + words.length * 2); + b.writeUInt32LE(b.length / 2, 0); + b.writeUInt16LE(func, 4); + words.forEach((w, i) => b.writeInt16LE(w, 6 + i * 2)); + return b; + }; + const placeable = Buffer.alloc(22); + placeable.writeUInt32LE(0x9ac6cdd7, 0); + placeable.writeInt16LE(99, 10); + placeable.writeInt16LE(49, 12); + placeable.writeUInt16LE(1440, 14); + const header = Buffer.alloc(18); + header.writeUInt16LE(1, 0); + header.writeUInt16LE(9, 2); + header.writeUInt16LE(0x300, 4); + const body = Buffer.concat([rec(0x020c, 49, 99), rec(0x041b, 49, 99, 0, 0), rec(0)]); + header.writeUInt32LE((18 + body.length) / 2, 6); + return Buffer.concat([placeable, header, body]); +} + +/** EMF with `n` no-output SETBKMODE records between the header and EOF. */ +export function buildEmfWithRecords(n: number): Buffer { + const base = buildEmf(); + const ellipse = Buffer.alloc(12); + ellipse.writeUInt32LE(18, 0); + ellipse.writeUInt32LE(12, 4); + ellipse.writeUInt32LE(1, 8); + const out = Buffer.concat([ + base.subarray(0, base.length - 20), + ...Array(n).fill(ellipse), + base.subarray(base.length - 20), + ]); + out.writeUInt32LE(out.length, 48); + return out; +} diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index e320abddf9c..c6577e1dde3 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -14,6 +14,8 @@ import { wordDocToHtml, } from './html'; import { ZipBombError } from './zipSafety'; +import * as metafiles from './metafiles'; +import { buildEmf } from './__tests__/emf.helper'; const fixturesDir = __dirname; const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixturesDir, name)); @@ -412,6 +414,46 @@ 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' }]), '', { + 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 2ee0ff163b9..88b10e1eeb0 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -1,6 +1,7 @@ import yauzl from 'yauzl'; import { excelMimeTypes, megabyte } from 'librechat-data-provider'; import { tryLibreOfficePreview } from './libreoffice'; +import { METAFILE_KEY_JS, extractPptxMetafileSvgs } from './metafiles'; import { assertSafeZipSize } from './zipSafety'; /** @@ -1080,7 +1081,17 @@ const MAX_PPTX_CDN_BINARY_BYTES = 350 * 1024; * iframe via `transform: scale(...)`. The slides scroll vertically * once the renderer paints them. */ -function buildPptxCdnDocument(base64: string, slideListFallbackBody: string): string { +function buildPptxCdnDocument( + base64: string, + slideListFallbackBody: string, + 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, @@ -1172,9 +1183,10 @@ ${PPTX_SLIDE_LIST_CSS} -