Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions packages/api/src/files/documents/html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -419,6 +464,49 @@ describe('Office HTML producers', () => {
return zip.generateAsync({ type: 'nodebuffer' });
};

describe('EMF/WMF metafile swap', () => {
const withEmf = async (): Promise<Buffer> => {
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 </script> cannot break out', async () => {
const html = await _internal.pptxToHtmlViaCdn(
await buildPptx([{ title: 'X' }]),
'',
false,
{ k: 'data:x</script><b>' },
);
const block = html.split('id="lc-metafiles"')[1].split('</script>')[0];
expect(block).toContain('\\u003c/script>');
expect(block).not.toContain('</script>');
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);
Expand Down
38 changes: 36 additions & 2 deletions packages/api/src/files/documents/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -1115,7 +1116,14 @@ function buildPptxCdnDocument(
base64: string,
slideListFallbackBody: string,
fileShell = false,
metafileSvgs: Record<string, string> = {},
): 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
? `<script id="lc-metafiles" type="application/json">${JSON.stringify(metafileSvgs).replace(/</g, '\\u003c')}</script>\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,
Expand Down Expand Up @@ -1229,9 +1237,10 @@ ${PPTX_SLIDE_LIST_CSS}
</details>
</div>
${fileShell ? OFFICE_DOC_DATA_SLOT : `<script id="lc-doc-data" type="application/octet-stream;base64">${base64}</script>`}
<script>
${metafileBlock}<script>
(function () {
var settled = false;
var metafileKey = ${METAFILE_KEY_JS};
function showFallback(reason) {
if (settled) { return; }
settled = true;
Expand Down Expand Up @@ -1428,13 +1437,24 @@ ${fileShell ? OFFICE_DOC_DATA_SLOT : `<script id="lc-doc-data" type="application
return false;
}

function swapMetafiles() {
try {
var m = JSON.parse(document.getElementById('lc-metafiles').textContent);
container.querySelectorAll('img[src^="data:image/x-emf;"],img[src^="data:image/x-wmf;"]').forEach(function (i) {
var u = m[metafileKey(i.getAttribute('src').split(',')[1])];
if (u) { i.src = u; }
});
} catch (e) {}
}

function finalize() {
/* previewer.preview() and the safety-net timer both call this; the
* timer already guards on settled before calling it, but the
* promise path does not, so guard here once for both callers
* instead of at each call site. */
if (settled) { return; }
wrapSlides();
swapMetafiles();
if (!hasRenderedContent()) {
showFallback('renderer-empty-slide-list');
return;
Expand Down Expand Up @@ -1499,11 +1519,13 @@ async function pptxToHtmlViaCdn(
buffer: Buffer,
slideListFallbackBody: string,
fileShell = false,
metafileSvgs: Record<string, string> = {},
): Promise<string> {
return buildPptxCdnDocument(
fileShell ? '' : buffer.toString('base64'),
slideListFallbackBody,
fileShell,
metafileSvgs,
);
}

Expand Down Expand Up @@ -1550,15 +1572,27 @@ export async function pptxToHtml(
* the empty-render case and reveals this slide-list fallback so the
* user always gets readable content. Manual e2e on PR #12934. */
const slideListBody = await renderPptxSlidesBodyForBuffer(buffer);
const cdnDoc = await pptxToHtmlViaCdn(buffer, slideListBody, fileShell);
const metafileSvgs = await extractPptxMetafileSvgs(buffer);
const cdnDoc = await pptxToHtmlViaCdn(buffer, slideListBody, fileShell, metafileSvgs);
if (fileShell && Buffer.byteLength(cdnDoc, 'utf-8') > OFFICE_HTML_OUTPUT_CAP) {
const shellDoc = await pptxToHtmlViaCdn(buffer, '', true, metafileSvgs);
if (Buffer.byteLength(shellDoc, 'utf-8') <= OFFICE_HTML_OUTPUT_CAP) {
return shellDoc;
}
return pptxToHtmlViaCdn(buffer, '', true);
}
/* Combined size budget: if base64 binary + slide-list fallback +
* wrapper would exceed the cache cap, drop CDN entirely and ship
* the slide-list standalone. Same pattern as the DOCX dispatcher's
* size budget. */
if (Buffer.byteLength(cdnDoc, 'utf-8') > OFFICE_HTML_OUTPUT_CAP) {
const plainDoc = await pptxToHtmlViaCdn(buffer, slideListBody, false);
if (
Object.keys(metafileSvgs).length > 0 &&
Buffer.byteLength(plainDoc, 'utf-8') <= OFFICE_HTML_OUTPUT_CAP
) {
return plainDoc;
}
return pptxToSlideListHtmlInternal(buffer);
}
return cdnDoc;
Expand Down
106 changes: 106 additions & 0 deletions packages/api/src/files/documents/metafiles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import JSZip from 'jszip';
import { convertMetafileToSvg } from 'emf-converter';
import { extractPptxMetafileSvgs, metafileKey, METAFILE_KEY_JS } 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 buildZip = async (files: Record<string, Buffer | string>): Promise<Buffer> => {
const zip = new JSZip();
for (const [name, data] of Object.entries(files)) {
zip.file(name, data);
}
return zip.generateAsync({ type: 'nodebuffer' });
};

describe('metafiles', () => {
const emf = buildEmf();

test('fixture converts with emf-converter', async () => {
const ab = emf.buffer.slice(emf.byteOffset, emf.byteOffset + emf.byteLength);
const svg = await convertMetafileToSvg(ab as ArrayBuffer, { idPrefix: 'x-' });
expect(svg).toContain('<svg');
});

test('extracts only convertible emf/wmf entries, keyed by base64 hash', async () => {
const pptx = await buildZip({
'ppt/media/image1.emf': emf,
'ppt/media/image2.png': Buffer.from([1, 2, 3]),
'ppt/media/logo.emf': emf,
'ppt/media/image3.emf': Buffer.from('garbage bytes that are not an emf'),
});
const map = await extractPptxMetafileSvgs(pptx);
expect(Object.keys(map)).toEqual([metafileKey(emf.toString('base64'))]);
const value = Object.values(map)[0];
expect(value.startsWith('data:image/svg+xml;base64,')).toBe(true);
expect(
Buffer.from(value.slice('data:image/svg+xml;base64,'.length), 'base64').toString(),
).toContain('<svg');
});

test('resolves {} for a non-zip buffer', async () => {
await expect(extractPptxMetafileSvgs(Buffer.from('not a zip'))).resolves.toEqual({});
});

test('stops converting once the time budget is spent', async () => {
const blue = Buffer.from(emf);
blue.writeInt32LE(0x000000ff, 108 + 16);
const pptx = await buildZip({ 'ppt/media/image1.emf': emf, 'ppt/media/image2.emf': blue });
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(10_000);
try {
const map = await extractPptxMetafileSvgs(pptx);
expect(Object.keys(map)).toEqual([metafileKey(emf.toString('base64'))]);
} finally {
now.mockRestore();
}
});

test('METAFILE_KEY_JS matches metafileKey', () => {
const fn = new Function('return (' + METAFILE_KEY_JS + ')')() as (s: string) => string;
for (const s of ['', 'abc', Buffer.alloc(52500, 7).toString('base64')]) {
expect(fn(s)).toBe(metafileKey(s));
}
});
});
Loading
Loading