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>
82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|