fix(export): keep embedded images in exported PDF and HTML - #1948
Draft
h4yfans wants to merge 10 commits into
Draft
fix(export): keep embedded images in exported PDF and HTML#1948h4yfans wants to merge 10 commits into
h4yfans wants to merge 10 commits into
Conversation
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.
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Codecov Report❌ Patch coverage is
📢 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A note that embeds an image exported to PDF with the image broken, while the same note exported to HTML looked fine.
EXPORT_PDFinapps/desktop/src/main/ipc/notes-handlers.tsrendered the note throughrenderNoteAsHtmland handed the result to a hiddenBrowserWindowasdata:text/html;charset=utf-8,…. Adata:URL has an opaque origin and no base URL, so the relative<img src="attachments/…/photo.png">thatmarkdownToHtmlemits had nothing to resolve against. Chromium drew a broken image andprintToPDFbaked it in.EXPORT_HTMLwrote 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.htmlout of the vault and its images break the same way.The new
apps/desktop/src/main/lib/export-image-inliner.tscarries the bytes inside the document instead.inlineExportImagescollects every<img src>in the rendered note, resolves each one to a file on disk, and rewrites it to adata:URI. Both handlers render through onerenderNoteForExporthelper, so the PDF and HTML paths cannot drift apart again.Resolution follows the rule the editor already uses. A
data:,http:orhttps:src is left alone. Afile:URL, amemry-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'sresolveNoteRelativeUrl, 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 throughcreateLogger.The PDF window now loads a file staged under
app.getPath('temp')rather than adata:URL, removed in the samefinallyblock that destroys the window. This is not cosmetic. Inlining grows the document, and the E2E showed Chromium rejecting the URL withERR_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
.htmlthan before. In exchange the PDF renders and the.htmlsurvives being moved or emailed.webPreferences.javascriptstaysfalse; 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:checkreports 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 exactdata: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, byfile:URL and bymemry-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, asrcon a non-imgtag, a readable file whose extension is not an image, and anid_rsanamed by absolute path,file:URL andmemry-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 onlysuccess: true, which is what let this bug ship. Red againstorigin/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 noattachments/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 againstorigin/main's handler, all three, withError: widest embedded image was 14px, which is Chromium's broken-image icon. Asserting only the presence of an/Imageobject did not discriminate, because Chromium writes that icon as an image too...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.