Skip to content

Add toPdf(): searchable PDF export with invisible RTR text overlay - #27

Merged
philips merged 5 commits into
mainfrom
worktree-rtr-searchable-pdf-plan
Jul 25, 2026
Merged

Add toPdf(): searchable PDF export with invisible RTR text overlay#27
philips merged 5 commits into
mainfrom
worktree-rtr-searchable-pdf-plan

Conversation

@philips

@philips philips commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds plans/rtr-searchable-pdf.md and plans/rtr-searchable-pdf-workers.md, the implementation plans for this feature and a follow-up worker-parallelism design.
  • Implements toPdf(note, options?) in src/pdf.ts, exported from the package root: renders each page's raster image into a PDF page and overlays each recognized handwriting (RTR) word as invisible (PDF text-rendering mode 3) text at its source location, so the resulting PDF is searchable/selectable over the handwriting image — the standard "OCR text layer" technique.
  • The recognition JSON's word bounding boxes turned out to live in a coordinate space that is the raster pixel space divided by a fixed 11.9 scale factor. This was verified two ways: empirically, by cropping the rasterized page at that scale and visually confirming the box tightly wraps the actual ink (both near the page origin and far from it), and independently, by cross-referencing another project's reverse-engineering of the same on-device Supernote recognition format, which found the same constant.
  • toPdf defaults to the standard Helvetica font (Latin-only); callers can pass fontBytes for a Unicode TTF/OTF (embedded via @pdf-lib/fontkit) to support other scripts.
  • Physical PDF page size is derived from an assumed 300 DPI (documented, overridable via options.dpi) since Supernote files don't record device DPI. This only affects print/physical sizing, not searchability.
  • Adds README.md usage docs and tests/pdf.test.ts.

Two follow-up fixes from real-world testing

While testing against a real 23MB multi-page note, found and fixed two crashes:

  • compositeImages() (src/conversion.ts) requires 8-bit RGBA on both layers, but user-uploaded background templates are arbitrary PNGs and can decode to other formats (this note had an 8-bit RGB template with no alpha channel). Now normalized via convertBitDepth(8).convertColor(RGBA) after decodePng().
  • toPdf() previously aborted the entire PDF if any single recognized word contained a character the active font couldn't encode (e.g. "→", not in WinAnsi/Helvetica). Now skips just that word instead of failing the whole page.
  • Added tests/input/moonchild-user-bg-and-bad-glyph.note, a 275KB fixture trimmed from the original 23MB note (which hit both bugs on the same page) by directly repacking the minimal set of address-referenced blocks the Supernote container format needs, rather than committing the full original file.

Test plan

  • npm run build (tsc typecheck)
  • npx eslint .
  • npx vitest run — all 18 tests pass
  • Manually rendered the generated PDF page to PNG via pdf-parse's getScreenshot and visually confirmed the image renders correctly with no visible artifacts from the invisible text layer
  • Verified the trimmed regression fixture reproduces both original crashes when the fixes are reverted, and passes cleanly with them applied

philips added 2 commits July 25, 2026 01:13
Renders each page's raster image into a PDF page and draws recognized
handwriting (RTR) words as invisible (render mode 3) text at their
source location, so PDF viewers can search/select/copy handwritten
text. The word bounding boxes in the recognition JSON are stored in a
coordinate space divided by a fixed 11.9 scale factor relative to the
raster pixel space; this was verified empirically (cropping the raster
image at candidate scales and visually confirming word alignment) and
cross-checked against an independent reverse-engineering of the same
Supernote file format.
@philips philips changed the title Add plan for RTR-overlay searchable PDF generation Add toPdf(): searchable PDF export with invisible RTR text overlay Jul 25, 2026
@philips
philips marked this pull request as ready for review July 25, 2026 08:29
philips added a commit to philips/supernote-obsidian-plugin that referenced this pull request Jul 25, 2026
… build

Points the submodule at philips/supernote-typescript#27
(worktree-rtr-searchable-pdf-plan, commit 2ad5251), which adds
toPdf(note, options?) to the library itself: real invisible PDF text
(render mode 3, via pdf-lib) instead of alpha=0 fill, embedded fonts, and
a properly verified recognition-box coordinate scale of 11.9 — found by
cropping the raster image at candidate scales and visually confirming
word alignment, cross-checked against an independent reverse-engineering
of the same format. Our own RECOGNITION_BOX_SCALE = 13.5 here was an
empirical guess against the same fixture without that cross-check; 11.9
supersedes it.

Deletes buildNotePdf/drawPositionedRecognizedText/decodeRecognizedLabel/
RECOGNITION_BOX_SCALE from main.ts and the jspdf dependency (and its
canvg/dompurify/html2canvas/core-js transitive tree) entirely — that
whole PDF-assembly path now lives in, and is tested by, the library.

VaultWriter.exportToPDF no longer needs its own ImageConverter pass
either, since toPdf() rasterizes internally. SupernoteView.onLoadFile
still needs `images` from ImageConverter separately for the thumbnail
sidebar/save-to-vault/drag-out, so that page duplicates a render pass
toPdf() also does internally — a small tradeoff for not having to thread
pre-rendered images through the library's PDF builder.
philips added 3 commits July 25, 2026 01:49
- compositeImages() requires 8-bit RGBA on both layers, but
  user-uploaded background templates are arbitrary PNGs and can
  decode to other formats (e.g. 8-bit RGB with no alpha). Normalize
  via convertBitDepth(8).convertColor(RGBA) after decodePng().
- toPdf() aborted the entire PDF if any recognized word contained a
  character the active font couldn't encode (e.g. "→", which isn't in
  WinAnsi/Helvetica). Skip just that word instead of failing the whole
  page.

Adds a 275KB fixture (tests/input/moonchild-user-bg-and-bad-glyph.note)
trimmed from a real 23MB note that hit both bugs on the same page, by
directly repacking the minimal set of address-referenced blocks the
Supernote container format needs (see the file's structure notes in
src/parsing.ts) rather than committing the full original file.
Previously the font was only shrunk when text overflowed the box
width, so narrower words (e.g. "I", short words, or compressed
cursive) left the invisible text run's advance width smaller than the
actual handwriting, throwing off the search-hit highlight rectangle
PDF viewers draw. Now font size is set to the box height and the `Tz`
horizontal-scaling operator stretches or squeezes the text run to
exactly match the box width in both directions.

Verified with pdfjs-dist: the generated text item width now matches
the expected box width (in points) to five decimal places.
@philips

philips commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: recognized words are now sized with the PDF horizontal-scaling operator (`Tz`) so the invisible text run's advance width matches the recognition bounding box exactly, in both directions — not just capped when it overflowed. This means a PDF viewer's search-hit highlight rectangle now lines up with the actual handwriting instead of sometimes being narrower (e.g. for short/compressed words). Verified with `pdfjs-dist` that the generated text item width matches the expected box width to 5 decimal places.

@philips
philips merged commit 55266a3 into main Jul 25, 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.

1 participant