Skip to content

Add IPdfPage/extractPdfPageData: a Worker-clonable slice for PDF assembly - #42

Merged
philips merged 2 commits into
mainfrom
pdf-page-data-for-worker
Aug 1, 2026
Merged

Add IPdfPage/extractPdfPageData: a Worker-clonable slice for PDF assembly#42
philips merged 2 commits into
mainfrom
pdf-page-data-for-worker

Conversation

@philips-clanker

Copy link
Copy Markdown
Collaborator

Summary

  • `addPdfPage()`/`addTextOnlyPdfPage()` required a full `IPage` (lots of protocol/address metadata fields neither function reads), which isn't easily obtainable in a structured-clone-safe way for building a PDF off-main-thread.
  • Adds `IPdfPage` (`IRenderablePage` + `recognitionElements`, the one extra field these functions actually use) and widens their signature to accept it. `IPage` still structurally satisfies `IPdfPage`, so existing callers are unaffected.
  • Adds `extractPdfPageData()`, mirroring `extractPageRenderData()`, to produce this slice from a parsed note.

Motivation

Building on this repo's supernote-obsidian-plugin: pdf-lib's own `embedPng()` fully decodes every embedded PNG to raw pixels and retains that until `pdfDoc.save()` - confirmed via real-device testing to peak ~1.7GB and take 10+ seconds for a 100-page note, entirely inherent to pdf-lib itself. Moving PDF assembly into a Worker keeps that off the host app's UI thread, but doing so needs a way to hand a whole note's pages (rasterization data and recognition text) to the Worker in one structured-clone message - this is that.

Test plan

  • `npm run build` (tsc) passes
  • `npm test` (vitest, 46 tests) passes

…mbly

addPdfPage()/addTextOnlyPdfPage() needed a full IPage (recognition data
plus a bunch of protocol/address metadata fields neither actually
reads), which isn't structured-clone-safe in the way it's normally
obtained (straight off a parsed SupernoteX). Widened their signature to
accept IPdfPage - IRenderablePage (already used for rasterization, see
extractPageRenderData) plus recognitionElements, the one extra field
addPdfPage/addTextOnlyPdfPage actually read off IPage. IPage still
satisfies IPdfPage structurally, so this is purely additive - existing
callers passing a full parsed IPage are unaffected.

extractPdfPageData() mirrors extractPageRenderData() to produce this
slice, so a whole PDF (rasterize + embed + save) can now be built
entirely inside a Worker from data sent in one structured-clone message,
without reconstructing a fake IPage there - see the supernote-obsidian-
plugin plugin's buildPdfInWorker() for the motivating use (pdf-lib's own
embedPng() is expensive enough that keeping it off the host app's UI
thread matters for a many-page note).
philips-clanker added a commit to philips/supernote-obsidian-plugin that referenced this pull request Aug 1, 2026
pdf-lib's own embedPng() is genuinely expensive - confirmed via
real-device testing to still peak ~1.7GB and take 10+ seconds for a
100-page note even after dropping each page's alpha channel, since it
always fully decodes every embedded PNG to raw pixels and retains that
until pdfDoc.save(). All of that is inherent to pdf-lib itself, not
fixable from this plugin's side short of not using it - so instead of
trying to shrink it further, move the whole rasterize-embed-save
sequence off the main thread entirely.

Bumps the supernote-typescript submodule (branch pdf-page-data-for-worker,
PR philips/supernote-typescript#42) for
IPdfPage/extractPdfPageData - a structured-clone-safe page slice that
carries recognition text alongside rasterization data, letting a whole
note's pages be sent to a Worker in one message.

- myworker.worker.ts: new 'buildPdf' message does createPdfContext()
  through pdfDoc.save() in one call (pdf-lib's objects aren't
  structured-clone-safe, so this can't be split across round trips) -
  same per-batch alpha-flatten and PDF_ASSEMBLY_BATCH_SIZE batching as
  before, just relocated, plus the same heap/timing diagnostics logged
  from the Worker's own console instead.
- main.ts: assemblePdfFromNote() replaced by buildPdfInWorker(), which
  spins up a *dedicated* Worker (not the shared pool used for on-screen
  rendering - tying up one of its 2 workers for 10+ seconds would stall
  page/thumbnail loading for just as long) and terminates it once done.
  Removed the now-dead `flatten` plumbing from the shared pool's
  'convert' path, since PDF building no longer goes through it at all.
…annel

A prior fix dropped the alpha channel before PDF-embedding a page
(convertColor('RGB')), reasoning that background/unwritten pixels were
already packed as opaque white regardless of alpha. That was wrong: per
RattaRLEDecoder.buildPackedTranslation(), a fully-transparent pixel is
packed with its color channels at *black* (0,0,0) and alpha 0 -
specifically because nothing looks at the color while alpha is
respected. Dropping alpha instead of compositing revealed that stored
black as if it were solid ink, producing a black-background PDF with
the actual strokes barely visible - confirmed via a real exported PDF.

flattenToWhite() properly alpha-composites each pixel toward white
(standard flatten-onto-a-background-color blending) instead, giving
the correct result for fully-transparent (-> white), fully-opaque (->
unchanged), and anti-aliased in-between pixels alike. Added tests
covering all three cases plus the no-op path for an already-alpha-free
image.
@philips
philips merged commit 18d5438 into main Aug 1, 2026
1 check passed
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