Files
InkNote/frontend/src/features/pdf/stampPdf.ts
T
marcandClaude Opus 5 a32d6eba99 Export a PDF with the drawings written into it
Stage seven, and the last of the PDF work. A PDF note now offers the
document with your marks stamped in, the untouched original, or just the
notes written beside it.

pdf.js only reads, so writing needs pdf-lib — behind a dynamic import
like pdf.js, so the bundle everyone downloads carries no PDF writer for
the sake of people who never export one. Main bundle grew 6 kB; the
180 kB writer arrives only when used.

The coordinate decision from stage four is what makes this a translation
rather than a reconstruction: strokes were stored in page points from the
start, the only space a PDF understands, so nothing has to be guessed
back from screen pixels. Only the origin moves, PDF putting it at the
bottom-left.

The stored file is never modified — pdf-lib builds a new document — so a
clean copy stays available however often it has been drawn on. The suite
re-fetches the stored bytes after exporting and compares them, and checks
the plain export is byte-identical to what was imported.

Eraser strokes are dropped. On screen they remove ink by drawing in
destination-out; a PDF content stream has no equivalent, and stamping
them would put grey scribbles exactly where something had been rubbed
out.

The suite renders both files with pdf.js and counts dark pixels, 1633
against 2557. A larger file only proves bytes were added; this proves ink
can be seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:49:01 +02:00

82 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Writing the drawings back into the PDF.
*
* pdf.js can only read; producing a file needs a second library, loaded the same
* way — behind a dynamic import, so the bundle everyone downloads does not carry
* a PDF writer for the sake of the people who never export one.
*
* This is the moment the coordinate decision in `pageStrokes` pays off: strokes
* were stored in page points all along, which is the only space a PDF
* understands. Nothing has to be guessed back from screen pixels.
*/
import type { Stroke } from "../../types";
import { HIGHLIGHTER_ALPHA } from "../drawing/strokes.js";
import type { PdfStrokeDoc } from "./pageStrokes";
import { drawnPages } from "./pageStrokes";
/** `#rrggbb` → the 0–1 components pdf-lib wants. */
function toRgb(hex: string): { r: number; g: number; b: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
if (!m) return { r: 0, g: 0, b: 0 };
const n = parseInt(m[1]!, 16);
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
}
/** Highlighter strokes are drawn translucent on screen; the export matches. */
const opacityFor = (tool: string) => (tool === "highlighter" ? parseInt(HIGHLIGHTER_ALPHA, 16) / 255 : 1);
/**
* Stamps a note's strokes onto a copy of its PDF.
*
* The original bytes are never modified — pdf-lib builds a new document — so the
* stored file stays exactly as it was imported and can be exported clean again
* afterwards.
*
* Erased areas cannot come along. On screen an eraser stroke removes ink by
* drawing in `destination-out`, which has no equivalent in a PDF content stream;
* such strokes are dropped rather than being stamped as visible marks, which
* would put grey scribbles where the user had rubbed something out.
*/
export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStrokeDoc): Promise<Uint8Array> {
const { PDFDocument, rgb } = await import("pdf-lib");
const doc = await PDFDocument.load(originalBytes);
const pages = doc.getPages();
for (const pageNo of drawnPages(ink)) {
const page = pages[pageNo - 1];
const stored = ink.pages[String(pageNo)];
if (!page || !stored) continue;
// The page may have been stored at a different size than the PDF reports —
// a rotated page, or a file edited elsewhere since. Scale rather than trust.
const { width, height } = page.getSize();
const sx = width / stored.w;
const sy = height / stored.h;
for (const stroke of stored.strokes as Stroke[]) {
if (stroke.t === "eraser") continue;
const pts = stroke.p;
if (!Array.isArray(pts) || pts.length < 4) continue;
const { r, g, b } = toRgb(stroke.c);
const opacity = opacityFor(stroke.t);
const thickness = stroke.s * ((sx + sy) / 2);
for (let i = 0; i + 3 < pts.length; i += 2) {
page.drawLine({
// PDF's origin is the bottom-left corner; the drawing's is the top-left.
start: { x: pts[i]! * sx, y: height - pts[i + 1]! * sy },
end: { x: pts[i + 2]! * sx, y: height - pts[i + 3]! * sy },
thickness,
color: rgb(r, g, b),
opacity,
lineCap: 1, // round, so segments of one stroke join without notches
});
}
}
}
return doc.save();
}