Skip to content

fix(export): keep embedded images in exported PDF and HTML - #1948

Draft
h4yfans wants to merge 10 commits into
mainfrom
export-images-resolve-base
Draft

fix(export): keep embedded images in exported PDF and HTML#1948
h4yfans wants to merge 10 commits into
mainfrom
export-images-resolve-base

Conversation

@h4yfans

@h4yfans h4yfans commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

A note that embeds an image exported to PDF with the image broken, while the same note exported to HTML looked fine. EXPORT_PDF in apps/desktop/src/main/ipc/notes-handlers.ts rendered the note through renderNoteAsHtml and handed the result to a hidden BrowserWindow as data:text/html;charset=utf-8,…. A data: URL has an opaque origin and no base URL, so the relative <img src="attachments/…/photo.png"> that markdownToHtml emits had nothing to resolve against. Chromium drew a broken image and printToPDF baked it in. EXPORT_HTML wrote the same markup to a real file, so the relative path resolved against wherever the user happened to save it. That was luck rather than correctness. Move the .html out of the vault and its images break the same way.

The new apps/desktop/src/main/lib/export-image-inliner.ts carries the bytes inside the document instead. inlineExportImages collects every <img src> in the rendered note, resolves each one to a file on disk, and rewrites it to a data: URI. Both handlers render through one renderNoteForExport helper, so the PDF and HTML paths cannot drift apart again.

Resolution follows the rule the editor already uses. A data:, http: or https: src is left alone. A file: URL, a memry-file: URL, an absolute path or a Windows drive path is read directly, which is what makes an image outside the vault work. Anything else is a note-relative ref resolved against the note's own directory inside the vault, mirroring the renderer's resolveNoteRelativeUrl, including its refusal to follow a ref that climbs above the vault root. Only the nine extensions on an explicit image allowlist are inlined, because a note is data and a synced or imported one can name any path its author liked; without the gate an <img src="file:///…/id_rsa"> would put those bytes into a document the user then emails. Anything the allowlist rejects, and anything unreadable, is left exactly as written and logged through createLogger.

The PDF window now loads a file staged under app.getPath('temp') rather than a data: URL, removed in the same finally block that destroys the window. This is not cosmetic. Inlining grows the document, and the E2E showed Chromium rejecting the URL with ERR_INVALID_URL (-300) once a 2.4 MB image was embedded, which a single phone photograph clears. Without this the fix would have traded a broken image for a failed export. The note's HTML touches the OS temp directory for the length of one print, which is the same disk the vault's own plaintext markdown already sits on.

The tradeoff is document size. Base64 runs about a third larger than the file on disk, so a note with several photographs produces a bigger PDF and a much bigger .html than before. In exchange the PDF renders and the .html survives being moved or emailed. webPreferences.javascript stays false; nothing here needs a script or a base URL.

Blast radius is the main process and those two export handlers. No IPC contract, database schema, settings shape or sync payload changes, and pnpm ipc:check reports the invoke map unchanged. Notes written by older versions export identically, because resolution reads the markdown as it stands and never rewrites the vault.

Carries the test-only main fix from the calendar-widget PR until it lands; rebasing after that drops it.

Closes #1935

Release note

Exported PDFs now show a note's embedded images, and an exported HTML file keeps its images after you move or send it.

Test plan

  • pnpm --filter @memry/desktop test:main. 567 files, 7751 passed, 3 files and 6 tests skipped, 1 expected fail.
  • pnpm --filter @memry/desktop test:renderer. 705 files, 8628 passed, 2 expected fail, 7 skipped.
  • apps/desktop/src/main/lib/export-image-inliner.test.ts. 15 passed. Pins the exact data:image/png;base64,… payload for a note-relative ref, a percent-encoded ref, a ref from a note in a subfolder, an image outside the vault by absolute path, by file: URL and by memry-file: URL, and every extension on the allowlist with its MIME type. Pins the untouched cases too: data:, http:, https:, a ref escaping the vault root, an unreadable path, a src on a non-img tag, a readable file whose extension is not an image, and an id_rsa named by absolute path, file: URL and memry-file: URL, which asserts its bytes appear nowhere in the output.
  • apps/desktop/src/main/ipc/notes-handlers-extra.test.ts. 10 passed. The export test now asserts the exact inlined <img src> in the HTML staged for the PDF window and in the HTML written to disk, and that the staged file is removed. It previously asserted only success: true, which is what let this bug ship. Red against origin/main's handler: expected '<html><body><img src="attachments/not…' to be '<html><body><img src="data:image/png;…'.
  • apps/desktop/tests/e2e/note-export-images.e2e.ts. 3 passed. Seeds a note embedding a real uploaded 613px PNG, exports HTML to a temp directory, moves the file to a second directory, and asserts the <img src> there is the image's data URI with no attachments/ reference left. Exports the same note to PDF, and a second note holding a 2.4 MB image, asserting in each case that the widest image object in the PDF is the note's own bitmap. Red against origin/main's handler, all three, with Error: widest embedded image was 14px, which is Chromium's broken-image icon. Asserting only the presence of an /Image object did not discriminate, because Chromium writes that icon as an image too.
  • Mutation check: six mutations of the fix, all killed. Dropping the rewrite, dropping the percent-decode, dropping the image-extension gate, changing the MIME fallback, hoisting the vault-path read to module scope, and collapsing .. instead of rejecting it. The last one initially survived, because the escaping ref pointed at a file that did not exist; the test now writes that file at the vault root so the guard itself is pinned.
  • pnpm --filter @memry/desktop typecheck:node, typecheck:web, typecheck:test, pnpm lint (0 errors), pnpm --filter @memry/desktop i18n:check, pnpm check:architecture, pnpm check:contracts, pnpm ipc:generate && pnpm ipc:check, pnpm docs:impact --base origin/main --strict, pnpm docs:build, git diff --check. All clean.

The export tests asserted only that the handler returned success, so the PDF
path could bake in a broken <img src> and stay green. Both cases now assert the
exact data URI the export is expected to carry.
PDF export loaded the rendered note through a data: URL, which has an opaque
origin and no base URL, so a relative <img src="attachments/..."> had nothing to
resolve against and printToPDF baked in a broken image. HTML export only looked
right because the file landed next to the attachments; moving it broke the same
way.

inlineExportImages resolves each src the way the editor does, against the note's
own directory inside the vault, and carries the bytes in the document. Both
export paths go through it, so a PDF renders its images with no base URL and no
script, and an exported .html stays self-contained. A src that names no readable
local file is left as written.
…t reason

The escaping ref resolved to a file that did not exist, so the case passed with
or without the guard. Writing that file at the vault root pins the guard itself.
A note is data. A synced or imported one can name any path its author liked, so
an <img src="file:///.../id_rsa"> would have put those bytes into a document the
user then emails. memry-file: has the protocol handler's vault and userData check
behind it; every other scheme had nothing, so the extension is now the gate.
Anything else is left as written and logged.

Also adds the large-image E2E case: inlining grows the data:text/html URL the PDF
window loads, and Chromium caps URL length, which a phone photograph would reach.
…ta: URL

Inlining the images grows the document, and Chromium rejects a data: URL past
its length ceiling with ERR_INVALID_URL (-300). A note holding a single phone
photograph clears that ceiling, so the previous commit would have traded a
broken image for a failed export. The hidden window now loads a file written
under app.getPath('temp') and removed in the same finally block that destroys
the window.
Asserting an /Image object proved nothing: Chromium writes the broken-image icon
as an image too, so both PDF cases passed against origin/main. They now read the
widest image object in the PDF, which separates the note's own 613px bitmap from
the icon.
The calendar widget refresh suite faked the clock onto 2026-08-31, the day it was
written. use-today snapshots the local date into module scope at import and re-reads
the wall clock for its first subscriber, so on every later day that fake clock arrives
as a midnight rollover. todayCalendarRange moves, the useCalendarRange query key moves
with it, and the widget fetches a second day during mount, which is the second
getRange call the first test counted.

Derive the fixture clock and its event hours from the real local date instead, so the
mount no longer straddles a day boundary. Local date fields rather than a UTC instant,
because far enough from UTC the two name different days.
@github-actions github-actions Bot added bug Something isn't working documentation Improvements or additions to documentation test labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit de024f5.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.75000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
apps/desktop/src/main/lib/export-image-inliner.ts 98.57% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Malformed percent-encoding, a malformed file: or memry-file: URL, and a staged
file that will not delete. Each is a guard whose job is to keep an export from
throwing, so each earns an assertion that it does not.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: PDF export drops embedded images while HTML export keeps them

1 participant