diff --git a/src/conversion.ts b/src/conversion.ts
index 63b2fae..beec106 100644
--- a/src/conversion.ts
+++ b/src/conversion.ts
@@ -1,4 +1,4 @@
-import { ILayerNames, ISupernote } from './format.js';
+import { ILayerNames, ISupernote, IRecognitionElement } from './format.js';
import { Image, ImageColorModel, decodePng } from "image-js";
import Color, { ColorInstance } from 'color';
@@ -29,6 +29,19 @@ export interface IRenderableNote {
pages: IRenderablePage[];
}
+/** The minimal page shape `addPdfPage`/`addTextOnlyPdfPage` need: everything
+ * `IRenderablePage` carries for rasterization, plus the already-parsed
+ * recognition data for the invisible searchable-text layer, without `IPage`'s
+ * various protocol/address/raw-file metadata fields neither function reads.
+ * `IPage` continues to satisfy this structurally, so existing callers
+ * passing a full parsed `IPage` are unaffected; this only additionally lets
+ * a minimal, Worker-clonable slice (see `extractPdfPageData`) be passed
+ * directly, e.g. to build a whole PDF inside a Worker without reconstructing
+ * a fake `IPage` there. */
+export interface IPdfPage extends IRenderablePage {
+ recognitionElements: IRecognitionElement[];
+}
+
// 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).
@@ -93,6 +106,42 @@ export function compositeImages(sourceImage: Image, destinationImage: Image) {
}
}
+/** Flattens an image with an alpha channel onto an opaque white background,
+ * producing a plain image (RGB, or GREY for a GREYA source) with no alpha
+ * channel at all - for a destination (e.g. a PDF page, see
+ * addPdfPage()/toPdf()) that has no "behind" for a transparent pixel to
+ * show, unlike an on-screen `
` sitting over a page background.
+ *
+ * This is *not* the same as just discarding the alpha channel
+ * (`convertColor()` to a non-alpha model, which keeps each pixel's existing
+ * color channels untouched and only drops alpha): background/unwritten
+ * pixels here are packed with their color channels at 0 - i.e. black - and
+ * alpha at 0 (see RattaRLEDecoder.buildPackedTranslation()), specifically
+ * *because* nothing ever looks at their color when alpha is respected. Only
+ * actually blending each pixel toward white by its own alpha (standard
+ * "flatten onto a background color" alpha compositing) gives the right
+ * result for both fully-transparent (-> white) and fully-opaque (-> its own
+ * color, unchanged) pixels, and everything anti-aliased in between; simply
+ * dropping alpha would instead reveal every background pixel's *actual*
+ * stored color - black - as if it had been solid ink all along. */
+export function flattenToWhite(image: Image): Image {
+ if (!image.alpha) return image;
+
+ const { width, height, data, channels } = image.getRawImage();
+ const colorChannels = channels - 1;
+ const out = new Uint8Array(width * height * colorChannels);
+
+ for (let i = 0, o = 0; i < data.length; i += channels, o += colorChannels) {
+ const alpha = data[i + colorChannels] / 255;
+ for (let c = 0; c < colorChannels; c++) {
+ out[o + c] = Math.round(data[i + c] * alpha + 255 * (1 - alpha));
+ }
+ }
+
+ const colorModel = image.colorModel === 'RGBA' ? ImageColorModel.RGB : ImageColorModel.GREY;
+ return new Image(width, height, { colorModel, data: out });
+}
+
export interface ToImageOptions {
/** Downsample factor (positive integer, default 1 = full resolution).
* Output pages are `ceil(pageWidth / scale)` x `ceil(pageHeight / scale)`.
@@ -205,6 +254,38 @@ export function extractPageRenderData(note: ISupernote, pageNumber: number): IRe
};
}
+/**
+ * Same as `extractPageRenderData`, but for building a PDF page (see
+ * `IPdfPage`) rather than just rasterizing one: additionally carries the
+ * recognized-text data `addPdfPage`/`addTextOnlyPdfPage` need for the
+ * invisible searchable-text layer. Lets a whole PDF be assembled
+ * off-main-thread (e.g. inside a Worker, alongside `createPdfContext` +
+ * `addPdfPage` there too - `pdf-lib` itself has no DOM dependency and runs
+ * fine in a Worker; only its *objects* aren't structured-clone-safe, so the
+ * whole `createPdfContext`-through-`save()` sequence has to happen in one
+ * place; see the Worker's own comment for why that matters for keeping a
+ * host UI responsive during a large export).
+ * @param note Parsed Supernote.
+ * @param pageNumber Page number to extract (1-indexed).
+ */
+export function extractPdfPageData(note: ISupernote, pageNumber: number): { pageWidth: number; pageHeight: number; pages: IPdfPage[] } {
+ const page = note.pages[pageNumber - 1];
+ const pdfPage: IPdfPage = {
+ PAGESTYLE: page.PAGESTYLE,
+ LAYERSEQ: page.LAYERSEQ,
+ recognitionElements: page.recognitionElements,
+ };
+ for (const name of page.LAYERSEQ) {
+ const layer = page[name];
+ pdfPage[name] = { LAYERNAME: layer.LAYERNAME, bitmapBuffer: layer.bitmapBuffer };
+ }
+ return {
+ pageWidth: note.pageWidth,
+ pageHeight: note.pageHeight,
+ pages: [pdfPage],
+ };
+}
+
/** Color palette to use as substitutes for the Supernote's colors. */
export interface IColorPalette extends Record {
background: ColorInstance;
diff --git a/src/index.ts b/src/index.ts
index dc4fb4f..8761dbf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,6 @@
export { SupernoteX, extractText, extractParagraphs } from './parsing.js';
-export { toImage, extractPageRenderData } from './conversion.js';
-export type { IRenderableNote, IRenderablePage, IRenderableLayer, ToImageOptions } from './conversion.js';
+export { toImage, extractPageRenderData, extractPdfPageData, flattenToWhite } from './conversion.js';
+export type { IRenderableNote, IRenderablePage, IRenderableLayer, IPdfPage, ToImageOptions } from './conversion.js';
export type { ILink, IPage } from './format.js';
export { RecognitionStatuses } from './format.js';
export { fetchMirrorFrame } from './mirror.js';
diff --git a/src/pdf.ts b/src/pdf.ts
index ecebfe5..12316f9 100644
--- a/src/pdf.ts
+++ b/src/pdf.ts
@@ -15,8 +15,8 @@ import {
} from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { Image, encodePng } from 'image-js';
-import { toImage } from './conversion.js';
-import { ISupernote, IPage } from './format.js';
+import { toImage, IPdfPage } from './conversion.js';
+import { ISupernote } from './format.js';
// Empirically-verified constant used by Supernote's own recognition format:
// recognized word bounding boxes are stored in raster-pixel units divided by
@@ -78,7 +78,7 @@ function drawRecognitionText(
pdfPage: PDFPage,
fontKey: PDFName,
font: PDFFont,
- page: IPage,
+ page: IPdfPage,
pointsPerPixel: number,
heightPts: number,
): void {
@@ -150,7 +150,7 @@ function drawRecognitionText(
*/
export async function addPdfPage(
ctx: PdfContext,
- page: IPage,
+ page: IPdfPage,
image: Image | Uint8Array,
options: AddPdfPageOptions = {},
): Promise {
@@ -187,7 +187,7 @@ export async function addPdfPage(
*/
export async function addTextOnlyPdfPage(
ctx: PdfContext,
- page: IPage,
+ page: IPdfPage,
pageWidth: number,
pageHeight: number,
options: AddPdfPageOptions = {},
diff --git a/tests/conversion.test.ts b/tests/conversion.test.ts
index ca3cfab..a1cd166 100644
--- a/tests/conversion.test.ts
+++ b/tests/conversion.test.ts
@@ -1,7 +1,8 @@
import * as fs from "fs-extra"
import * as imagejs from "image-js"
+import { Image, ImageColorModel } from "image-js"
import { describe, test, expect } from 'vitest'
-import { toImage, RattaRLEDecoder } from "../src/conversion"
+import { toImage, RattaRLEDecoder, flattenToWhite } from "../src/conversion"
import { SupernoteX } from "../src/parsing"
function readFileToUint8Array(filePath: string): Promise {
@@ -108,6 +109,42 @@ describe("RattaRLEDecoder.decodeAtScale", () => {
})
})
+describe("flattenToWhite", () => {
+ test("blends toward white by alpha, not just dropping the alpha channel", () => {
+ // Pixel 0: fully-transparent background, packed as black+alpha 0 (see
+ // RattaRLEDecoder.buildPackedTranslation) - must come out white, not
+ // black, or a PDF/PNG export with no alpha channel to fall back on would
+ // show ink-black everywhere nothing was actually drawn.
+ // Pixel 1: fully-opaque red ink - unchanged.
+ // Pixel 2: 50%-alpha black (an anti-aliased edge) - should land roughly
+ // halfway to white, not stay black.
+ const data = new Uint8Array([
+ 0, 0, 0, 0,
+ 255, 0, 0, 255,
+ 0, 0, 0, 128,
+ ]);
+ const image = new Image(3, 1, { colorModel: ImageColorModel.RGBA, data });
+
+ const flattened = flattenToWhite(image);
+
+ expect(flattened.colorModel).toBe('RGB');
+ expect(flattened.alpha).toBe(false);
+ expect(Array.from(flattened.getPixel(0, 0))).toEqual([255, 255, 255]);
+ expect(Array.from(flattened.getPixel(1, 0))).toEqual([255, 0, 0]);
+ const [r, g, b] = flattened.getPixel(2, 0);
+ for (const channel of [r, g, b]) {
+ expect(channel).toBeGreaterThan(100);
+ expect(channel).toBeLessThan(155);
+ }
+ })
+
+ test("returns the same image unchanged when it has no alpha channel", () => {
+ const data = new Uint8Array([10, 20, 30]);
+ const image = new Image(1, 1, { colorModel: ImageColorModel.RGB, data });
+ expect(flattenToWhite(image)).toBe(image);
+ })
+})
+
describe("toImage scale option", () => {
test("renders a downscaled page directly, without decoding at full resolution first", { timeout: 30000 }, async () => {
const sn = new SupernoteX(await readFileToUint8Array("nomad-3.15.27-blank-shapes-and-RTR.note"));