Compare commits

...

2 Commits

Author SHA1 Message Date
ee1b3a39f1 feat(editor): M1 pdfrx spike + pen/touch capture
Some checks failed
CI / Windows build (push) Has been cancelled
Add pdfrx 2.4.4. PenCaptureRegion routes stylus to ink (arena-bypass
via PenCaptureBinding) while touch falls through to pdfrx scroll/zoom.
Spike pane hosts PdfViewer with page-overlay ink at normalized coords +
frame-time HUD; reachable from home screen for on-device testing.
Bench/coordinate harness under integration_test. Generated assets are
gitignored (regenerate via tool/gen_*.dart).
2026-06-21 20:15:21 +08:00
2afc126f30 docs(plan): phase 1 rebuild plan + M1 gate results
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.
2026-06-21 20:15:13 +08:00
18 changed files with 2404 additions and 2 deletions

4
.gitignore vendored
View File

@@ -56,3 +56,7 @@ server/.omc/
server/data/ server/data/
server/*.db server/*.db
server/.env server/.env
# M1 spike generated bench assets (regenerate via tool/gen_*.dart)
/test/assets/large_300p.pdf
/test/assets/dense_strokes.json

View File

@@ -0,0 +1,461 @@
# 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)
1. **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.
2. **G2 — High-performance inking engine.** Static layer baked into a cached `ui.Picture` rebuilt only on a per-host `revision` bump (O(1) `shouldRepaint`); live layer paints only the in-progress stroke; per-page `RepaintBoundary`; zero per-frame object allocation for committed strokes.
3. **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.
4. **G4 — Infinite annotation side-canvas (toggle).** A blank, vertically-infinite ink board sharing the **same** ink engine through a `CoordinateSpaceHost` abstraction. Independent scroll/zoom from the PDF pane.
5. **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.
6. **G6 — Keyboard/mouse modes.** A `Browse` mode (wheel scroll, Ctrl+wheel zoom, space/middle-drag pan, no accidental ink) and a `Type` mode (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 `docType` column stays but only `pdf` is 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 P2P4 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 `CoordinateSpaceHost` abstraction (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)` gives `pageRect` **already scaled to current zoom** in viewer-local coords **[VERIFY-IN-M1]**.
- **Correction (do NOT overstate "no manual zoom math"):** `pageOverlaysBuilder` returns *raw widgets*, and per the pdfrx docs in-page offsets normally still need `* controller.currentZoom`. The `canvas.scale(size.width, size.height)` trick is correct **only if** the ink `CustomPaint` is laid out at **exactly `pageRect.size`** — otherwise `size` ≠ the zoomed page box and strokes mis-scale. We therefore require: wrap `PageAnnotationLayer` in `SizedBox.fromSize(size: pageRect.size)` (equivalently `Positioned.fromRect(rect: Offset.zero & pageRect.size, ...)` inside the page-local stack) so the `CustomPaint`'s `size` *is* the zoomed page box, then `canvas.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 `pageRect` for 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`/`_inverseRotate` math.)
### 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.vertical` locks 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 whether `PanAxis.free` is required while zoomed (and `vertical` only 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.dart`**
- `abstract class CoordinateSpaceHost`
- `String get hostId;` (e.g. `"page:3"`, `"board"`)
- `int get pageIndex;` (board → a sentinel, e.g. `-1`)
- `Offset toContent(Offset deviceLocal, Size deviceSize);` — device→truth
- `Offset 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 (the `InteractiveViewer` supplies pan/zoom).
- **Deps:** none. **Used by:** `AnnotationLayer`, painters, `InputArbiter`, `EditorController`.
### 3.2 Editor state (single source of truth)
- **[ADD] `lib/editor/state/editor_models.dart`**
- `class StrokeStore` — holds `List<InkStroke> committed` + `int revision`; `add()/remove()/replace()` bump `revision`.
- `class HostState``{ CoordinateSpaceHost host, StrokeStore strokes, List<TextBoxModel> textBoxes }`. (Undo is **global**, not per-host — see §3.2 undo scope below; a single `UndoManager` lives on `EditorController`, with each action tagged by `hostId`+`pageIndex`.)
- `class LiveStroke``{ List<InkPoint> rawPoints, PenTool tool, Color color, double width }` (the in-progress polyline).
- **[ADD] `lib/editor/state/editor_controller.dart`**
- `class EditorController extends ChangeNotifier` (or Riverpod `Notifier`):
- 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 `PictureCache` LRU (§3.3) evicts only the rendered `ui.Picture` — it NEVER drops the `HostState`/`StrokeStore`. A `HostState` is retained in `pageHosts` (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. `pageHosts` entries 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()` — runs `getStroke` once, appends `InkStroke`, `revision++`, pushes undo, schedules save.
- `void eraseAt(CoordinateSpaceHost host, Offset content, double radius)` — reuses split logic from `ink_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 its `hostId`+`pageIndex` so the action is reversed on the correct host and (optionally) the viewer scrolls that host into view. The existing per-stroke `UndoManager` semantics are reused, but there is **one** manager keyed globally rather than `Map<int, UndoManager>`. Board-host commits enter the same global stack.
- **Undo on an evicted/unmounted host (MAJOR-4 path):** because the `HostState`/`StrokeStore` is retained whenever undo history references it (§ State above), `undo()/redo()` for a host whose Picture was evicted simply (1) mutates that host's retained `StrokeStore` (bump `revision`), (2) calls `SaveScheduler.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, `StaticInkPainter` rebuilds the `ui.Picture` from 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).
- **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.dart`**
- `class AnnotationLayer extends StatelessWidget` params: `host`, `controller`. Builds a `RepaintBoundary` wrapping a `Stack`:
- `CustomPaint(painter: StaticInkPainter(store, host))`
- `CustomPaint(painter: LiveInkPainter(controller.liveStroke, host))` (repaints via `repaint: liveStroke` Listenable)
- `TextBoxLayer(host, controller)`
- **[ADD] `lib/editor/render/static_ink_painter.dart`**
- Caches a `ui.Picture` keyed by `revision`. `shouldRepaint` = `old.revision != revision`**O(1)**. On rebuild: `applyContentToCanvas`, draw all committed strokes (reuse `_drawStroke` body extracted to `lib/editor/render/stroke_drawing.dart`). Viewport culling retained (existing `_strokeInViewport`).
- **[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.Picture` forever — 300 retained Pictures blows the memory budget. Resolution: a bounded LRU `PictureCache` whose lifetime is **tied to pdfrx's visible + cache window**:
- A page's `ui.Picture` is built/retained **only while its `AnnotationLayer` overlay 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 `K` page Pictures (default `K` ≈ visible pages + 2×cache-extent, e.g. ~812) 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.Picture`s ONLY. It must never reach into or evict `HostState`/`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 via `SchedulerBinding.instance.addPostFrameCallback` (or a one-frame quarantine queue) so the raster thread is done with it first.
- **Used by:** `StaticInkPainter` (asks the cache for the page's Picture by `(hostId, revision)`).
- **[ADD] `lib/editor/render/live_ink_painter.dart`**
- Paints only `liveStroke`. During draw: render a **raw pressure-polyline** (cheap) rather than recomputing `getStroke` every move (§6.4). `repaint:` bound to the `ValueNotifier<LiveStroke?>` so only this painter invalidates on pointer move.
- **[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)) ]`**the `SizedBox.fromSize(size: pageRect.size)` wrap is mandatory** so the child's `CustomPaint.size` equals the zoomed page box (see §2.1). If pdfrx positions overlay children at `pageRect.topLeft` already, this is sufficient; if it expects an absolutely-positioned child, use `Positioned.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].**
- **[ADD] `lib/editor/pdf/page_annotation_layer.dart`** — adapter: builds `NormalizedPageHost(pageIndex)`, takes `pageSize` (= `pageRect.size`), and renders `AnnotationLayer`. Its root `CustomPaint` must receive `size == pageSize` (guaranteed by the `SizedBox.fromSize` wrap above) so `canvas.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 `Listener` wrapping an `IgnorePointer` child either (a) sits *above* pdfrx and, being non-opaque, may still let the parent `Listener` see events while hit-testing passes through to pdfrx — but `Listener` does **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 `RenderProxyBox` subclass (`_PenCaptureRenderBox`) placed in `viewerOverlayBuilder` whose `hitTestSelf`/`hitTest` returns **true only when the incoming pointer's `kind ∈ {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 to `InputArbiter`) 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 small `PenCaptureRegion({onPenEvent, child})` widget wrapping the `RenderProxyBox`.
- **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 upstream `gestureDeviceFilter` PR (R1).
- Modes: in `Type`/`Browse` the region's `hitTestSelf` predicate 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}`.
### 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/rename `scratchpad`).
- **[ADD] `lib/editor/state/save_scheduler.dart`** — `class SaveScheduler` debounced (≈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 any `await`** — 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 captures `targetPage`+serializes before the first `await`). 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 an `await`.
### 3.7 Erase / split util
- **[ADD] `lib/editor/input/stroke_eraser.dart`** — pure `List<InkStroke> splitStroke(InkStroke, Set<int> erasedIdx)` + `eraseHits(strokes, point, radius)` extracted from `ink_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 `Positioned` Flutter widgets (`EditableText`), not canvas paint — they **cannot** ride the `canvas.scale` transform 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 by `controller.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`** — a `Positioned` `TextField`/`EditableText` placed via `host.toDevice(rect.topLeft)` with size and font scaled by `controller.currentZoom` (widget-space, per the §3.8 exception); draggable handle to move; focus participates in `Type` mode.
- **[ADD] `lib/editor/text/text_box_layer.dart`** — renders all text boxes for a host; hit-test add on tap in `Type` mode.
### 3.9 Toolbar + modes + shortcuts
- **[MODIFY] `lib/widgets/annotation_toolbar.dart`** → re-home as `lib/editor/ui/editor_toolbar.dart` (or adapt in place). Add: `EditorMode` selector (Draw/Browse/Type), board toggle, tool number indicators. Keep tool/color/width/pressure/stabilization/undo/redo.
- **[ADD] `lib/editor/ui/editor_shortcuts.dart`** — `CallbackShortcuts` map: 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 (replaces `pdf_annotator_screen.dart` usage).
- **[MODIFY] navigation entry points** that push `PdfAnnotatorScreen` → push `EditorScreen`. (Grep for `PdfAnnotatorScreen(` and `SplitViewScreen(` constructors.)
- **[DELETE]** after parity is reached and tests pass:
- `lib/widgets/pdf_annotation_layer.dart` (replaced by `page_annotation_layer.dart`)
- `lib/screens/pdf_annotator_screen.dart` (replaced by `editor_screen.dart`)
- `lib/screens/split_view_screen.dart` (replaced by board pane inside `editor_screen.dart`)
- `lib/widgets/ink_canvas.dart` **only** once its draw/erase/stabilizer logic is fully extracted to `stroke_drawing.dart` + `stroke_eraser.dart`. **"Keep until extracted" means: `ink_canvas.dart` stays compiled-in AND remains the live reference implementation until the extraction is *verified* (the new `stroke_drawing`/`stroke_eraser` units 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 (uses `syncfusion_flutter_pdf`, headless, no viewer). `exportAnnotatedPdf` and page delete/insert/rotate still operate on file bytes. Two required changes:
1. Export `_renderStrokes` must use the **same** outline geometry as on-screen (call `stroke_drawing` outline builder, not the crude line-segments path) so export matches what the user saw. **Critically: `getStroke` returns 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 a `PdfPath` from the `getStroke` outline points and **fill** it with a `PdfBrush` (solid color), not stroke it with a `PdfPen`. (Shape/line/arrow/text tools keep their stroke/fill semantics as today.) This is what makes the R4 golden pass.
2. Rotation: since strokes are now stored in **unrotated** page space, drop the `_rotateStroke90CW` in-memory transform from the editor; export reads pdfium/syncfusion page rotation and applies it once.
- **[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 after `flutter pub add pdfrx`).
- **REMOVE** `syncfusion_flutter_pdfviewer` and `syncfusion_pdfviewer_platform_interface` (viewer + thumbnail-render interface). Note: `thumbnail_service.dart` uses `syncfusion_pdfviewer_platform_interface` to render pages off-screen — **migrate thumbnails to pdfrx page rendering** before removing it. **[VERIFY-IN-M1 (SHOULD #6): confirm pdfrx exposes an off-screen `PdfPage.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 in `pdf_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.2` override + vendored-binary `hooks` block (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` / `InkPoint` models **[KEEP]** (freezed, already JSON-serializable). Strokes stored as JSON blob per host (matches current `annotations.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 `SaveScheduler` full-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]** (drop `rotation` column reliance from editor logic; pdfium owns page rotation). Column may stay for compatibility.
- `annotations(document_id, page_number, annotation_json, ...)` **[KEEP shape]**. **ADD** batch read `getAllAnnotationsForDocument`.
- **[ADD]** `text_boxes(id, document_id, host_id, page_number, content, rect_json, font_size, color, created_at, updated_at)` — addressable by host; `content` is plain text for future FTS.
- `scratchpads(document_id UNIQUE, strokes_json)` **[REPURPOSE]** → board host store. Optionally rename to `boards` and add `text_boxes` rows with `host_id='board'`. Keep `scratchpads` name 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 (12 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. (`Draw` mode entry in the `mouse left drag` row reflects this.)
### 6.3 State machine (`InputArbiter`)
States: `idle → inking → erasing → touchNav`.
- `idle + stylusDown` (mode≠Browse) → `inking`; emit `beginStroke`. Set `_stylusActive=true`.
- `idle + invertedStylusDown``erasing`; emit `eraseAt`.
- `inking + stylusMove``extendStroke`. `inking + stylusUp``commitStroke``idle`.
- **Palm rejection:** any `touchDown` while `_stylusActive`**dropped** (not forwarded, not drawn). `touchDown` while `idle` → 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 bare `Listener`+`IgnorePointer` would route by kind — it does not (a `Listener` does 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 recognizer `supportedDevices` is hardcoded, §6.5.]**
### 6.4 Live-stroke incremental rendering (perf correctness, root cause 1)
- During `inking`, accumulate raw `InkPoint`s; `LiveInkPainter` draws a **pressure-aware polyline / quad path** (cheap, O(points added)), NOT `getStroke` over the whole stroke each move.
- On `commitStroke`, run `getStroke(..., 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: run `getStroke` only 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 `InkPoint` is captured in **normalized content coords** at the moment of the pointer event — `host.toContent(event.localPosition, currentDeviceSize)` — NOT raw device pixels. So if `pageRect.size` changes mid-stroke (tile reflow / zoom between `beginStroke` and `commitStroke`), 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. The `LiveInkPainter` likewise paints through `host.applyContentToCanvas` at 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==false` while `revision` constant). 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: `getAllAnnotationsForDocument` is 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.Picture` memory on the 300-page asset (annotations on 50 pages) ≤ **64 MB** at any time, enforced by the `PictureCache` LRU + 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 sampling `ui.Picture` count × estimated bytes (or `dart:developer` memory snapshot) during a full scroll.
### 7.2 How to verify
- **Benchmark asset:** add `test/assets/large_300p.pdf` (generate via a script using `syncfusion_flutter_pdf`; commit or generate in a `tool/gen_bench_pdf.dart`). Plus `test/assets/dense_strokes.json` (2k/5k synthetic strokes).
- **Frame timing harness:** integration test using `WidgetController` + `SchedulerBinding.addTimingsCallback` (or `flutter run --profile --trace-skia`); record `FrameTiming.totalSpan`. A `tool/perf_scroll_bench.dart` drives programmatic scroll and prints median/p95.
- **shouldRepaint assertion:** unit/widget test wraps `StaticInkPainter` and 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 test` fails to download sqlite3 locally; run DB-touching tests with system sqlite + `LD_LIBRARY_PATH` (see `badnote-local-test-sqlite.md`). Provide a `tool/test.sh` wrapper that sets `LD_LIBRARY_PATH` to the system sqlite and runs `flutter 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`/`BoardHost` round-trip `toContent(toDevice(x))≈x`; rotation handled by pdfrx geometry (test our hosts assume unrotated).
- **Stroke split/erase:** port + extend existing `undo_manager_test.dart` discipline; cover full-erase (empty replacements), mid-erase (2 segments), endpoint erase, <2-point dropping.
- **Revision gating:** `StrokeStore.add/remove` bumps revision; `StaticInkPainter.shouldRepaint` true 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 `await`s on a controllable completer, assert that mutating controller state / changing the "current host" *after* `scheduleSave` but *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 `Listener` test harness) and synthesize `PointerEventKind.stylus` vs `.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) + `StaticInkPainter` no-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.dart` green (OCR untouched). Update/replace `widget_test.dart` to boot `EditorScreen`.
---
## 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)
1. **"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.
2. **"Pen draws but the page won't scroll with touch."** Cause: naive `Listener`/`IgnorePointer` layering swallowed touch (a `Listener` cannot 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 the `PenCaptureRegion` `RenderProxyBox` per-kind transport (§3.5). M2 is blocked until PASS.
3. **"Export looks nothing like the screen."** Cause: export kept the old line-segment renderer while screen used `getStroke` outlines. *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`** — generates `test/assets/large_300p.pdf` (300 pages, mixed text+vector content) using `syncfusion_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 exports `LD_LIBRARY_PATH` to the system sqlite (per `badnote-local-test-sqlite.md`) then runs `flutter 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):*
1. **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's `supportedDevices` set. Record each in `phase1-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).
2. **Coordinate-correctness assertion (MUST #2, blocking).** With `PageAnnotationLayer` wrapped in `SizedBox.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.
3. **Pen/touch arbitration exit gate (MUST #3, blocking, requires physical Surface Pen):** using the `PenCaptureRegion` `RenderProxyBox` transport (§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.
4. **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.
5. **Perf gate — WITH ink-overlay build cost (MUST #5, blocking):** add a throwaway `AnnotationLayer` stub that paints a **non-trivial `ui.Picture` on every visible page** (~300 synthetic strokes/page from `dense_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` + `EditorController` remap + `database_service` remap fns (kept) | delete page; later pages' ink/bookmarks shift correctly |
| C3 | Insert blank page | `pdf_service.insertBlankPage` + remap | insert; 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 | `EditorController` bookmark state + `database_service` bookmarks (kept) | add, toggle off, jump-to from drawer |
| C6 | Thumbnail-sidebar nav | re-homed `PageThumbnailSidebar` rendering 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 dependency | place a text box (full edit lands M4) |
| C8 | Undo / redo | global `UndoManager` on `EditorController` (§3.2) | Ctrl+Z/Y across pages reverses last commit |
| C9 | Save-on-page-leave | `SaveScheduler` flush-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_pane` zoom wrappers over `PdfViewerController` | buttons 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 C1C11 (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 (35)
1. **Single source of truth = host content coordinates.** Screen mapping is a paint-time transform, never stored/duplicated geometry.
2. **Separate static (committed) from live (in-progress) ink**, gated by an O(1) revision so steady-state inking touches only the live layer.
3. **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 "raw `Listener` claims stylus" framing — a bare `Listener` cannot route by pointer kind.)
4. **De-risk perf before features.** The PDF backend must prove 60fps on a large doc before any ink/board/text code is written.
5. **Host-agnostic ink engine.** PDF page and infinite board are two `CoordinateSpaceHost`s behind one renderer, so P4's CAS overlay reuses the same seam.
### Decision Drivers (top 3)
1. **D1 — "Ink follows PDF scroll/zoom" must be structural**, not manually synced (current arch makes it impossible; root cause 4).
2. **D2 — 60fps on hundreds of pages with thousands of strokes** on Windows tablet + Surface Pen.
3. **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 `supportedDevices` hardcoded (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 `SfPdfViewer` with 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 D1D3 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]) with `pageOverlaysBuilder`-hosted ink and a **per-kind `RenderProxyBox` (`PenCaptureRegion`)** input transport (not a bare `Listener`); host-agnostic ink engine over `CoordinateSpaceHost`.
- **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 `CustomPaint` sized to `pageRect.size`; (3) bounded `PictureCache` LRU + unmount-dispose with memory target P-6 (reconciles P-1 vs P-3); (4) concrete per-kind `RenderProxyBox` transport replaces the `Listener`/`IgnorePointer` hand-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`)
- [x] ~~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_pdf` for export, or move export to pdfium? Decide at M1 from fidelity/feasibility. — *Affects §4 deps + R4.*
- [ ] Board persistence: rename `scratchpads``boards` or keep name? — *Cosmetic; affects §5.*
- [ ] Does the `PenCaptureRegion` `RenderProxyBox` (§3.5) over `viewerOverlayBuilder` reliably let single-finger touch scroll on the actual Surface Pen device? — *Hard exit criterion for M1 (MUST #3 / R1).*
- [x] ~~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.
```

View File

@@ -0,0 +1,65 @@
# BadNote Phase 1 — Performance Gate Results
**Status:** IN PROGRESS — MUST #1 (API source-pin) GREEN on pdfrx **2.4.4** (Dart-only verification, done). MUST #2/#4/#5 require a device/profile run; MUST #3 requires the physical Surface Pen. Spike code compiles clean (`flutter analyze`: no issues).
---
## M1 Gate Results
| Gate | Target | Result | Device | Commit | PASS/FAIL |
|------|--------|--------|--------|--------|-----------|
| MUST #1 — pdfrx API source-pin | All `[VERIFY-IN-M1]` tags replaced with pinned signatures from `~/.pub-cache/…/pdfrx-2.4.4/lib/src/` | All APIs confirmed; match plan (see table below) | Dev box (Linux) | feat/m1-pdfrx-spike | **PASS** |
| MUST #2 — Coordinate assertion | Stroke at normalized (0.5, 0.5) lands at visual page center at fit / 2× / 4× zoom | **PASS** on real pdfium (`integration_test/coordinate_assertion_test.dart`, "All tests passed", PDF load 74ms). Re-confirm on Windows. | Linux desktop (WSL2) | feat/m1-pdfrx-spike | **PASS (Linux)** |
| MUST #3 — Pen/touch arbitration on Surface Pen | Pen draws AND single-finger touch scrolls AND pinch zooms simultaneously in same overlay; no mode switching | PENDING | Surface Pro (physical device) | — | PENDING |
| MUST #4 — pdfrx-alone perf (300-page fling-scroll) | Median frame (build+raster) ≤ 16.6 ms, p95 ≤ 22 ms; N ≥ 120 frames; profile mode | Harness ready (`integration_test/perf_scroll_bench.dart`). NOT measured — WSL2 dev box was under heavy concurrent load → invalid signal, discarded. | — | — | PENDING (run on Surface, profile, idle machine) |
| MUST #5 — ink-overlay build cost (300 synthetic strokes/page) | Median BUILD time ≤ 16.6 ms during fling-scroll; N ≥ 120 frames; profile mode | Harness ready (same bench, ink-load toggle). NOT measured (same reason). | — | — | PENDING (run on Surface, profile, idle machine) |
---
## Pinned pdfrx APIs
To be filled during M1 sub-task 1 (`flutter pub add pdfrx` + read `~/.pub-cache/…/pdfrx-*/lib/src/`).
All signatures verified against `~/.pub-cache/hosted/pub.dev/pdfrx-2.4.4/` on 2026-06-21.
| API | Pinned signature / notes | Source file:line |
|-----|--------------------------|-----------------|
| `pageOverlaysBuilder` | `typedef PdfPageOverlaysBuilder = List<Widget> Function(BuildContext context, Rect pageRectInViewer, PdfPage page)`. `pageRectInViewer` is **already scrolled+zoomed** (viewer coords). Children laid out in a per-page `Stack`. Matches plan §2.1. | `lib/src/widgets/pdf_viewer_params.dart:1623`, field `:554` |
| `viewerOverlayBuilder` | `typedef PdfViewerOverlaysBuilder = List<Widget> Function(BuildContext context, Size size, PdfViewerHandleLinkTap handleLinkTap)`. Docs: a `GestureDetector` here must use `HitTestBehavior.translucent` + `IgnorePointer` child to let events reach the viewer. | `:1572`, field `:513` |
| `PdfViewerController.goToPage` | `Future<void> goToPage({required int pageNumber, PdfPageAnchor? anchor, Duration duration = 200ms})`. (Also `goToRectInsidePage({required int pageNumber, required PdfRect rect, ...})`.) | `lib/src/widgets/pdf_viewer.dart:4140` |
| `PdfViewerController.currentZoom` | `double get currentZoom => value.zoom;` | `:4318` |
| `PdfViewerController.layout` | `PdfPageLayout get layout;``layout.pageLayouts` is `List<Rect>` in **document** coords (index by page). | `:4029`; pageLayouts usage `:895,1068` |
| `PdfViewerController.globalToDocument` | `Offset? globalToDocument(Offset global)` (public). Also `Offset documentToGlobal`, `Offset? localToGlobal`. | `:4403` |
| `PdfViewerController.documentToLocal` | `Offset documentToLocal(Offset document)` (public). | `:4412` |
| `PdfRect.toRect` | Extension in pdfrx_flutter: `Rect toRect({required PdfPage page, Size? scaledPageSize, int? rotation})`**handles rotation** (removes our bespoke rotate math). Also `toRectInDocument({required PdfPage page, required Rect pageRect})`. | `lib/src/pdfrx_flutter.dart:107` |
| Gesture recognizer (pan/zoom) | pdfrx pan+zoom is an **internal InteractiveViewer** using `GestureDetector(onScaleStart/Update/End)` (scale gesture covers pan+pinch). Default `GestureDetector` accepts all device kinds incl. stylus → **a stylus drag WOULD be claimed by the scale recognizer unless intercepted above**. Confirms R1 and justifies the `PenCaptureRegion` arena-bypass. | `lib/src/widgets/interactive_viewer.dart:1560-1564, 839-948` |
| `PdfOverlayInteractionRegion` (fallback) | Exists (`class PdfOverlayInteractionRegion extends StatefulWidget`), but pdfrx docs state it is **tap-oriented only** (tap/double-tap/long-press/secondary) — **NOT a freehand-drag stream**. ∴ it cannot be the primary draw transport; `PenCaptureRegion` (RenderProxyBox per-kind hit-test) is THE path, not just a preference. | `lib/src/widgets/pdf_viewer.dart:4659`; docs `pdf_viewer_params.dart:483-538` |
| `PdfPage.render` (thumbnails) | To confirm in M6 prep — pdfrx exposes page render via `PdfPage` (pdfrx_engine); verify exact RGBA path before removing syncfusion-viewer dep. | (deferred to M6 prep) |
| `panAxis` | Still **[VERIFY-IN-M1 on device]** whether `PanAxis.vertical` blocks the horizontal component of a pinch-zoom pan; spike uses it — confirm on tablet whether `PanAxis.free` is needed when zoomed. | `pdf_viewer_params.dart` (panAxis field) |
**Transport finding (R1):** Flutter hit-tests a pointer only on its DOWN and caches the path; `RenderBox.hitTest` receives only the position, **not** the pointer kind. The spike resolves this with `PenCaptureBinding` (overrides `GestureBinding.handlePointerEvent` to stash the in-flight `event.kind` before the synchronous hit-test) which `_RenderPenCapture.hitTest` reads — capturing stylus outside the gesture arena while touch/mouse fall through to pdfrx. Compiles clean; **empirical pen-vs-touch behavior remains MUST #3 (physical Surface Pen).**
---
## Sample Protocol (§7.1)
- **Minimum sample size:** N ≥ 120 frames per measurement.
- **Mode:** profile build (`flutter run --profile`), **not** debug.
- **Cache:** warm cache — scroll once end-to-end before recording.
- **Runs:** 3 independent runs; report median across runs.
- **Asset:** `test/assets/large_300p.pdf` (300 pages, non-blank content).
- **Stroke asset:** `test/assets/dense_strokes.json` key `"2000"` for MUST #5 (~300 strokes/page spread across visible pages).
- **Reporting:** paste Flutter DevTools frame chart screenshot + numeric summary (median, p95, p99) into this doc with commit hash and device spec.
---
## Device Specs
| Field | Value |
|-------|-------|
| Device | PENDING |
| OS | PENDING |
| Flutter version | PENDING |
| Dart version | PENDING |
| pdfrx version (resolved) | PENDING |
| Surface Pen model | PENDING |

View File

@@ -0,0 +1,166 @@
// integration_test/coordinate_assertion_test.dart
//
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
// this is a blocking gate.
//
// RUN (on a device/desktop with a display + working pdfium):
// flutter test integration_test/coordinate_assertion_test.dart
// or, on the Windows tablet via a driver:
// flutter drive --driver=test_driver/integration_test.dart \
// --target=integration_test/coordinate_assertion_test.dart
//
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
// resolve. If pdfium cannot render under the harness on a headless Linux box
// (no GL/surface), this test will time out at `_waitForReady`; that is an
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
// assertion logic below is correct and must not be weakened to force a pass.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
void main() {
// Standard integration binding. This test drives zoom + reads geometry only;
// it does not inject pen events, so PenCaptureRegion stays transparent
// (currentPointerKind == null → never captures), which is exactly correct
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
// which the runner initializes first.)
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
pdfrxFlutterInitialize();
late File pdfFile;
setUpAll(() async {
pdfFile = await _writeTinyPdf();
});
tearDownAll(() async {
if (await pdfFile.exists()) await pdfFile.delete();
});
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
(tester) async {
final controller = PdfViewerController();
PdfDocument? readyDoc;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
pdfPath: pdfFile.path,
controller: controller,
onViewerReady: (doc, _) => readyDoc = doc,
),
),
),
);
// Wait for pdfrx to load + lay out the page.
final ready = await _waitForReady(tester, controller);
if (!ready) {
fail(
'pdfrx did not become ready (page layout unavailable). This is almost '
'certainly the headless-Linux pdfium limitation described in the file '
'header — run on the Windows tablet:\n'
' flutter drive --driver=test_driver/integration_test.dart '
'--target=integration_test/coordinate_assertion_test.dart',
);
}
expect(readyDoc, isNotNull);
// The page-center in DOCUMENT space is the layout rect center of page 1.
final pageRect = controller.layout.pageLayouts.first;
final pageCenterDoc = pageRect.center;
Future<void> assertCenterAtCurrentZoom(String label) async {
await tester.pumpAndSettle();
// Project the page-center document point to viewer-local (== screen,
// since the viewer fills the Scaffold body) coordinates.
final localCenter = controller.documentToLocal(pageCenterDoc);
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
// the page center within tolerance, proving the coordinate model maps
// normalized→document→screen consistently at this zoom.
final box = tester.renderObject<RenderBox>(
find.byType(SpikeEditorPane),
);
final globalCenter = box.localToGlobal(localCenter);
final roundTripDoc = controller.globalToDocument(globalCenter);
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
// Tolerance: 1 document unit (sub-pixel at these zooms).
expect(dx, lessThan(1.0),
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
expect(dy, lessThan(1.0),
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
}
// --- fit ---
await controller.goTo(
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
duration: Duration.zero,
);
await assertCenterAtCurrentZoom('fit');
final fitZoom = controller.currentZoom;
// --- 2x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
await assertCenterAtCurrentZoom('2x');
// --- 4x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
await assertCenterAtCurrentZoom('4x');
});
}
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
/// or the timeout elapses. Returns whether it became ready.
Future<bool> _waitForReady(
WidgetTester tester,
PdfViewerController controller, {
Duration timeout = const Duration(seconds: 20),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
return true;
}
}
return false;
}
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
Future<File> _writeTinyPdf() async {
final doc = sf.PdfDocument();
final page = doc.pages.add();
final size = page.getClientSize();
page.graphics.drawRectangle(
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
);
page.graphics.drawString(
'M1 coord test',
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
);
final bytes = await doc.save();
doc.dispose();
final file = File(
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
);
await file.writeAsBytes(bytes, flush: true);
return file;
}

View File

@@ -0,0 +1,245 @@
// integration_test/perf_scroll_bench.dart
//
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
//
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
// time ≤ 16.6ms.
//
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
// are populated before sampling.
//
// RUN (profile mode, on the Windows tablet or a desktop with a display):
// flutter test --profile integration_test/perf_scroll_bench.dart
// or via the driver for on-device profiling:
// flutter drive --profile \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/perf_scroll_bench.dart
//
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
// always read the numbers from a PROFILE run on the target device. On a
// headless Linux box pdfium may fail to render; if so the bench prints a clear
// skip and must be run on the tablet (see coordinate_assertion_test header).
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
const String _kPdfPath = 'test/assets/large_300p.pdf';
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
/// Frames to sample after warm-up.
const int _kSampleFrames = 120;
/// Frames to discard before sampling (cache warm-up, §7.1).
const int _kWarmupFrames = 30;
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Report raw frame timings to the device lab / driver too.
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
pdfrxFlutterInitialize();
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
final pdf = File(_kPdfPath);
if (!pdf.existsSync()) {
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
return;
}
// ---- MUST #4: pdfrx alone ----
final r4 = await _runScrollPass(
tester,
label: 'MUST #4 — pdfrx alone (no ink overlay)',
inkLoad: false,
);
// ---- MUST #5: WITH dense ink overlay ----
final r5 = await _runScrollPass(
tester,
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
inkLoad: true,
);
if (r4 == null || r5 == null) {
stdout.writeln(
'\n=== PERF BENCH SKIPPED ===\n'
'pdfrx did not become ready (headless pdfium limitation). Run on the '
'Windows tablet in profile mode:\n'
' flutter drive --profile '
'--driver=test_driver/integration_test.dart '
'--target=integration_test/perf_scroll_bench.dart\n',
);
return;
}
_printReport('MUST #4', r4, buildOnlyGate: false);
_printReport('MUST #5', r5, buildOnlyGate: true);
});
}
class _Stats {
_Stats(this.label, this.build, this.raster, this.total);
final String label;
final _Series build;
final _Series raster;
final _Series total;
}
class _Series {
_Series(List<double> values)
: median = _pct(values, 50),
p95 = _pct(values, 95),
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
jankFrames = values.where((v) => v > 32.0).length,
n = values.length;
final double median;
final double p95;
final double worst;
final int jankFrames;
final int n;
static double _pct(List<double> v, int p) {
if (v.isEmpty) return 0;
final s = List<double>.from(v)..sort();
final i = ((p / 100.0) * (s.length - 1)).round();
return s[i.clamp(0, s.length - 1)];
}
}
/// Pumps the spike pane, warms up, then drives a sustained fling while
/// collecting FrameTiming. Returns null if pdfrx never became ready.
Future<_Stats?> _runScrollPass(
WidgetTester tester, {
required String label,
required bool inkLoad,
}) async {
final controller = PdfViewerController();
final paneKey = GlobalKey<SpikeEditorPaneState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
key: paneKey,
pdfPath: _kPdfPath,
controller: controller,
denseStrokesAsset: _kDenseStrokesAsset,
),
),
),
);
// Wait for the document to lay out.
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
}
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
return null;
}
if (inkLoad) {
await paneKey.currentState!.setInkLoad(true);
await tester.pumpAndSettle();
}
// Collect frame timings.
final build = <double>[];
final raster = <double>[];
final total = <double>[];
var seen = 0;
void onTimings(List<FrameTiming> timings) {
for (final t in timings) {
seen++;
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
if (build.length >= _kSampleFrames) continue;
build.add(t.buildDuration.inMicroseconds / 1000.0);
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
total.add(t.totalSpan.inMicroseconds / 1000.0);
}
}
SchedulerBinding.instance.addTimingsCallback(onTimings);
try {
// Sustained fling: repeated downward flings across the viewport center to
// keep the document scrolling continuously while we gather ≥150 frames.
final center = tester.getCenter(find.byType(SpikeEditorPane));
var safety = 0;
while (build.length < _kSampleFrames && safety < 400) {
await tester.fling(
find.byType(SpikeEditorPane),
const Offset(0, -600),
2000,
warnIfMissed: false,
);
// Pump several frames to let the fling settle and emit timings.
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
// Nudge back up occasionally so we don't run off the end of 300 pages.
if (safety % 8 == 7) {
await tester.fling(find.byType(SpikeEditorPane),
const Offset(0, 1200), 2000, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 16));
}
safety++;
// Keep `center` referenced (avoids unused warning) and re-target if needed.
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
}
} finally {
SchedulerBinding.instance.removeTimingsCallback(onTimings);
}
return _Stats(
label,
_Series(build),
_Series(raster),
_Series(total),
);
}
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
final buf = StringBuffer();
buf.writeln('\n========================================================');
buf.writeln('$tag${s.label}');
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
buf.writeln('--------------------------------------------------------');
buf.writeln('phase median p95 worst jank(>32ms)');
buf.writeln('build ${_row(s.build)}');
buf.writeln('raster ${_row(s.raster)}');
buf.writeln('total ${_row(s.total)}');
buf.writeln('--------------------------------------------------------');
if (buildOnlyGate) {
final pass = s.build.median <= 16.6;
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
} else {
final passMed = s.total.median <= 16.6;
final passP95 = s.total.p95 <= 22.0;
buf.writeln('GATE (MUST #4): build+raster median '
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
'${passMed ? "PASS" : "FAIL"}; '
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
'${passP95 ? "PASS" : "FAIL"}');
}
buf.writeln('========================================================\n');
stdout.write(buf.toString());
}
String _row(_Series s) =>
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
'${s.jankFrames.toString().padLeft(6)}';

View File

@@ -0,0 +1,198 @@
// lib/editor/pdf/pen_capture_region.dart
//
// Per-pointer-kind input transport for the BadNote PDF editor (plan §3.5).
//
// PROBLEM
// -------
// pdfrx's PdfViewer uses an internal InteractiveViewer-style pan/zoom driven by
// a GestureDetector (onScaleStart/Update/End). A widget layered on top of the
// viewer that wants to *draw* with the pen would normally compete with that
// recognizer in Flutter's **gesture arena** — and the scale recognizer is
// greedy, so a freehand pen drag would frequently be claimed by the viewer
// (pen → pan) or starve touch scrolling. pdfrx's own
// `PdfOverlayInteractionRegion` is tap-oriented only and gives us no freehand
// drag stream that bypasses the arena.
//
// SOLUTION (arena-bypass, per-kind hit-test split)
// ------------------------------------------------
// We do NOT use a GestureDetector for pen capture. Instead we install a custom
// RenderProxyBox (`_RenderPenCapture`) whose hit-test answer is conditioned on
// the pointer device kind of the pointer-DOWN currently being routed:
//
// * stylus / invertedStylus -> hitTest returns TRUE -> this box becomes the
// pointer's hit-test target and receives the
// entire down/move/up stream directly via
// RenderObject.handleEvent, NOT through the
// gesture arena (we never add a recognizer).
//
// * touch / mouse / trackpad -> hitTest returns FALSE -> the hit-test
// continues past us to the pdfrx viewer
// underneath, so single-finger touch scroll and
// pinch-zoom reach pdfrx untouched.
//
// WHY A CUSTOM BINDING IS REQUIRED (the only correct seam)
// --------------------------------------------------------
// Flutter only hit-tests a pointer on its PointerDownEvent, then CACHES the
// resulting HitTestResult and reuses it for every subsequent move/up
// (GestureBinding._handlePointerEventImmediately). Crucially,
// `hitTestInView(result, position, viewId)` is passed only the *position*, NOT
// the event — so a RenderBox.hitTest cannot read the pointer's kind from its
// arguments or from the HitTestResult. `pointerRouter.addGlobalRoute` fires
// during dispatchEvent, which is AFTER hit-test, so it is too late to influence
// the DOWN routing.
//
// The single reliable, fully-public seam is `GestureBinding.handlePointerEvent`
// (virtual), which is called with the live event immediately before the
// synchronous hit-test of that same event. [PenCaptureBinding] overrides it to
// stash the in-flight pointer kind into [PenCaptureBinding.currentPointerKind]
// before delegating to super; `_RenderPenCapture.hitTest` reads that field. The
// app must install [PenCaptureBinding] in main() (see spike_main.dart). If a
// non-PenCapture binding is in use, [currentPointerKind] stays null and the
// region defaults to NOT capturing — so touch scrolling is never accidentally
// stolen.
//
// The `captureEnabled` flag gates capture by editor mode (plan §6.1: Browse
// never captures pen; Draw/Type do). When disabled the box is hit-test
// transparent for all kinds.
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Pointer kinds this region captures (plan §3.5 / §6.2: pen only; mouse is
/// never captured for drawing — pen required).
const Set<PointerDeviceKind> kPenCaptureKinds = {
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
/// A [WidgetsFlutterBinding] that records the device kind of the pointer event
/// currently being routed, so that kind-gated hit-testing
/// ([PenCaptureRegion]) can consult it during the synchronous DOWN hit-test.
///
/// Install in main():
/// ```dart
/// void main() {
/// PenCaptureBinding.ensureInitialized();
/// pdfrxFlutterInitialize();
/// runApp(const SpikeApp());
/// }
/// ```
class PenCaptureBinding extends WidgetsFlutterBinding {
/// The device kind of the pointer event currently being handled by
/// [handlePointerEvent], valid for the duration of the synchronous hit-test
/// the framework performs for that event. Null when no binding override is
/// active or between events.
static PointerDeviceKind? currentPointerKind;
/// Ensures a [PenCaptureBinding] is the active binding and returns it.
static WidgetsBinding ensureInitialized() {
PenCaptureBinding();
return WidgetsBinding.instance;
}
@override
void handlePointerEvent(PointerEvent event) {
// Set the kind BEFORE super performs the synchronous hit-test for a DOWN.
currentPointerKind = event.kind;
try {
super.handlePointerEvent(event);
} finally {
// Leave currentPointerKind set to the last event's kind; it is only read
// transiently during hit-test (which happens inside super for DOWNs).
// We do not null it here because moves reuse the cached hit path and never
// re-hit-test, so staleness between events is harmless.
}
}
}
/// A transparent capture region that forwards the full pointer stream for
/// stylus/invertedStylus pointers to [onPenEvent] while letting touch and mouse
/// pointers fall through to whatever is painted underneath (typically a pdfrx
/// `PdfViewer`).
///
/// See the file header for the arena-bypass rationale and binding requirement.
class PenCaptureRegion extends SingleChildRenderObjectWidget {
const PenCaptureRegion({
super.key,
required this.onPenEvent,
this.captureEnabled = true,
required Widget child,
}) : super(child: child);
/// Called with every [PointerEvent] (down/move/up/cancel) of a captured pen
/// pointer, delivered raw and in order without arena arbitration.
final void Function(PointerEvent event) onPenEvent;
/// When false the region is hit-test transparent for all pointer kinds, so
/// even pen events fall through to the viewer underneath (e.g. Browse mode).
final bool captureEnabled;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderPenCapture(
onPenEvent: onPenEvent,
captureEnabled: captureEnabled,
);
}
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _RenderPenCapture)
..onPenEvent = onPenEvent
..captureEnabled = captureEnabled;
}
}
/// RenderProxyBox that conditionally participates in hit-testing based on the
/// in-flight pointer's device kind (via [PenCaptureBinding.currentPointerKind])
/// and forwards captured pen events.
class _RenderPenCapture extends RenderProxyBox {
_RenderPenCapture({
required void Function(PointerEvent) onPenEvent,
required bool captureEnabled,
}) : _onPenEvent = onPenEvent,
_captureEnabled = captureEnabled;
void Function(PointerEvent) _onPenEvent;
set onPenEvent(void Function(PointerEvent) value) => _onPenEvent = value;
bool _captureEnabled;
set captureEnabled(bool value) => _captureEnabled = value;
/// Returns true only for pen kinds while capture is enabled. Returning false
/// continues the hit-test to the pdfrx viewer underneath, which is how
/// touch/mouse reach it for scroll/zoom.
///
/// We override [hitTest] directly (rather than relying on
/// [hitTestChildren]+[hitTestSelf]) so the kind check is the single decision
/// point and covers the entire box area, regardless of the translucent paint
/// child.
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!_captureEnabled) return false;
if (!_shouldCaptureCurrentPointer()) return false;
if (!size.contains(position)) return false;
result.add(BoxHitTestEntry(this, position));
return true;
}
/// Mirrors the predicate for any caller routing through the default
/// RenderBox.hitTest dispatch (hitTestChildren then hitTestSelf).
@override
bool hitTestSelf(Offset position) =>
_captureEnabled && _shouldCaptureCurrentPointer();
bool _shouldCaptureCurrentPointer() {
final kind = PenCaptureBinding.currentPointerKind;
if (kind == null) return false; // No kind-aware binding → never steal touch.
return kPenCaptureKinds.contains(kind);
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (!_captureEnabled) return;
if (!kPenCaptureKinds.contains(event.kind)) return;
_onPenEvent(event);
}
}

View File

@@ -0,0 +1,187 @@
// lib/editor/pdf/spike_app.dart
//
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
// on-screen frame-timing HUD (median build & raster ms over the last ~120
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pdfrx/pdfrx.dart';
import 'spike_editor_pane.dart';
class SpikeApp extends StatelessWidget {
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BadNote M1 Spike',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: SpikeHome(
pdfPath: pdfPath,
denseStrokesAsset: denseStrokesAsset,
),
);
}
}
class SpikeHome extends StatefulWidget {
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
State<SpikeHome> createState() => _SpikeHomeState();
}
class _SpikeHomeState extends State<SpikeHome> {
final GlobalKey<SpikeEditorPaneState> _paneKey =
GlobalKey<SpikeEditorPaneState>();
final PdfViewerController _controller = PdfViewerController();
bool _inkLoad = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SpikeEditorPane(
key: _paneKey,
controller: _controller,
pdfPath: widget.pdfPath,
denseStrokesAsset: widget.denseStrokesAsset,
),
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
],
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
heroTag: 'inkload',
onPressed: () async {
final next = !_inkLoad;
await _paneKey.currentState?.setInkLoad(next);
setState(() => _inkLoad = next);
},
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
icon: const Icon(Icons.brush),
),
],
),
);
}
}
/// On-screen median build/raster frame-time HUD, driven by
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
/// [_window] frames for both the build (`buildDuration`) and raster
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
/// MUST #4/#5.
class FrameTimingHud extends StatefulWidget {
const FrameTimingHud({super.key});
@override
State<FrameTimingHud> createState() => _FrameTimingHudState();
}
class _FrameTimingHudState extends State<FrameTimingHud> {
static const int _window = 120;
final List<double> _build = <double>[];
final List<double> _raster = <double>[];
double _medBuild = 0;
double _medRaster = 0;
double _p95Build = 0;
double _p95Raster = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onTimings);
}
@override
void dispose() {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
super.dispose();
}
void _onTimings(List<FrameTiming> timings) {
for (final t in timings) {
_build.add(t.buildDuration.inMicroseconds / 1000.0);
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
}
while (_build.length > _window) {
_build.removeAt(0);
}
while (_raster.length > _window) {
_raster.removeAt(0);
}
if (!mounted) return;
setState(() {
_medBuild = _percentile(_build, 50);
_medRaster = _percentile(_raster, 50);
_p95Build = _percentile(_build, 95);
_p95Raster = _percentile(_raster, 95);
});
}
static double _percentile(List<double> values, int p) {
if (values.isEmpty) return 0;
final sorted = List<double>.from(values)..sort();
final idx = ((p / 100.0) * (sorted.length - 1)).round();
return sorted[idx.clamp(0, sorted.length - 1)];
}
@override
Widget build(BuildContext context) {
Color budget(double ms) => ms <= 16.6
? Colors.greenAccent
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
return IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(8),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.white,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('frames: ${_build.length}/$_window'),
Text.rich(TextSpan(children: [
const TextSpan(text: 'build med '),
TextSpan(
text: '${_medBuild.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medBuild))),
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
])),
Text.rich(TextSpan(children: [
const TextSpan(text: 'raster med '),
TextSpan(
text: '${_medRaster.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medRaster))),
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
])),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,344 @@
// lib/editor/pdf/spike_editor_pane.dart
//
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
// PdfViewer.file and exercises the three things the M1 gate must prove:
//
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
// diagnostic crosshair at normalized (0.5, 0.5) using
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
//
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
// touch scrolls and pinch zooms — same overlay, no mode switch.
//
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
// per page (from dense_strokes.json) into the page overlay so the perf
// bench can measure BUILD time with a non-trivial ui.Picture per page.
//
// This file is NOT production code and is excluded from the real editor.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
/// Normalized page-space point the diagnostic marker is painted at. The M1
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
const Offset kMarkerNormalized = Offset(0.5, 0.5);
/// A single captured pen sample in normalized page space, tagged with its page.
class _PenSample {
const _PenSample(this.pageIndex, this.normalized);
final int pageIndex;
final Offset normalized;
}
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
class SpikeEditorPane extends StatefulWidget {
const SpikeEditorPane({
super.key,
required this.pdfPath,
this.denseStrokesAsset,
this.strokesPerPage = 300,
this.strokeCountKey = '2000',
this.onViewerReady,
this.controller,
});
final String pdfPath;
final String? denseStrokesAsset;
final int strokesPerPage;
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
final String strokeCountKey;
/// Forwarded from pdfrx once the document is laid out and interactive.
final void Function(PdfDocument document, PdfViewerController controller)?
onViewerReady;
/// Optional externally-owned controller (tests drive zoom through this).
final PdfViewerController? controller;
@override
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
}
class SpikeEditorPaneState extends State<SpikeEditorPane> {
late final PdfViewerController _controller =
widget.controller ?? PdfViewerController();
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
List<_PenSample>? _activeStroke;
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
/// list of normalized polylines (one stroke = list of points).
List<List<Offset>>? _syntheticStrokes;
bool _inkLoadEnabled = false;
bool _loadingSynthetic = false;
bool get inkLoadEnabled => _inkLoadEnabled;
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
/// enable. Public so the perf bench can drive it programmatically.
Future<void> setInkLoad(bool enabled) async {
if (enabled && _syntheticStrokes == null) {
await _loadSyntheticStrokes();
}
if (mounted) setState(() => _inkLoadEnabled = enabled);
}
Future<void> _loadSyntheticStrokes() async {
final asset = widget.denseStrokesAsset;
if (asset == null || _loadingSynthetic) return;
_loadingSynthetic = true;
try {
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
final raw = await File(asset).readAsString();
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final strokesJson =
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
final result = <List<Offset>>[];
for (final s in strokesJson) {
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
final poly = <Offset>[];
for (final p in points) {
final pt = p as Map<String, dynamic>;
poly.add(Offset(
(pt['x'] as num).toDouble(),
(pt['y'] as num).toDouble(),
));
}
if (poly.length >= 2) result.add(poly);
}
_syntheticStrokes = result;
} finally {
_loadingSynthetic = false;
}
}
// --- Pen capture (viewer-level) ---------------------------------------
void _onPenEvent(PointerEvent event) {
// Convert global → document → which page + normalized page coords.
final doc = _controller.globalToDocument(event.position);
if (doc == null) return;
final hit = _documentToPage(doc);
if (hit == null) return;
if (event is PointerDownEvent) {
_activeStroke = <_PenSample>[hit];
_penStrokes.add(_activeStroke!);
setState(() {});
} else if (event is PointerMoveEvent) {
_activeStroke?.add(hit);
setState(() {});
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_activeStroke = null;
}
}
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
/// controller's page layout rects (document coordinates). Returns null if the
/// point is outside every page box.
_PenSample? _documentToPage(Offset doc) {
if (!_controller.isReady) return null;
final rects = _controller.layout.pageLayouts;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
if (r.contains(doc)) {
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
return _PenSample(i, Offset(nx, ny));
}
}
return null;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
PdfViewer.file(
widget.pdfPath,
controller: _controller,
params: PdfViewerParams(
onViewerReady: widget.onViewerReady,
// (1) Per-page overlay: diagnostic center marker + optional synthetic
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
// normalized [0,1] → zoomed pixels (plan §2.1).
pageOverlaysBuilder: (context, pageRectInViewer, page) {
final pageIndex = page.pageNumber - 1;
return [
SizedBox.fromSize(
size: pageRectInViewer.size,
child: CustomPaint(
painter: _SpikeInkPainter(
synthetic:
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
),
),
),
];
},
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
Positioned.fill(
child: PenCaptureRegion(
onPenEvent: _onPenEvent,
child: IgnorePointer(
child: CustomPaint(
size: size,
painter: _LivePenPainter(
strokes: _penStrokes,
controller: _controller,
),
),
),
),
),
];
},
),
),
],
);
}
/// Deterministic per-page slice of the synthetic stroke pool so each page
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
List<List<Offset>> _strokesForPage(int pageIndex) {
final pool = _syntheticStrokes;
if (pool == null || pool.isEmpty) return const [];
final n = widget.strokesPerPage.clamp(0, pool.length);
final start = (pageIndex * n) % pool.length;
final out = <List<Offset>>[];
for (var i = 0; i < n; i++) {
out.add(pool[(start + i) % pool.length]);
}
return out;
}
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
}
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
/// inspects.
class _SpikeInkPainter extends CustomPainter {
_SpikeInkPainter({this.synthetic});
final List<List<Offset>>? synthetic;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
// Map normalized [0,1] → zoomed pixels (plan §2.1).
canvas.scale(size.width, size.height);
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
final syn = synthetic;
if (syn != null && syn.isNotEmpty) {
final inkPaint = Paint()
..color = const Color(0x5500AAFF)
..style = PaintingStyle.stroke
// Stroke width is in normalized units post-scale; keep it page-relative
// and hairline-ish so 300 strokes are visible but cheap.
..strokeWidth = 0.002
..strokeCap = StrokeCap.round;
for (final poly in syn) {
if (poly.length < 2) continue;
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
for (var i = 1; i < poly.length; i++) {
path.lineTo(poly[i].dx, poly[i].dy);
}
canvas.drawPath(path, inkPaint);
}
}
canvas.restore();
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
// restore) so its line thickness is constant on screen and its CENTER is at
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
// this pixel.
final center = Offset(
size.width * kMarkerNormalized.dx,
size.height * kMarkerNormalized.dy,
);
final markerPaint = Paint()
..color = const Color(0xFFFF0066)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
const arm = 16.0;
canvas.drawLine(
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
canvas.drawLine(
center.translate(0, -arm), center.translate(0, arm), markerPaint);
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
}
@override
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
oldDelegate.synthetic != synthetic;
}
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
/// in normalized page space, so for each sample we re-project page→document→
/// local each paint via the controller (keeps strokes glued to pages under
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
class _LivePenPainter extends CustomPainter {
_LivePenPainter({required this.strokes, required this.controller})
: super(repaint: controller);
final List<List<_PenSample>> strokes;
final PdfViewerController controller;
@override
void paint(Canvas canvas, Size size) {
if (!controller.isReady) return;
final rects = controller.layout.pageLayouts;
final paint = Paint()
..color = const Color(0xFF1565C0)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final stroke in strokes) {
Path? path;
for (final s in stroke) {
if (s.pageIndex >= rects.length) continue;
final r = rects[s.pageIndex];
// normalized page → document
final docPt = Offset(
r.left + s.normalized.dx * r.width,
r.top + s.normalized.dy * r.height,
);
// document → local (viewer) coords
final local = controller.documentToLocal(docPt);
if (path == null) {
path = Path()..moveTo(local.dx, local.dy);
} else {
path.lineTo(local.dx, local.dy);
}
}
if (path != null) canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,34 @@
// lib/editor/pdf/spike_launcher.dart
//
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
// real Surface Pen with the user's OWN large PDFs). Remove together with the
// rest of lib/editor/pdf/spike_* once M1 is signed off.
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'spike_editor_pane.dart';
/// Opens a file picker for a PDF, then pushes the spike pane on it.
/// Pass the user's own large PDF to get a realistic 60fps / pen test.
Future<void> openM1Spike(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
final path = result?.files.single.path;
if (path == null) return;
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(title: const Text('M1 Spike — pen / scroll / zoom')),
// denseStrokesAsset is null here: the bundled synthetic-stroke load is
// only for the perf bench. For manual on-device testing, draw real ink
// and scroll a real large PDF while watching the frame-time HUD.
body: SpikeEditorPane(pdfPath: path),
),
),
);
}

View File

@@ -0,0 +1,62 @@
// lib/editor/pdf/spike_main.dart
//
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
//
// Launch on the Windows tablet (or any desktop with a display):
// flutter run -t lib/editor/pdf/spike_main.dart
//
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
// observable on-device.
//
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
// below before pdfrx init). pdfrx itself is initialized via
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
import 'spike_app.dart';
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
/// Filesystem path for the synthetic ink load (regenerate via
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
void main(List<String> args) {
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
pdfrxFlutterInitialize();
// Allow overriding the PDF path as the first CLI arg (otherwise the default
// 300-page bench asset relative to the project root / cwd).
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
runApp(
SpikeApp(
pdfPath: pdfPath,
denseStrokesAsset: _kDenseStrokesAsset,
),
);
}
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
/// the relative asset path works on desktop; we also try a couple of fallbacks.
String _resolvePdfPath() {
final candidates = <String>[
_kDefaultPdfRelPath,
'${Directory.current.path}/$_kDefaultPdfRelPath',
];
for (final c in candidates) {
if (File(c).existsSync()) return c;
}
// Return the primary path anyway; pdfrx will surface a clear load error.
return _kDefaultPdfRelPath;
}

View File

@@ -1,14 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'editor/pdf/pen_capture_region.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'services/database_service.dart'; import 'services/database_service.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // Kind-aware binding (extends WidgetsFlutterBinding) must be the active
// binding before runApp so the M1 spike's PenCaptureRegion can gate
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
// region mounted it behaves exactly like the default binding.
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (required before any PdfViewer is built).
pdfrxFlutterInitialize();
// Ensure DB is ready before the app starts so providers can use it eagerly // Ensure DB is ready before the app starts so providers can use it eagerly
await DatabaseService.getInstance(); await DatabaseService.getInstance();

View File

@@ -5,6 +5,7 @@ import '../models/note.dart';
import '../providers/document_provider.dart'; import '../providers/document_provider.dart';
import '../providers/note_provider.dart'; import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart'; import '../providers/ocr_provider.dart';
import '../editor/pdf/spike_launcher.dart';
import '../services/pdf_service.dart'; import '../services/pdf_service.dart';
import '../services/pptx_service.dart'; import '../services/pptx_service.dart';
import 'note_editor_screen.dart'; import 'note_editor_screen.dart';
@@ -67,6 +68,12 @@ class HomeScreen extends ConsumerWidget {
).push(MaterialPageRoute(builder: (_) => const SearchScreen())); ).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
}, },
), ),
// THROWAWAY M1 spike entry (remove with lib/editor/pdf/spike_*).
IconButton(
icon: const Icon(Icons.science_outlined),
tooltip: 'M1 Spike (pen/scroll/zoom test)',
onPressed: () => openM1Spike(context),
),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(

View File

@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.4" version: "0.13.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -318,6 +326,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -384,6 +397,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -440,6 +458,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -504,6 +530,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.2" version: "0.2.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: transitive dependency: transitive
description: description:
@@ -712,6 +743,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdfium_dart:
dependency: transitive
description:
name: pdfium_dart
sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
pdfium_flutter:
dependency: transitive
description:
name: pdfium_flutter
sha256: "420ba8e7673b54da387ceeeb18a72c8bc6e4452128dbb391dca900c748f9e9ba"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
pdfrx:
dependency: "direct main"
description:
name: pdfrx
sha256: e0ca318004c3f32144db8e74fa612abb80ebe79004677293af74ed9af119f47f
url: "https://pub.dev"
source: hosted
version: "2.4.4"
pdfrx_engine:
dependency: transitive
description:
name: pdfrx_engine
sha256: "89865e158ced818690ab207a13a34a65803cd67ee1bec9f5f87838eb5a79600b"
url: "https://pub.dev"
source: hosted
version: "0.4.3"
perfect_freehand: perfect_freehand:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -752,6 +815,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -808,6 +887,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.4" version: "2.6.4"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1005,6 +1092,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
syncfusion_flutter_core: syncfusion_flutter_core:
dependency: transitive dependency: transitive
description: description:
@@ -1229,6 +1324,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
@@ -1271,4 +1374,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.8 <4.0.0" dart: ">=3.10.8 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.41.0"

View File

@@ -50,6 +50,7 @@ dependencies:
# Embedded ONNX runtime (local OCR recognition backend) # Embedded ONNX runtime (local OCR recognition backend)
flutter_onnxruntime: ^1.8.0 flutter_onnxruntime: ^1.8.0
pdfrx: ^2.4.4
# Pin sqlite3 to the exact version whose native binaries are vendored under # Pin sqlite3 to the exact version whose native binaries are vendored under
# vendor/sqlite3/ (see hooks block below). Without this, pub re-resolves to the # vendor/sqlite3/ (see hooks block below). Without this, pub re-resolves to the
@@ -61,6 +62,9 @@ dependency_overrides:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# M1 spike perf/coordinate gates (integration_test harness).
integration_test:
sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
# Code Generation # Code Generation

View File

@@ -0,0 +1,16 @@
// test_driver/integration_test.dart
//
// Driver entry point for running the M1 integration_test gates on a real device
// (Windows tablet) in profile mode, e.g.:
//
// flutter drive --profile \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/perf_scroll_bench.dart
//
// flutter drive \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/coordinate_assertion_test.dart
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();

239
tool/gen_bench_pdf.dart Normal file
View File

@@ -0,0 +1,239 @@
// tool/gen_bench_pdf.dart
//
// Generates a benchmark PDF at test/assets/large_300p.pdf using
// package:syncfusion_flutter_pdf.
//
// syncfusion_flutter_pdf imports dart:ui which is only available inside the
// Flutter SDK runtime, so this file CANNOT be run via plain `dart run`.
//
// USAGE:
// flutter test tool/gen_bench_pdf.dart
// flutter test tool/gen_bench_pdf.dart --dart-define=PAGE_COUNT=50
//
// The script is structured as a flutter_test file (one `test(...)` block) so
// that `flutter test` invokes it with the full Flutter engine (dart:ui present).
// It is NOT a real unit test — it is a code-generation tool that happens to
// need the Flutter runtime. The test "passes" as long as the file is written
// successfully.
//
// Each page contains:
// - A bold title (page number heading)
// - Two paragraphs of body text
// - A ruled grid of lines (10x10)
// - A filled rectangle and an outlined ellipse
// This gives a realistic (non-blank) render load for pdfrx frame-timing tests.
// ignore_for_file: avoid_print
import 'dart:io';
import 'dart:math';
import 'dart:ui' show Offset, Rect, Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart';
void main() {
// Read PAGE_COUNT from --dart-define (default 300).
const int pageCount = int.fromEnvironment('PAGE_COUNT', defaultValue: 300);
test('generate test/assets/large_300p.pdf ($pageCount pages)', () {
final outputPath = _resolveOutputPath();
final outFile = File(outputPath);
outFile.parent.createSync(recursive: true);
final pdf = PdfDocument();
// Reusable fonts and brushes (created once, shared across pages).
final titleFont = PdfStandardFont(PdfFontFamily.helvetica, 18,
style: PdfFontStyle.bold);
final bodyFont = PdfStandardFont(PdfFontFamily.helvetica, 10);
final smallFont = PdfStandardFont(PdfFontFamily.helvetica, 8);
final blackBrush = PdfSolidBrush(PdfColor(0, 0, 0));
final darkBlueBrush = PdfSolidBrush(PdfColor(10, 30, 80));
final lightGrayBrush = PdfSolidBrush(PdfColor(220, 220, 220));
final accentBrush = PdfSolidBrush(PdfColor(60, 100, 200));
final gridPen = PdfPen(PdfColor(180, 180, 180), width: 0.3);
final borderPen = PdfPen(PdfColor(0, 0, 0), width: 1.0);
final accentPen = PdfPen(PdfColor(60, 100, 200), width: 1.5);
final rng = Random(42); // deterministic
for (int i = 1; i <= pageCount; i++) {
final page = pdf.pages.add();
final g = page.graphics;
final w = page.getClientSize().width;
final h = page.getClientSize().height;
// ── Title ──────────────────────────────────────────────────────────────
g.drawString(
'BadNote Benchmark — Page $i of $pageCount',
titleFont,
brush: darkBlueBrush,
bounds: Rect.fromLTWH(36, 30, w - 72, 28),
);
// Horizontal rule under title
g.drawLine(
PdfPen(PdfColor(60, 100, 200), width: 1.0),
Offset(36, 62),
Offset(w - 36, 62),
);
// ── Body text (two paragraphs) ────────────────────────────────────────
final paragraph1 =
'This page is part of a synthetic $pageCount-page benchmark PDF '
'generated by BadNote\'s tool/gen_bench_pdf.dart. Each page carries '
'non-trivial content (text, vector shapes, a line grid) to simulate '
'realistic rendering load for pdfrx frame-timing measurements. '
'Page index: $i. Seed value: ${rng.nextInt(99999)}.';
final paragraph2 =
'Performance target (MUST #4, §10/M1): pdfrx fling-scroll over '
'$pageCount pages in profile mode must stay at ≤ 16.6 ms median '
'frame time (build + raster) and ≤ 22 ms at p95, measured over '
'N ≥ 120 frames per §7.1 of the BadNote Phase 1 plan. If this gate '
'fails the backend choice is invalidated. Fill: ${_lorem(rng, 60)}.';
g.drawString(
paragraph1,
bodyFont,
brush: blackBrush,
bounds: Rect.fromLTWH(36, 72, w - 72, 80),
format: PdfStringFormat(lineSpacing: 4),
);
g.drawString(
paragraph2,
bodyFont,
brush: blackBrush,
bounds: Rect.fromLTWH(36, 158, w - 72, 80),
format: PdfStringFormat(lineSpacing: 4),
);
// ── 10×10 ruled grid ──────────────────────────────────────────────────
const gridLeft = 36.0;
const gridTop = 260.0;
final gridWidth = w - 72;
const gridHeight = 220.0;
const cols = 10;
const rows = 10;
final cellW = gridWidth / cols;
const cellH = gridHeight / rows;
for (int col = 0; col <= cols; col++) {
final x = gridLeft + col * cellW;
g.drawLine(
gridPen, Offset(x, gridTop), Offset(x, gridTop + gridHeight));
}
for (int row = 0; row <= rows; row++) {
const y = gridTop;
g.drawLine(gridPen, Offset(gridLeft, y + row * cellH),
Offset(gridLeft + gridWidth, y + row * cellH));
}
for (int row = 0; row < rows; row++) {
for (int col = 0; col < cols; col++) {
if ((row + col) % 3 == 0) {
g.drawRectangle(
brush: lightGrayBrush,
bounds: Rect.fromLTWH(
gridLeft + col * cellW + 0.5,
gridTop + row * cellH + 0.5,
cellW - 1,
cellH - 1,
),
);
}
}
}
g.drawRectangle(
pen: borderPen,
bounds: Rect.fromLTWH(gridLeft, gridTop, gridWidth, gridHeight),
);
for (int row = 0; row < rows; row++) {
g.drawString(
'R${row + 1}',
smallFont,
brush: blackBrush,
bounds: Rect.fromLTWH(
gridLeft + 2,
gridTop + row * cellH + 2,
cellW - 4,
cellH - 4,
),
);
}
// ── Accent shapes ──────────────────────────────────────────────────────
const shapeTop = gridTop + gridHeight + 18;
final rectW = 60.0 + (i % 8) * 10.0;
g.drawRectangle(
pen: accentPen,
brush: accentBrush,
bounds: Rect.fromLTWH(36, shapeTop, rectW, 24),
);
g.drawString(
'Page $i',
smallFont,
brush: PdfSolidBrush(PdfColor(255, 255, 255)),
bounds: Rect.fromLTWH(40, shapeTop + 6, rectW - 8, 14),
);
g.drawEllipse(
Rect.fromLTWH(36 + rectW + 16, shapeTop, 80, 24),
pen: accentPen,
);
// ── Footer ────────────────────────────────────────────────────────────
g.drawString(
'BadNote bench PDF • page $i/$pageCount • tool/gen_bench_pdf.dart',
smallFont,
brush: PdfSolidBrush(PdfColor(140, 140, 140)),
bounds: Rect.fromLTWH(36, h - 28, w - 72, 18),
format: PdfStringFormat(alignment: PdfTextAlignment.center),
);
}
final bytes = pdf.saveSync();
pdf.dispose();
outFile.writeAsBytesSync(bytes);
final sizeKb = (outFile.lengthSync() / 1024).toStringAsFixed(1);
print('Generated: $outputPath');
print('Pages: $pageCount');
print('Size: ${sizeKb} KB (${outFile.lengthSync()} bytes)');
expect(outFile.existsSync(), isTrue);
expect(outFile.lengthSync(), greaterThan(1024),
reason: 'PDF must be at least 1 KB');
}, timeout: const Timeout(Duration(minutes: 5)));
}
/// Resolves test/assets/large_300p.pdf relative to this script's location.
/// tool/gen_bench_pdf.dart → project root → test/assets/large_300p.pdf
String _resolveOutputPath() {
// When run via `flutter test`, the CWD is the project root.
return 'test/assets/large_300p.pdf';
}
/// Generates a deterministic Lorem-Ipsum-style filler of roughly [words] words.
String _lorem(Random rng, int words) {
const vocab = [
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
'adipiscing', 'elit', 'sed', 'eiusmod', 'tempor', 'incididunt',
'labore', 'dolore', 'magna', 'aliqua', 'enim', 'minim', 'veniam',
'quis', 'nostrud', 'exercitation', 'ullamco', 'laboris', 'nisi',
'aliquip', 'commodo', 'consequat', 'duis', 'aute', 'irure',
'reprehenderit', 'voluptate', 'velit', 'esse', 'cillum', 'fugiat',
'nulla', 'pariatur', 'excepteur', 'sint', 'occaecat', 'cupidatat',
'proident', 'culpa', 'officia', 'deserunt', 'mollit', 'anim',
];
return List.generate(words, (_) => vocab[rng.nextInt(vocab.length)])
.join(' ');
}

220
tool/gen_dense_strokes.dart Normal file
View File

@@ -0,0 +1,220 @@
// tool/gen_dense_strokes.dart
//
// Generates synthetic ink-stroke datasets as JSON matching InkStroke.toJson()
// (from lib/models/ink_stroke.dart + lib/models/ink_point.dart) exactly.
//
// Output: test/assets/dense_strokes.json
// Format:
// {
// "2000": [ ...2000 InkStroke objects... ],
// "5000": [ ...5000 InkStroke objects... ]
// }
//
// Each stroke:
// - 820 InkPoint objects
// - x/y ∈ [0,1] (normalized page space, matching InkStroke coordinate model)
// - pressure ∈ [0.2, 1.0]
// - tilt ∈ [0.0, 30.0] degrees
// - pointerDeviceKind: "stylus" (surface pen benchmark)
// - tool: "pen"
// - color: varied from a palette of realistic ink colors
// - strokeWidth: 1.04.0
//
// Usage:
// dart run tool/gen_dense_strokes.dart # 2000 + 5000 (defaults)
// dart run tool/gen_dense_strokes.dart 500 1000 # custom counts
//
// The counts are also the JSON keys (converted to strings).
import 'dart:convert';
import 'dart:io';
import 'dart:math';
void main(List<String> args) {
final counts = args.isNotEmpty
? args.map(int.parse).toList()
: [2000, 5000];
final outputPath = _resolveOutputPath();
File(outputPath).parent.createSync(recursive: true);
final rng = Random(12345); // deterministic seed for reproducibility
final Map<String, dynamic> result = {};
for (final count in counts) {
final strokes = List.generate(count, (i) => _generateStroke(rng, i));
result['$count'] = strokes;
print('Generated $count strokes');
}
final jsonStr = const JsonEncoder.withIndent(null).convert(result);
File(outputPath).writeAsStringSync(jsonStr);
final sizeKb = (File(outputPath).lengthSync() / 1024).toStringAsFixed(1);
print('Output: $outputPath');
print('Size: ${sizeKb} KB');
for (final count in counts) {
print(' "$count": ${(result[count.toString()] as List).length} strokes');
}
// ── Inline round-trip sanity check ─────────────────────────────────────
// Verify that the first stroke in the first dataset round-trips through the
// InkStroke JSON shape without data loss (field names, enum values, types).
_verifyRoundTrip(result[counts.first.toString()]);
}
/// Generates one InkStroke as a plain Map matching InkStroke.toJson().
///
/// Field names and enum string values are taken directly from the generated
/// code in:
/// lib/models/ink_stroke.g.dart (_$$InkStrokeImplToJson)
/// lib/models/ink_point.g.dart (_$$InkPointImplToJson)
///
/// InkStroke fields:
/// id, points, tool, color, strokeWidth, createdAt, filled,
/// textContent, fontSize
///
/// InkPoint fields:
/// x, y, pressure, tilt, timestamp, pointerDeviceKind
Map<String, dynamic> _generateStroke(Random rng, int index) {
// Pick a random color from a set of realistic ink tones.
// Stored as ARGB int (0xFF......) matching @Default(0xFF000000).
final color = _pickColor(rng);
final strokeWidth = 1.0 + rng.nextDouble() * 3.0; // [1.0, 4.0]
final pointCount = 8 + rng.nextInt(13); // [8, 20]
// Start position — random page location
double x = 0.05 + rng.nextDouble() * 0.90; // [0.05, 0.95]
double y = 0.05 + rng.nextDouble() * 0.90;
// Simulate a realistic hand-drawn stroke: incremental movement with
// small steps (realistic velocity on a ~A4 page at ~1000 DPI effective).
final points = <Map<String, dynamic>>[];
int timestamp = DateTime.now().millisecondsSinceEpoch - (5000 - index * 2);
for (int p = 0; p < pointCount; p++) {
// Step in a semi-consistent direction with jitter
final angle = rng.nextDouble() * 2 * pi;
final step = 0.005 + rng.nextDouble() * 0.015; // [0.005, 0.02] page-units
x = (x + cos(angle) * step).clamp(0.0, 1.0);
y = (y + sin(angle) * step).clamp(0.0, 1.0);
// Pressure ramps up then down (pen-press profile)
final t = p / (pointCount - 1);
final basePressure = sin(t * pi); // 0→1→0 over the stroke
final pressure = (0.2 + basePressure * 0.8 + (rng.nextDouble() - 0.5) * 0.1)
.clamp(0.2, 1.0);
final tilt = rng.nextDouble() * 30.0; // [0, 30] degrees
timestamp += 8 + rng.nextInt(8); // ~816 ms between points (120 Hz stylus)
points.add({
'x': _round6(x),
'y': _round6(y),
'pressure': _round6(pressure),
'tilt': _round6(tilt),
'timestamp': timestamp,
// enum string from _$InputDeviceKindEnumMap in ink_point.g.dart
'pointerDeviceKind': 'stylus',
});
}
// createdAt as ISO-8601 string (DateTime.toIso8601String() format)
final createdAt = DateTime.fromMillisecondsSinceEpoch(timestamp - pointCount * 12)
.toIso8601String();
return {
'id': 'bench_${index.toString().padLeft(6, '0')}',
'points': points,
// enum string from _$PenToolEnumMap in ink_stroke.g.dart
'tool': 'pen',
'color': color,
'strokeWidth': _round6(strokeWidth),
'createdAt': createdAt,
'filled': false,
'textContent': null,
'fontSize': 14.0,
};
}
/// Returns one of several realistic ink colors as an ARGB int.
/// These match the range of values that @Default(0xFF000000) int color stores.
int _pickColor(Random rng) {
// Palette: black, dark-blue, dark-red, dark-green, dark-purple, charcoal
const palette = [
0xFF000000, // black
0xFF0A1E50, // dark navy
0xFF800020, // dark red
0xFF1A4D1A, // dark green
0xFF3D0066, // dark purple
0xFF1C1C1C, // charcoal
0xFF002B5C, // midnight blue
0xFF4B0000, // deep crimson
];
return palette[rng.nextInt(palette.length)];
}
/// Rounds a double to 6 decimal places to keep JSON compact and exact.
double _round6(double v) => double.parse(v.toStringAsFixed(6));
/// Resolves test/assets/dense_strokes.json relative to this script.
String _resolveOutputPath() {
final scriptUri = Platform.script;
final toolDir = File.fromUri(scriptUri).parent;
final projectRoot = toolDir.parent;
return '${projectRoot.path}/test/assets/dense_strokes.json';
}
/// Minimal round-trip verification that confirms the JSON shape produced
/// here matches InkStroke.fromJson() expectations.
///
/// We cannot call actual Dart model classes (they import Flutter packages),
/// so we do a structural check: re-parse the JSON and assert that every
/// required field survives the round-trip with the correct type.
void _verifyRoundTrip(dynamic dataset) {
final strokes = dataset as List<dynamic>;
assert(strokes.isNotEmpty, 'Dataset must not be empty');
final raw = strokes.first as Map<String, dynamic>;
// Re-encode → decode to simulate fromJson parsing.
final encoded = jsonEncode(raw);
final decoded = jsonDecode(encoded) as Map<String, dynamic>;
// Assert required InkStroke fields exist with correct types.
void check(String field, Type type) {
final val = decoded[field];
assert(
val == null || val.runtimeType.toString().contains(type.toString()) || val is num || val is String || val is bool || val is List,
'Field "$field" missing or wrong type: ${val.runtimeType}',
);
}
assert(decoded['id'] is String, 'id must be String');
assert(decoded['points'] is List, 'points must be List');
assert(decoded['tool'] == 'pen', 'tool enum must be "pen"');
assert(decoded['color'] is int || decoded['color'] is num, 'color must be int/num');
assert(decoded['strokeWidth'] is double || decoded['strokeWidth'] is num,
'strokeWidth must be num');
assert(decoded['createdAt'] is String, 'createdAt must be String (ISO-8601)');
assert(decoded['filled'] is bool, 'filled must be bool');
assert(decoded['fontSize'] is double || decoded['fontSize'] is num,
'fontSize must be num');
final points = decoded['points'] as List<dynamic>;
assert(points.isNotEmpty, 'stroke must have at least one point');
final p0 = points.first as Map<String, dynamic>;
assert(p0['x'] is num, 'InkPoint.x must be num');
assert(p0['y'] is num, 'InkPoint.y must be num');
assert(p0['pressure'] is num, 'InkPoint.pressure must be num');
assert(p0['tilt'] is num, 'InkPoint.tilt must be num');
assert(p0['timestamp'] is int || p0['timestamp'] is num,
'InkPoint.timestamp must be int/num');
assert(p0['pointerDeviceKind'] == 'stylus',
'pointerDeviceKind must be "stylus"');
print('Round-trip check: PASS (all required fields present with correct types)');
}

39
tool/test.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# tool/test.sh — flutter test wrapper with sqlite3 workaround.
#
# WHY THIS EXISTS:
# BadNote vendors sqlite3 native binaries under vendor/sqlite3/ (selected via
# the pubspec.yaml `hooks.user_defines.sqlite3.source: test-sqlite3` block).
# On Linux the vendored file is `vendor/sqlite3/libsqlite3.x64.linux.so`.
# Without pointing the dynamic linker at it, `flutter test` either falls back
# to a system sqlite3 (wrong version / missing) or tries to download one at
# build time (blocked behind the GFW on this machine).
#
# Setting LD_LIBRARY_PATH to the vendor dir tells the linker to prefer the
# vendored shared library. The Flutter toolchain here (3.41.4 / Dart 3.10.8)
# does NOT forward proxy env vars to build hooks, so LD_LIBRARY_PATH is the
# reliable workaround for local Linux development.
#
# On Windows CI the vendored sqlite3.x64.windows.dll is picked up
# automatically by the native-asset build — no wrapper needed there.
#
# USAGE:
# tool/test.sh # run all tests
# tool/test.sh test/foo_test.dart # run a specific test file
# tool/test.sh --coverage # pass any flutter test flags
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
VENDOR_SQLITE="${PROJECT_ROOT}/vendor/sqlite3"
if [ ! -d "${VENDOR_SQLITE}" ]; then
echo "WARNING: vendor/sqlite3/ not found at ${VENDOR_SQLITE}" >&2
echo " Proceeding without LD_LIBRARY_PATH override." >&2
else
export LD_LIBRARY_PATH="${VENDOR_SQLITE}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
exec flutter test "$@"