# Plan: 1:1 Port of dart-pdf `path.dart` → `nigig-pdf` (Phase 7.5 — Path Object Parity) **Date:** 2026-09-06 **Reference:** `dart-pdf/packages/pdf_graphics/lib/src/path.dart:1` (343 LOC), `dart-pdf/packages/pdf_graphics/test/path_test.dart:1` (73 LOC) **Nigig baseline:** `crates/apps/pdf/pdf-graphics/src/recording.rs:11` (`RenderCommand` path ops), `crates/apps/pdf/pdf-graphics/src/lib.rs:1` **Master plan:** `NIGIG_PDF_FEATURE_PARITY_PLAN.md:746` (`path_test | DONE` — overstated, see §1) --- ## 1. Objective Bring `nigig-pdf` path object model to 1:1 parity with dart-pdf's `path.dart` without changing interpreter correctness. `RenderCommand` stays as the paint-level wire; `PdfPath` becomes the packed, allocation-conscious path payload that dense CAD streams need and that `PdfPathCursor` traverses without allocating. Non-goal: path booleans, stroking, simplification (no dart equivalent). ## 2. Gap Audit (why `DONE` is partial) | Aspect | dart-pdf `path.dart` | nigig `recording.rs` | Gap | |---|---|---|---| | **Path type** | `sealed PdfPathSegment` + `PdfPath` dual storage: `List` *or* packed `Uint8List verbs` + `Float32List coordinates` (wire f32 preserved) + `segmentCount` + `Expando` materialized cache `path.dart:38` | `RenderCommand::MoveTo/LineTo/CurveTo/ClosePath/Rectangle` — path implicit as command sequence | No `PdfPath` struct, no packed wire, no materialized cache | | **Builder** | `PdfPathBuilder(verbCapacity=16, coordinateCapacity=48)` pools buffers, `_ensure` grows `>>1`, `takePath()` copies exact-size `Uint8List`/`Float64List` and reuses scratch (tens of k one-segment CAD paths) `path.dart:196` | `RecordingDevice::move_to` pushes `RenderCommand` directly `recording.rs:554` | No pooling, no exact-size copy, no reuse | | **Cursor** | `PdfPathCursor` non-allocating `moveNext()` over packed or object storage `path.dart:106` | `for cmd in &commands` over `Vec` | No cursor, allocates per segment if `segments` needed | | **Fill rule** | `enum PdfFillRule { nonzero, evenOdd }` `path.dart:302` | Two commands `FillWinding`/`FillEvenOdd` `recording.rs:21` — no enum | Missing enum (trivial) | | **Stroke** | `class PdfStroke { width cap join miterLimit dashArray dashPhase }` page-space `path.dart:305` | Five commands `SetStrokeWidth/Cap/Join/MiterLimit/Dash` `recording.rs:27` | Not bundled | Correctness is already 1:1 (ops round-trip). Performance + wire exactness is ~60%. ## 3. Scope ### 3.1 New file `crates/apps/pdf/pdf-graphics/src/path.rs` (~350 LOC) ```rust pub enum PdfPathSegment { MoveTo{x:f64,y:f64}, LineTo{x,y}, CubicTo{x1,y1,x2,y2,x3,y3}, Close } pub struct PdfPath { // dual storage, like dart segments: Option>, verbs: Option>, coords: Option>, // f32 on wire, f64 in builder — mirrors dart Float32List vs Float64List len: usize, materialized: OnceCell>, } impl PdfPath { pub fn from_segments(Vec) -> Self; pub fn packed_float32(Vec, Vec, usize) -> Self; // wire exactness pub fn segments(&self) -> &[PdfPathSegment]; // lazy materialize + cache pub fn cursor(&self) -> PdfPathCursor; pub fn segment_count(&self) -> usize; pub fn is_empty(&self) -> bool; } pub enum PdfPathVerb { MoveTo, LineTo, CubicTo, Close } pub struct PdfPathCursor { verb: PdfPathVerb, x1,y1,x2,y2,x3,y3, move_next() -> bool } pub struct PdfPathBuilder { verbs: Vec, coords: Vec, verb_count, coord_count } impl PdfPathBuilder { pub fn new(verb_capacity: usize, coord_capacity: usize) -> Self; // defaults 16/48 pub fn move_to(&mut self, x:f64, y:f64); pub fn line_to(&mut self, x:f64, y:f64); pub fn cubic_to(&mut self, x1:f64,y1:f64,x2:f64,y2:f64,x3:f64,y3:f64); pub fn close(&mut self); pub fn add_segment(&mut self, PdfPathSegment); pub fn take_path(&mut self) -> PdfPath; // exact-size copy + reuse scratch pub fn clear(&mut self); } pub enum PdfFillRule { NonZero, EvenOdd } pub struct PdfStroke { pub width:f64, pub cap:u32, pub join:u32, pub miter_limit:f64, pub dash_array:Vec, pub dash_phase:f64 } ``` Constants: `_moveTag=0, _lineTag=1, _cubicTag=2, _closeTag=3` `path.dart:184`, builder defaults `16/48` `path.dart:198`. ### 3.2 Modified files * `crates/apps/pdf/pdf-graphics/src/recording.rs:11` — add `RenderCommand::Path(Rc, PdfFillRule, Option)` alternative *or* keep `MoveTo` etc. for back-compat and add `PdfPath` as optional payload; update `format_command` + `wire.rs` codec to preserve f32 bit-exact (dart guarantee). * `crates/apps/pdf/pdf-graphics/src/device.rs:6` `MakepadPdfDevice` — when a path is open, delegate `move_to` etc. to `PdfPathBuilder`, on `fill`/`stroke` consume via `take_path()`. * `crates/apps/pdf/pdf-graphics/src/lib.rs:1` — `pub mod path; pub use path::{PdfPath, PdfPathBuilder, PdfPathCursor, PdfFillRule, PdfStroke, PdfPathVerb};` * `NIGIG_PDF_FEATURE_PARITY_PLAN.md:746` — flip `path_test | DONE` → `path_test | Phase 7.5 | packed + cursor + builder pool + stroke/fill_rule, wire f32 exact` until landed, then back to `DONE`. ## 4. Test Port (dart → Rust) | # | dart `pdf_graphics/test/path_test.dart` | Rust `crates/apps/pdf/pdf-graphics/src/path.rs` or `tests/path.rs` | Notes | |---|---|---|---| | T1 | `packed path cursor and compatibility segments agree` — `PdfPathBuilder(1,2) moveTo→lineTo→cubicTo→close`, `cursor.moveNext()` verbs/coords, `segments` lazy + `identical` cache `path_test.dart:5` | `packed_path_cursor_and_compatibility_segments_agree` | Verbatim port, checks `segment_count==4`, cursor `(verb,x1,y1…)`, `segments[0].is::()`, `ptr_eq(segments, segments)` via `Rc::ptr_eq` | | T2 | `takePath copies immutable storage and reuses the builder` — `takePath` empties builder, second `takePath` independent `path_test.dart:47` | `take_path_copies_immutable_storage_and_reuses_builder` | Verbatim, checks `(segment_count, x1, y1)` for both paths | | T3 | `ordinary const paths retain their supplied segment list` — `const PdfPath(segments)` returns `identical` list `path_test.dart:64` | `ordinary_const_paths_retain_supplied_segment_list` | `PdfPath::from_segments(Rc::new(vec![]))` → `ptr_eq` | | T4 | *(new, perf)* dense CAD does not reallocate per path | `dense_cad_does_not_reallocate_per_path` | 10k `moveTo→lineTo→close` via one builder, assert `verbs.capacity()` amortized, `take_path` exact-size (`verbs.len()==verb_count`) | | T5 | *(new, wire)* wire round-trip preserves f32 bit-exact | `wire_round_trip_preserves_float32_bit_exact` | `PdfPath` via `wire::encode`/`decode` → `coords` bitwise equal (dart wire guarantee) | | Tool | `pdf_graphics/tool/dump_paths.dart`, `dump_bar_paths.dart` | `cargo run --bin dump_paths` (optional) | Perf dump for dense files, not required for green | All 3 dart tests must pass as Rust equivalents before `746` flips to `DONE`. ## 5. Exit Criteria * All 5 tests green: `cargo test -p nigig-pdf-graphics --lib path` + `cargo test -p nigig-pdf-graphics --test path` (if separate) + golden. * `cargo test -p nigig-pdf-graphics` full suite still green (no interpreter regression). * `NIGIG_PDF_FEATURE_PARITY_PLAN.md:746` updated to `DONE` with note `— packed + cursor + builder pool + stroke/fill_rule, wire f32 exact`. * ADR `REVIEWS/adr/00xx-pdf-path-object-parity.md` records why `RenderCommand::Rectangle` stays as sugar (PDF `re` is rect) and why builder coords are `f64` but wire is `f32` (mirrors dart `Float64List` vs `Float32List`). * No change to `pdf-makepad` rendering path except via `PdfPath` payload; existing goldens unchanged (wire is additive). ## 6. Risks & Mitigations * **Wire breaking change** — mitigation: keep `MoveTo/LineTo` variants, add `Path` as new variant; old goldens unchanged; deprecate after one release. * **Allocation win not measurable** — mitigation: T4 asserts amortized growth; bench with `cargo bench -p nigig-pdf-graphics path_dense`. * **Const `identical` semantics** — Rust `const` not same as Dart `const`; use `Rc` + `ptr_eq` to emulate. ## 7. Estimate * ~1 session, no fork needed, pure `pdf-graphics` crate. * LOC: ~350 new + ~50 modified. * Dependencies: none new.