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"));