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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,36 @@ The default font (Helvetica) only supports Latin text. Pass `fontBytes` with a U
const pdfBytes = await toPdf(note, { fontBytes: await fs.readFile('NotoSans-Regular.ttf') });
```

#### Rendering pages in parallel across Workers

`toPdf` is a convenience wrapper around three lower-level pieces, exported so applications can render pages in parallel (across Web Workers or Node `worker_threads`) instead of one at a time on the main thread:

- `extractPageRenderData(note, pageNumber)` — pulls out the minimal, structured-clone-safe slice of one page needed to render it, safe to `postMessage` to a Worker.
- `toImage` and `encodePng` (from `image-js`) — both safe to call inside a Worker; render the page and encode it to PNG bytes there.
- `createPdfContext(options?)` / `addPdfPage(ctx, page, image, options?)` — must run on the main thread (they hold `pdf-lib` objects, which aren't structured-clone-safe); `addPdfPage` accepts either an `Image` or already-encoded PNG bytes, so it can take a Worker's output directly.

```ts
import { SupernoteX, extractPageRenderData, createPdfContext, addPdfPage } from 'supernote-typescript';

const note = new SupernoteX(buffer);

// In each Worker: toImage(pageRenderData, [1]) then encodePng(image), then
// postMessage the PNG bytes back. See tests/fixtures/render-worker.mjs and
// tests/pdf-worker-roundtrip.test.ts for a full worker_threads example.
const pngBuffers = await Promise.all(
note.pages.map((_, i) => renderInWorker(extractPageRenderData(note, i + 1))),
);

// Back on the main thread: assemble the PDF from the rendered pages.
const ctx = await createPdfContext();
for (let i = 0; i < note.pages.length; i++) {
await addPdfPage(ctx, note.pages[i], pngBuffers[i]);
}
const pdfBytes = await ctx.pdfDoc.save();
```

Note that only page rendering (`toImage`/`encodePng`) is parallelizable this way — PDF assembly, including the final `pdfDoc.save()`, is a single main-thread operation regardless of how many Workers rendered pages.

## Developer Notes

### Test Individual Suite
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
"build": "tsc",
"clean": "rm -f tests/output/*.png tests/output/*.jpg tests/output/*.pdf tests/output/*.cpuprofile",
"lint": "eslint .",
"pretest": "npm run build",
"test": "vitest run",
"test-watch": "vitest",
"prebench": "npm run build",
"bench": "vitest bench",
"test-mirror": "vitest --watch -t mirror",
"coverage": "vitest run --coverage",
Expand Down
13 changes: 11 additions & 2 deletions plans/rtr-searchable-pdf-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ Let an application render pages in parallel across Web Workers (browser) or `wor
- Not parallelizing the invisible-text-drawing loop itself (cheap relative to RLE decode; not worth the complexity per the spike in step 1, pending its result).
- Not pursuing per-page PDF merge (Option B: build single-page PDFs in workers, merge via `PDFDocument.copyPages`) unless step 1's profiling shows assembly is a meaningful fraction of total time — it adds real complexity (duplicated embedded fonts inflate file size unless deduped).

## Open question for whoever implements this
## Step 1 profiling result (resolves the open question below)

Step 1's profiling result decides whether this plan is even the right shape — if PDF assembly turns out to be non-trivial too, Option B needs its own design pass before implementation starts.
Measured on `tests/input/1to10.note` (10 pages), one-shot timing (not the noisier `vitest bench` warmup average) via `createPdfContext`/`addPdfPage`:

| Phase | Time | Worker-eligible? |
|---|---|---|
| `toImage` (RLE decode + composite) | 120ms (5%) | yes |
| `encodePng` | 1059ms (40%) | yes |
| `addPdfPage` loop (`embedPng` + `drawImage` + text operators) | 538ms (21%) | no — needs `pdf-lib` objects |
| `pdfDoc.save()` | 886ms (34%) | no — one-time, whole-document |

Contrary to this plan's original assumption, PDF assembly is *not* cheap relative to rendering — `encodePng` (not raster decode) dominates the worker-eligible side, and main-thread-only work is 55% of total time. However, 34 of those 55 points are `pdfDoc.save()`, a single whole-document serialization that happens once regardless of how many pages were rendered where — it isn't a per-page cost Option B's per-page-PDF-merge approach would eliminate (a merged document still needs one final `save()` over the same total embedded-image payload). So Option B's added complexity (duplicate embedded fonts, `copyPages` merge) would only reach the 21% `addPdfPage`-loop slice, not the dominant 34% `save()` floor — not judged worth it. **Decision: implement Option A only** (this is what's built below); an app parallelizing `toImage`+`encodePng` across N workers should expect roughly `(45%/N) + 55%` of baseline wall-clock time, not a full `1/N`.
63 changes: 59 additions & 4 deletions src/conversion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
import { ILayer, ISupernote } from './format';
import { ILayerNames, ISupernote } from './format.js';
import { Image, ImageColorModel, decodePng } from "image-js";
import Color, { ColorInstance } from 'color';

/** The minimal layer shape `toImage` needs to rasterize a page: enough to
* decode/composite, without the address/protocol metadata `ILayer` carries. */
export interface IRenderableLayer {
LAYERNAME: ILayerNames;
bitmapBuffer: Uint8Array | null;
}

/** The minimal page shape `toImage` needs to rasterize a page. Only layers
* named in `LAYERSEQ` need to be present. */
export interface IRenderablePage extends Partial<Record<ILayerNames, IRenderableLayer>> {
PAGESTYLE: string;
LAYERSEQ: ILayerNames[];
}

/** The minimal note shape `toImage` needs: page pixel dimensions plus the
* per-page layer data, without the class instance or unrelated pages/fields.
* `ISupernote` (and its full `IPage`/`ILayer` types) satisfy this structurally,
* so `toImage` continues to accept `ISupernote` unchanged; this narrower type
* additionally lets a single-page slice (see `extractPageRenderData`) be
* passed to `toImage` after being sent through a Worker's structured clone,
* without reconstructing a fake `ISupernote`. */
export interface IRenderableNote {
pageWidth: number;
pageHeight: number;
pages: IRenderablePage[];
}

// True when the platform stores the least-significant byte of a multi-byte
// value first in memory (true for every runtime this library targets, i.e.
// x86/x64/ARM Node.js and browsers).
Expand Down Expand Up @@ -68,17 +95,18 @@ function compositeImages(sourceImage: Image, destinationImage: Image) {

/**
* Convert a Supernote file to one or more image-js image objects.
* @param note Parsed Supernote.
* @param note Parsed Supernote, or the minimal `IRenderableNote` slice
* produced by `extractPageRenderData` (e.g. when rendering off-main-thread).
* @param pageNumbers Optional page numbers to export (defaults to all). Indexing starts at 1.
*/
export function toImage(note: ISupernote, pageNumbers?: number[]) {
export function toImage(note: IRenderableNote, pageNumbers?: number[]) {
const pages = pageNumbers
? pageNumbers.map((n) => note.pages[n - 1])
: note.pages;
const decoder = new RattaRLEDecoder();
return Promise.all(
pages.map(async (page) => {
const overlays = page.LAYERSEQ.map((name) => page[name] as ILayer).filter(
const overlays = page.LAYERSEQ.map((name) => page[name] as IRenderableLayer).filter(
(layer) => layer.bitmapBuffer !== null && layer.bitmapBuffer.length,
);

Expand Down Expand Up @@ -115,6 +143,33 @@ export function toImage(note: ISupernote, pageNumbers?: number[]) {
);
}

/**
* Extracts the minimal, structured-clone-safe slice of `note` needed to
* render page `pageNumber` off-main-thread (e.g. posted to a Web Worker or
* `worker_threads` worker), without cloning the whole note (every other
* page's buffers, or the `SupernoteX` instance and its methods, which don't
* survive structured clone). Feed the result straight back into `toImage`:
* `toImage(extractPageRenderData(note, n), [1])`.
* @param note Parsed Supernote.
* @param pageNumber Page number to extract (1-indexed).
*/
export function extractPageRenderData(note: ISupernote, pageNumber: number): IRenderableNote {
const page = note.pages[pageNumber - 1];
const renderablePage: IRenderablePage = {
PAGESTYLE: page.PAGESTYLE,
LAYERSEQ: page.LAYERSEQ,
};
for (const name of page.LAYERSEQ) {
const layer = page[name];
renderablePage[name] = { LAYERNAME: layer.LAYERNAME, bitmapBuffer: layer.bitmapBuffer };
}
return {
pageWidth: note.pageWidth,
pageHeight: note.pageHeight,
pages: [renderablePage],
};
}

/** Color palette to use as substitutes for the Supernote's colors. */
export interface IColorPalette extends Record<string, ColorInstance> {
background: ColorInstance;
Expand Down
11 changes: 6 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { SupernoteX, extractText, extractParagraphs } from './parsing';
export { toImage } from './conversion';
export { fetchMirrorFrame } from './mirror';
export { toPdf } from './pdf';
export type { ToPdfOptions } from './pdf';
export { SupernoteX, extractText, extractParagraphs } from './parsing.js';
export { toImage, extractPageRenderData } from './conversion.js';
export type { IRenderableNote, IRenderablePage, IRenderableLayer } from './conversion.js';
export { fetchMirrorFrame } from './mirror.js';
export { toPdf, createPdfContext, addPdfPage } from './pdf.js';
export type { ToPdfOptions, PdfContext, AddPdfPageOptions } from './pdf.js';
2 changes: 1 addition & 1 deletion src/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
IPage,
ISupernote,
ITitle,
} from './format';
} from './format.js';

/*
* Need to make sure that buffer isn't trying to write out of bounds.
Expand Down
Loading