diff --git a/backend/server.js b/backend/server.js
index abca15e..6b26da7 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1035,6 +1035,30 @@ app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => {
/* ─── Drawing data route ─── */
const MAX_STROKES = 20000;
+/**
+ * Counts the strokes in either shape of drawing document.
+ *
+ * A note's drawing is one page: `{ w, h, strokes }`. A PDF's is one entry per
+ * page it has been drawn on: `{ pages: { "1": { w, h, strokes }, … } }`, because
+ * every PDF page has its own size and a stroke belongs to the page it was drawn
+ * on, not to a canvas shared by all of them.
+ *
+ * Returns null for anything that is neither, so the caller can refuse it.
+ */
+function countStrokes(doc) {
+ if (!doc || typeof doc !== "object") return null;
+ if (Array.isArray(doc.strokes)) return doc.strokes.length;
+ if (doc.pages && typeof doc.pages === "object") {
+ let n = 0;
+ for (const page of Object.values(doc.pages)) {
+ if (!page || !Array.isArray(page.strokes)) return null;
+ n += page.strokes.length;
+ }
+ return n;
+ }
+ return null;
+}
+
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
const { drawingStrokes, drawingData } = req.body;
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
@@ -1042,13 +1066,13 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
if (drawingStrokes !== undefined) {
// Vector strokes in the note's canonical coordinate space — the current format.
+ let count = 0;
if (drawingStrokes !== null) {
- if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes))
- return res.status(400).json({ error: "Invalid strokes format" });
- if (drawingStrokes.strokes.length > MAX_STROKES)
- return res.status(413).json({ error: "Too many strokes" });
+ count = countStrokes(drawingStrokes);
+ if (count === null) return res.status(400).json({ error: "Invalid strokes format" });
+ if (count > MAX_STROKES) return res.status(413).json({ error: "Too many strokes" });
}
- const empty = !drawingStrokes || drawingStrokes.strokes.length === 0;
+ const empty = !drawingStrokes || count === 0;
const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id);
// Clearing a drawing is always worth being able to undo.
snapshotNote(req.userId, req.params.id, req.deviceId, {
diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs
index 6da9f06..54a74c1 100644
--- a/e2e/pdf.mjs
+++ b/e2e/pdf.mjs
@@ -100,6 +100,87 @@ try {
"pdf.js was fetched only once it was needed"
);
+ /* ── drawing on a page ── */
+ const inkOnPage = (n) =>
+ p.evaluate((page) => {
+ const c = document.querySelector(`[data-pdf-ink="${page}"]`);
+ if (!c) return -1;
+ const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
+ let painted = 0;
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 40) painted++;
+ return painted;
+ }, n);
+
+ /** Draws a line across the page in fractions of its box, then waits for the save. */
+ const drawOn = async (pageNo, x1, y1, x2, y2) => {
+ const box = await p.locator(`[data-pdf-page="${pageNo}"]`).boundingBox();
+ await p.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
+ await p.mouse.down();
+ await p.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 });
+ await p.mouse.up();
+ await p.waitForTimeout(1600);
+ };
+
+ check((await inkOnPage(1)) === 0, "no ink before anything is drawn");
+ await p.locator("[data-pdf-mode]").click();
+ check((await p.locator('[data-pdf-mode="draw"]').count()) === 1, "drawing mode can be switched on");
+
+ await drawOn(1, 0.2, 0.25, 0.7, 0.3);
+ const inkedFirst = await inkOnPage(1);
+ check(inkedFirst > 100, "the stroke appears on the page", `${inkedFirst} painted pixels`);
+ check((await inkOnPage(2)) === 0, "and only on the page it was drawn on");
+ await p.screenshot({ path: "/out/pdf-drawn.png" });
+
+ /* Where the stroke sits relative to the page, so zoom can be compared against it. */
+ const inkBounds = (n) =>
+ p.evaluate((page) => {
+ const c = document.querySelector(`[data-pdf-ink="${page}"]`);
+ const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
+ let minX = 1e9, maxX = -1, minY = 1e9, maxY = -1;
+ for (let i = 3, px = 0; i < d.length; i += 4, px++) {
+ if (d[i] <= 40) continue;
+ const x = px % c.width, y = (px / c.width) | 0;
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ // As fractions of the canvas, so they can be compared across zoom levels.
+ return { x: minX / c.width, y: minY / c.height, w: (maxX - minX) / c.width, h: (maxY - minY) / c.height };
+ }, n);
+
+ const before = await inkBounds(1);
+ await p.getByTitle("Größer").click();
+ await p.waitForTimeout(2500);
+ const after = await inkBounds(1);
+ const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
+ check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
+ await p.getByTitle("Kleiner").click();
+ await p.waitForTimeout(1500);
+
+ /* ── hiding it ── */
+ await p.locator("[data-pdf-hide-ink]").click();
+ await p.waitForTimeout(600);
+ check((await inkOnPage(1)) >= 0, "hiding the drawing leaves the document readable");
+ const hiddenOpacity = await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity);
+ check(hiddenOpacity === "0", "the ink layer is hidden, not erased", hiddenOpacity);
+ await p.locator("[data-pdf-hide-ink]").click();
+ await p.waitForTimeout(600);
+
+ /* ── it survives ── */
+ await p.reload({ waitUntil: "networkidle" });
+ await p.waitForTimeout(2500);
+ await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
+ await p.waitForTimeout(1200);
+ await p.locator("[data-note-row]").first().click();
+ await p.waitForTimeout(4500);
+ const afterReload = await inkOnPage(1);
+ check(afterReload > 100, "the stroke survived a reload", `${afterReload} painted pixels`);
+
+ /* ── it must not be undone by the background poller ── */
+ await p.waitForTimeout(9000);
+ check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
+
/* ── it stays readable offline ── */
// A reload lands on the dashboard, which has no note list; the notebook has
// to be opened first before a row exists to click.
diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx
index 7ef1521..95a5708 100644
--- a/frontend/src/features/notes/EditorPane.jsx
+++ b/frontend/src/features/notes/EditorPane.jsx
@@ -212,7 +212,7 @@ export function EditorPane({
{note.type === "pdf" ? (
-
+
) : (
Math.round(n * 10) / 10;
+
+/**
+ * The drawing surface over one PDF page.
+ *
+ * Coordinates go in and out in *page space* — PDF points at scale 1 — never in
+ * screen pixels. That is what keeps a mark on the word it was drawn over when
+ * the zoom changes, when the same note is opened on a phone, and later when the
+ * strokes are stamped back into the file.
+ *
+ * The canvas only takes pointer events while drawing is on. Otherwise it stays
+ * transparent to them, so scrolling and selecting text underneath work as if it
+ * were not there.
+ */
+export function PdfDrawLayer({
+ pageNo,
+ pageSize,
+ cssWidth,
+ cssHeight,
+ strokes,
+ drawing,
+ tool,
+ color,
+ sizeIdx,
+ hidden,
+ onCommit,
+}) {
+ const canvasRef = useRef(null);
+ const liveRef = useRef(null);
+ const activeRef = useRef(false);
+ const frameRef = useRef(0);
+ // The pointer handlers read and write this synchronously; state would lag a
+ // frame behind the hand.
+ const strokesRef = useRef(strokes);
+ strokesRef.current = strokes;
+
+ const doc = (list) => ({ v: 1, w: pageSize.width, h: pageSize.height, strokes: list });
+
+ const repaint = () => {
+ const c = canvasRef.current;
+ if (!c) return;
+ const { bw, bh } = sizeCanvas(c, cssWidth, cssHeight);
+ const live = liveRef.current;
+ paintStrokeDoc(
+ c.getContext("2d"),
+ doc(live ? [...strokesRef.current, live] : strokesRef.current),
+ bw,
+ bh
+ );
+ };
+
+ // Repaint on any change of ink or geometry. Zooming changes cssWidth, and a
+ // canvas stretched by CSS would go soft.
+ useEffect(repaint, [strokes, cssWidth, cssHeight, hidden, pageSize.width, pageSize.height]);
+
+ const schedulePaint = () => {
+ if (frameRef.current) return;
+ frameRef.current = requestAnimationFrame(() => {
+ frameRef.current = 0;
+ repaint();
+ });
+ };
+
+ /** Screen point → page space. */
+ const toPageSpace = (e) => {
+ const r = canvasRef.current.getBoundingClientRect();
+ return {
+ x: ((e.clientX - r.left) / r.width) * pageSize.width,
+ y: ((e.clientY - r.top) / r.height) * pageSize.height,
+ };
+ };
+
+ const strokeWidth = () => {
+ // Pen sizes are calibrated for the 1200-unit note page; an A4 PDF page is
+ // 595 points across, so the same number would draw twice as thick.
+ const base = PEN_SIZES[sizeIdx].w * (pageSize.width / 1200);
+ if (tool === "highlighter") return base * HIGHLIGHTER_FACTOR;
+ if (tool === "eraser") return base * ERASER_FACTOR;
+ return base;
+ };
+
+ const onPointerDown = (e) => {
+ if (!drawing || hidden) return;
+ // A finger should still scroll the document; only a pen or the mouse draws.
+ if (e.pointerType === "touch") return;
+ e.preventDefault();
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ const p = toPageSpace(e);
+ liveRef.current = { t: tool, c: color, s: r1(strokeWidth()), p: [r1(p.x), r1(p.y)] };
+ activeRef.current = true;
+ schedulePaint();
+ };
+
+ const onPointerMove = (e) => {
+ if (!activeRef.current) return;
+ e.preventDefault();
+ const p = toPageSpace(e);
+ const live = liveRef.current;
+ const n = live.p.length;
+ const dx = p.x - live.p[n - 2];
+ const dy = p.y - live.p[n - 1];
+ // Dropping points closer than this keeps a slow hand from filling the file
+ // with a thousand identical coordinates.
+ const min = MIN_POINT_DIST * (pageSize.width / 1200);
+ if (dx * dx + dy * dy < min * min) return;
+ live.p.push(r1(p.x), r1(p.y));
+ schedulePaint();
+ };
+
+ const onPointerUp = (e) => {
+ if (!activeRef.current) return;
+ e.preventDefault();
+ activeRef.current = false;
+ const live = liveRef.current;
+ liveRef.current = null;
+ if (live && live.p.length >= 2) onCommit(pageNo, [...strokesRef.current, live]);
+ else schedulePaint();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/features/pdf/PdfViewer.jsx b/frontend/src/features/pdf/PdfViewer.jsx
index 0300bf5..2b005c2 100644
--- a/frontend/src/features/pdf/PdfViewer.jsx
+++ b/frontend/src/features/pdf/PdfViewer.jsx
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import * as sync from "../../syncEngine";
import { openPdf, renderPage } from "./pdfjs";
+import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
+import { asPdfStrokeDoc, strokesForPage, totalStrokes, withPage } from "./pageStrokes";
+import { COLORS } from "../../ui/constants.jsx";
+import { I } from "../../ui/icons.jsx";
+import { PEN_SIZES, DEFAULT_PEN_SIZE, newSaveToken } from "../drawing/strokes.js";
import { FONT_UI } from "../../ui/theme";
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
@@ -16,7 +21,7 @@ const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
* list: a PDF page is expensive to draw and cheap to keep, and scrolling back up
* through a document should not repaint everything.
*/
-function PdfPage({ doc, index, width, height, visible, onRendered }) {
+function PdfPage({ doc, index, width, height, size, visible, ink }) {
const canvasRef = useRef(null);
const renderedAt = useRef(0);
@@ -32,10 +37,7 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
const { done, cancel } = renderPage(page, canvasRef.current, width);
cancelRender = cancel;
await done;
- if (!cancelled) {
- renderedAt.current = width;
- onRendered?.(index, page);
- }
+ if (!cancelled) renderedAt.current = width;
})().catch(() => {
/* a page that will not draw leaves its placeholder rather than the whole
document failing */
@@ -45,7 +47,7 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
cancelled = true;
cancelRender?.();
};
- }, [doc, index, width, visible, onRendered]);
+ }, [doc, index, width, visible]);
return (
);
}
@@ -74,9 +89,76 @@ function PdfPage({ doc, index, width, height, visible, onRendered }) {
* proportions before anything is drawn, so the scrollbar is honest from the
* first frame instead of jumping as pages arrive.
*/
-export function PdfViewer({ note, dark, onPagesReady }) {
+export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
+ const [drawing, setDrawing] = useState(false);
+ const [hideInk, setHideInk] = useState(false);
+ const [tool, setTool] = useState("pen");
+ const [color, setColor] = useState(COLORS[1]);
+ const [sizeIdx, setSizeIdx] = useState(DEFAULT_PEN_SIZE);
+ const [showPalette, setShowPalette] = useState(false);
+
+ /* The drawing as it stands, and where it came from.
+ *
+ * Held in state so the pages repaint, and mirrored in a ref because the undo
+ * stack and the save both need the current value synchronously. */
+ const [ink, setInk] = useState(() => asPdfStrokeDoc(note?.drawingStrokes));
+ const inkRef = useRef(ink);
+ inkRef.current = ink;
+ const undoRef = useRef([]);
+ const saveTimer = useRef(0);
+ const pendingToken = useRef(null);
+
+ /* A drawing arriving from the server is adopted unless it is the echo of our
+ * own save — the same stale-read race that once ate strokes in notes. */
+ useEffect(() => {
+ const incoming = asPdfStrokeDoc(note?.drawingStrokes);
+ if (note?.drawingStrokes?.t && note.drawingStrokes.t === pendingToken.current) return;
+ if (JSON.stringify(incoming) === JSON.stringify(inkRef.current)) return;
+ if (saveTimer.current) return; // a save of ours is still on its way out
+ setInk(incoming);
+ }, [note?.drawingStrokes]);
+
+ const saveInk = useCallback(
+ (next) => {
+ setInk(next);
+ inkRef.current = next;
+ clearTimeout(saveTimer.current);
+ saveTimer.current = setTimeout(() => {
+ saveTimer.current = 0;
+ const token = newSaveToken();
+ pendingToken.current = token;
+ onUpdate?.({ drawingStrokes: totalStrokes(next) ? { ...next, t: token } : null });
+ }, 700);
+ },
+ [onUpdate]
+ );
+
+ const commitPage = useCallback(
+ (pageNo, strokes) => {
+ const size = state.sizes?.[pageNo - 1];
+ if (!size) return;
+ undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
+ saveInk(withPage(inkRef.current, pageNo, size, strokes));
+ },
+ [state.sizes, saveInk]
+ );
+
+ const undo = useCallback(() => {
+ const previous = undoRef.current.pop();
+ if (previous) saveInk(previous);
+ }, [saveInk]);
+
+ const clearPage = useCallback(
+ (pageNo) => {
+ const size = state.sizes?.[pageNo - 1];
+ if (!size) return;
+ undoRef.current = [...undoRef.current.slice(-40), inkRef.current];
+ saveInk(withPage(inkRef.current, pageNo, size, []));
+ },
+ [state.sizes, saveInk]
+ );
const [pageNo, setPageNo] = useState(1);
const [visible, setVisible] = useState(() => new Set([0, 1]));
const [containerWidth, setContainerWidth] = useState(0);
@@ -257,6 +339,123 @@ export function PdfViewer({ note, dark, onPagesReady }) {
{state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"}
+
+
+ {/* Reading and drawing are separate modes: while drawing, the ink layer
+ takes the pointer, and scrolling with a finger still has to work. */}
+ setDrawing((v) => !v)}
+ title={drawing ? "Zeichnen beenden" : "Zeichnen"}
+ style={{
+ ...barButton,
+ width: "auto",
+ padding: "0 10px",
+ gap: 6,
+ background: drawing ? T.accent + "22" : "transparent",
+ color: drawing ? T.accent : T.muted,
+ fontWeight: 600,
+ }}
+ >
+ {I.pen} {drawing ? "Zeichnen" : "Lesen"}
+
+
+ {drawing && (
+ <>
+ {["pen", "highlighter", "eraser"].map((t) => (
+ setTool(t)}
+ title={t === "pen" ? "Stift" : t === "highlighter" ? "Marker" : "Radierer"}
+ style={{ ...barButton, background: tool === t ? T.accent + "22" : "transparent" }}
+ >
+ {t === "pen" ? I.pen : t === "highlighter" ? I.highlight : I.eraser}
+
+ ))}
+
+
+
setShowPalette((v) => !v)}
+ title="Farbe"
+ style={{ ...barButton, width: 22, height: 22, background: color, borderRadius: "50%" }}
+ />
+ {showPalette && (
+
+ {COLORS.map((c) => (
+ {
+ setColor(c);
+ setShowPalette(false);
+ }}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: "50%",
+ background: c,
+ border: c === color ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
+ cursor: "pointer",
+ }}
+ />
+ ))}
+
+ )}
+
+
+ setSizeIdx(Number(e.target.value))}
+ title="Strichstärke"
+ style={{
+ background: "transparent",
+ color: T.muted,
+ border: `1px solid ${T.border}`,
+ borderRadius: 8,
+ fontSize: 11,
+ padding: "2px 4px",
+ }}
+ >
+ {PEN_SIZES.map((s, i) => (
+
+ {s.label}
+
+ ))}
+
+
+
+ {I.undo}
+
+ clearPage(pageNo)} title="Seite leeren" style={barButton}>
+ {I.trash}
+
+ >
+ )}
+
+ setHideInk((v) => !v)}
+ title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
+ style={{ ...barButton, color: hideInk ? T.accent : T.muted }}
+ >
+ {hideInk ? "◌" : "●"}
+
+
stepZoom(-1)} title="Kleiner" style={barButton}>
−
@@ -288,7 +487,17 @@ export function PdfViewer({ note, dark, onPagesReady }) {
// narrow pages to match.
width={Math.round(size.width * scale)}
height={Math.round(size.height * scale)}
+ size={size}
visible={visible.has(i)}
+ ink={{
+ strokes: strokesForPage(ink, i + 1),
+ drawing,
+ tool,
+ color,
+ sizeIdx,
+ hidden: hideInk,
+ onCommit: commitPage,
+ }}
/>
))}
diff --git a/frontend/src/features/pdf/pageStrokes.ts b/frontend/src/features/pdf/pageStrokes.ts
new file mode 100644
index 0000000..6098597
--- /dev/null
+++ b/frontend/src/features/pdf/pageStrokes.ts
@@ -0,0 +1,80 @@
+/**
+ * Strokes drawn on a PDF, kept per page.
+ *
+ * A note's drawing lives in one fixed 1200×1600 page space. A PDF cannot: every
+ * page has its own size, and a stroke belongs to the page it was drawn on. So
+ * each page keeps its own entry, in *that page's* coordinate space — PDF points
+ * at scale 1, exactly what pdf.js reports.
+ *
+ * Storing points in page space rather than screen pixels is what makes a mark
+ * stay on the word it was drawn over: at any zoom, on any screen, and later when
+ * the drawing is stamped back into a PDF, where those are the only coordinates
+ * that mean anything.
+ */
+
+import type { Stroke } from "../../types";
+
+export const PDF_STROKES_VERSION = 2;
+
+export interface PageStrokes {
+ /** Page width in PDF points — the space `strokes` is expressed in. */
+ w: number;
+ h: number;
+ strokes: Stroke[];
+}
+
+export interface PdfStrokeDoc {
+ v: number;
+ /** Keyed by 1-based page number, and only for pages actually drawn on. */
+ pages: Record;
+ /** Save token — lets the viewer recognise its own write coming back. */
+ t?: string;
+}
+
+export const emptyPdfDoc = (): PdfStrokeDoc => ({ v: PDF_STROKES_VERSION, pages: {} });
+
+/** True for a document belonging to a PDF rather than to a single-page note. */
+export const isPdfStrokeDoc = (doc: unknown): doc is PdfStrokeDoc =>
+ !!doc && typeof doc === "object" && !!(doc as PdfStrokeDoc).pages;
+
+/**
+ * Reads a stored drawing as a PDF document.
+ *
+ * Anything that is not one — null, or a single-page note drawing that ended up
+ * on a PDF note somehow — becomes an empty document rather than throwing. A
+ * drawing that cannot be read should leave the PDF readable.
+ */
+export function asPdfStrokeDoc(stored: unknown): PdfStrokeDoc {
+ if (!isPdfStrokeDoc(stored)) return emptyPdfDoc();
+ const pages: Record = {};
+ for (const [key, page] of Object.entries(stored.pages)) {
+ if (page && Array.isArray(page.strokes) && page.w > 0 && page.h > 0) pages[key] = page;
+ }
+ return { v: PDF_STROKES_VERSION, pages };
+}
+
+export const strokesForPage = (doc: PdfStrokeDoc, pageNo: number): Stroke[] =>
+ doc.pages[String(pageNo)]?.strokes ?? [];
+
+/** A copy of the document with one page replaced. Empty pages are dropped. */
+export function withPage(
+ doc: PdfStrokeDoc,
+ pageNo: number,
+ size: { width: number; height: number },
+ strokes: Stroke[]
+): PdfStrokeDoc {
+ const pages = { ...doc.pages };
+ if (strokes.length === 0) delete pages[String(pageNo)];
+ else pages[String(pageNo)] = { w: size.width, h: size.height, strokes };
+ return { v: PDF_STROKES_VERSION, pages };
+}
+
+export const totalStrokes = (doc: PdfStrokeDoc): number =>
+ Object.values(doc.pages).reduce((n, p) => n + p.strokes.length, 0);
+
+/** Page numbers that carry ink, in order — used by the export. */
+export const drawnPages = (doc: PdfStrokeDoc): number[] =>
+ Object.keys(doc.pages)
+ .map(Number)
+ .filter((n) => Number.isFinite(n) && (doc.pages[String(n)]?.strokes.length ?? 0) > 0)
+ .sort((a, b) => a - b);
diff --git a/readme.md b/readme.md
index 0e76ad5..ba21fa2 100644
--- a/readme.md
+++ b/readme.md
@@ -330,7 +330,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
-| `pdf` | 10 | import, page proportions, lazy loading, and reading with the network off |
+| `pdf` | 19 | import, page proportions, lazy loading, and reading with the network off |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
@@ -442,6 +442,32 @@ storage is content-addressed, a cached copy can never go stale.
---
+## Drawing on a PDF
+
+Strokes are stored **per page, in that page's own coordinate space** — PDF
+points at scale 1, exactly what pdf.js reports — rather than in the fixed
+1200×1600 space a note's drawing uses. A PDF cannot use one space: every page
+has its own size, and a stroke belongs to the page it was drawn on.
+
+That choice is what keeps a mark on the word it was drawn over at any zoom, on
+any screen, and it is the only form that can be stamped back into the file later,
+where page points are the only coordinates that mean anything. The suite measures
+it: zooming in moves a stroke by 0.001 of the page.
+
+```jsonc
+{ "v": 2, "pages": { "1": { "w": 595, "h": 842, "strokes": [...] } } }
+```
+
+Only pages that carry ink get an entry. Pen sizes are scaled from the note page
+width, or the same number would draw twice as thick on an A4 page half as wide.
+
+Reading and drawing are separate modes. While reading, the ink layer takes no
+pointer events at all, so scrolling and selecting text reach the page beneath;
+while drawing, a finger still scrolls and only a pen or mouse leaves a mark. The
+drawing can be hidden without being erased, for reading the original clean.
+
+---
+
## PDF storage
An imported PDF is stored as an attachment: encrypted, named by the hash of its