Skip to content
Open
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.16",
"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 @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions packages/api/src/files/documents/__tests__/emf.helper.ts
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 42 additions & 0 deletions packages/api/src/files/documents/html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
sanitizeOfficeHtml,
wordDocToHtml,
} from './html';
import { buildEmf } from './__tests__/emf.helper';
import { ZipBombError } from './zipSafety';
import * as metafiles from './metafiles';

const fixturesDir = __dirname;
const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixturesDir, name));
Expand Down Expand Up @@ -412,6 +414,46 @@ 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' }]), '', {
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
45 changes: 40 additions & 5 deletions packages/api/src/files/documents/html.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import yauzl from 'yauzl';
import { excelMimeTypes, megabyte } from 'librechat-data-provider';
import { METAFILE_KEY_JS, extractPptxMetafileSvgs } from './metafiles';
import { tryLibreOfficePreview } from './libreoffice';
import { assertSafeZipSize } from './zipSafety';

Expand Down Expand Up @@ -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, 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 @@ -1172,9 +1183,10 @@ ${PPTX_SLIDE_LIST_CSS}
</details>
</div>
<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 @@ -1341,8 +1353,19 @@ ${PPTX_SLIDE_LIST_CSS}
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() {
wrapSlides();
swapMetafiles();
if (!hasRenderedContent()) {
showFallback('renderer-empty-slide-list');
return;
Expand Down Expand Up @@ -1403,8 +1426,12 @@ async function renderPptxSlidesBodyForBuffer(buffer: Buffer): Promise<string> {
return renderPptxSlidesBody(slides);
}

async function pptxToHtmlViaCdn(buffer: Buffer, slideListFallbackBody: string): Promise<string> {
return buildPptxCdnDocument(buffer.toString('base64'), slideListFallbackBody);
async function pptxToHtmlViaCdn(
buffer: Buffer,
slideListFallbackBody: string,
metafileSvgs: Record<string, string> = {},
): Promise<string> {
return buildPptxCdnDocument(buffer.toString('base64'), slideListFallbackBody, metafileSvgs);
}

/**
Expand Down Expand Up @@ -1446,12 +1473,20 @@ export async function pptxToHtml(buffer: Buffer): Promise<string> {
* 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);
const metafileSvgs = await extractPptxMetafileSvgs(buffer);
const cdnDoc = await pptxToHtmlViaCdn(buffer, slideListBody, metafileSvgs);
/* 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);
if (
Object.keys(metafileSvgs).length > 0 &&
Buffer.byteLength(plainDoc, 'utf-8') <= OFFICE_HTML_OUTPUT_CAP
) {
return plainDoc;
}
return pptxToSlideListHtmlInternal(buffer);
}
return cdnDoc;
Expand Down
86 changes: 86 additions & 0 deletions packages/api/src/files/documents/metafiles.runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import JSZip from 'jszip';
import { JSDOM } from 'jsdom';
import { buildEmf } from './__tests__/emf.helper';
import { pptxToHtml } from './html';

const PNG = 'data:image/png;base64,iVBORw0KGgo=';

const buildPptx = async (withEmf: boolean): Promise<Buffer> => {
const zip = new JSZip();
zip.file(
'[Content_Types].xml',
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>',
);
zip.file(
'ppt/slides/slide1.xml',
'<p:sld xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>Title</a:t></a:r></a:p></p:sld>',
);
if (withEmf) {
zip.file('ppt/media/image1.emf', buildEmf());
}
return zip.generateAsync({ type: 'nodebuffer' });
};

/**
* Loads the generated iframe document into jsdom with a fake `pptxPreview`
* that mimics pptx-preview's metafile output, and resolves once the
* bootstrap's finalize() has wrapped the slides.
*/
const doms: JSDOM[] = [];

async function runIframe(html: string): Promise<Document> {
const emfB64 = buildEmf().toString('base64');
const doc = html.replace(/<script[^>]*src=[^>]*><\/script>/g, '');
const dom = new JSDOM(doc, {
runScripts: 'dangerously',
beforeParse(window) {
(window as unknown as Record<string, unknown>).pptxPreview = {
init: (container: HTMLElement) => ({
preview: async () => {
const wrapper = window.document.createElement('div');
wrapper.className = 'pptx-preview-slide-wrapper';
wrapper.innerHTML =
`<img id="emf" src="data:image/x-emf;base64,${emfB64}">` +
`<img id="png" src="${PNG}">`;
container.appendChild(wrapper);
return { slides: [{}] };
},
}),
};
},
});
doms.push(dom);
const { document } = dom.window;
for (let i = 0; i < 100 && !document.querySelector('.lc-slide-wrap'); i++) {
await new Promise((r) => setTimeout(r, 10));
}
expect(document.querySelector('.lc-slide-wrap')).not.toBeNull();
return document;
}

describe('pptx iframe metafile swap (runtime)', () => {
/* closing the window clears the bootstrap's 8s safety-net timer */
afterEach(() => doms.splice(0).forEach((d) => d.window.close()));

test('replaces the x-emf img src with the converted SVG and leaves other images alone', async () => {
const html = await pptxToHtml(await buildPptx(true));
expect(html).toContain('id="lc-metafiles"');
const document = await runIframe(html);
expect(document.getElementById('emf')?.getAttribute('src')).toMatch(
/^data:image\/svg\+xml;base64,/,
);
expect(document.getElementById('png')?.getAttribute('src')).toBe(PNG);
expect(document.getElementById('lc-fallback')?.hasAttribute('hidden')).toBe(true);
});

test('without a metafile map the emf src is unchanged and the fallback stays hidden', async () => {
const html = await pptxToHtml(await buildPptx(false));
expect(html).not.toContain('id="lc-metafiles"');
const document = await runIframe(html);
expect(document.getElementById('emf')?.getAttribute('src')).toMatch(
/^data:image\/x-emf;base64,/,
);
expect(document.getElementById('png')?.getAttribute('src')).toBe(PNG);
expect(document.getElementById('lc-fallback')?.hasAttribute('hidden')).toBe(true);
});
});
Loading