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
83 changes: 82 additions & 1 deletion src/conversion.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 `<img>` 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)`.
Expand Down Expand Up @@ -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<string, ColorInstance> {
background: ColorInstance;
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
10 changes: 5 additions & 5 deletions src/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,7 +78,7 @@ function drawRecognitionText(
pdfPage: PDFPage,
fontKey: PDFName,
font: PDFFont,
page: IPage,
page: IPdfPage,
pointsPerPixel: number,
heightPts: number,
): void {
Expand Down Expand Up @@ -150,7 +150,7 @@ function drawRecognitionText(
*/
export async function addPdfPage(
ctx: PdfContext,
page: IPage,
page: IPdfPage,
image: Image | Uint8Array,
options: AddPdfPageOptions = {},
): Promise<void> {
Expand Down Expand Up @@ -187,7 +187,7 @@ export async function addPdfPage(
*/
export async function addTextOnlyPdfPage(
ctx: PdfContext,
page: IPage,
page: IPdfPage,
pageWidth: number,
pageHeight: number,
options: AddPdfPageOptions = {},
Expand Down
39 changes: 38 additions & 1 deletion tests/conversion.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
Expand Down Expand Up @@ -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"));
Expand Down