Files
Voiced/src/pdf/flatten.ts
T
xavierk 98e9e5860f Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
2026-10-04 06:11:23 +05:30

158 lines
4.9 KiB
TypeScript

/** The slice of pdf.js the flatten needs, so Node tests and the webview can supply their own. */
export interface PageViewportLike {
width: number;
height: number;
}
export interface RenderTaskLike {
promise: Promise<unknown>;
cancel?: () => void;
}
export interface PageLike {
getViewport(params: { scale: number }): PageViewportLike;
render(params: { canvasContext: unknown; canvas: unknown; viewport: PageViewportLike; intent?: string }): RenderTaskLike;
cleanup?: () => unknown;
}
export interface PdfDocLike {
numPages: number;
getPage(n: number): Promise<PageLike>;
}
export interface CanvasLike {
width: number;
height: number;
getContext(kind: "2d"): unknown;
}
export interface FlatPage {
jpeg: Uint8Array;
widthPt: number;
heightPt: number;
pxWidth: number;
pxHeight: number;
renderMs: number;
encodeMs: number;
}
export interface FlattenResult {
pages: FlatPage[];
}
export interface FlattenOptions {
pdf: PdfDocLike;
dpi?: number;
quality?: number;
createCanvas: (width: number, height: number) => CanvasLike;
encodeJpeg: (canvas: CanvasLike, quality: number) => Promise<Uint8Array>;
signal?: AbortSignal;
/** Called as each page starts: (page being rendered, page count). */
onProgress?: (page: number, pages: number) => void;
/** Awaited after each page so the UI can paint and handle input between the long tasks. */
yieldToUi?: () => Promise<void>;
now?: () => number;
}
export class FlattenCancelled extends Error {
constructor() {
super("The flatten was cancelled");
this.name = "FlattenCancelled";
}
}
export class FlattenError extends Error {
readonly code = "FLATTEN_FAILED";
constructor(
readonly page: number,
readonly reason: string,
) {
super(page > 0 ? `Could not flatten page ${page}: ${reason}` : `Could not flatten the PDF: ${reason}`);
this.name = "FlattenError";
}
}
const reasonOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
/**
* Rasterises every page to a JPEG at `dpi` (default 300) on a white background. One canvas lives at a
* time and is released before the next page. There is deliberately no fallback to a lower DPI: a canvas
* or encode failure throws a FlattenError and the caller decides what to offer.
*/
export async function flattenPages(opts: FlattenOptions): Promise<FlattenResult> {
const { pdf, dpi = 300, quality = 0.92, createCanvas, encodeJpeg, signal, onProgress, yieldToUi, now = () => performance.now() } = opts;
const scale = dpi / 72;
const total = pdf.numPages;
if (!(total > 0)) throw new FlattenError(0, "the PDF has no pages");
const checkAbort = () => {
if (signal?.aborted) throw new FlattenCancelled();
};
const pages: FlatPage[] = [];
for (let n = 1; n <= total; n++) {
checkAbort();
onProgress?.(n, total);
checkAbort(); // onProgress may be where the caller cancels
let canvas: CanvasLike | null = null;
let page: PageLike | null = null;
try {
page = await pdf.getPage(n);
const unit = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale });
// The epsilon keeps 792 pt x 300/72 (3300.0000000000005) from becoming a 3301 px page.
const pxWidth = Math.ceil(viewport.width - 1e-6);
const pxHeight = Math.ceil(viewport.height - 1e-6);
canvas = createCanvas(pxWidth, pxHeight);
const ctx = canvas.getContext("2d") as { fillStyle: unknown; fillRect(x: number, y: number, w: number, h: number): void } | null;
if (!ctx) throw new Error("the canvas has no 2D context");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, pxWidth, pxHeight);
checkAbort();
const t0 = now();
const task = page.render({ canvasContext: ctx, canvas, viewport, intent: "print" });
const onAbort = () => task.cancel?.();
signal?.addEventListener("abort", onAbort, { once: true });
try {
await task.promise;
} finally {
signal?.removeEventListener("abort", onAbort);
}
checkAbort();
const t1 = now();
const jpeg = await encodeJpeg(canvas, quality);
const t2 = now();
if (!jpeg || jpeg.byteLength < 4) throw new Error("the JPEG encoder returned no data");
pages.push({
jpeg,
widthPt: unit.width,
heightPt: unit.height,
pxWidth,
pxHeight,
renderMs: Math.round(t1 - t0),
encodeMs: Math.round(t2 - t1),
});
} catch (e) {
if (e instanceof FlattenCancelled || signal?.aborted) throw new FlattenCancelled();
if (e instanceof FlattenError) throw e;
throw new FlattenError(n, reasonOf(e));
} finally {
if (canvas) {
// Releases the backing store now rather than at garbage collection (2480 x 3508 x 4 = 35 MB).
canvas.width = 0;
canvas.height = 0;
}
try {
page?.cleanup?.();
} catch {
/* cleanup is best effort */
}
}
if (yieldToUi) await yieldToUi();
}
return { pages };
}