Skip to content

🖼️ fix: Show EMF and WMF Pictures in PowerPoint Previews - #16672

Open
TomasPalsson wants to merge 1 commit into
LibreChat-AI:devfrom
TomasPalsson:fix/pptx-emf-pictures
Open

TomasPalsson wants to merge 1 commit into
LibreChat-AI:devfrom
TomasPalsson:fix/pptx-emf-pictures

Conversation

@TomasPalsson

Copy link
Copy Markdown
Contributor

Summary

When a code run produces a PowerPoint deck built from a corporate template, logos stored as Windows metafiles (.emf / .wmf) show as broken-image icons in the artifact panel. pptx-preview@1.0.7 loads every ppt/media/image* entry into a data: URI with the type image/x-emf or image/x-wmf, and no browser can decode either format. Templates exported from PowerPoint commonly keep their logos this way, usually in the slide layouts, so the broken icon repeats on almost every slide. A real template that prompted this has 8 of its 11 media files as EMF.

This PR converts those metafiles to SVG on the server while the preview HTML is built, embeds the SVGs in the preview document, and swaps them into the rendered <img> elements once the slides are drawn. Decks without metafiles render exactly as before.

How it works

pptxToHtml(buffer)                                         packages/api/src/files/documents/html.ts
  extractPptxMetafileSvgs(buffer)                          packages/api/src/files/documents/metafiles.ts
    ppt/media/image*.emf|wmf  (same prefix rule as pptx-preview)
      -> emf-converter convertMetafileToSvg -> data:image/svg+xml;base64,...
      -> keyed by metafileKey(base64 of the entry)  = FNV-1a over the base64 + ':' + length
  buildPptxCdnDocument(..., metafileSvgs)
    <script id="lc-metafiles" type="application/json">{ key: svgDataUri }</script>   ('<' escaped)

iframe bootstrap
  finalize()
    wrapSlides()
    swapMetafiles()   # img[src^="data:image/x-emf;"|"data:image/x-wmf;"] -> key -> SVG data URI

The key works because pptx-preview builds each image source as "data:" + mime + ";base64," + zip.async("base64"), which is the same standard base64 that Buffer.toString('base64') produces, so the iframe hashes the part after the comma and looks it up. The hash is defined once in TypeScript (metafileKey) and once as ES5 source for the bootstrap (METAFILE_KEY_JS), and a test runs both on the same inputs. swapMetafiles is wrapped in try/catch and runs inside finalize(), so a failure can never reveal the slide-list fallback.

Conversion is synchronous CPU work, so it is bounded. Per entry: 512 KB of input and at most 10,000 records, counted by walking the record headers before converting. Per deck: 64 entries, 8 MB of input, 1 s of conversion (checked between entries), 256 KB per SVG and 384 KB of SVG in total. An entry over a limit, or one the converter rejects, keeps its original source, which is the same broken image as today. If the embedded map would push the document past OFFICE_HTML_OUTPUT_CAP, the document is rebuilt without it before any existing fallback runs, so no deck that gets the rendered preview today drops to the slide list.

Type of change

  • Bug fix

Testing

Tested environments/configuration:

  • Chrome (Playwright Chromium) on macOS, with the generated preview HTML loaded through a local static server and the pinned pptx-preview@1.0.7 from jsDelivr.
  • Decks generated with python-pptx and hand-written metafiles: D1 a vector EMF logo in the slide layout, D2 the same logo as WMF, D3 an EMF logo with a PNG photo and a JPG background, D4 no metafiles as a control, D5 a deck near the 350 KB inline limit. Rendered with dev and with this branch:
Deck dev (img loaded / broken) This branch (img loaded / broken)
D1 EMF logo 0 / 2 2 / 0
D2 WMF logo 0 / 2 2 / 0
D3 EMF + PNG + JPG 4 / 2 6 / 0 (PNG and JPG sources unchanged)
D4 no metafiles 4 / 0 4 / 0 (no lc-metafiles block emitted)
D5 large deck 3 / 2 5 / 0
  • A real corporate PowerPoint template: all 7 vector EMF logos convert and render.
  • Hostile input timing on the final limits: one EMF with 9,998 rounded rectangles takes 0.85 s and is rejected by the SVG size cap. One 128k-point polygon takes 0.37 s. A deck of 30 of either takes about 1.1 s in total, because the 1 s budget stops the run. An EMF over 10,000 records is skipped in 4 ms without being converted.

Automated tests:

  • metafiles.spec.ts: EMF and WMF conversion, name filtering (only ppt/media/image*), corrupt bytes skipped without throwing, a non-zip buffer resolves {}, the per-entry byte cap, the record budget, the time budget, and parity between metafileKey and METAFILE_KEY_JS.
  • metafiles.runtime.spec.ts: runs the real bootstrap in JSDOM against a fake pptxPreview that renders an EMF <img> and a PNG <img>. The EMF source becomes an SVG data URI, the PNG is untouched, and the fallback stays hidden. A deck without metafiles is left as is. Removing the swapMetafiles() call fails this spec.
  • html.spec.ts: the map is emitted, a hostile map value cannot close the <script>, decks without metafiles emit no map, and an over-cap map falls back to the map-free document.
  • Mutation check: removing the swap, changing the iframe hash, dropping the < escaping, ignoring .wmf, removing the time budget, or returning the over-cap document each fail at least one test.
  • cd packages/api && npx jest --ci --testPathIgnorePatterns='\.*integration\.|\.*helper\.|__tests__/helpers/|\.*manual\.spec\.' -- src/files: 1143 passed. libreoffice.spec.ts has 3 failures on this machine that also fail on unmodified dev: LibreOffice is installed locally while in-process conversion is disabled, so the spec does not skip.
  • npx tsc --noEmit, ESLint and Prettier are clean on the changed files.

Screenshots / recordings

The first slide of D1, rendered in the preview iframe at the same size from dev and from this branch. The logo is a synthetic three-shape vector EMF placed in the slide layout.

Before (dev): a broken-image icon where the layout logo should be. After (this branch): the vector logo rendered as SVG. Screenshots are being added through the web editor.

Risk / compatibility

  • New dependency. emf-converter@4.8.7 is pinned exactly in packages/api and api. It is Apache-2.0 and has no dependencies. @napi-rs/canvas is an optional peer and is not installed, so the few EMF+ records that need it fail to convert and keep today's broken image.
  • Server CPU. Conversion runs on the event loop while the preview is built. The limits above cap a hostile deck at roughly 2 s in the worst case: the 1 s budget plus one in-flight entry. Real template logos are tens of KB and convert in a few milliseconds.
  • Scope. Only <img> pictures are swapped. Metafiles used as a slide background or a shape fill, which pptx-preview paints through CSS or an SVG pattern, still show nothing, as today. Media whose file name does not start with image is never loaded by pptx-preview, so it is not converted either. DOCX previews are unchanged.
  • Security. No CSP change: img-src already allows data:. SVG shown through <img> cannot run script or load remote resources, and emf-converter only emits data: URLs for embedded bitmaps. The JSON map is escaped against </script> break-out.
  • Stored previews. Previews already stored keep their old HTML until the file is produced again.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published
  • Required documentation PR: N/A

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 <img> 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.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 14:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

TomasPalsson added a commit to aproorg/LibreChat that referenced this pull request Oct 2, 2026
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 <img> 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.

(cherry picked from commit 6b076b2)

Upstream: LibreChat-AI#16672. Adapted for the storage-backed file
shell on this branch: buildPptxCdnDocument/pptxToHtmlViaCdn keep their
fileShell parameter, a shell over the cap retries without the map, and
layout.spec.ts/html.spec.ts cover the shell path.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants