Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
115 changes: 83 additions & 32 deletions src/pdf.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
PDFDocument,
PDFFont,
PDFName,
PDFPage,
StandardFonts,
TextRenderingMode,
beginText,
Expand Down Expand Up @@ -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<void> {
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;

Expand Down Expand Up @@ -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<void> {
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<void> {
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
Expand Down
37 changes: 36 additions & 1 deletion tests/pdf.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
}
})
})