PDF ink: a pen rail on the paper, a note beside it, OneNote in
Status: working · Branch: feat/pdf-ink (worktree off main) · Written: 2026-09-18
What a tablet PDF reader does: open a paper, pick a pen, write on the page exactly where you want — a bracket beside a paragraph, a word in the margin, a highlighter stroke over a sentence — and have it stay there through zoom, rotation, reopening and Zotero sync. Then a place to write beside the paper: a text or ink note split next to the PDF, linked to it. And the notes the user already has in OneNote, brought in as WeaveForge notes.
The reader can already write on the page. pdf-reader.tsx has the tools
Draw freehand / Highlighter pen / Erase ink; a stroke is a ReaderAnnotation
of type: "ink" in Zotero's own shape (anchor.zoteroPosition.paths, one
width in PDF points), stored through IReaderAnnotationSink, listed in the
annotation sidebar next to the Zotero-imported ones, and written back by R5.
Zotero's ink annotations import through the same type. So the model, the
storage, the projection and the sync exist. What is missing is the pen
experience: the tools hide in a <select>, there is one nib, no undo, no
colour swatches, and a paper tab in the workspace has no mode that says
"I am writing on this". This plan adds that experience over the existing
model, then the companion note, then OneNote import. It does not add a
second ink store: a stroke on a PDF is a reader annotation, full stop, so it
stays a Zotero annotation.
0. Ground rules for the agent
- Read
docs/building/design.md§3–4 first. Feature-module shape, dependencies inward only, no SDK/HTTP/DOM inpackages/core. - Read before writing:
packages/core/src/reader/reader-annotation.ts(the annotation model, the sink port),packages/core/src/reader/ink-stroke.ts(clampInkWidth,inkWidthForPressure,INK_DEFAULT_WIDTH, point caps),packages/core/src/reader/zotero-write-back.ts(what R5 syncs — ink is in),apps/web/src/features/reader/ui/pdf-reader/pdf-reader.tsx(createTool,createColor,canCreate, the tool<select>near line 630, the page loop near line 730),apps/web/src/features/reader/ui/pdf-reader/use-page-pointer.ts(ink capture,endInkGroup, the sharedInkPenGate),apps/web/src/features/reader/ui/annotation-overlay.tsx(how a stored stroke is drawn:pdf-reader-ink,--highlighter, width in PDF units),apps/web/src/features/reader/application/draft-local-annotation.ts,apps/web/src/features/reader/ui/paper-pdf-pane.tsx(onAnnotationsChange, theasideslot),apps/web/src/features/ink/ui/ink-bar.tsx(the ink note's tool bar:INK_BAR_TOOLS,nibForTool,InkBar),packages/core/src/ink/width.ts(the ink note's nib set),apps/web/src/features/editor-workspace/application/pane-tree.ts(splitPane,openTab,setTabMode,DocumentMode) andapps/web/src/features/editor-workspace/ui/kind.ts. - Pure logic goes in
packages/corewith tests underpackages/core/test/; hooks and components inapps/web. Nothing over 800 lines (npm run check:hygiene); split into sibling modules. - Match the surrounding comment density and voice: comments say why, in
prose, and name the rule of
docs/building/design.mdthey serve. - Per phase:
npm run build -w packages/core,npm test -w packages/core,npm test -w apps/web,npx tsc --noEmit -p tsconfig.jsoninapps/web,npm run check:boundariesandnpm run check:hygieneat the root, then one signed commit (git commit -s, conventional subject, endingCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>). - Verify in the installed desktop app over CDP, not by asking the user: the
loop is in the project memory (
desktop-cdp-verify): build, silent install, launch with--remote-debugging-port=9222, drive, screenshot. - Generate docs (
npm run docs:generate) from the tracked tree only (stash untracked files first): the generator counts every file it sees, and CI has no scratch files.
1. Design
1.1 What the user sees
┌──────────────────────────────────────────────────────────────────────────────┐
│ Explorer │ ◂ lippe-2023.pdf ▸ [Edit] [Read] [PDF] [Ink] [Notes ▾] ⋯ │
│ ├─────────────────────────────────────────────┬──────────┬─────────┤
│ ▸ Papers │ │ Annot. │ ↶ ↷ │
│ ▸ Lippe│ ┌──────────────────────────────────┐ │ ▤ Zotero │ ↖ sel │
│ 23 │ │ Figure 2: A representation ... │ │ hl p.1 │ ○ lasso│
│ ↳ 📝 │ │ ┌────┐ ┌────┐ │ │ ink p.1 │ ────── │
│ ↳ ✒ │ │ │ X │ │ X │ ⌒⌒ graph │ │ ▤ Mine │ ✒ pen │
│ │ │ └────┘ └────┘ ~~~~~~ │ │ ink p.1 │ ▬ hi │
│ │ │ ... │ │ hl p.2 │ ◻ erase│
│ │ │ ████ (highlighter, multiply) ██ │ │ │ ────── │
│ │ │ dependencies on the previous... │ │ │ ●●●● + │
│ │ └──────────────────────────────────┘ │ │ nib ▂▄▆█│
│ │ ┌──────────────────────────────────┐ │ │ ✕ │
│ └───┴──────────────────────────────────┴──────┴──────────┴─────────┘
│ status: p. 1 / 12 · pen 1.4 pt · 14 annotations · 3 pending sync │
└──────────────────────────────────────────────────────────────────────────────┘
- A paper tab gains a fourth mode, Ink, beside Edit / Read / PDF. It is the PDF view with the pen rail docked at the right and the pen armed. PDF mode is unchanged (ink visible, tools in the toolbar as today); Ink mode is "the rail is out and the pen is in my hand". Both show the same annotations: Zotero's and the user's, in the sidebar, as today.
- The pen rail: undo · redo · select · lasso · pen · highlighter · eraser
· colour swatches (last 4 used + picker) · nib sizes (4) · close. Same
layout as the ink note's
InkBarin its vertical form — same component where it can be, same icons and tokens everywhere. - Notes ▾ on a paper tab: Text beside · Ink beside · Side: right /
below · Copy page link. Opens the paper's companion note in a split next
to the PDF (§1.3).
Ctrl+Shift+N= Text beside.
┌──────────────────────────────────────────────────────────────────────────────┐
│ ◂ lippe-2023.pdf [Ink] ▸ │ ◂ Notes — Lippe 2023 [Ink] ▸ │
│ ┌────────────────────────────────┐ │ ┌─────────────────────────────┐ │
│ │ PDF page with ink over it │ │ │ ruled sheet │ │
│ │ │ │ │ ~ the graph is a DBN ~ │ │
│ └────────────────────────────────┘ │ └─────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
1.2 The stroke stays a reader annotation
Nothing changes in the model. A pen stroke is NewReaderAnnotation{type: "ink", color, anchor.zoteroPosition.{pageIndex, paths, width}}, created
through onAnnotationsChange → IReaderAnnotationSink, so it:
- sits beside Zotero's imported ink in the sidebar and on the page
(
origin: "local"vs"zotero"); - is written back to Zotero by R5 when the library is
bidirectional, and a Zotero ink annotation edited elsewhere comes back through the same merge; - is anchored in PDF points, so zoom and rotation are the reader's
projection (
project-annotation-geometry.ts), already tested.
What the rail adds is state around creation, not a new record:
- Nib set — four widths in PDF points. Match the ink note's feel
(
packages/core/src/ink/width.tsis 0.1 / 0.3 / 0.5 / 0.7 mm): in points that is 0.28 / 0.85 / 1.42 / 1.98, default 1.42. AddINK_NIB_WIDTHS_PTnext toclampInkWidthinpackages/core/src/reader/ink-stroke.tsand derive it from the ink set (mm × 72 / 25.4), tested, so the two never drift. Highlighter keeps its own nib asink-bar.tsxdoes (HIGHLIGHTER_NIB). - Undo / redo — a stack of
{created: id}/{removed: annotation}over ink creates and erases in this session, in a new hookapps/web/src/features/reader/ui/pdf-reader/use-ink-undo.ts; undo of a create isremove(id), undo of an erase iscreate(draft)with the same anchor. Pure stack inpackages/core/src/reader/ink-undo.ts(new), tested.Ctrl+Z/Ctrl+Shift+Zwhile the rail is open. - Colours — the reader's
createColorplus the last four used, kept in the reader's preferences the wayuse-ink-prefs.tskeeps the ink note's. - Tool memory — the rail's tool, colour and nib persist per user (not per paper).
1.3 The companion note
- A vault page or ink note whose header carries
paper: <paperId>(frontmatter on a text note;ink-paper:header key on an ink note, new inpackages/core/src/ink/ink-note.ts). TitleNotes — <paper title>; created on first request, opened after.findCompanion(paperId, kind)on the vault indexes the key — no full-body scan per call. - Explorer nests companions under the paper (
workspace-tree.ts): 📝 text, ✒ ink. - Split =
splitPane(layout, paneId, side)+openTab, both inpane-tree.ts; addopenBeside(layout, tab, side)there so the sequence is one pure, tested operation. Side remembered in preferences, default right, ratio 0.55. - A text companion is born with the paper's wikilink on its first line; Copy
page link on the PDF toolbar (
buildLocusLinkexists) gives a link into a page. Stretch: selection → Quote to note appends> …with the link.
1.4 OneNote import
Two doors, in order of value:
- Microsoft Graph (
/me/onenote/pages/{id}/content?includeInkML=true): the page's HTML plus an InkML part for handwriting. An integration in the shape of Zotero's (integration-descriptors.ts,user-integration-credentials.ts, an OAuth code flow through the desktop's browser window, tokens insecretStore()). Import maps:- page HTML → a vault page (Markdown through the existing HTML→MD path the clipboard import uses; images → attachments);
- InkML traces → strokes of an ink note (InkML units are himetric, 0.01 mm; the ink note is 0.1 mm — divide by 10; pressure channel → width);
- section / notebook → folders; OneNote page title → note title; the
OneNote page id in frontmatter (
onenote: <id>) so a re-import updates instead of duplicating.
- Files —
.oneis a closed binary; do not parse it. Accept a folder of OneNote exports instead: PDF (→ paper, then §1.2 works on it) and.docx(→ vault page via the docx path if one exists, else skip with a message). This is the door that needs no account.
The importer is a use case in packages/core (importOneNotePage(page, ink) → {notes, inkNotes}, pure, tested on fixture HTML + InkML), fed by
an apps/web infrastructure client. Settings gets an Import from OneNote
panel next to the Zotero one; progress and skips are listed, nothing is
silent.
1.5 Components and data flow
flowchart LR
subgraph reader [reader/ui]
PdfReader -->|"createTool, createColor, nib"| PenRail
PenRail -->|"undo / redo"| InkUndo[use-ink-undo]
PdfReader -->|"pointer → paths in pt"| PagePointer[use-page-pointer]
PagePointer -->|"NewReaderAnnotation ink"| Sink[IReaderAnnotationSink]
end
Sink --> Repo[(reader annotations)]
Repo -->|"R5"| Zotero[(Zotero)]
Zotero -->|"import"| Repo
subgraph core [packages/core]
Nibs[ink-stroke.ts: INK_NIB_WIDTHS_PT]
Undo[ink-undo.ts]
PaneTree[pane-tree.ts: openBeside]
OneNote[import-onenote.use-case.ts]
end
Workspace[editor-workspace] -->|"Notes ▾"| Companion[findCompanion / create]
Workspace --> PaneTree
Graph[(MS Graph)] --> OneNote --> Vault[(vault)]
PenRail(new,apps/web/src/features/reader/ui/pdf-reader/pen-rail.tsx): props{tool, color, nib, canUndo, canRedo, onTool, onColor, onNib, onUndo, onRedo, onClose}. Renders with the ink note's rail classes and icons; ifInkBarcan take avariant="reader"cleanly, do that instead of a new component — decide after reading it, say which in the commit.PaperPdfPanegetsinkRail?: boolean;document-host.tsxpasses it for the paper tab's new modeink. The/readerroute gets a Pen toggle in its toolbar that shows the same rail.
2. Phases
Each phase ends green, committed, and (from phase 1) verified over CDP with a screenshot in the PR comment.
Phase 1 — pen rail
INK_NIB_WIDTHS_PT,ink-undo.tsin core, tests.PenRail,use-ink-undo.ts; the tool<select>stays for PDF mode, the rail replaces it when open; colour swatches; nib buttons set the width thatuse-page-pointer.tshands toinkWidthForPressure.- Paper tab mode
inkinpane-tree.tsDocumentMode,kind.ts(ink: trueforpaper), the mode button inpane-view.tsx;PaperPdfPane inkRail;/readerPen toggle. - CDP check: open a paper, Ink mode, pick red + nib 3, draw a bracket at a
known word's rect (
.pdf-reader-textlayerspans give word boxes), undo, redo, zoom 50→200 %, reload; assert the stroke's SVG path bbox still overlaps the word's rect, and the sidebar lists it under Mine beside the Zotero ones.
Phase 2 — beside
openBeside, companion header keys,findCompanion, explorer nesting,Notes ▾, side preference,Ctrl+Shift+N, Copy page link.- CDP check: Text beside → split appears, note titled
Notes — …, first line is the paper's wikilink; Ink beside → ruled sheet in the split, a stroke saves and survives reload.
Phase 3 — OneNote import
- Core use case + fixtures first (HTML with headings, lists, an image, a table; InkML with two traces and pressure).
- Graph client + OAuth in the shape of Zotero's integration; Settings panel; file-export door.
- CDP check: import a fixture served from a local stub of the Graph endpoint; assert one vault page and one ink note appear, ink note has two strokes, second import of the same page updates in place.
Phase 4 — polish (only if time)
- Quote to note. Lasso on PDF ink (select several ink annotations, move or
recolour together —
batch-annotation-ops.tshas the batch shape). Recognition of PDF ink into the companion's "margin notes" section.
3. Not in scope
- A second ink store for PDF strokes. If the reader's ink needs something,
it goes into
ReaderAnnotationand R5, so Zotero sees it too. - Parsing
.onefiles. - Rasterising the PDF into an ink note as a background (that import exists for freestanding notes and stays as it is).