From 7db209b5e84b88b573872d101dcfdb82535450ec Mon Sep 17 00:00:00 2001 From: Brandon Philips's Clanker Date: Mon, 27 Jul 2026 20:47:09 -0700 Subject: [PATCH] Add addTextOnlyPdfPage(): a PDF page with recognized text but no image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addPdfPage() embeds the full page image plus the invisible RTR text layer, correct for a PDF the user actually opens/exports. But some callers only need the resulting PDF to hand to pdf.js for getTextContent()/getViewport() (e.g. to build a search/select text layer over a page that's actually displayed some other way, like a directly rendered canvas) and never call pdf.js's render() against it — for them, the embedded image is pure dead weight. pdf-lib's PNG embedding always fully decodes and recompresses the image at save() time regardless of whether the source PNG was already compressed, which is real, size-proportional cost: profiling against a real 12-page, ~1404x1872, partly user-background note showed ~2.9s and ~4.5MB for the full image+text PDF, versus ~40ms and ~43KB for text-only, with identical extracted text. Refactors the recognition-text-drawing loop out of addPdfPage() into a shared drawRecognitionText() so both functions stay in sync. --- src/index.ts | 2 +- src/pdf.ts | 115 +++++++++++++++++++++++++++++++++------------- tests/pdf.test.ts | 37 ++++++++++++++- 3 files changed, 120 insertions(+), 34 deletions(-) diff --git a/src/index.ts b/src/index.ts index b71dc75..2f09db0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,5 @@ export { toImage, extractPageRenderData } from './conversion.js'; export type { IRenderableNote, IRenderablePage, IRenderableLayer } from './conversion.js'; export type { ILink, IPage } from './format.js'; export { fetchMirrorFrame } from './mirror.js'; -export { toPdf, createPdfContext, addPdfPage } from './pdf.js'; +export { toPdf, createPdfContext, addPdfPage, addTextOnlyPdfPage } from './pdf.js'; export type { ToPdfOptions, PdfContext, AddPdfPageOptions } from './pdf.js'; diff --git a/src/pdf.ts b/src/pdf.ts index e3bd565..ecebfe5 100644 --- a/src/pdf.ts +++ b/src/pdf.ts @@ -1,6 +1,8 @@ import { PDFDocument, PDFFont, + PDFName, + PDFPage, StandardFonts, TextRenderingMode, beginText, @@ -67,39 +69,19 @@ export interface AddPdfPageOptions { dpi?: number; } -/** - * Adds one rendered page to `ctx`: the page image, plus the recognized - * handwriting (RTR) text drawn invisibly on top of it at the position it was - * written, so PDF viewers can search for and select the handwritten words. - * - * `image` may be an `image-js` `Image` (e.g. straight from `toImage`) or - * already-PNG-encoded bytes (e.g. from a Worker that already called - * `toImage` + `encodePng` off-main-thread) — accepting bytes avoids the main - * thread needing to reconstruct an `Image` just to re-encode it. - * - * Main-thread only: `ctx` holds `pdf-lib` objects. - */ -export async function addPdfPage( - ctx: PdfContext, +// Draws the recognized handwriting (RTR) text invisibly onto `pdfPage` at +// the position it was written, so PDF viewers (or pdf.js's getTextContent()) +// can find/select/extract the handwritten words. Shared by addPdfPage() (page +// also gets the rendered image drawn beneath this) and addTextOnlyPdfPage() +// (no image at all — see its doc comment for when that's the right choice). +function drawRecognitionText( + pdfPage: PDFPage, + fontKey: PDFName, + font: PDFFont, page: IPage, - image: Image | Uint8Array, - options: AddPdfPageOptions = {}, -): Promise { - const { dpi = 300 } = options; - const { pdfDoc, font } = ctx; - const pointsPerPixel = 72 / dpi; - - const pngBytes = image instanceof Uint8Array ? image : encodePng(image); - const pngImage = await pdfDoc.embedPng(pngBytes); - - const widthPts = pngImage.width * pointsPerPixel; - const heightPts = pngImage.height * pointsPerPixel; - - const pdfPage = pdfDoc.addPage([widthPts, heightPts]); - const fontKey = pdfPage.node.newFontDictionary(font.name, font.ref); - - pdfPage.drawImage(pngImage, { x: 0, y: 0, width: widthPts, height: heightPts }); - + pointsPerPixel: number, + heightPts: number, +): void { for (const element of page.recognitionElements) { if (element.type !== 'Text') continue; @@ -154,6 +136,75 @@ export async function addPdfPage( } } +/** + * Adds one rendered page to `ctx`: the page image, plus the recognized + * handwriting (RTR) text drawn invisibly on top of it at the position it was + * written, so PDF viewers can search for and select the handwritten words. + * + * `image` may be an `image-js` `Image` (e.g. straight from `toImage`) or + * already-PNG-encoded bytes (e.g. from a Worker that already called + * `toImage` + `encodePng` off-main-thread) — accepting bytes avoids the main + * thread needing to reconstruct an `Image` just to re-encode it. + * + * Main-thread only: `ctx` holds `pdf-lib` objects. + */ +export async function addPdfPage( + ctx: PdfContext, + page: IPage, + image: Image | Uint8Array, + options: AddPdfPageOptions = {}, +): Promise { + const { dpi = 300 } = options; + const { pdfDoc, font } = ctx; + const pointsPerPixel = 72 / dpi; + + const pngBytes = image instanceof Uint8Array ? image : encodePng(image); + const pngImage = await pdfDoc.embedPng(pngBytes); + + const widthPts = pngImage.width * pointsPerPixel; + const heightPts = pngImage.height * pointsPerPixel; + + const pdfPage = pdfDoc.addPage([widthPts, heightPts]); + const fontKey = pdfPage.node.newFontDictionary(font.name, font.ref); + + pdfPage.drawImage(pngImage, { x: 0, y: 0, width: widthPts, height: heightPts }); + + drawRecognitionText(pdfPage, fontKey, font, page, pointsPerPixel, heightPts); +} + +/** + * Adds one page to `ctx` with the recognized text drawn invisibly, same as + * addPdfPage(), but with no image at all — for a PDF whose only purpose is + * to be handed to pdf.js so its getTextContent()/getViewport() can be used + * (e.g. to build a searchable/selectable text layer over a page that's + * actually displayed some other way, such as a directly-rendered canvas). + * `pdfPage.render()` is never called against such a PDF, so the image would + * be pure dead weight: embedding a full-resolution PNG only for pdf-lib to + * decode, recompress, and serialize it is real, size-proportional work + * (seconds for a many-page/high-resolution note) for bytes nothing ever + * looks at. `pageWidth`/`pageHeight` (pixels) size the PDF page the same way + * the image's own dimensions would via addPdfPage(). + */ +export async function addTextOnlyPdfPage( + ctx: PdfContext, + page: IPage, + pageWidth: number, + pageHeight: number, + options: AddPdfPageOptions = {}, +): Promise { + const { dpi = 300 } = options; + const { pdfDoc, font } = ctx; + const pointsPerPixel = 72 / dpi; + + const widthPts = pageWidth * pointsPerPixel; + const heightPts = pageHeight * pointsPerPixel; + + const pdfPage = pdfDoc.addPage([widthPts, heightPts]); + const fontKey = pdfPage.node.newFontDictionary(font.name, font.ref); + + drawRecognitionText(pdfPage, fontKey, font, page, pointsPerPixel, heightPts); +} + /** * Render a Supernote note to a PDF where each page shows the rasterized * page image with the recognized handwriting (RTR) text drawn invisibly on diff --git a/tests/pdf.test.ts b/tests/pdf.test.ts index 4543c3e..a798949 100644 --- a/tests/pdf.test.ts +++ b/tests/pdf.test.ts @@ -1,6 +1,6 @@ import * as fs from "fs-extra" import { encodePng } from "image-js" -import { toPdf, createPdfContext, addPdfPage } from "../src/pdf" +import { toPdf, createPdfContext, addPdfPage, addTextOnlyPdfPage } from "../src/pdf" import { toImage } from "../src/conversion" import { SupernoteX } from "../src/parsing" import { PDFParse } from "pdf-parse" @@ -115,4 +115,39 @@ describe("pdf", () => { expect(textA.text).toBe(textB.text) }) + + test("addTextOnlyPdfPage produces the same searchable text as addPdfPage, without embedding an image", { timeout: 30000 }, async () => { + const sn = new SupernoteX(await readFileToUint8Array("rtr.note")) + const images = await toImage(sn) + + const ctxWithImage = await createPdfContext() + for (let i = 0; i < sn.pages.length; i++) { + await addPdfPage(ctxWithImage, sn.pages[i], images[i]) + } + const pdfWithImage = await ctxWithImage.pdfDoc.save() + + const ctxTextOnly = await createPdfContext() + for (let i = 0; i < sn.pages.length; i++) { + await addTextOnlyPdfPage(ctxTextOnly, sn.pages[i], sn.pageWidth, sn.pageHeight) + } + const pdfTextOnly = await ctxTextOnly.pdfDoc.save() + + // No image data to encode/compress/embed, so this should be dramatically + // smaller than the equivalent PDF with real page images — not just a + // marginal difference — since that's the entire point of this function. + expect(pdfTextOnly.byteLength).toBeLessThan(pdfWithImage.byteLength / 10) + + const parserA = new PDFParse({ data: pdfWithImage }) + const textA = await parserA.getText() + await parserA.destroy() + + const parserB = new PDFParse({ data: pdfTextOnly }) + const textB = await parserB.getText() + await parserB.destroy() + + expect(textB.text).toBe(textA.text) + for (const word of ["Real", "time", "recognition", "paragraph", "reflow", "together"]) { + expect(textB.text).toContain(word) + } + }) })