pdfrx render core, static/live ink layers, RenderProxyBox pen/touch arbitration, infinite board, text boxes. Planner-Architect-Critic consensus APPROVE. MUST #1 (API source-pin) and MUST #2 (coordinate assertion) verified on pdfium; MUST #3/#4/#5 pending on Surface Pen.
58 KiB
BadNote Phase 1 — Smooth Editing Core + Infinite Annotation Space
Status: PLAN (ralplan consensus — Architect APPROVE-WITH-MUST-FIXES applied 2026-06-21; pending Critic + M1 spike verification)
Date: 2026-06-21
Mode: DELIBERATE (high-risk: full PDF-backend swap + new input architecture + 60fps target)
Owner plan file: docs/plans/2026-06-21-badnote-phase1.md
Grounding note: this plan was written after reading the actual sources (
pdf_annotator_screen.dart,ink_canvas.dart,pdf_annotation_layer.dart,database_service.dart,undo_manager.dart,pdf_service.dart,split_view_screen.dart,stroke_rasterizer.dart,stroke_stabilizer.dart, models) and after fetching pdfrx v2.4.4 API facts from Context7 / GitHub.pdfrx is NOT yet installed in this repo's pub-cache, so every pdfrx API claim below is doc-derived inference, not compile-checked. Such claims are tagged [VERIFY-IN-M1] (doc-derived; must be source-pinned during the Milestone-1 spike) or [UNCONFIRMED] (doc could not even establish an inference). The very first build task (M1) installs pdfrx and converts every [VERIFY-IN-M1] and [UNCONFIRMED] tag into a source-pinned, compile-checked fact. No tag in this document should be read as "already verified in code."
1. Goals / Non-goals
1.1 Phase 1 Goals (the P1 boundary)
- G1 — pdfrx render core. Replace
SfPdfViewer+ Stack-overlay with pdfrx (PdfViewer.file). Continuous vertical scroll, pinch-zoom + pan, tiled hi-res rendering. Re-home page management (delete/insert/rotate), bookmarks, thumbnails, and export onto pdfrx's page-coordinate model. - G2 — High-performance inking engine. Static layer baked into a cached
ui.Picturerebuilt only on a per-hostrevisionbump (O(1)shouldRepaint); live layer paints only the in-progress stroke; per-pageRepaintBoundary; zero per-frame object allocation for committed strokes. - G3 — Pen-first input arbitration. Stylus draws; inverted-stylus erases; touch scrolls/pinches via pdfrx; palm rejection (ignore touch while stylus down); mouse behaves per active mode. Implemented as an explicit state machine.
- G4 — Infinite annotation side-canvas (toggle). A blank, vertically-infinite ink board sharing the same ink engine through a
CoordinateSpaceHostabstraction. Independent scroll/zoom from the PDF pane. - G5 — OneNote-like editable text boxes. Real in-place editable text (replacing the modal-dialog text tool), placeable/movable, stored as plain text in host coordinates.
- G6 — Keyboard/mouse modes. A
Browsemode (wheel scroll, Ctrl+wheel zoom, space/middle-drag pan, no accidental ink) and aTypemode (focus/edit text boxes). Tool number shortcuts (1=pen,2=eraser,…). Keep existing Ctrl+Z/Y/F/S.
1.2 Phase 1 Non-goals (explicitly out of scope; context only)
- N1 Search / review UI and handwriting formula OCR → searchable (P2). P1 stores text-box plain text so P2 can index it, but builds no search UI.
- N2 Server sync,
llm_wiki, VLM/LLM note refinement (P3). - N3 Ink-recognition CAS assisted computation (P4).
- N4 PPT/image-doc ingestion. P1 targets PDF only (the
docTypecolumn stays but onlypdfis exercised). - N5 Cross-device migration of existing data. Existing data may be reset. No migration code; a clean schema is acceptable (see §5).
- N6 Existing text-line ONNX OCR pipeline (
StrokeRasterizer,ctc_decoder) is retained as-is and not wired into the new editor in P1.
1.3 P2–P4 forward-compatibility constraints (so P1 doesn't paint us into a corner)
- Text boxes and strokes are stored with stable IDs + page/board coordinates so P2 search can reference them.
- The
CoordinateSpaceHostabstraction (G4) is the seam P4's CAS overlay will also attach to. - Persistence keeps a per-page text content string addressable by
(documentId, hostId, pageIndex)for future FTS indexing.
2. Architecture Overview
2.1 Coordinate model — single source of truth
Truth = host content coordinates. Two host kinds:
- PDF page host: coordinates are normalized [0,1] relative to that page's unrotated content box. Stored per
(documentId, pageIndex). - Board host: coordinates are absolute logical units on an unbounded canvas (origin top-left, +y down), independent of zoom.
Rendering maps truth → screen at paint time via a canvas transform, never by allocating transformed point objects (this kills the _scaledStrokes deep-copy and the pdf_annotation_layer rotation re-mapping).
For a PDF page, the device-space rect is supplied by pdfrx:
pageOverlaysBuilder(context, pageRect, page)givespageRectalready scaled to current zoom in viewer-local coords [VERIFY-IN-M1].- Correction (do NOT overstate "no manual zoom math"):
pageOverlaysBuilderreturns raw widgets, and per the pdfrx docs in-page offsets normally still need* controller.currentZoom. Thecanvas.scale(size.width, size.height)trick is correct only if the inkCustomPaintis laid out at exactlypageRect.size— otherwisesize≠ the zoomed page box and strokes mis-scale. We therefore require: wrapPageAnnotationLayerinSizedBox.fromSize(size: pageRect.size)(equivalentlyPositioned.fromRect(rect: Offset.zero & pageRect.size, ...)inside the page-local stack) so theCustomPaint'ssizeis the zoomed page box, thencanvas.scale(size.width, size.height)maps normalized [0,1] → zoomed pixels. With that constraint satisfied, the point positions follow scroll+zoom without per-point math; the constraint itself is the "zoom math," made explicit and verified once. M1 assertion: a stroke stored at normalized(0.5, 0.5)renders at the visual page center across 3 zoom levels (e.g. fit, 2×, 4×) — golden/coordinate check, not eyeballed. - Page rotation: pdfrx renders the rotated page and reports
pageRectfor the rotated box;PdfRect.toRect(page:, scaledPageSize:)handles rotation [VERIFY-IN-M1]. We therefore store strokes in unrotated normalized page space and let pdfrx's reported geometry carry rotation. (This removes the bespoke_forwardRotate/_inverseRotatemath.)
2.2 Widget tree (PDF editor, single document)
EditorScreen (ConsumerStatefulWidget)
└─ ProviderScope override: editorControllerProvider(documentId)
├─ EditorToolbar (tool/color/width/mode, undo/redo, board toggle)
└─ Body (Row)
├─ [optional] PageThumbnailSidebar (re-homed; renders via pdfrx)
├─ Expanded → EditorPdfPane
│ └─ PdfViewer.file(
│ params: PdfViewerParams(
│ pageOverlaysBuilder: → [ PageAnnotationLayer(pageIndex, page, pageRect) ],
│ viewerOverlayBuilder: → [ InputArbiterOverlay(...) ], // stylus capture
│ panAxis: PanAxis.vertical, // [VERIFY-IN-M1] see note below
│ ...perf params (see §7)
│ ))
└─ [optional, board toggle on] BoardPane
└─ InteractiveViewer(constrained:false)
└─ BoardAnnotationLayer(boardHost) // same ink engine
PageAnnotationLayer and BoardAnnotationLayer are thin adapters over one shared AnnotationLayer widget parameterized by a CoordinateSpaceHost.
[VERIFY-IN-M1]
panAxis:PanAxis.verticallocks panning to vertical, which is what we want for continuous reading, but it may block the horizontal component of a pinch-zoom pan (zooming in then dragging sideways to inspect). M1 must verify whetherPanAxis.freeis required while zoomed (andverticalonly at fit-width), or whether pdfrx already exempts pinch from the axis lock. Resolve before M3.
2.3 Data flow
PointerEvent
→ InputArbiter (state machine; classifies device + mode)
├─ stylus/pen-draw → EditorController.beginStroke / extendStroke / commitStroke
├─ inverted-stylus → EditorController.eraseAt
└─ touch/mouse-nav → (not consumed) → pdfrx gesture recognizers
→ EditorController mutates per-host stroke list + bumps host.revision
├─ live stroke held in a ValueNotifier<LiveStroke?> (drives LiveInkPainter only)
└─ on commit: append to committed list, revision++, schedule debounced save
→ AnnotationLayer rebuilds:
├─ StaticInkPainter (revision-gated → rebuilds ui.Picture only on change)
├─ LiveInkPainter (listens to ValueNotifier; repaints current stroke only)
└─ TextBoxLayer (Positioned EditableTextBox widgets)
2.4 Why this fixes the four root causes
| Root cause (current) | P1 mechanism |
|---|---|
1. Full repaint + O(n²) live getStroke every PointerMove |
Static ui.Picture cache (committed) + LiveInkPainter that incrementally extends a raw polyline and runs getStroke once at commit (§6.4); RepaintBoundary per page. |
2. _scaledStrokes deep-copies all strokes each build |
No copies; paint-time canvas.scale/transform over stored points. |
3. _loadAllAnnotations serial per-page DB await on open |
Single batched query (WHERE document_id=?) → group in memory; lazy-hydrate per page on first paint. |
| 4. Overlay Stack doesn't share viewer transform | Ink lives inside pageOverlaysBuilder (page coords); with the CustomPaint sized to pageRect.size (§2.1), point positions track scroll+zoom without per-point math. |
3. Component-by-Component Implementation Plan
File status legend: [ADD] new, [MODIFY] edit existing, [DELETE] remove, [KEEP] unchanged.
3.1 Coordinate-space host abstraction
- [ADD]
lib/editor/hosts/coordinate_space_host.dartabstract class CoordinateSpaceHostString get hostId;(e.g."page:3","board")int get pageIndex;(board → a sentinel, e.g.-1)Offset toContent(Offset deviceLocal, Size deviceSize);— device→truthOffset toDevice(Offset content, Size deviceSize);— truth→device (for hit-test / text-box placement)void applyContentToCanvas(Canvas canvas, Size deviceSize);— sets transform so painters draw in truth units
class NormalizedPageHost implements CoordinateSpaceHost— truth ∈ [0,1]; transform =scale(size.width, size.height).class BoardHost implements CoordinateSpaceHost— truth = absolute logical px; transform = identity (theInteractiveViewersupplies pan/zoom).- Deps: none. Used by:
AnnotationLayer, painters,InputArbiter,EditorController.
3.2 Editor state (single source of truth)
- [ADD]
lib/editor/state/editor_models.dartclass StrokeStore— holdsList<InkStroke> committed+int revision;add()/remove()/replace()bumprevision.class HostState—{ CoordinateSpaceHost host, StrokeStore strokes, List<TextBoxModel> textBoxes }. (Undo is global, not per-host — see §3.2 undo scope below; a singleUndoManagerlives onEditorController, with each action tagged byhostId+pageIndex.)class LiveStroke—{ List<InkPoint> rawPoints, PenTool tool, Color color, double width }(the in-progress polyline).
- [ADD]
lib/editor/state/editor_controller.dartclass EditorController extends ChangeNotifier(or RiverpodNotifier):- State:
Map<int, HostState> pageHosts,HostState? boardHost, tool/color/width/filled/mode,ValueNotifier<LiveStroke?> liveStroke. - Store retention vs Picture eviction are DECOUPLED (MAJOR-4 resolution). The
PictureCacheLRU (§3.3) evicts only the renderedui.Picture— it NEVER drops theHostState/StrokeStore. AHostStateis retained inpageHosts(not garbage-collected) for any host that has undo history referencing it OR unsaved/dirty changes, regardless of whether its Picture is evicted or its overlay is unmounted.pageHostsentries may be discarded only when a host has no pending save and no undo/redo entry pointing at it. This guarantees the undo path below always finds a live store. void beginStroke(CoordinateSpaceHost host, InkPoint p)void extendStroke(InkPoint p)void commitStroke()— runsgetStrokeonce, appendsInkStroke,revision++, pushes undo, schedules save.void eraseAt(CoordinateSpaceHost host, Offset content, double radius)— reuses split logic fromink_canvas._splitStroke(extracted to a pure util, §3.7).- Undo scope under continuous scroll (decided): use a single global undo stack across all hosts, ordered by commit time — NOT per-page. On a continuous viewer the user may ink across several pages without an explicit "page change", so per-page undo stacks feel broken ("Ctrl+Z did nothing" because the active page silently changed).
undo()/redo()pop the global stack; each entry records itshostId+pageIndexso the action is reversed on the correct host and (optionally) the viewer scrolls that host into view. The existing per-strokeUndoManagersemantics are reused, but there is one manager keyed globally rather thanMap<int, UndoManager>. Board-host commits enter the same global stack. - Undo on an evicted/unmounted host (MAJOR-4 path): because the
HostState/StrokeStoreis retained whenever undo history references it (§ State above),undo()/redo()for a host whose Picture was evicted simply (1) mutates that host's retainedStrokeStore(bumprevision), (2) callsSaveScheduler.schedule(hostId)to persist the change, and (3) optionally scrolls the host into view (pdfController.goToPage). If/when the host's overlay later re-mounts,StaticInkPainterrebuilds theui.Picturefrom the (now-updated) retained store on first paint — no special case, because the store was never lost. The Picture being absent at undo time is irrelevant: undo operates on the store, not the Picture. void addTextBox / updateTextBox / moveTextBox / deleteTextBox.void setMode(EditorMode),setTool, etc.- Debounced persistence via
SaveScheduler(§3.6).
- State:
- Deps:
DatabaseService, hosts, models,UndoManager(kept),getStroke. - Used by: all layers + toolbar via Riverpod provider
editorControllerProvider(documentId).
3.3 Render layers
- [ADD]
lib/editor/render/annotation_layer.dartclass AnnotationLayer extends StatelessWidgetparams:host,controller. Builds aRepaintBoundarywrapping aStack:CustomPaint(painter: StaticInkPainter(store, host))CustomPaint(painter: LiveInkPainter(controller.liveStroke, host))(repaints viarepaint: liveStrokeListenable)TextBoxLayer(host, controller)
- [ADD]
lib/editor/render/static_ink_painter.dart- Caches a
ui.Picturekeyed byrevision.shouldRepaint=old.revision != revision→ O(1). On rebuild:applyContentToCanvas, draw all committed strokes (reuse_drawStrokebody extracted tolib/editor/render/stroke_drawing.dart). Viewport culling retained (existing_strokeInViewport).
- Caches a
- [ADD]
lib/editor/render/picture_cache.dart— Picture memory budget + eviction (gates P-1 vs P-3 contradiction).- On a tablet, P-1 (300 pages at 60fps) and P-3 (a 2,000-stroke page never rebuilds its Picture) conflict if every page retains a
ui.Pictureforever — 300 retained Pictures blows the memory budget. Resolution: a bounded LRUPictureCachewhose lifetime is tied to pdfrx's visible + cache window:- A page's
ui.Pictureis built/retained only while itsAnnotationLayeroverlay is mounted (i.e. pdfrx has it within visible +verticalCacheExtent). On overlay unmount (page scrolled far off-screen),dispose()the Picture and drop it from the cache. - LRU cap: retain at most
Kpage Pictures (defaultK≈ visible pages + 2×cache-extent, e.g. ~8–12) regardless of mount churn, evicting least-recently-painted and disposing. - Re-entry: a page re-entering the window rebuilds its Picture from committed strokes (cheap relative to scroll budget; revision unchanged so it's a one-time rebuild, not per-frame).
- DECOUPLED from stroke stores (MAJOR-4): this cache holds
ui.Pictures ONLY. It must never reach into or evictHostState/StrokeStore. Store lifetime is governed solely by §3.2 (retain while undo history or dirty state references the host). Evicting a Picture is always safe because it can be rebuilt from the retained store. - Safe dispose (deferred to post-frame): never call
ui.Picture.dispose()synchronously on a Picture that may still be referenced by an in-flight raster frame (e.g. evicting during the same frame that painted it). Defer disposal viaSchedulerBinding.instance.addPostFrameCallback(or a one-frame quarantine queue) so the raster thread is done with it first.
- A page's
- Used by:
StaticInkPainter(asks the cache for the page's Picture by(hostId, revision)).
- On a tablet, P-1 (300 pages at 60fps) and P-3 (a 2,000-stroke page never rebuilds its Picture) conflict if every page retains a
- [ADD]
lib/editor/render/live_ink_painter.dart- Paints only
liveStroke. During draw: render a raw pressure-polyline (cheap) rather than recomputinggetStrokeevery move (§6.4).repaint:bound to theValueNotifier<LiveStroke?>so only this painter invalidates on pointer move.
- Paints only
- [ADD]
lib/editor/render/stroke_drawing.dart— pure functions extracted from_InkPainter._drawStroke/_drawFreehand/_drawRect/...(single source of truth shared by static painter, live painter raw-mode, and export).
3.4 PDF pane + overlay wiring
- [ADD]
lib/editor/pdf/editor_pdf_pane.dart- Wraps
PdfViewer.file(filePath, controller: pdfController, params: ...). pageOverlaysBuilder: (ctx, pageRect, page) => [ SizedBox.fromSize(size: pageRect.size, child: PageAnnotationLayer(pageIndex: page.pageNumber-1, pageSize: pageRect.size, controller: ctrl)) ]— theSizedBox.fromSize(size: pageRect.size)wrap is mandatory so the child'sCustomPaint.sizeequals the zoomed page box (see §2.1). If pdfrx positions overlay children atpageRect.topLeftalready, this is sufficient; if it expects an absolutely-positioned child, usePositioned.fromRect(rect: pageRect, child: …). [VERIFY-IN-M1: exact builder return shape + whether children are page-local or viewer-local positioned].viewerOverlayBuilder: (ctx, size, handleLinkTap) => [ InputArbiterOverlay(size: size, controller: ctrl, pdfController: pdfController) ]. [VERIFY-IN-M1: arg order/types + return type].- Bridges page-change → controller current page; exposes
goToPage, zoom controls. [VERIFY-IN-M1:PdfViewerController.{goToPage, currentZoom, layout, globalToDocument, documentToLocal}signatures].
- Wraps
- [ADD]
lib/editor/pdf/page_annotation_layer.dart— adapter: buildsNormalizedPageHost(pageIndex), takespageSize(=pageRect.size), and rendersAnnotationLayer. Its rootCustomPaintmust receivesize == pageSize(guaranteed by theSizedBox.fromSizewrap above) socanvas.scale(size.width, size.height)is valid.
3.5 Input arbitration
- [ADD]
lib/editor/input/input_arbiter.dart— pure state machine (no Flutter widgets) — see §6. Unit-testable. - [ADD]
lib/editor/input/input_arbiter_overlay.dart— CONCRETE per-kind routing transport.- The contradiction to resolve: a plain
Listenerwrapping anIgnorePointerchild either (a) sits above pdfrx and, being non-opaque, may still let the parentListenersee events while hit-testing passes through to pdfrx — butListenerdoes not consume events from the gesture arena, so pdfrx's pan recognizer also sees stylus moves; or (b) if made opaque, swallows touch and pdfrx never scrolls. Neither alone gives "stylus→us, touch→pdfrx." - Decision — Primary transport (custom RenderProxyBox): implement a
RenderProxyBoxsubclass (_PenCaptureRenderBox) placed inviewerOverlayBuilderwhosehitTestSelf/hitTestreturns true only when the incoming pointer'skind ∈ {stylus, invertedStylus}, and false for touch/mouse so the hit-test continues to pdfrx underneath. On a stylus hit it becomes the pointer's target and receives the full down/move/up stream (which it feeds toInputArbiter) while never entering the gesture arena; touch/mouse are not hit by us at all and reach pdfrx normally. This is the explicit, deterministic per-kind split. Exposed as a smallPenCaptureRegion({onPenEvent, child})widget wrapping theRenderProxyBox. - Fallback transport (if M1 shows the RenderProxyBox path fights pdfrx): adopt pdfrx's sanctioned
PdfOverlayInteractionRegion([VERIFY-IN-M1: full constructor + whether it coexists with pan/zoom and exposes raw pointer kind]) for tap/stroke capture, or, last resort, the upstreamgestureDeviceFilterPR (R1). - Modes: in
Type/Browsethe region'shitTestSelfpredicate is adjusted (e.g. Browse → never capture pen; Draw/Type → capture pen). Mouse is never captured for drawing in any mode (pen-required, §6.2); the region's predicate matches only{stylus, invertedStylus}.
- The contradiction to resolve: a plain
3.6 Persistence
- [MODIFY]
lib/services/database_service.dart— see §5. Add:getAllAnnotationsForDocument(documentId) → Map<int,String>(one query), text-box CRUD, board-strokes CRUD (reuse/renamescratchpad). - [ADD]
lib/editor/state/save_scheduler.dart—class SaveSchedulerdebounced (≈800ms) + flush-on-dispose + flush-on-page-leave.- Ordering invariant (decided):
stylusUp → commitStroke → (revision++, push undo) → scheduleSave. The save snapshot for a host is serialized synchronously at schedule/flush time, before anyawait— i.e.jsonEncode(host.strokes.committed)runs in the same synchronous frame, then the encoded string is handed to the async DB write. This preserves the existing wrong-page-saved guard from_saveCurrentPageAnnotations(currently it capturestargetPage+serializes before the firstawait). Continuous scroll makes the race more likely (the "current host" can change mid-flush), so the snapshot must capture(hostId, encodedJson)synchronously and the async writer must use only those captured values, never re-reading controller state after anawait.
- Ordering invariant (decided):
3.7 Erase / split util
- [ADD]
lib/editor/input/stroke_eraser.dart— pureList<InkStroke> splitStroke(InkStroke, Set<int> erasedIdx)+eraseHits(strokes, point, radius)extracted fromink_canvas._eraseAt/_splitStroke. Unit-tested.
3.8 Text boxes
Sanctioned exception to Principle 1 (single source of truth = paint-time transform). Text boxes are real
PositionedFlutter widgets (EditableText), not canvas paint — they cannot ride thecanvas.scaletransform the ink painters use. Their placement is therefore computed in widget space: position =host.toDevice(contentRect.topLeft)and the rendered box multiplies its content-space size bycontroller.currentZoom(font size scales with zoom too). This is an explicit, bounded carve-out; the stored truth remains content coordinates (so Principle 1 holds for persistence and for P2 search), only the render path differs. Documented here so it is not mistaken for a violation.
- [ADD]
lib/editor/text/text_box_model.dart—TextBoxModel { id, content, contentRect (truth coords), fontSize, color }(freezed). - [ADD]
lib/editor/text/editable_text_box.dart— aPositionedTextField/EditableTextplaced viahost.toDevice(rect.topLeft)with size and font scaled bycontroller.currentZoom(widget-space, per the §3.8 exception); draggable handle to move; focus participates inTypemode. - [ADD]
lib/editor/text/text_box_layer.dart— renders all text boxes for a host; hit-test add on tap inTypemode.
3.9 Toolbar + modes + shortcuts
- [MODIFY]
lib/widgets/annotation_toolbar.dart→ re-home aslib/editor/ui/editor_toolbar.dart(or adapt in place). Add:EditorModeselector (Draw/Browse/Type), board toggle, tool number indicators. Keep tool/color/width/pressure/stabilization/undo/redo. - [ADD]
lib/editor/ui/editor_shortcuts.dart—CallbackShortcutsmap: 1..9 tool select, Ctrl+Z/Y, Ctrl+Shift+Z, Ctrl+F (no-op stub in P1 / scrolls to top), Ctrl+S (force flush), Esc → Browse mode, Space/middle-drag pan handled by pdfrx.
3.10 Screen assembly + retirement of old editor
- [ADD]
lib/screens/editor_screen.dart— the new top-level editor (replacespdf_annotator_screen.dartusage). - [MODIFY] navigation entry points that push
PdfAnnotatorScreen→ pushEditorScreen. (Grep forPdfAnnotatorScreen(andSplitViewScreen(constructors.) - [DELETE] after parity is reached and tests pass:
lib/widgets/pdf_annotation_layer.dart(replaced bypage_annotation_layer.dart)lib/screens/pdf_annotator_screen.dart(replaced byeditor_screen.dart)lib/screens/split_view_screen.dart(replaced by board pane insideeditor_screen.dart)lib/widgets/ink_canvas.dartonly once its draw/erase/stabilizer logic is fully extracted tostroke_drawing.dart+stroke_eraser.dart. "Keep until extracted" means:ink_canvas.dartstays compiled-in AND remains the live reference implementation until the extraction is verified (the newstroke_drawing/stroke_eraserunits pass the ported unit tests with identical output, §8.1). Do not fork/duplicate its logic into the new units and leave the old one drifting — extract, prove equivalence, then delete in one step to avoid silent divergence between two stroke renderers.
3.11 Re-homed PDF mutation/export
- [MODIFY]
lib/services/pdf_service.dart— keep (usessyncfusion_flutter_pdf, headless, no viewer).exportAnnotatedPdfand page delete/insert/rotate still operate on file bytes. Two required changes:- Export
_renderStrokesmust use the same outline geometry as on-screen (callstroke_drawingoutline builder, not the crude line-segments path) so export matches what the user saw. Critically:getStrokereturns a closed fill polygon, not a centerline. The current export pen-strokes line segments, which would render a hairline outline instead of the filled nib shape. Export must build aPdfPathfrom thegetStrokeoutline points and fill it with aPdfBrush(solid color), not stroke it with aPdfPen. (Shape/line/arrow/text tools keep their stroke/fill semantics as today.) This is what makes the R4 golden pass. - Rotation: since strokes are now stored in unrotated page space, drop the
_rotateStroke90CWin-memory transform from the editor; export reads pdfium/syncfusion page rotation and applies it once.
- Export
- [KEEP]
thumbnail_service.dart,stroke_rasterizer.dart,ctc_decoder, OCR assets (P2).
4. Dependency Changes
pubspec.yaml:
- ADD
pdfrx: ^2.4.4(latest stable observed on pub.dev at planning time; [VERIFY-IN-M1] the exact resolved version afterflutter pub add pdfrx). - REMOVE
syncfusion_flutter_pdfviewerandsyncfusion_pdfviewer_platform_interface(viewer + thumbnail-render interface). Note:thumbnail_service.dartusessyncfusion_pdfviewer_platform_interfaceto render pages off-screen — migrate thumbnails to pdfrx page rendering before removing it. [VERIFY-IN-M1 (SHOULD #6): confirm pdfrx exposes an off-screenPdfPage.render→ RGBA bytes path (e.g.PdfPage.render(...) → PdfImage/RGBA) before scheduling the Syncfusion-viewer dep removal in M6.] Keep this dep until thumbnails are ported and that path is proven. - KEEP
syncfusion_flutter_pdf(headless export/page-mutation inpdf_service.dart) unless the spike confirms pdfrx/pdfium can do equivalent vector page delete/insert/rotate + ink draw at acceptable fidelity (decision deferred to §10 Milestone 1 spike; default = keep syncfusion_flutter_pdf for export). - KEEP
perfect_freehand,flutter_riverpod,riverpod_annotation,sqflite*,freezed,json_serializable,flutter_onnxruntime,file_picker,image_picker,google_fonts. - KEEP the
sqlite3: 3.3.2override + vendored-binaryhooksblock (offline/GFW build) unchanged.
Windows build prerequisite [VERIFY-IN-M1]: pdfrx docs state it requires Windows Developer Mode enabled (symlinks at build time). Confirm during the M1 Windows build; document in README + CI runner setup.
5. Data Model + Persistence Changes
Because data may reset, prefer the cleanest schema over migrations. Bump DB to a fresh version with _onCreate only (no upgrade path required, but keep _onUpgrade harmless).
5.1 Strokes (normalized model)
InkStroke/InkPointmodels [KEEP] (freezed, already JSON-serializable). Strokes stored as JSON blob per host (matches currentannotations.annotation_json). Blob-per-page is the default for write simplicity.- Blob-vs-per-stroke-rows is decided in M2 by a measured trigger, not deferred: blob-per-page rewrites the whole page on each save, which collides with the P-3 2,000-stroke target. Rule: during M2, measure the
SaveSchedulerfull-page flush at 2,000 strokes; if flush > 50ms, switch the strokes table to per-stroke rows (strokes(id, document_id, host_id, page_number, stroke_json, ...)with a(document_id, host_id, page_number)index) and incremental insert/delete on commit/erase instead of full-page rewrite. This keeps save off the inking-frame budget regardless of page density.
5.2 Tables (clean schema)
documents[KEEP] (droprotationcolumn reliance from editor logic; pdfium owns page rotation). Column may stay for compatibility.annotations(document_id, page_number, annotation_json, ...)[KEEP shape]. ADD batch readgetAllAnnotationsForDocument.- [ADD]
text_boxes(id, document_id, host_id, page_number, content, rect_json, font_size, color, created_at, updated_at)— addressable by host;contentis plain text for future FTS. scratchpads(document_id UNIQUE, strokes_json)[REPURPOSE] → board host store. Optionally rename toboardsand addtext_boxesrows withhost_id='board'. Keepscratchpadsname to minimize churn; document the rename decision.bookmarks[KEEP].ocr_results,document_fts,notes*[KEEP] (untouched in P1).
5.3 Coordinate semantics on disk
- PDF page strokes: normalized [0,1] unrotated page space (changed from current "possibly rotated" representation — acceptable because data resets).
- Board strokes: absolute logical px (as today in
split_view).
6. Input Arbitration Design
6.1 Modes (user-selectable, G6)
Draw— pen draws; touch scrolls; mouse pans/selects (does not draw — pen required, see §6.2).Browse— nothing draws; wheel scroll, Ctrl+wheel zoom, space/middle-drag pan; pen does nothing (prevents accidental ink during reading/复习).Type— text boxes focusable/editable; pen still draws (so you can annotate around a text box); touch scrolls.
6.2 Device → action matrix
| Device | Draw mode | Browse mode | Type mode |
|---|---|---|---|
| stylus (tip) | draw | ignore | draw |
| invertedStylus | erase | ignore | erase |
| touch (1–2 fingers) | pdfrx scroll/zoom | pdfrx scroll/zoom | pdfrx scroll/zoom |
| mouse (left drag) | pan/select (does NOT draw — pen required to draw) | pan (if space/middle) else select | text caret / select |
| mouse wheel | scroll (Ctrl=zoom) | scroll (Ctrl=zoom) | scroll (Ctrl=zoom) |
Decided (reconciles former Open Question 5): in Draw mode, mouse left-drag does NOT draw — drawing requires a pen (stylus). Desktop review/复习 with a mouse pans/selects; this prevents accidental ink while a mouse user scrolls/reads, and keeps "pen = ink" unambiguous across devices. (
Drawmode entry in themouse left dragrow reflects this.)
6.3 State machine (InputArbiter)
States: idle → inking → erasing → touchNav.
idle + stylusDown(mode≠Browse) →inking; emitbeginStroke. Set_stylusActive=true.idle + invertedStylusDown→erasing; emiteraseAt.inking + stylusMove→extendStroke.inking + stylusUp→commitStroke→idle.- Palm rejection: any
touchDownwhile_stylusActive→ dropped (not forwarded, not drawn).touchDownwhileidle→ not consumed → falls through to pdfrx (touchNav, but we don't model it; pdfrx owns it). stylusCancel/pointer-leave → discard live stroke →idle.- Transport (see §3.5): the per-kind split is done by a
RenderProxyBox(PenCaptureRegion) whose hit-test returns true only for{stylus, invertedStylus}, so stylus events target us (outside the gesture arena) while touch/mouse are never hit by us and reach pdfrx's recognizer underneath. This replaces the earlier hand-wave that a bareListener+IgnorePointerwould route by kind — it does not (aListenerdoes not consume from the arena). [VERIFY-IN-M1: that a stationary stylus-down does not trigger pdfrx pan AND single-finger touch still scrolls — pdfrx's recognizersupportedDevicesis hardcoded, §6.5.]
6.4 Live-stroke incremental rendering (perf correctness, root cause 1)
- During
inking, accumulate rawInkPoints;LiveInkPainterdraws a pressure-aware polyline / quad path (cheap, O(points added)), NOTgetStrokeover the whole stroke each move. - On
commitStroke, rungetStroke(..., isComplete:true)once to produce the final outline, store as committed, bump revision. Visual "pop" at commit must be imperceptible — verified by the perf/quality check (§8). If the polyline preview diverges too much, fallback option: rungetStrokeonly over a trailing window of the last N points (incremental tail) — flagged as a tuning task, not a redesign. - Mid-stroke resize safety (§2.1 interaction): each live
InkPointis captured in normalized content coords at the moment of the pointer event —host.toContent(event.localPosition, currentDeviceSize)— NOT raw device pixels. So ifpageRect.sizechanges mid-stroke (tile reflow / zoom betweenbeginStrokeandcommitStroke), already-captured points stay correct (they are resolution-independent) and the committed stroke cannot mis-scale; only the in-flight render re-maps via the new transform. TheLiveInkPainterlikewise paints throughhost.applyContentToCanvasat the current size, so the live preview follows a resize too.
6.5 pdfrx gesture caveat [RISK — gesture-device set is [VERIFY-IN-M1]]
The pdfrx-v2.4.4 docs indicate its gesture recognizer supportedDevices includes stylus+touch+mouse with no param to restrict by pointer kind; this is doc-derived and [VERIFY-IN-M1] by reading ~/.pub-cache/.../pdfrx-*/lib/src/ (the recognizer's supportedDevices set). Mitigation: the RenderProxyBox per-kind hit-test transport (§3.5) keeps stylus out of pdfrx's arena without a fork. M1 must empirically confirm that a stationary stylus-down inside viewerOverlayBuilder does not trigger pdfrx pan AND that single-finger touch still scrolls. If it conflicts, fallback = PdfOverlayInteractionRegion ([VERIFY-IN-M1 capability]) or an upstream gestureDeviceFilter PR. This is the #1 thing the spike de-risks; it is a hard M1 exit gate (see §10/M1).
7. Performance Strategy (measurable)
7.1 Targets
Sample protocol (applies to all frame-time targets P-1, P-2, P-4, and the M1 perf gates): "median"/"p95" are computed over N ≥ 120 frames during sustained programmatic scroll (a continuous fling driven by
tool/perf_scroll_bench.dart), profile mode, warm cache (discard the first 30 frames so tile/Picture caches are populated before sampling). Each run is repeated 3× and the median run is reported. P-5 (open latency) is a single cold-open measurement averaged over 3 runs.
- P-1: Continuous scroll/zoom on a 300-page PDF: median frame build+raster ≤ 16.6ms (60fps); p95 ≤ 22ms; zero sustained jank (>32ms) during steady scroll. Sample: N ≥ 120 frames, sustained scroll, warm cache (per protocol above).
- P-2: Inking latency: a single stroke of 500 points keeps frame time ≤ 16.6ms on the live layer (static layer untouched during draw).
- P-3: Page with 2,000 committed strokes (page currently within the visible+cache window): pointer-move during a new stroke does not rebuild that page's static picture (assert
StaticInkPainter.shouldRepaint==falsewhilerevisionconstant). Scope: applies to mounted pages; off-window pages are evicted by design (see P-6). - P-4: Board host with 5,000 strokes: pan/zoom ≤ 16.6ms median (relies on static Picture cache + cull).
- P-5: Document open:
getAllAnnotationsForDocumentis a single query; time-to-first-page-interactive < 500ms on a 300-page doc with annotations on 50 pages. Sample: single cold-open, averaged over 3 runs (per protocol above). - P-6 (memory budget; reconciles P-1 vs P-3): Total retained ink
ui.Picturememory on the 300-page asset (annotations on 50 pages) ≤ 64 MB at any time, enforced by thePictureCacheLRU + unmount-dispose (§3.3). P-3's "no rebuild while revision constant" holds for mounted pages only; off-window pages are intentionally evicted, so P-1 and P-3 do not contradict — P-3 is scoped to the visible+cache window. Verify by samplingui.Picturecount × estimated bytes (ordart:developermemory snapshot) during a full scroll.
7.2 How to verify
- Benchmark asset: add
test/assets/large_300p.pdf(generate via a script usingsyncfusion_flutter_pdf; commit or generate in atool/gen_bench_pdf.dart). Plustest/assets/dense_strokes.json(2k/5k synthetic strokes). - Frame timing harness: integration test using
WidgetController+SchedulerBinding.addTimingsCallback(orflutter run --profile --trace-skia); recordFrameTiming.totalSpan. Atool/perf_scroll_bench.dartdrives programmatic scroll and prints median/p95. - shouldRepaint assertion: unit/widget test wraps
StaticInkPainterand asserts O(1) behavior (P-3). - Manual gate (Windows + Surface Pen): documented checklist run on target hardware (subjective 60fps + no ink lag) since CI lacks a pen. Recorded in
docs/plans/phase1-perf-results.md. - Acceptance: P-1..P-5 numeric targets met in profile mode on the dev Windows tablet; results pasted into the perf-results doc with the commit hash.
8. Testing Strategy
sqlite test workaround [CONFIRMED in repo memory]:
flutter testfails to download sqlite3 locally; run DB-touching tests with system sqlite +LD_LIBRARY_PATH(seebadnote-local-test-sqlite.md). Provide atool/test.shwrapper that setsLD_LIBRARY_PATHto the system sqlite and runsflutter test. Pure-logic tests (transforms, eraser, arbiter) must NOT touch the DB so they run without the workaround.
8.1 Unit (no DB, no Flutter binding where possible)
- Coordinate transforms:
NormalizedPageHost/BoardHostround-triptoContent(toDevice(x))≈x; rotation handled by pdfrx geometry (test our hosts assume unrotated). - Stroke split/erase: port + extend existing
undo_manager_test.dartdiscipline; cover full-erase (empty replacements), mid-erase (2 segments), endpoint erase, <2-point dropping. - Revision gating:
StrokeStore.add/removebumps revision;StaticInkPainter.shouldRepainttrue iff revision changed. - InputArbiter state machine: table-driven tests over the §6.2 matrix incl. palm rejection (touch dropped while stylus active) and mode transitions. No widgets → fast, deterministic.
- getStroke commit-equivalence: committing a polyline produces a non-empty outline; live-polyline bounds ⊆ committed-outline bounds (sanity for §6.4 "no pop").
- SaveScheduler synchronous-snapshot invariant (§3.6): with a fake DB whose write
awaits on a controllable completer, assert that mutating controller state / changing the "current host" afterscheduleSavebut before the write completes does NOT change what gets persisted — the snapshot(hostId, encodedJson)was captured synchronously. This is the regression test for the wrong-page-saved race under continuous scroll.
8.2 Widget tests
- Input routing: pump editor with a fake pdfrx pane (or a
Listenertest harness) and synthesizePointerEventKind.stylusvs.touch; assert stylus → stroke committed, touch → not consumed. - Text box edit: tap in Type mode adds a box; typing updates model; drag moves it; persists via fake DB.
- Mode behavior: Browse mode → stylus down produces no stroke; Draw mode → it does.
8.3 Perf/benchmark check
tool/perf_scroll_bench.dart(P-1) +StaticInkPainterno-rebuild assertion (P-3) run in profile mode; not a hard CI gate (CI has no GPU profile reliability) but required before milestone sign-off, output archived.
8.4 Regression
- Keep
ctc_decoder_test.dartgreen (OCR untouched). Update/replacewidget_test.dartto bootEditorScreen.
9. Risks & Mitigations
| # | Risk | Likelihood | Impact | Mitigation / Trigger |
|---|---|---|---|---|
| R1 | pdfrx stylus-vs-touch arbitration infeasible without a fork (stylus triggers pdfrx pan, or touch stops scrolling). | Med | High | Milestone-1 spike proves it with a Listener. Fallback: upstream PR adding pointer-kind filter, or restrict pan region. Do not build the rest until R1 is GREEN. |
| R2 | 60fps on 300 pages not met even with pdfrx tiling. | Med | High | Measure pdfrx-alone first (no ink) at Milestone 1; tune verticalCacheExtent, maxImageBytesCachedOnMemory, onePassRenderingSizeThreshold. If pdfrx itself can't hit it, that invalidates the chosen backend → escalate (this is why perf is de-risked first). |
| R3 | Infinite-board perf with thousands of strokes. | Med | Med | Static Picture cache + viewport cull + spatial tiling of the board into chunks if needed (deferred sub-task). |
| R4 | Export fidelity drift: exported ink ≠ on-screen ink. | Med | Med | Share stroke_drawing outline geometry between screen + export; golden-image compare a known page. |
| R5 | perfect_freehand incremental correctness (live polyline vs committed outline "pop"). | Med | Low | §6.4 commit-once; if visible, switch to trailing-window incremental getStroke. Covered by §8.1 equivalence test + manual check. |
| R6 | Thumbnail rendering loses its syncfusion platform-interface backend. | High | Low | M1 (SHOULD #6) confirms pdfrx exposes an off-screen PdfPage.render → RGBA path before M6 schedules the syncfusion-viewer dep removal; port thumbnails to it; keep the dep until ported and proven. |
| R7 | pdfrx controller API drift (all pdfrx APIs are [VERIFY-IN-M1], not compile-checked — pdfrx not yet installed). | Med | Low | M1 sub-task 1 installs pdfrx and source-pins exact signatures from ~/.pub-cache/.../pdfrx-*/lib/src/, updating every [VERIFY-IN-M1] tag. |
| R8 | Windows Dev Mode not enabled on CI/dev → build fails. | Med | Med | Document + add CI step to enable; covered in §4. |
Pre-mortem (DELIBERATE mode — 3 failure scenarios)
- "Six weeks in, scrolling a big PDF still janks." Cause: we built ink/board/text first and only profiled pdfrx at the end. Prevention: Milestone 1 is pdfrx-only perf with a hard 60fps gate before any ink code lands.
- "Pen draws but the page won't scroll with touch." Cause: naive
Listener/IgnorePointerlayering swallowed touch (aListenercannot route by pointer kind), or pdfrx arena conflict (R1). Prevention: promoted to a hard M1 exit gate (§10/M1 sub-task 3) with written PASS/FAIL — "pen draws AND single-finger touch scrolls AND pinch zooms in the same overlay" — tested on the physical Surface Pen device, using thePenCaptureRegionRenderProxyBoxper-kind transport (§3.5). M2 is blocked until PASS. - "Export looks nothing like the screen." Cause: export kept the old line-segment renderer while screen used
getStrokeoutlines. Prevention: R4 shared-geometry task + golden test in Milestone 4.
10. Milestones / Sequencing (de-risk perf FIRST)
M1 — pdfrx spike + perf gate (de-risk R1, R2, R7). Hard gate — M2 does not begin until ALL of MUST #1–#5 below are GREEN (the two perf gates and the coordinate assertion can run on the Windows tablet without a pen; MUST #3 specifically requires the physical Surface Pen).
M1 sub-task 0 — bootstrap (no acceptance gate; creates the assets every later gate depends on; owner: implementer of M1):
- [ADD]
tool/gen_bench_pdf.dart— generatestest/assets/large_300p.pdf(300 pages, mixed text+vector content) usingsyncfusion_flutter_pdf. Run once; commit the asset (or document regenerating it). - [ADD]
tool/gen_dense_strokes.dart→test/assets/dense_strokes.json— synthetic stroke sets at 300/2,000/5,000 strokes for P-3/P-4/P-6 and the M1 ink-overlay gate. - [ADD]
tool/test.sh— wrapper that exportsLD_LIBRARY_PATHto the system sqlite (perbadnote-local-test-sqlite.md) then runsflutter test "$@". All DB-touching tests run through it. - [ADD]
docs/plans/phase1-perf-results.md— seed with empty result tables for each gate (pinned-API table, coordinate assertion, pdfrx-alone perf, ink-overlay perf, pen-arbitration PASS/FAIL), to be filled with device + commit hash.
M1 acceptance sub-tasks (all blocking):
- Install + source-pin (MUST #1, blocking).
flutter pub add pdfrx, then read~/.pub-cache/hosted/pub.dev/pdfrx-*/lib/src/and pin EXACT signatures (arg order/types + return type) for:pageOverlaysBuilder,viewerOverlayBuilder,PdfViewerController.{goToPage, currentZoom, layout, globalToDocument, documentToLocal},PdfRect.toRect, and the gesture recognizer'ssupportedDevicesset. Record each inphase1-perf-results.md, replacing every [VERIFY-IN-M1] tag in this plan with the pinned fact (or with a corrected approach if the doc inference was wrong). Runs on Windows tablet or dev box (no pen needed). - Coordinate-correctness assertion (MUST #2, blocking). With
PageAnnotationLayerwrapped inSizedBox.fromSize(size: pageRect.size), assert a stroke at normalized(0.5,0.5)lands at visual page center across 3 zoom levels (fit / 2× / 4×). Automatable widget/golden test — no pen needed. Blocking: a wrong coordinate model invalidates the entire ink approach. - Pen/touch arbitration exit gate (MUST #3, blocking, requires physical Surface Pen): using the
PenCaptureRegionRenderProxyBoxtransport (§3.5), on the physical Surface Pen tablet: PASS iff (a) pen tip draws a stroke on the page overlay, AND (b) single-finger touch scrolls the document, AND (c) pinch zooms — all in the same overlay without mode switching. FAIL if pen triggers pan, or if touch stops scrolling. On FAIL, switch to the §3.5 fallback transport before re-testing; M2 blocked until PASS. Owner/runner: the developer with the Surface Pro tablet (the project's primary-target device). Fallback if device unavailable: CI has no pen — this gate is not automatable there; M2 stays blocked until a human runs it on real hardware. As an interim signal only (does NOT satisfy the gate), a Windows-mouse/synthesized-stylus smoke check may run in CI to catch gross regressions. - Perf gate — pdfrx alone (MUST #4, blocking; Principle 4 / pre-mortem #1 primary de-risk): 300-page asset, fling-scroll in profile mode on the Windows tablet → median frame (build+raster) ≤ 16.6ms, p95 ≤ 22ms (sample protocol per §7.1). Blocking: if pdfrx alone cannot hit 60fps the chosen backend is invalidated — escalate before any further work. No pen needed.
- Perf gate — WITH ink-overlay build cost (MUST #5, blocking): add a throwaway
AnnotationLayerstub that paints a non-trivialui.Pictureon every visible page (~300 synthetic strokes/page fromdense_strokes.json), wrapped per the real cache/RepaintBoundary design. Fling-scroll the 300-page asset and assert frame BUILD time (not just raster) ≤ 16.6ms median (sample protocol per §7.1). Proves overlay mounting/unmounting + Picture (re)build during scroll stays within budget — empty-pdfrx perf alone is insufficient. No pen needed.
Deliverable: the sub-task-0 assets above + a throwaway lib/editor/pdf/editor_pdf_pane.dart skeleton + PenCaptureRegion prototype + docs/plans/phase1-perf-results.md populated with pinned APIs, MUST #2/#4/#5 numeric results, and the MUST #3 pen-arbitration PASS/FAIL (device + commit hash). No further work proceeds if ANY of MUST #1–#5 fails.
M2 — Ink engine on a single PDF page. Precondition: ALL of M1 MUST #1–#5 are GREEN (MUST #3 confirmed on the physical Surface Pen). CoordinateSpaceHost, EditorController, StaticInkPainter + PictureCache (revision Picture cache w/ LRU + unmount-dispose, §3.3), LiveInkPainter, stroke_drawing, RepaintBoundary. Exit: draw/erase/undo on one page; P-2/P-3/P-6 met; ink follows scroll+zoom structurally (§2.1 sizing constraint honored). Includes the SHOULD #1 measured trigger: if full-page save flush > 50ms at 2,000 strokes, switch to per-stroke rows (§5.1).
M3 — Full PDF editor parity. Multi-page hosting, batched annotation load (root cause 3), SaveScheduler, re-home page mgmt/bookmarks/thumbnails, modes + shortcuts (G6), input arbiter integrated.
-
Exit — every item in this parity CHECKLIST passes (each individually checkable; old-screen DELETE in §3.10 is gated on ALL passing):
# Capability (current pdf_annotator_screen.dart/ toolbar)New-code location Check C1 Page rotate 90° pdf_service.rotatePage(kept) + editor re-render; strokes unrotated (§2.1)rotate a page; ink stays aligned C2 Page delete (+ remap annotations/bookmarks) pdf_service.deletePage+EditorControllerremap +database_serviceremap fns (kept)delete page; later pages' ink/bookmarks shift correctly C3 Insert blank page pdf_service.insertBlankPage+ remapinsert; subsequent ink shifts C4 Insert image page (camera/gallery) pdf_service.insertImageOnPage+camera_service(kept)image lands on page; ink overlays C5 Bookmark add / toggle / jump EditorControllerbookmark state +database_servicebookmarks (kept)add, toggle off, jump-to from drawer C6 Thumbnail-sidebar nav re-homed PageThumbnailSidebarrendering via pdfrx (§4 SHOULD #6)tap thumbnail scrolls to page C7 Text placement text_box_layer/editable_text_box(§3.8) — delivered in M4; M3 exit notes dependencyplace a text box (full edit lands M4) C8 Undo / redo global UndoManageronEditorController(§3.2)Ctrl+Z/Y across pages reverses last commit C9 Save-on-page-leave SaveSchedulerflush-on-page-leave (§3.6)leave page; reopen; ink persisted C10 Shortcuts Ctrl+Z/Y/F/S editor_shortcuts.dart(§3.9)each shortcut fires its action C11 Zoom controls (in/out/fit) editor_pdf_panezoom wrappers overPdfViewerControllerbuttons change zoom; ink tracks Note: C7 full editing depends on M4; M3 may ship a placement stub, but the old screen is NOT deleted until C1–C11 (incl. C7 via M4) all pass. Old screen swapped in nav; widget tests green.
M4 — Export + text boxes. Shared-geometry export (R4 + golden), editable text boxes (G5), persistence (§5 text_boxes table). Exit: export matches screen; text boxes place/move/edit/persist.
M5 — Infinite board. BoardHost, BoardPane, board toggle, reuse ink engine; migrate split_view behavior; delete split_view_screen.dart. Exit: board draw at P-4; independent scroll/zoom; G4 done.
M6 — Cleanup + sign-off. Delete pdf_annotation_layer.dart, pdf_annotator_screen.dart, ink_canvas.dart (after extraction), remove syncfusion viewer deps, final perf-results doc, full test pass via tool/test.sh.
Each milestone ends with a verifier/critic pass and the perf-results doc updated with the commit hash.
11. RALPLAN-DR Summary
Principles (3–5)
- Single source of truth = host content coordinates. Screen mapping is a paint-time transform, never stored/duplicated geometry.
- Separate static (committed) from live (in-progress) ink, gated by an O(1) revision so steady-state inking touches only the live layer.
- Pen-first, arena-free input. A per-kind
RenderProxyBox(PenCaptureRegion, §3.5) hit-tests true only for{stylus, invertedStylus}so stylus is captured outside the gesture arena while touch/mouse fall through to pdfrx's own recognizers; palm rejection is explicit. (Corrected from an earlier "rawListenerclaims stylus" framing — a bareListenercannot route by pointer kind.) - De-risk perf before features. The PDF backend must prove 60fps on a large doc before any ink/board/text code is written.
- Host-agnostic ink engine. PDF page and infinite board are two
CoordinateSpaceHosts behind one renderer, so P4's CAS overlay reuses the same seam.
Decision Drivers (top 3)
- D1 — "Ink follows PDF scroll/zoom" must be structural, not manually synced (current arch makes it impossible; root cause 4).
- D2 — 60fps on hundreds of pages with thousands of strokes on Windows tablet + Surface Pen.
- D3 — Reusable ink across PDF + infinite board (G4) without duplicating the engine, and forward-compatible with P2 search / P4 CAS.
Viable Options (≥2) with bounded pros/cons
Option A — pdfrx + pageOverlaysBuilder (CHOSEN, pre-decided).
- Pros: page-coordinate overlay inherits scroll+zoom automatically (D1 ✔, structural); pdfium vector + tiled rendering targets D2; Windows supported; gives text-extraction API for P2; viewer-overlay seam for input arbiter.
- Cons: gesture
supportedDeviceshardcoded (doc-derived, [VERIFY-IN-M1]) → stylus/touch arbitration must be proven (R1); several pdfrx APIs are [VERIFY-IN-M1] until source-pinned; new dependency + Windows Dev Mode build requirement.
Option B — Custom pdfium wrapper inside InteractiveViewer.
- Pros: total control over gestures (cleanly solves R1); one transform owns both PDF tiles and ink (D1 trivially); board and PDF share the exact same viewer.
- Cons: must hand-roll tiled hi-res rendering, page layout, text extraction, link handling → very large surface, high schedule risk against D2; reinvents what pdfrx already ships. Rejected (see invalidation).
Option C — Keep SfPdfViewer, optimize the overlay only.
- Pros: smallest change; no new dependency; export path unchanged.
- Cons: overlay Stack fundamentally cannot share the viewer transform → D1 unachievable (root cause 4 is structural, not a perf tweak); continuous-scroll Notability feel impossible; licensing/cost of Syncfusion. Rejected (see invalidation).
Invalidation rationale for rejected options
- C rejected: it cannot satisfy D1. The overlay is a sibling of
SfPdfViewerwith no access to its internal pan/zoom matrix, so ink can never track the page during continuous scroll/zoom — exactly the structural defect (root cause 4) this phase exists to fix. Optimizing the overlay improves frame cost but not the architecture. - B rejected (deferred fallback, not chosen): it would satisfy D1–D3 but at the cost of re-implementing tiled rendering, layout, and text extraction that pdfrx provides for free, putting D2's 60fps and the overall schedule at serious risk. It survives only as the R1 fallback if the M1 spike proves pdfrx's gesture arena truly cannot be worked around — in which case we reconsider B or an upstream pdfrx PR.
- Net: two options remain genuinely viable (A chosen, B as documented fallback); C is invalidated against the primary driver D1.
ADR (Architect review applied; finalize after M1 spike)
- Status: Architect verdict APPROVE-WITH-MUST-FIXES applied (2026-06-21). All 5 MUST-FIXes + 6 SHOULD-FIXes incorporated. Remaining gate: M1 spike must turn every [VERIFY-IN-M1] tag into a source-pinned fact and pass the pen-arbitration + perf gates.
- Decision: Adopt pdfrx (
^2.4.4, exact version [VERIFY-IN-M1]) withpageOverlaysBuilder-hosted ink and a per-kindRenderProxyBox(PenCaptureRegion) input transport (not a bareListener); host-agnostic ink engine overCoordinateSpaceHost. - Drivers: D1 structural ink-follows-page, D2 60fps@300pages, D3 reusable ink across PDF/board.
- Alternatives considered: B (custom pdfium/InteractiveViewer — retained as R1 fallback), C (optimize Syncfusion overlay — invalidated vs D1).
- Why chosen: A is the only option that gives D1 for free while leveraging shipped tiled rendering + text extraction toward D2 and P2.
- Hardening from review: (1) all pdfrx claims downgraded to [VERIFY-IN-M1], source-pinned in M1; (2) §2.1 "no zoom math" corrected — requires
CustomPaintsized topageRect.size; (3) boundedPictureCacheLRU + unmount-dispose with memory target P-6 (reconciles P-1 vs P-3); (4) concrete per-kindRenderProxyBoxtransport replaces theListener/IgnorePointerhand-wave; (5) M1 perf gate now includes ink-overlay BUILD cost, and the pen/touch arbitration is a hard M1 exit gate on the physical Surface Pen before M2. - Consequences: new dep + Windows Dev Mode build step; must prove gesture arbitration (R1) + ink-overlay build budget in M1; export keeps headless syncfusion_flutter_pdf and must fill getStroke outlines (not stroke them); global (not per-page) undo; text boxes are a sanctioned widget-space exception to Principle 1.
- Follow-ups: finalize after M1 spike results; resolve the remaining Open Questions (export backend; board table rename;
panAxis-during-zoom). (Mouse-in-Draw UX is now resolved: pen required to draw.)
Open Questions (persist to .omc/plans/open-questions.md)
Per-page stroke blob vs per-stroke rows— RESOLVED into M2 with a measured trigger (§5.1): switch to per-stroke rows if 2,000-stroke flush > 50ms. No longer an open question.- Keep
syncfusion_flutter_pdffor export, or move export to pdfium? Decide at M1 from fidelity/feasibility. — Affects §4 deps + R4. - Board persistence: rename
scratchpads→boardsor keep name? — Cosmetic; affects §5. - Does the
PenCaptureRegionRenderProxyBox(§3.5) overviewerOverlayBuilderreliably let single-finger touch scroll on the actual Surface Pen device? — Hard exit criterion for M1 (MUST #3 / R1). Mouse-in-Draw-mode: should left-drag draw, or require pen?— RESOLVED (§6.1/§6.2): pen required to draw; mouse left-drag pans/selects in Draw mode. No longer open.