diff --git a/Cargo.lock b/Cargo.lock index 82903b196..a1729a997 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2344,10 +2344,15 @@ dependencies = [ name = "moli-canvas" version = "0.1.0" dependencies = [ + "anyrender", + "anyrender_vello_cpu", "base64 0.22.1", "font8x8", + "kurbo", "moli-image", "moli-web-mime", + "peniko", + "vello_cpu", ] [[package]] diff --git a/docs/canvas-architecture-m0.md b/docs/canvas-architecture-m0.md new file mode 100644 index 000000000..6d5586dad --- /dev/null +++ b/docs/canvas-architecture-m0.md @@ -0,0 +1,414 @@ +# Canvas 2D — M0 Baseline, Ownership Note, and Migration Inventory + +Status: implementation baseline (M0), not a description of completed work. + +This document is the first deliverable of the Canvas 2D re-architecture covered by +`canvas-2d-proposal.en.md`. It records the **current** (pre-migration) pixel +ownership, every read/write/reset/retirement entry point, a reproducible baseline, +and the routing checklist used to review the final integration. It is intentionally +a snapshot of reality, not of the target design. + +Reference revision for this note: branch `canvas_2D_moli`, commit `4d6e0373`. + +--- + +## 1. Building blocks / terminology + +| Term | Meaning in this project | +| --- | --- | +| Backing store | The V8 `Uint8ClampedArray` private slot on a canvas-like object that holds the writable RGBA8 pixels that all 2D operations mutate | +| CanvasResourceStore | `HashMap>` in `moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs`; the published, immutable, page-visible image per HTML canvas | +| Context state | V8 private-slot strings/numbers/bools on the context object: `fillStyle`, `font`, `globalAlpha`, `globalCompositeOperation`, `lineWidth`, `lineCap`, `lineJoin`, `miterLimit`, `lineDashOffset`, `lineDash`, `strokeStyle`, `imageSmoothingEnabled`, `imageSmoothingQuality` | +| Path state | `Canvas2dPathState` held per context in a weak-keyed store (`canvas/state.rs`), reclaimed by GC / isolate teardown | +| Rasterization | `moli_paint::raster_snapshot(&PaintSnapshot)`, invoked per draw by `rasterize_canvas_fragment()` in `canvas/context2d.rs` | +| Snapshot | `moli_image::RgbaImage` (straight-alpha RGBA8); the immutable unit pages and `getImageData`-adjacent consumers use | + +--- + +## 2. Current pixel ownership (two writable/observable planes today) + +There are **two** pixel stores that must be kept in sync, which is the core +redundancy the project removes: + +1. **Mutable backing store** — `CANVAS_BACKING_STORE_SLOT` (`__moliCanvasBackingStore`), a + `Uint8ClampedArray` owned by the canvas-like JS object. All 2D drawing and + `putImageData` mutate a **fresh byte copy** of this view, then write it back. + - Managed by `backing_store.rs`: `with_canvas_like_pixels_mut()`, `canvas_like_pixels_copy()`, `ensure_canvas_like_backing_store()`. + - It is straight-alpha RGBA8, row major, dimensions from the width/height slots. + +2. **Published page image** — `CanvasResourceStore.pixels_by_element: HashMap>`. + - `replace_canvas_pixels(handle, w, h, rgba)` replaces the whole stored + `Arc` on every mutation, bumps `VisualResourceGeneration`, and + enforces `MAX_RETAINED_CANVAS_PAINT_BYTES` (256 MiB). + - Page painting reads it via `JsContextHost::canvas_pixels_for_layout()` → + `source_view.rs` → `LayoutImageResource`. This is an **immutable snapshot** plane + already; it is not copied per pixel-read. + +The renderer's `rasterize_canvas_fragment()` performs, per-path-draw: + copy backing view -> Vec, build a fresh `PaintSnapshot` covering the **whole + canvas**, run `moli_paint::raster_snapshot` (allocates a new `VelloCpuImageRenderer` + and a full RgbaImage), `composite_rgba8_over` the premultiplied result into the + straight copy, write back, then `replace_canvas_pixels(...)` with the full copy. + +Other ordinary draws (`fillRect`, `clearRect`, `fillText`/`strokeText`, `drawImage`) +go through the same full-copy `with_canvas_like_pixels_mut` path but write pixels +directly instead of building a `PaintSnapshot`. `putImageData` writes raw pixels +directly (correct raw overwrite semantics) via `blit_image_data`. + +Consequence: cost scales with canvas area for hundreds of small operations because +every operation copies the whole plane and (for paths) allocates a fresh backend + full +raster. + +--- + +## 3. Entry-point inventory (complete routing checklist) + +Legend — **D** = ordinary draw, **W** = explicit pixel write, **R** = pixel read/ +observation, **S** = state/geometry (no pixel work), **RST** = reset/dimension, +**STUB** = existing incomplete implementation, **OOB** = out of scope for this +project. + +### 3.1 CanvasRenderingContext2D / OffscreenCanvasRenderingContext2D — `canvas/context2d.rs` + +| # | Entry point (fn/line) | Kind | Route today | Target stage | +|---|---|---|---|---| +| 1 | fillRect (700) | D | full-copy `with_canvas_like_pixels_mut` → `paint_rect` | M2 D record | +| 2 | clearRect (719) | D | full-copy → `paint_rect` `[0,0,0,0]` (destructive) | M2 record w/ ordered clear | +| 3 | fill / stroke (1066/1094) | D | `PaintSnapshot` via `rasterize_canvas_fragment` | M4 recorded path | +| 4 | strokeRect (1123) | D | builds temp rect path, same fragment route | M4 recorded | +| 5 | fillText / strokeText (1572/1586) | D | `with_canvas_like_pixels_mut` → `draw_text` (font8x8) | M1–M4 recorded text | +| 6 | drawImage (1618) | D | `html_image_pixels_copy`/`canvas_like_pixels_copy` then `blit_draw_image_filtered` | M4 recorded + source snapshot | +| 7 | putImageData (1856) | W | full-copy → `blit_image_data` (raw overwrite) | M2/M4 ordered pixel-write boundary | +| 8 | getImageData (1921) | R | `canvas_like_pixels_copy` → `extract_image_data` | M2/M5 flush+readback | +| 9 | measureText (1815) | S | `measure_text_width` | S | +| 10 | createImageData (1834) | S | alloc empty ImageData | S | +| 11 | isPointInPath (1564) | STUB | always returns `false` | STUB/OOB | +| 12 | createLinearGradient (1766) | STUB | returns inert object; `addColorStop` validates offset only, no rendering | STUB/OOB | +| 13 | setLineDash/getLineDash (1690/1742) | S | V8-array slot | S | +| 14 | noop (1683) | STUB | no-op | STUB | +| 15 | path builders (rect, beginPath, closePath, moveTo, lineTo, quadraticCurveTo, bezierCurveTo, arc, arcTo, ellipse) | S | `Canvas2dPathState` | M1 → `moli-canvas::path` | +| 16 | transform (translate/scale/rotate/transform/setTransform/resetTransform) | S | `Canvas2dPathState.transform` | M1 → `moli-canvas::context` | +| 17 | state setters/getters (fillStyle, strokeStyle, font, lineWidth/Cap/Join, miterLimit, lineDashOffset, globalAlpha, globalCompositeOperation, imageSmoothing*) | S | V8 private slots | M1 state → `moli-canvas::context` | +| 18 | reset_canvas_context_state (29) | RST | re-init slots + reset path | M4 reset | +| 19 | rasterize_canvas_fragment (1512) | (impl) | per-path full-frame page pipeline | Removed (M4) | +| 20 | composite_rgba8_over (1538) | (impl) | premult→straight composite helper | Removed (M4); replaced by premultiplied-space compositing (M6) | + +### 3.2 Backing store / pixels — `canvas/backing_store.rs` + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 21 | attach_canvas_like_context_object | init | links context↔canvas, initializes backing store | M3 ownership | +| 22 | canvas_2d_context (74) | R | returns stored 2D context object | M3 | +| 23 | canvas_owner_from_context (115) | R | reverse context→canvas | M3 | +| 24 | with_canvas_like_pixels_mut (122) | W | **full-copy mutate+write-back** (major copy source) | Removed (M6); direct ops use premultiplied-space helpers | +| 25 | canvas_like_pixels_copy (144) | R | **full copy** (major copy source for drawImage/getImageData/page) | Flushes recording then reads snapshot (M5/M6) | +| 26 | reset_canvas_like_backing_store / reset_html_canvas_backing_store_for_dimension_assignment | RST | zero-fills backing store on dimension change | M4/M6 | +| 27 | canvas_like_to_data_url (93) | R | full copy → `encode_data_url` | M5 flush+encode | +| 28 | ensure_canvas_like_backing_store | alloc | lazily creates/zeros backing view | M3 surface | + +### 3.3 Canvas element (browser-facing) — `native_bridge/element/canvas.rs`, `context_bootstrap/canvas.rs` + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 29 | HTMLCanvasElement width/height setters | RST | set reflected attribute → resets backing store + context | M3/M4 resize semantics | +| 30 | HTMLCanvasElement width/height getters | S | default 300×150 | S | +| 31 | getContext (`CanvasContextKind`) | init | returns cached context object per kind slot | M3 identity | +| 32 | toDataURL | R | `canvas_like_to_data_url` | M5 flush+encode | +| 33 | build HTML/Offscreen canvas objects | init | constructors | M3 | + +### 3.4 OffscreenCanvas — `canvas/offscreen.rs` + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 34 | OffscreenCanvas width/height setters | RST | reset backing store | M3/M4 | +| 35 | getContext | init | builds 2D/WebGL context, attaches | M3 | +| 36 | convertToBlob (172) | STUB | resolves a blob of empty bytes | STUB/OOB | +| 37 | 2D context constructor, object init | init | `canvas_rendering_context_2d_constructor_callback` etc. | M3 | + +### 3.5 Publication and page painting + +| # | Entry point | Kind | Notes | Target | +|---|---|---|---|---| +| 38 | CanvasResourceStore.replace/remove/get | R | replaces entire `Arc` per change | M3 publish via snapshot | +| 39 | retire_canvas_resources_for_document | RST | removes images whose owner document retired, but *resolves ownership at retirement* to preserve adopted canvases | M3 lifecycle | +| 40 | canvas_pixels_for_layout → source_view.rs | R | page painting reads immutable `Arc` | M5 read snapshot | +| 41 | VisualResourceGeneration bump | RST | marks page dirty; drives repaint/screencast | M5 invalidation on record | + +--- + +## 4. Read / write / reset / retirement matrix + +**Pixel owners today** +- Mutable: V8 `Uint8ClampedArray` (canvas-like object). +- Immutable published: `CanvasResourceStore` `Arc` (per HTML canvas). + +**Every place pixels are read or written** +- Write (full-plane copy): fillRect, clearRect, fillText, strokeText, drawImage, putImageData (raw), path fill/stroke (via snapshot+composite). +- Read: getImageData, drawImage(source=canvas), toDataURL, page painting (canvas_pixels_for_layout), screencast (through page painting), clone/copy in `canvas_like_pixels_copy`. +- Reset: width/height attribute set (HTML via reflected attr, Offscreen via slot), `reset_canvas_context_state`, backing-store reset, `reset_canvas_like_backing_store_for_dimension_assignment`. +- Retirement/adoption: `retire_canvas_resources_for_document` (adopts resolved by owner document). + +**State vs geometry** +- State lives on V8 private slots (helpers.rs declaration + lineDash slot). +- Path geometry + transform lives in `Canvas2dPathState` (weak-keyed per-context store, `state.rs`). + +--- + +## 5. Existing conformance/stub gaps (honest inventory) + +These are **not** evidence of completed API support and are declared (per proposal +§8) either as out-of-scope API expansion or as correctness gaps to resolve in-line: +- `isPointInPath`/`isPointInStroke` always return `false` (STUB). +- `createLinearGradient`/`CanvasGradient.addColorStop` validate but never render gradients; `fillStyle`/`strokeStyle` are color-only strings (STUB-ish; gradient rendering is unrelated API expansion). +- `convertToBlob` returns an empty blob (STUB). +- `drawImage` supports the 3/5/9-arg forms via `DrawImageBlit`; HTMLVideoElement/CanvasImageSource breadth is limited. +- Text is `font8x8` monochrome glyphs only (functional, not a real font engine); this is the supported text path today and must be captured through the native recorder, not expanded. +- No `getTransform`, `reset`, `setLineDash` canonicalization quirks are necessarily complete; correctness is defined by existing JS regressions, not by parity claims. +- `globalCompositeOperation` is validated/canonicalized but only `source-over` semantics are actually composited. + +These gaps do not block the architecture; per the proposal, only gaps that prevent +recording/ownership/readback/lifecycle correctness must be resolved in-project. + +--- + +## 6. Reproducible baseline + +Fixture + runner live under `moli-benchmark/fixtures/canvas/` (see +`moli-benchmark/fixtures/canvas/README.md` for how to run and reproduce). + +Method: drive the Moli binary over CDP, load an HTML fixture for each workload +(256/1024/2048 canvas sizes × 100/1000 ops over path-fill, rect, image, text, +draw/clear/write, readback-after-every-draw, repeated clean getImageData, and +canvas-to-canvas/self-draw), measure wall time in JS around a recorded event loop, +and report per-phase timings. A Rust-side native cost model benchmark +(`moli-canvas/tests/baseline_cost.rs`, native, no V8) reports the full-plane copy +and full-raster counts for the current design to make the structural cost visible +independently of wall-clock noise. + +Raw results, machine info, build mode, and revision are recorded in +`moli-benchmark/fixtures/canvas/README.md` and `moli-benchmark/fixtures/canvas/results/`. + +### Captured native baseline (this machine; branch `canvas_2D_moli` @ `4d6e0373`, debug build) + +The native cost model (`moli-canvas/tests/baseline_cost.rs`, V8-free) reproduces +the current design's per-draw full-plane copy + format-conversion work. Its +**arithmetic byte-cost evidence** (asserted, instant) shows the cost is linear in +canvas area, not paint size, because every ordinary draw copies the whole plane: + +| canvas | ops | bytes/plane | full copies/draw | bytes copied (total) | +|---|---|---|---|---| +| 256² | 100 | 262,144 | 2 | 52,428,800 | +| 1024² | 1000 | 4,194,304 | 2 | 8,388,608,000 | +| 2048² | 1000 | 16,777,216 | 2 | 33,554,432,000 | + +Moving 33.5 GB to draw 1,000 small shapes on a 2048² canvas is the structural +problem this project removes (path fills additionally allocate a fresh full-canvas +raster to composite). A reduced timing matrix is kept in the test so the check +suite stays fast; see `moli-benchmark/fixtures/canvas/results/README.md`. + +### Baseline commands (run before any migration code) + +```sh +# correctness regression baseline +cargo nextest run -p moli-renderer-v8 --lib canvas_paths canvas_arguments --no-fail-fast + +# native cost model (no V8) +cargo test -p moli-canvas --test baseline_cost -- --nocapture +``` + +### M5 status (read/flush consistency + invalidation on record) + +M5 verified and hardened the flush/read/invalidation contract: + +- **Read/flush consistency**: All R routes flush before reading. `getImageData` + reads via `canvas_like_pixels_copy` (flushes per-canvas recording first); + `toDataURL` reads via `canvas_like_to_data_url` (same path); `drawImage` from + canvas source flushes the source; page painting (`canvas_pixels_for_layout`) + reads the published `Arc` snapshot; screencast (`capture_screencast_frame_with_before_layout`) + and screenshot (`capture_screenshot`) both call `flush_all_recordings` (via + `with_default_context_scope`) before computing `visual_state_before`; layout + (`with_fresh_layout_pass`) flushes at the start of every layout pass. +- **Snapshot cache invalidation**: `CanvasSurface::published` (the cached + straight-alpha `Arc`) is set to `None` on every write path — + `render`, `render_replace`, `clear`, `reset`, `resize`, and + `with_straight_pixels_mut` — so `snapshot()` never returns stale data after a + flush executes new ops against the surface. +- **Item 41 — VisualResourceGeneration bump on record**: Every draw callback now + calls `bump_canvas_visual_generation(scope, canvas)` immediately after pushing + ops, which bumps the `VisualResourceGeneration` atomic via + `JsContextHost::touch_canvas_visual_generation` → `CanvasResourceStore::touch`. + This ensures the page is marked dirty the instant canvas draw ops are recorded, + not only when they are flushed. The `CanvasResourceStore::touch` method bumps + the generation only if the element has published pixels (avoiding false-dirty + on canvases that have never been painted). +- **`with_default_context_scope` visibility**: Changed from `pub(super)` to + `pub(crate)` on `ScriptVm` (context_scope.rs) so `runtime::page_screenshot` + can call it. +- **RefCell panic avoidance**: In `flush_all_recordings`, surface `borrow_mut()` + is scoped in a block; `drop(rec)` before `publish_canvas_snapshot` to avoid + double-borrow. + +All 43 moli-canvas tests pass (27 unit + 2 baseline + 6 recording + 8 surface_api). +The `moli-renderer-v8` lib compiles clean with clippy; the test binary OOMs on +link in constrained environments due to its 1.3 GB size (v8 + all deps). + + +--- + +## 7. Decisions carried forward + +Recorded from the M0 discussion (see proposal §6.2 and the session decisions): + +- **Internal surface format: premultiplied RGBA8** in the target core, matching + Vello output. Conversion to straight alpha happens only at observation/export/ + publication boundaries (getImageData, toDataURL, page snapshots via + `RgbaImage`, ImageData writes, canvas-as-source capture). +- **Backend: `moli-canvas` gains direct `anyrender` + `anyrender_vello_cpu` + dependencies** (same pinned rev `18fd67d…` moli-paint uses) with its own + `backend/vello_cpu.rs`. `moli-canvas` stays free of V8/DOM/layout/moli-paint. +- **Backend reuse**: one `VelloCpuImageRenderer` per canvas, sized to the surface, + rendering into a caller-owned buffer (`render(&mut scene, &mut [u8])`), reused + across flushes. Whether `RenderContext::reset()` retains the large fine-stage + buffers is verified empirically in M2 (dedicated micro-benchmark). +- `moli_image::RgbaImage` is the published page-visible snapshot unit (straight + alpha), already the type `CanvasResourceStore` stores and page painting consumes. + +### M2 status (surface/backend contract implemented) + +M2 delivered the independently tested canvas surface and reusable backend in +`moli-canvas`: + +- `surface.rs::CanvasSurface` is the single authoritative writable pixel store: + **premultiplied RGBA8** internally, with lazy materialization, a reusable + backend, an immutable cached straight-alpha snapshot, region readback, and + reset/resize. Deterministic `flush_count`/`snapshot_count` counters prove + scheduling properties (N draws share the surface; a clean observation does + zero additional conversion). +- `backend/vello_cpu.rs::VelloCpuBackend` reuses one `VelloCpuScenePainter` per + canvas and renders into the caller-owned persistent buffer with + `CompositeMode::SrcOver` (incremental source-over batches preserving prior + content) or `CompositeMode::Replace` (destructive clear/overwrite). This + uses `VelloCpuScenePainter`'s public `render_ctx`/`resources` rather than + `VelloCpuImageRenderer`, whose `render` hardcodes `Replace`. Whether + `RenderContext::reset()` retains the large fine-stage buffers remains an + empirical M2 detail noted for the reuse benchmark. +- Pixel-format contract: premultiplied internal (Vello-native); `unpremultiply` + happens only at snapshot/readback/export boundaries; transparent pixels + normalize to transparent black so repeated conversion is stable. +- Native tests (`tests/surface_api.rs`, no V8/Document) cover repeated + source-over rendering, snapshot isolation and clean repeated reads, clear + ordering, reset/resize (same/different/zero size), region readback clipping + and independence, low-alpha round-trip, oversized/invalid failure without + mutation, and deterministic scheduling counters. + +### M3 status (single native pixel owner connected) + +M3 replaced the mutable V8 `Uint8ClampedArray` backing store with a single, +per-context, weak-keyed native `CanvasSurface` owner: + +- `canvas/backing_store.rs` now keeps each canvas's authoritative pixels in a + `moli_canvas::CanvasSurface` held in a weak-keyed per-context registry (the + same GC-finalizer/isolation pattern as `canvas/state.rs`), so surface + lifetime is reclaimed with the canvas (GC) and with the isolate. +- The existing straight-RGBA8 draw helpers run against the surface through the + transitional `CanvasSurface::with_straight_pixels_mut` adapter, keeping output + byte-identical while the surface stays the single owner; M4's recorder + replaces this with batched Vello rendering. +- `getImageData`, `toDataURL`, `createImageBitmap` sources, and every draw path + read/write the native owner; HTML page publication (`CanvasResourceStore`) is + driven from the surface snapshot. +- A native GC/reclamation test (`canvas_surfaces_are_reclaimed_with_canvas_gc_and_isolate_destruction`) + proves lifecycle release. + +All 115 canvas JS regressions (including the `__moliCanvasBackingStore` +reflection/spoofing robustness test) and the moli-canvas native surface tests +pass against the native owner. + +### M4 status (ordered recording core implemented) + +M4 delivered the reusable ordered recording engine in `moli-canvas/src/recording.rs`: + +- `DrawRecording` captures every ordinary 2D draw operation as a frozen `DrawOp` in + call order: `FillPath`, `StrokePath`, `FillRect`, `StrokeRect`, `ClearRect`, + `DrawImage`, `Text` (fill/stroke), `PutImageData`. +- Scene-expressible ops (`FillPath`, `StrokePath`, `FillRect`, `StrokeRect`) are + batched into a single `surface.render` (SrcOver) flush, minimizing backend + submissions. `ClearRect` forces a flush boundary and executes with `Replace` mode. +- Direct-ordered ops (`DrawImage`, `Text`, `PutImageData`) execute against the + surface via `with_straight_pixels_mut` between scene batches, preserving call order. +- `StrokeSpec` carries frozen `peniko::Stroke` metrics across the recording boundary; + `to_stroke` converts `kurbo::Stroke` via peniko's `Stroke`/`Dashes` APIs. +- `DrawImage` holds a source snapshot (`Arc>`) so later source mutation does + not affect replay. +- `reset` discards all pending ops; `snapshot` on the underlying surface is immutable. +- Native tests (`tests/recording.rs`, 6 tests, V8-free): batched flush count, + ClearRect segmentation, source immutability, reset, PutImageData ordering, stroke + metrics carry. + +### M4 renderer integration (recording wired through the renderer) + +The renderer now drives all Canvas 2D draws through the recording engine: + +- `recording_store.rs` holds one `DrawRecording` per 2D context (isolate-owned, + weak-keyed with GC finalization, mirroring `state.rs`). `recording.rs:98` + `flush_all_recordings` executes every live non-empty recording against its + surface and publishes a snapshot. +- All context2d draw callbacks (`fillRect`, `clearRect`, `fill`, `stroke`, + `strokeRect`, `drawImage`, `fillText`/`strokeText`, `putImageData`) push frozen + `DrawOp`s into the per-context recording instead of mutating pixels directly. +- Recording colors are **straight** (non-premultiplied) RGBA8: `peniko::Color` + (`color::AlphaColor`) expects straight channels, so `to_color` divides the + stored byte values by 255 and Vello premultiplies internally. `globalAlpha` is + applied for path ops (`fill`, `stroke`, `strokeRect`) via `apply_global_alpha` + but not for direct ops (`fillRect`, `fillText`, `strokeText`, `drawImage`), + matching the previous per-op semantics. +- Flush happens before every page-level pixel read: `with_fresh_layout_pass` + (script_vm.rs) flushes recordings before layout, and `capture_screencast_frame` + / `capture_screenshot` (page_screenshot.rs) flush before computing the visual + state token, so fresh backing store pixels are reflected both in the paint and + in the screencast `Unchanged` token short-circuit. +- Result: all 115 canvas JS tests, the screencast visual-token test, screenshot, + layout, and rendering-update suites pass. Dead rasterize/composite/color helpers + were removed from `context2d.rs`. + +### M6 status (full-plane round-trip eliminated) + +M6 removed the O(canvas area) unpremultiply/premultiply round-trip from all four +direct recording operations: + +- **`with_straight_pixels_mut` removed**: The transitional full-surface + unpremultiply → mutate → premultiply adapter has been deleted from + `CanvasSurface`. All direct pixel operations now work directly on the + premultiplied pixel buffer. +- **ClearRect**: Zeros pixels in the target rectangle directly on the + premultiplied surface (`clear_rect_premul` in `recording.rs`). O(rect area), + no conversion. +- **DrawImage**: `blit_draw_image_filtered_premul` in `blit.rs` scales the + straight-alpha source, then unpremultiplies only the scaled pixels and + composites with source-over blending in premultiplied space. O(scaled source + area), not O(canvas area). +- **Text**: `draw_text_premul` in `text.rs` composites each font8x8 glyph with + `paint_rect_premul` in premultiplied space. O(glyph area), not O(canvas area). +- **PutImageData**: `blit_image_data_premul` in `blit.rs` unpremultiplies only + the source `ImageData` bytes and composites with source-over blending in + premultiplied space. O(source area), not O(canvas area). +- All four premultiplied-space functions use integer-only source-over compositing + (`src + dst * (1 - src_a)`) matching the Canvas 2D spec's source-over + composite operation. +- The `CanvasSurface` API now exposes `premutated_mut()` for direct pixel access + and `mark_dirty_and_invalidate_snapshot()` for operations that modify pixels + outside the Vello backend. + +--- + +## 8. Checklist for final review (routed against this inventory) + +Every functional 2D route above must, at M6, be accounted for by the final +architecture per the proposal's §5 table. The numbering above is the audit key: +- [x] All **D** routes route through the native ordered recorder (M4). +- [x] **W** boundaries (`putImageData`) are ordered native pixel writes. +- [x] **R** routes (`getImageData`, exports, source-canvas, page painting, screencast) read a single authoritative surface/snapshot after flush (M5). +- [x] **S** geometry/state operations update native state/path without rasterizing or flushing (M1/M4). +- [x] **RST** dimension assignment/reset preserves the required reset semantics incl. same-size (M4/M6 verified). +- [x] **STUB** items are honestly declared out-of-scope in §5 (`isPointInPath` always false, `createLinearGradient` validates but does not render, `convertToBlob` returns empty blob). +- [x] The dual-plane backing store + full-frame raster path (`with_canvas_like_pixels_mut` for draws, `rasterize_canvas_fragment`) is removed (M6). diff --git a/moli-benchmark/fixtures/canvas/README.md b/moli-benchmark/fixtures/canvas/README.md new file mode 100644 index 000000000..d784b349b --- /dev/null +++ b/moli-benchmark/fixtures/canvas/README.md @@ -0,0 +1,101 @@ +# Canvas 2D M0 baseline fixtures + +Reproducible benchmark inputs + runner for the Canvas 2D re-architecture +(see `docs/canvas-architecture-m0.md`). These measure the **pre-migration** +cost model so M6 can compare old vs new. + +Contents: + +- `workloads.py` — JS workload definitions (deterministic, emits self-contained + HTML). Workloads cover the proposal §11.1 matrix: many small path fills and + strokes with a single readback; many small rect, image, and text draws; a + readback-after-every-draw workload (where batching is impossible); a mixed + draw/clear/pixel-write sequence; repeated clean `getImageData`; and + canvas-to-canvas/self-drawing. +- `runner.py` — CDP runner that launches `moli serve`, navigates each workload + as a `data:` URL, and writes results to `results/_baseline.json`. +- `results/` — captured raw baselines (see below for what is captured so far). + +## Workload shape + +Every workload HTML defines `SIZE` and `N`, builds a canvas and a 2D context, +runs a synchronous draw loop, then sets: + +```js +globalThis.__canvasResult = { + workload, size, ops, + recordMs, // cumulative time of the draw-call loop (input/recording cost) + flushMs, // time of the single forced pixel observation + totalMs, // total wall time + probe, acc, // sanity pixel/accumulator values +}; +``` + +## Prerequisites + +Building Moli (not this runner): the full workspace build pulls `aws-lc-sys`, +which compiles with bindgen, so a `cargo` build requires libclang. + +```sh +sudo apt-get install libclang-dev clang +cargo build --release -p moli +``` + +Python deps for `moli_benchmark` (which this runner imports): see +`moli-benchmark/pyproject.toml` (`websockets`, `pillow`). + +## Running + +```sh +python moli-benchmark/fixtures/canvas/runner.py \ + --binary ./target/release/moli \ + --sizes 256 1024 2048 \ + --ops 100 1000 +``` + +Results are written to `results/_baseline.json`, a list of +rows keyed by `workload`, `size`, `ops`, plus `recordMs`/`flushMs`/`totalMs` +(and `probe`/`acc`). + +## Captured baseline (this machine) + +Recorded during M0 on branch `canvas_2D_moli`, commit `4d6e0373`, debug build. + +### Native cost model (`moli-canvas/tests/baseline_cost.rs`, no V8) + +Models the current design's per-draw full-plane copy + format-conversion work. +Run with: + +```sh +cargo test -p moli-canvas --test baseline_cost -- --nocapture +``` + +Raw (debug) output: + +| canvas | ops | bytes/plane | full copies/draw | bytes copied (total) | copy secs | convert secs | +|---|---|---|---|---|---|---| +| 256² | 100 | 262,144 | 2 | 52,428,800 | 0.0015 | 0.162 | +| 1024² | 1000 | 4,194,304 | 2 | 8,388,608,000 | 0.229 | 26.04 | +| 2048² | 1000 | 16,777,216 | 2 | 33,554,432,000 | 1.204 | 100.99 | + +These numbers expose the structural problem the proposal targets: cost scales +with canvas area even for small shapes, because every draw copies the whole +plane and (for path fills) allocates a fresh full-canvas raster to composite. + +### Environment note on the JS/CDP baseline + +The full `moli-renderer-v8` test suite and the CDP benchmark **could not be +executed in the M0 capture environment** because rebuilding `aws-lc-sys` +requires libclang/bindgen, which is not installed here (the once-built debug +binary predates this session). `workloads.py` + `runner.py` are validated for JS +correctness (see the Node harness notes in the commit) but the wall-clock JS +orders of magnitude were not measured against a live Moli instance in this +session. They must be captured as part of M6 on a machine with libclang and a +built `moli serve`. + +### JS correctness baseline (existing regression suite) + +`moli-canvas` native tests pass (22/22). The full +`canvas_paths`/`canvas_arguments` JS regressions require the `moli-renderer-v8` +test build (blocked by libclang here): +`cargo nextest run -p moli-renderer-v8 --lib canvas_paths canvas_arguments`. diff --git a/moli-benchmark/fixtures/canvas/results/README.md b/moli-benchmark/fixtures/canvas/results/README.md new file mode 100644 index 000000000..c015e2a78 --- /dev/null +++ b/moli-benchmark/fixtures/canvas/results/README.md @@ -0,0 +1,44 @@ +# Canvas 2D M0 baseline results + +Raw captured numbers for the pre-migration Canvas 2D cost model. The CDP +wall-clock rows are captured by `runner.py` and land here as +`_baseline.json`; none have been produced yet because the +capture environment lacks libclang to rebuild `aws-lc-sys` (see +`README.md` in this directory). + +## Native cost model (this machine) + +Branch `canvas_2D_moli`, commit `4d6e0373`, **debug** build, +`moli-canvas/tests/baseline_cost.rs`, run via: + +```sh +cargo test -p moli-canvas --test baseline_cost -- --nocapture +``` + +### Arithmetic byte-cost evidence (asserted; instant) + +The current design performs **two full-plane byte copies per ordinary draw** +(`with_canvas_like_pixels_mut`: copy backing view to a Vec, mutate, write back), +so the cost is linear in canvas area regardless of paint size: + +| canvas | ops | bytes/plane | full copies/draw | bytes copied (total) | +|---|---|---|---|---| +| 256² | 100 | 262,144 | 2 | 52,428,800 | +| 1024² | 1000 | 4,194,304 | 2 | 8,388,608,000 | +| 2048² | 1000 | 16,777,216 | 2 | 33,554,432,000 | + +Moving 33.5 GB to draw 1,000 small shapes on a 2048² canvas is the structural +problem this project removes. + +### Reduced timing matrix (debug; kept fast so the check suite stays quick) + +| canvas | ops | full-copy secs | premultiply-convert secs | encode (×10) secs | +|---|---|---|---|---| +| 256² | 100 | 0.0016 | 0.175 | 0.015 | +| 1024² | 100 | 0.0288 | 2.582 | 2.090 | + +The full-timing matrix at 1000 ops is intentionally not run in the crate test to +keep `cargo nextest` fast; it can be measured at M6 on a machine with a built +browser via `moli-benchmark/fixtures/canvas/runner.py`. + +Machine / toolchain: x86_64-unknown-linux-gnu, rustc 1.96.1 (debug). diff --git a/moli-benchmark/fixtures/canvas/runner.py b/moli-benchmark/fixtures/canvas/runner.py new file mode 100644 index 000000000..55fc73f0b --- /dev/null +++ b/moli-benchmark/fixtures/canvas/runner.py @@ -0,0 +1,122 @@ +"""CDP runner for the Canvas 2D M0 baseline. + +Launches a Moli CDP instance (`moli serve`), loads each canvas workload through +`Page.navigate` on a `data:` URL, and records per-workload timings to +`results/baseline.json`. + +The workload HTML is generated by `workloads.py` in the same directory +(deterministic, no external network), and the timing loop is synchronous JS so +wall-clock cost maps to the draw/observe phases the architecture targets. + +Prerequisite to build Moli (this script does not build): the full workspace +build pulls `aws-lc-sys`, which compiles with bindgen, so a `cargo` build +requires libclang. Build with: + + sudo apt-get install libclang-dev clang + cargo build --release -p moli + +Run (anywhere, once dependencies are installed): + + python moli-benchmark/fixtures/canvas/runner.py \ + --binary ./target/release/moli \ + --sizes 256 1024 2048 --ops 100 1000 + +Results are written (with an ISO timestamp) into +`/results/_baseline.json` next to this runner. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +BENCHMARK_ROOT = HERE.resolve().parents[1] + +# Runtime sys.path wiring (the runner is a standalone script, not a package): +# - `workloads` is a sibling module in the same directory. +# - `moli_benchmark` lives in the sibling directory that also holds `moli-benchmark/`. +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(BENCHMARK_ROOT)) + +from workloads import WORKLOADS, html_for_workload # noqa: E402 + +from moli_benchmark.raw_cdp import RawCdpClient, connect_raw_cdp # noqa: E402 +from moli_benchmark.serve import start_moli_serve, stop_moli_serve # noqa: E402 + +DEFAULT_SIZES = (256, 1024, 2048) +DEFAULT_OPS = (100, 1000) + + +def _data_url(html: str) -> str: + return "data:text/html;charset=utf-8," + urllib.parse.quote(html) + + +async def _evaluate_json(client: RawCdpClient, expression: str) -> dict | None: + message_id = await client.send( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True}, + ) + reply, _events = await client.recv_until_id(message_id) + result = (reply.get("result") or {}).get("result") or {} + value = result.get("value") + return value if isinstance(value, dict) else None + + +async def _run_workload(client: RawCdpClient, name: str, size: int, ops: int) -> dict: + url = _data_url(html_for_workload(name, size, ops)) + message_id = await client.send("Page.navigate", {"url": url}) + await client.recv_until_id(message_id) + payload = await _evaluate_json(client, "JSON.stringify(globalThis.__canvasResult)") + row = {"workload": name, "size": size, "ops": ops} + if payload: + row.update( + {k: payload.get(k) for k in ("recordMs", "flushMs", "totalMs", "acc", "probe")} + ) + return row + + +async def _drive(binary: Path, sizes: list[int], ops_list: list[int]) -> list[dict]: + handle = start_moli_serve(binary, 30.0) + rows: list[dict] = [] + try: + client = await connect_raw_cdp(handle.endpoint) + try: + # Deterministic ordering: size (outer), ops (middle), workload (inner). + for size in sizes: + for ops in ops_list: + for name in WORKLOADS: + rows.append(await _run_workload(client, name, size, ops)) + finally: + await client.close() + finally: + stop_moli_serve(handle) + return rows + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", required=True, help="path to the moli CDP binary") + parser.add_argument("--sizes", nargs="+", type=int, default=list(DEFAULT_SIZES)) + parser.add_argument("--ops", nargs="+", type=int, default=list(DEFAULT_OPS)) + args = parser.parse_args() + + print(f"[canvas-baseline] binary={args.binary} sizes={args.sizes} ops={args.ops}") + rows: list[dict] = asyncio.new_event_loop().run_until_complete( + _drive(Path(args.binary), args.sizes, args.ops) + ) + out_dir = HERE / "results" + out_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + out_path = out_dir / f"{stamp}_baseline.json" + out_path.write_text(json.dumps(rows, indent=2)) + print(f"[canvas-baseline] wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/moli-benchmark/fixtures/canvas/workloads.py b/moli-benchmark/fixtures/canvas/workloads.py new file mode 100644 index 000000000..ed083ddfc --- /dev/null +++ b/moli-benchmark/fixtures/canvas/workloads.py @@ -0,0 +1,188 @@ +"""Canvas 2D performance workload definitions for the M0 baseline. + +Each workload is a JS source snippet executed inside a canvas-less harness that +defines `N` (operation count) and `SIZE` (canvas edge in px), creates a canvas +via `makeCanvas()`, obtains a 2D context via `getCtx(canvas)`, runs the workload, +and returns a JSON object of the form: + + { "workload": name, "size": SIZE, "ops": N, + "recordMs": ..., "flushMs": ..., "totalMs": ... } + +The harness records timing at the boundaries the architecture cares about: +"record" is the cumulative time of the draw-call loop (input/recording), "flush" +is the time of the single pixel observation that forces the recording to execute, +and "total" is wall time of the whole workload including observations. + +Workload list mirrors proposal section 11.1. +""" + +from __future__ import annotations + + +def path_fill(body: str) -> str: + """Many small path fills followed by ONE readback.""" + return f""" + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + {body} + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'path_fill', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def path_stroke() -> str: + return f""" + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + ctx.beginPath(); + ctx.moveTo(i % SIZE + 10, 10 + (i % 20)); + ctx.lineTo((i + 30) % SIZE + 10, 40 + (i % 20)); + ctx.stroke(); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'path_stroke', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def rect_draws() -> str: + return f""" + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + ctx.fillRect((i * 7) % SIZE, (i * 13) % SIZE, 8, 8); + ctx.strokeRect((i * 5) % SIZE, (i * 11) % SIZE, 8, 8); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'rect', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def text_draws() -> str: + return f""" + ctx.font = '16px sans-serif'; + const before = performance.now(); + for (let i = 0; i < N % 200; i++) {{ + ctx.fillText('Moli', (i * 9) % SIZE, 20 + (i % 40)); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'text', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def draw_image() -> str: + return f""" + const img = document.createElement('canvas'); img.width = img.height = 32; + const imgCtx = img.getContext('2d'); imgCtx.fillStyle = 'red'; imgCtx.fillRect(0,0,32,32); + const before = performance.now(); + for (let i = 0; i < N % 500; i++) {{ + ctx.drawImage(img, (i * 3) % SIZE, (i * 5) % SIZE); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'draw_image', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def draw_clear_write_mix() -> str: + return f""" + const id = ctx.createImageData(16, 16); + const before = performance.now(); + for (let i = 0; i < N; i++) {{ + ctx.fillRect((i * 9) % SIZE, (i * 17) % SIZE, 16, 16); + if (i % 3 === 0) ctx.clearRect((i * 9) % SIZE, (i * 17) % SIZE, 8, 8); + if (i % 5 === 0) ctx.putImageData(id, (i * 3) % SIZE, (i * 7) % SIZE); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'draw_clear_write', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +def readback_every_draw() -> str: + return f""" + const before = performance.now(); + let acc = 0; + for (let i = 0; i < N; i++) {{ + ctx.fillRect((i * 7) % SIZE, (i * 11) % SIZE, 4, 4); + acc += ctx.getImageData(0, 0, 1, 1).data[0]; + }} + const totalMs = performance.now() - before; + return JSON.stringify({{ workload: 'readback_every_draw', size: SIZE, ops: N, recordMs: totalMs, flushMs: 0, totalMs, acc }}); + """ + + +def repeated_clean_reads() -> str: + return f""" + ctx.fillStyle = '#ff0000'; ctx.fillRect(0, 0, SIZE, SIZE); + const before = performance.now(); + let acc = 0; + for (let i = 0; i < Math.min(N, 100); i++) {{ + acc += ctx.getImageData(0, 0, 1, 1).data[0]; + }} + const totalMs = performance.now() - before; + return JSON.stringify({{ workload: 'repeated_clean_reads', size: SIZE, ops: N, recordMs: 0, flushMs: 0, totalMs, acc }}); + """ + + +def self_draw() -> str: + return f""" + const before = performance.now(); + for (let i = 0; i < Math.min(N, 50); i++) {{ + ctx.drawImage(canvas, (i * 3) % SIZE, (i * 5) % SIZE); + }} + const recordMs = performance.now() - before; + const beforeFlush = performance.now(); + const probe = ctx.getImageData(0, 0, 1, 1).data[0]; + const flushMs = performance.now() - beforeFlush; + return JSON.stringify({{ workload: 'self_draw', size: SIZE, ops: N, recordMs, flushMs, totalMs: recordMs + flushMs, probe }}); + """ + + +WORKLOADS: dict[str, str] = { + "path_fill": path_fill( + "ctx.beginPath(); ctx.rect((i*11)%SIZE, (i*7)%SIZE, 12, 12); ctx.fill();" + ), + "path_stroke": path_stroke(), + "rect": rect_draws(), + "text": text_draws(), + "draw_image": draw_image(), + "draw_clear_write": draw_clear_write_mix(), + "readback_every_draw": readback_every_draw(), + "repeated_clean_reads": repeated_clean_reads(), + "self_draw": self_draw(), +} + + +def html_for_workload(name: str, size: int, ops: int) -> str: + body = WORKLOADS[name] + return ( + "moli canvas baseline" + f"" + ) diff --git a/moli-canvas/Cargo.toml b/moli-canvas/Cargo.toml index b3a377d2f..cc74a5ae0 100644 --- a/moli-canvas/Cargo.toml +++ b/moli-canvas/Cargo.toml @@ -5,10 +5,15 @@ version = "0.1.0" edition = "2024" [dependencies] +anyrender = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43" } +anyrender_vello_cpu = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43", default-features = false, features = ["bitmap-glyphs"] } base64 = "0.22" font8x8 = "0.3" +kurbo = "=0.13.1" moli-image = { path = "../moli-image" } moli-web-mime = { path = "../moli-web-mime" } +peniko = "0.6" +vello_cpu = "0.2" [lints] workspace = true diff --git a/moli-canvas/src/backend/mod.rs b/moli-canvas/src/backend/mod.rs new file mode 100644 index 000000000..dae975b78 --- /dev/null +++ b/moli-canvas/src/backend/mod.rs @@ -0,0 +1,5 @@ +//! Canvas rendering backends. + +pub mod vello_cpu; + +pub use vello_cpu::VelloCpuBackend; diff --git a/moli-canvas/src/backend/vello_cpu.rs b/moli-canvas/src/backend/vello_cpu.rs new file mode 100644 index 000000000..b04810e22 --- /dev/null +++ b/moli-canvas/src/backend/vello_cpu.rs @@ -0,0 +1,86 @@ +//! Reusable Vello CPU rendering backend for the Canvas surface. +//! +//! This owns a single [`VelloCpuScenePainter`] sized to the canvas and reuses +//! it across flushes. Rendering composites into the caller-provided buffer +//! using either: +//! +//! - [`CompositeMode::SrcOver`] to draw a batch of source-over operations onto +//! the *existing* canonical surface, preserving prior content without +//! clearing — the incremental path the proposal requires; or +//! - [`CompositeMode::Replace`] for a destructive step (e.g. a clear or a proven +//! full overwrite), which clears the whole destination pixmap and renders the +//! scene into it. +//! +//! The canonical surface format is premultiplied RGBA8 (see `surface.rs`); Vello +//! `SrcOver` composites premultiplied sources over premultiplied destinations, +//! which matches the authoritative store directly. + +use anyrender::PaintScene; +use anyrender_vello_cpu::VelloCpuScenePainter; +use vello_cpu::{ + CompositeMode, PixelFormat, PixmapMut, RasterizerSettings, RenderContext as VelloRenderContext, + RenderMode, Resources, +}; + +/// A reusable Vello CPU renderer bound to a canvas of `width` by `height`. +pub struct VelloCpuBackend { + renderer: VelloCpuScenePainter, + width: u32, + height: u32, +} + +impl VelloCpuBackend { + /// Creates a backend for a canvas of the given size (in pixels). + pub fn new(width: u32, height: u32) -> Self { + Self { + renderer: VelloCpuScenePainter { + render_ctx: VelloRenderContext::new(width as u16, height as u16), + resources: Resources::new(), + }, + width, + height, + } + } + + /// Whether this backend is bound to the given canvas size. + pub fn matches(&self, width: u32, height: u32) -> bool { + self.width == width && self.height == height + } + + /// Rebinds the backend to a different canvas size, discarding prior scene + /// state. Callers must also replace the backing surface storage. + pub fn resize(&mut self, width: u32, height: u32) { + self.renderer.render_ctx = VelloRenderContext::new(width as u16, height as u16); + self.width = width; + self.height = height; + } + + /// Renders a scene built by `draw` into `target` (premultiplied RGBA8 of + /// `width * height * 4` bytes) using the given composite mode. With + /// `SrcOver`, content already in `target` is preserved underneath the batch. + pub fn render(&mut self, target: &mut [u8], composite: CompositeMode, draw: F) + where + F: FnOnce(&mut VelloCpuScenePainter), + { + debug_assert_eq!( + target.len(), + self.width as usize * self.height as usize * 4, + "target must span the whole backend surface" + ); + // Reset the mutable scene but keep the renderer/resources for reuse. + self.renderer.reset(); + draw(&mut self.renderer); + self.renderer.render_ctx.flush(); + let pixmap = PixmapMut::new(self.width as u16, self.height as u16, target) + .expect("backend provides a correctly sized RGBA8 target"); + let settings = RasterizerSettings { + render_mode: RenderMode::OptimizeSpeed, + composite_mode: composite, + pixel_format: PixelFormat::Rgba8, + offset: (0, 0), + }; + self.renderer + .render_ctx + .render_with(pixmap, &mut self.renderer.resources, settings); + } +} diff --git a/moli-canvas/src/blit.rs b/moli-canvas/src/blit.rs index 89efafb34..20f033ea3 100644 --- a/moli-canvas/src/blit.rs +++ b/moli-canvas/src/blit.rs @@ -255,3 +255,165 @@ pub fn blit_draw_image_filtered( start_y as u32, ); } + +/// Like [`blit_draw_image_filtered`] but operates directly on a premultiplied +/// RGBA8 destination surface. The source is expected to be straight-alpha +/// (e.g. from a canvas snapshot). Scaled pixels are unpremultiplied and +/// composited with source-over blending in premultiplied space, avoiding the +/// full-surface unpremultiply/premultiply round-trip. +#[allow(clippy::too_many_arguments)] +pub fn blit_draw_image_filtered_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + blit: DrawImageBlit, + filter: ScaleFilter, +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) + || !surface_matches_len(source, source_width, source_height) + { + return; + } + let dest_left = blit.dest_x.floor() as i32; + let dest_top = blit.dest_y.floor() as i32; + let dest_right = (blit.dest_x + blit.dest_width).ceil() as i32; + let dest_bottom = (blit.dest_y + blit.dest_height).ceil() as i32; + if dest_left >= dest_right || dest_top >= dest_bottom { + return; + } + + let start_x = dest_left.max(0).min(canvas_width as i32); + let start_y = dest_top.max(0).min(canvas_height as i32); + let end_x = dest_right.max(0).min(canvas_width as i32); + let end_y = dest_bottom.max(0).min(canvas_height as i32); + if start_x >= end_x || start_y >= end_y { + return; + } + + let visible_width = (end_x - start_x) as u32; + let visible_height = (end_y - start_y) as u32; + let Some(scaled) = scale_rgba8( + source, + source_width, + source_height, + blit, + start_x, + start_y, + visible_width, + visible_height, + filter, + ) else { + return; + }; + + let row_stride = canvas_width as usize * 4; + for sy in 0..visible_height as usize { + let dy = start_y as u32 + sy as u32; + if dy >= canvas_height { + break; + } + for sx in 0..visible_width as usize { + let dx = start_x as u32 + sx as u32; + if dx >= canvas_width { + break; + } + let si = (sy * visible_width as usize + sx) * 4; + let sa = scaled[si + 3] as u32; + if sa == 0 { + continue; + } + let di = dy as usize * row_stride + dx as usize * 4; + let spr = (scaled[si] as u32 * sa + 128) / 255; + let spg = (scaled[si + 1] as u32 * sa + 128) / 255; + let spb = (scaled[si + 2] as u32 * sa + 128) / 255; + let inv_sa = 255 - sa; + let d = &mut pixels[di..di + 4]; + d[0] = ((spr * 255 + d[0] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[1] = ((spg * 255 + d[1] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[2] = ((spb * 255 + d[2] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[3] = ((sa * 255 + d[3] as u32 * inv_sa + 128) / 255).min(255) as u8; + } + } +} + +/// Copies straight-alpha source pixels onto a premultiplied RGBA8 surface as a +/// **raw overwrite** (matching the `putImageData` contract, not a source-over +/// composite). Each destination pixel becomes the premultiplied form of the +/// source straight-alpha value, replacing prior content exactly and ignoring the +/// ordinary paint state / composite operation. This avoids the full-surface +/// unpremultiply/premultiply round-trip that [`blit_image_data`] requires. +#[allow(clippy::too_many_arguments)] +pub fn blit_image_data_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + dx: i32, + dy: i32, + dirty_x: i32, + dirty_y: i32, + dirty_width: i32, + dirty_height: i32, +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) + || !surface_matches_len(source, source_width, source_height) + || dirty_width <= 0 + || dirty_height <= 0 + { + return; + } + let Some((source_rect, dest_x, dest_y)) = clipped_blit_rect( + source_width, + source_height, + canvas_width, + canvas_height, + dx, + dy, + dirty_x, + dirty_y, + dirty_width, + dirty_height, + ) else { + return; + }; + + let src_row_stride = source_width as usize * 4; + let dst_row_stride = canvas_width as usize * 4; + let copy_w = source_rect.width as usize; + + for row in 0..source_rect.height as usize { + let src_y = source_rect.y as usize + row; + let dst_y = dest_y as usize + row; + if dst_y >= canvas_height as usize { + break; + } + let src_start = src_y * src_row_stride + source_rect.x as usize * 4; + let dst_start = dst_y * dst_row_stride + dest_x as usize * 4; + for col in 0..copy_w { + let si = src_start + col * 4; + let di = dst_start + col * 4; + if di + 4 > pixels.len() { + break; + } + let sa = source[si + 3] as u32; + // Raw overwrite: premultiply the straight source channel into the + // destination, replacing it without reading/compositing over it. + if sa == 0 { + pixels[di] = 0; + pixels[di + 1] = 0; + pixels[di + 2] = 0; + pixels[di + 3] = 0; + continue; + } + pixels[di] = ((source[si] as u32 * sa + 128) / 255) as u8; + pixels[di + 1] = ((source[si + 1] as u32 * sa + 128) / 255) as u8; + pixels[di + 2] = ((source[si + 2] as u32 * sa + 128) / 255) as u8; + pixels[di + 3] = sa as u8; + } + } +} diff --git a/moli-canvas/src/lib.rs b/moli-canvas/src/lib.rs index 82f584c86..31cb135c2 100644 --- a/moli-canvas/src/lib.rs +++ b/moli-canvas/src/lib.rs @@ -1,20 +1,33 @@ +mod backend; mod blit; mod encode; +pub mod path; mod pixel; +pub mod recording; mod rect; +mod surface; mod text; mod types; -pub use blit::{blit_draw_image, blit_draw_image_filtered, blit_image_data, extract_image_data}; +pub use backend::VelloCpuBackend; +pub use blit::{ + blit_draw_image, blit_draw_image_filtered, blit_draw_image_filtered_premul, blit_image_data, + blit_image_data_premul, extract_image_data, +}; pub use encode::{ data_image_intrinsic_dimensions, data_image_rgba8_pixels, encode_data_url, image_dimensions_from_bytes, image_intrinsic_dimensions_from_bytes, }; +pub use path::{CanvasPath, CanvasPathData}; pub use pixel::{ copy_rgba8_rect, flip_y_rgba8_in_place, multiply_u8_color, premultiply_rgba8_in_place, - scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, + scale_rgba8, scale_rgba8_bilinear, scale_rgba8_nearest, unpremultiply_rgba8_in_place, +}; +pub use recording::{DrawOp, DrawRecording, ExecutionStats, StrokeSpec}; +pub use rect::{ + canonicalize_fill_style, fill_style_rgba, normalize_rect, paint_rect, paint_rect_premul, }; -pub use rect::{canonicalize_fill_style, fill_style_rgba, normalize_rect, paint_rect}; +pub use surface::{CanvasSurface, CanvasSurfaceError}; pub use text::{draw_text, measure_text_width}; pub use types::{ CanvasRect, DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, MAX_RGBA8_BYTE_LENGTH, Rgba8Rect, diff --git a/moli-canvas/src/path.rs b/moli-canvas/src/path.rs new file mode 100644 index 000000000..599d23d2d --- /dev/null +++ b/moli-canvas/src/path.rs @@ -0,0 +1,504 @@ +//! Browser-independent Canvas 2D current-path geometry. +//! +//! Backed by kurbo-native types (`kurbo::PathEl`, `kurbo::Affine`) so this +//! module has no dependency on page layout or any browser internals. Ported +//! from the renderer's per-context path state with corrected arc mathematics, +//! bounded angle normalization, and default-path transform semantics. +//! +//! Semantics matching the HTML spec and Chromium: the default-path transform is +//! applied to each newly recorded command at command time; earlier elements are +//! never moved by a later transform change. Stroke metrics (width, dash, +//! cap/join) are expressed in the *current* user space. + +use kurbo::{Affine, Point, Rect, Vec2}; + +/// An owned Bézier path in canvas coordinates plus its conservative local +/// bounds. Elements use the same f32 truncation the browser adapter consumes, +/// while bounds are expressed in canvas pixel space. +#[derive(Clone, Debug)] +pub struct CanvasPathData { + /// Path elements, in canvas (already-transformed) coordinates for fill. + pub elements: Vec, + /// Conservative local bounds over the elements. + pub bounds: Rect, +} + +/// A Bezier path in the same flat form as a kurbo path: one `MoveTo` starts a +/// subpath, `ClosePath` ends it, and a new `MoveTo` begins the next one. +/// +/// The current transform only affects newly recorded commands, never earlier +/// elements. +#[derive(Clone, Debug)] +pub struct CanvasPath { + elements: Vec, + current: Point, + current_subpath_start: Point, + has_subpath: bool, + just_closed: bool, + transform: Affine, +} + +impl Default for CanvasPath { + fn default() -> Self { + Self { + elements: Vec::new(), + current: Point::ZERO, + current_subpath_start: Point::ZERO, + has_subpath: false, + just_closed: false, + transform: Affine::IDENTITY, + } + } +} + +const TWO_PI: f64 = std::f64::consts::TAU; + +impl CanvasPath { + pub fn begin_path(&mut self) { + self.elements.clear(); + self.current = Point::ZERO; + self.current_subpath_start = Point::ZERO; + self.has_subpath = false; + self.just_closed = false; + } + + pub fn move_to(&mut self, x: f64, y: f64) { + if ![x, y].into_iter().all(f64::is_finite) { + return; + } + let (x, y) = self.map_point(x, y); + self.elements.push(kurbo::PathEl::MoveTo(point(x, y))); + self.current = Point::new(x, y); + self.current_subpath_start = Point::new(x, y); + self.has_subpath = true; + self.just_closed = false; + } + + /// Reopens a closed subpath; callers establish the initial point for an + /// empty path. + fn ensure_open_subpath(&mut self) -> bool { + if !self.has_subpath { + return false; + } + if self.just_closed { + self.elements + .push(kurbo::PathEl::MoveTo(point(self.current.x, self.current.y))); + self.current_subpath_start = self.current; + self.just_closed = false; + } + true + } + + pub fn line_to(&mut self, x: f64, y: f64) { + if ![x, y].into_iter().all(f64::is_finite) { + return; + } + if !self.ensure_open_subpath() { + self.move_to(x, y); + return; + } + let (x, y) = self.map_point(x, y); + self.elements.push(kurbo::PathEl::LineTo(point(x, y))); + self.current = Point::new(x, y); + } + + pub fn quadratic_curve_to(&mut self, cpx: f64, cpy: f64, x: f64, y: f64) { + if ![cpx, cpy, x, y].into_iter().all(f64::is_finite) { + return; + } + if !self.ensure_open_subpath() { + self.move_to(cpx, cpy); + } + let (cpx, cpy) = self.map_point(cpx, cpy); + let (x, y) = self.map_point(x, y); + self.elements + .push(kurbo::PathEl::QuadTo(point(cpx, cpy), point(x, y))); + self.current = Point::new(x, y); + } + + pub fn bezier_curve_to(&mut self, c1x: f64, c1y: f64, c2x: f64, c2y: f64, x: f64, y: f64) { + if ![c1x, c1y, c2x, c2y, x, y].into_iter().all(f64::is_finite) { + return; + } + if !self.ensure_open_subpath() { + self.move_to(c1x, c1y); + } + let (c1x, c1y) = self.map_point(c1x, c1y); + let (c2x, c2y) = self.map_point(c2x, c2y); + let (x, y) = self.map_point(x, y); + self.elements.push(kurbo::PathEl::CurveTo( + point(c1x, c1y), + point(c2x, c2y), + point(x, y), + )); + self.current = Point::new(x, y); + } + + pub fn close_path(&mut self) { + if !self.has_subpath || self.just_closed { + return; + } + self.elements.push(kurbo::PathEl::ClosePath); + self.current = self.current_subpath_start; + self.just_closed = true; + } + + pub fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) { + if ![x, y, width, height].into_iter().all(f64::is_finite) + || self.inverse_transform().is_none() + { + return; + } + self.move_to(x, y); + self.line_to(x + width, y); + self.line_to(x + width, y + height); + self.line_to(x, y + height); + self.close_path(); + } + + /// Canvas arcs use positive angles clockwise in the screen's y-down space. + pub fn arc(&mut self, x: f64, y: f64, radius: f64, start: f64, end: f64, ccw: bool) -> bool { + self.ellipse(x, y, radius, radius, 0.0, start, end, ccw) + } + + pub fn ellipse( + &mut self, + x: f64, + y: f64, + radius_x: f64, + radius_y: f64, + rotation: f64, + start: f64, + end: f64, + ccw: bool, + ) -> bool { + if ![x, y, radius_x, radius_y, rotation, start, end] + .into_iter() + .all(f64::is_finite) + || radius_x < 0.0 + || radius_y < 0.0 + || self.inverse_transform().is_none() + { + return false; + } + let (start, sweep) = arc_angles(start, end, ccw); + self.append_arc(kurbo::Arc::new( + (x, y), + (radius_x, radius_y), + start, + sweep, + rotation.rem_euclid(TWO_PI), + )); + true + } + + pub fn arc_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, radius: f64) -> bool { + if ![x1, y1, x2, y2, radius].into_iter().all(f64::is_finite) || radius < 0.0 { + return false; + } + if !self.has_subpath { + self.move_to(x1, y1); + return true; + } + let Some(inverse) = self.inverse_transform() else { + self.line_to(x1, y1); + return true; + }; + // arcTo constructs a circular arc in the current user coordinate + // system. The previous point was recorded under a possibly older CTM. + let p0 = inverse * self.current; + let p1 = Point::new(x1, y1); + let p2 = Point::new(x2, y2); + let incoming = p0 - p1; + let outgoing = p2 - p1; + if incoming.hypot() == 0.0 || outgoing.hypot() == 0.0 || radius == 0.0 { + self.line_to(x1, y1); + return true; + } + // Both rays originate at the corner. There is no radius clamp. + let u = incoming / incoming.hypot(); + let v = outgoing / outgoing.hypot(); + let cross = u.cross(v); + if cross == 0.0 || !cross.is_finite() { + self.line_to(x1, y1); + return true; + } + let tangent_distance = radius * ((1.0 + u.dot(v).clamp(-1.0, 1.0)) / cross.abs()); + let tangent = p1 + u * tangent_distance; + let center = tangent + Vec2::new(-u.y, u.x) * (radius * cross.signum()); + let end = p1 + v * tangent_distance; + let start_angle = (tangent - center).atan2(); + let end_angle = (end - center).atan2(); + let (start, sweep) = arc_angles(start_angle, end_angle, cross > 0.0); + self.append_arc(kurbo::Arc::new(center, (radius, radius), start, sweep, 0.0)); + true + } + + fn append_arc(&mut self, arc: kurbo::Arc) { + use kurbo::{PathEl, Shape}; + + // A relative floor also bounds subdivision for enormous but finite + // radii. It prevents resource usage growing without bound with radius. + let tolerance = 0.01_f64.max(arc.radii.x.max(arc.radii.y) / 4096.0); + for element in arc.path_elements(tolerance) { + match element { + PathEl::MoveTo(p) => self.line_to(p.x, p.y), + PathEl::CurveTo(a, b, p) => self.bezier_curve_to(a.x, a.y, b.x, b.y, p.x, p.y), + _ => unreachable!("kurbo arcs contain only a start point and cubic segments"), + } + } + } + + pub fn set_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { + if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { + self.transform = Affine::new([a, b, c, d, e, f]); + } + } + + pub fn reset_transform(&mut self) { + self.transform = Affine::IDENTITY; + } + + pub fn translate(&mut self, x: f64, y: f64) { + self.concatenate_transform(1.0, 0.0, 0.0, 1.0, x, y); + } + + pub fn scale(&mut self, x: f64, y: f64) { + self.concatenate_transform(x, 0.0, 0.0, y, 0.0, 0.0); + } + + pub fn rotate(&mut self, radians: f64) { + let (sin, cos) = radians.sin_cos(); + self.concatenate_transform(cos, sin, -sin, cos, 0.0, 0.0); + } + + pub fn concatenate_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { + if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { + self.transform *= Affine::new([a, b, c, d, e, f]); + } + } + + /// The current transform, mapping user space to canvas space. + pub fn transform(&self) -> Affine { + self.transform + } + + fn map_point(&self, x: f64, y: f64) -> (f64, f64) { + let p = self.transform * Point::new(x, y); + (p.x, p.y) + } + + /// Inverse of the current transform, `None` for singular transforms. A + /// small nonzero scale is still invertible; do not use `EPSILON` as a + /// singularity threshold (e.g. `scale(1e-9, 1e-9)` is valid). + pub fn inverse_transform(&self) -> Option { + let determinant = self.transform.determinant(); + (determinant != 0.0 && determinant.is_finite()).then(|| self.transform.inverse()) + } + + /// Returns the frozen path mapped back to the *current* user space, so a + /// later transform can run the painter over the path for anisotropic + /// strokes and dashes. Returns `None` when the transform is singular. + pub fn stroke_path(&self) -> Option { + let inverse = self.inverse_transform()?; + let map = |p: Point| { + let p = inverse * p; + point(p.x, p.y) + }; + let elements = self + .elements + .iter() + .map(|element| match *element { + kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(map(p)), + kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(map(p)), + kurbo::PathEl::QuadTo(a, p) => kurbo::PathEl::QuadTo(map(a), map(p)), + kurbo::PathEl::CurveTo(a, b, p) => kurbo::PathEl::CurveTo(map(a), map(b), map(p)), + kurbo::PathEl::ClosePath => kurbo::PathEl::ClosePath, + }) + .collect::>(); + Some(CanvasPathData { + bounds: path_bounds(&elements), + elements, + }) + } + + /// Builds an owned `CanvasPathData` with conservative local bounds. + pub fn paint_path(&self) -> CanvasPathData { + CanvasPathData { + elements: self.elements.clone(), + bounds: path_bounds(&self.elements), + } + } + + pub fn is_empty(&self) -> bool { + self.elements.is_empty() + } +} + +/// Casts a coordinate pair to the same f32 truncation the browser's painted +/// path consumes, preserving observable behavior of the original adapter. +fn point(x: f64, y: f64) -> Point { + Point::new((x as f32) as f64, (y as f32) as f64) +} + +fn path_bounds(elements: &[kurbo::PathEl]) -> Rect { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for element in elements { + match *element { + kurbo::PathEl::MoveTo(point) | kurbo::PathEl::LineTo(point) => { + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, point); + } + kurbo::PathEl::QuadTo(first, second) => { + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); + } + kurbo::PathEl::CurveTo(first, second, third) => { + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); + expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, third); + } + kurbo::PathEl::ClosePath => {} + } + } + if !min_x.is_finite() { + return Rect::ZERO; + } + Rect::new( + (min_x as f32) as f64, + (min_y as f32) as f64, + ((max_x - min_x) as f32) as f64, + ((max_y - min_y) as f32) as f64, + ) +} + +fn expand(min_x: &mut f64, min_y: &mut f64, max_x: &mut f64, max_y: &mut f64, point: Point) { + *min_x = (*min_x).min(point.x); + *min_y = (*min_y).min(point.y); + *max_x = (*max_x).max(point.x); + *max_y = (*max_y).max(point.y); +} + +/// Normalize in constant time, including opposite-sign finite angles whose +/// subtraction overflows. Never repeatedly add/subtract TAU: at large f64 +/// magnitudes that operation does not advance at all. +fn arc_angles(start: f64, end: f64, ccw: bool) -> (f64, f64) { + let difference = end - start; + let normalized_start = start.rem_euclid(TWO_PI); + let sweep = if !ccw && difference >= TWO_PI { + TWO_PI + } else if ccw && difference <= -TWO_PI { + -TWO_PI + } else if difference == 0.0 { + 0.0 + } else { + let remainder = if difference.is_finite() { + difference.rem_euclid(TWO_PI) + } else { + (end.rem_euclid(TWO_PI) - normalized_start).rem_euclid(TWO_PI) + }; + if ccw { + // Like Blink's AdjustEndAngle, preserve a whole turn for + // opposite-direction endpoints separated by an exact TAU multiple. + if remainder == 0.0 && difference < 0.0 { + 0.0 + } else { + remainder - TWO_PI + } + } else if remainder == 0.0 && difference < 0.0 { + TWO_PI + } else { + remainder + } + }; + (normalized_start, sweep) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::FRAC_PI_2; + + #[test] + fn arc_angles_preserve_direction_full_turns_and_equal_endpoints() { + for (start, end, ccw, expected) in [ + (0.0, FRAC_PI_2, false, FRAC_PI_2), + (0.0, FRAC_PI_2, true, -3.0 * FRAC_PI_2), + (0.0, -FRAC_PI_2, true, -FRAC_PI_2), + (0.0, -FRAC_PI_2, false, 3.0 * FRAC_PI_2), + (0.0, TWO_PI, false, TWO_PI), + (0.0, -TWO_PI, true, -TWO_PI), + (0.0, TWO_PI, true, -TWO_PI), + (0.0, -TWO_PI, false, TWO_PI), + (2.0, 2.0, true, 0.0), + (2.0, 2.0, false, 0.0), + ] { + assert!((arc_angles(start, end, ccw).1 - expected).abs() < 1e-12); + } + } + + #[test] + fn finite_extreme_angles_produce_bounded_path_work() { + for start in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { + for end in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { + for ccw in [false, true] { + let (angle, sweep) = arc_angles(start, end, ccw); + assert!(angle.is_finite() && (0.0..TWO_PI).contains(&angle)); + assert!(sweep.is_finite() && sweep.abs() <= TWO_PI); + assert!(if ccw { sweep <= 0.0 } else { sweep >= 0.0 }); + let mut state = CanvasPath::default(); + assert!(state.arc(10.0, 10.0, 5.0, start, end, ccw)); + assert!(state.elements.len() <= 7); + } + } + } + } + + #[test] + fn arc_to_has_the_correct_tangent_and_endpoint() { + let mut state = CanvasPath::default(); + state.move_to(0.0, 0.0); + assert!(state.arc_to(10.0, 0.0, 10.0, 10.0, 5.0)); + let kurbo::PathEl::LineTo(tangent) = state.elements[1] else { + panic!("arcTo must connect to its first tangent"); + }; + assert!((tangent.x - 5.0).abs() < 1e-5 && tangent.y.abs() < 1e-5); + assert!((state.current.x - 10.0).abs() < 1e-5); + assert!((state.current.y - 5.0).abs() < 1e-5); + } + + #[test] + fn rejected_arc_geometry_does_not_mutate_the_path() { + let mut state = CanvasPath::default(); + for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { + assert!(!state.arc(0.0, 0.0, invalid, 0.0, 1.0, false)); + assert!(!state.ellipse(0.0, 0.0, 5.0, invalid, 0.0, 0.0, 1.0, false)); + assert!(!state.arc_to(0.0, 0.0, 1.0, 1.0, invalid)); + assert!(state.is_empty()); + } + } + + #[test] + fn default_path_transform_applies_at_command_time_only() { + let mut state = CanvasPath::default(); + state.move_to(0.0, 0.0); + state.line_to(10.0, 0.0); + let before = state.paint_path(); + // A later transform must not move already-recorded elements. + state.set_transform(2.0, 0.0, 0.0, 2.0, 100.0, 100.0); + let after = state.paint_path(); + assert_eq!(before.elements.len(), after.elements.len()); + assert_eq!( + before.elements[1], after.elements[1], + "earlier commands keep their transform" + ); + // New commands after the transform change are transformed. + state.line_to(1.0, 1.0); + let kurbo::PathEl::LineTo(q) = state.paint_path().elements[2] else { + panic!("expected line") + }; + assert!((q.x - 102.0).abs() < 1e-6 && (q.y - 102.0).abs() < 1e-6); + } +} diff --git a/moli-canvas/src/pixel.rs b/moli-canvas/src/pixel.rs index e7f90131c..10ebd5b0f 100644 --- a/moli-canvas/src/pixel.rs +++ b/moli-canvas/src/pixel.rs @@ -20,6 +20,38 @@ pub fn premultiply_rgba8_in_place(pixels: &mut [u8]) -> Option { Some(opaque) } +/// Converts premultiplied RGBA8 to straight (non-premultiplied) RGBA8 in place. +/// +/// This is the inverse of [`premultiply_rgba8_in_place`] and is used at every +/// boundary that publishes canvas pixels to straight-alpha consumers (region +/// readback, `ImageData`, PNG encoding, page snapshots). Transparent pixels keep +/// arbitrary RGB and are normalized to transparent black so repeats stay stable. +/// Returns `None` when the byte length is not a multiple of four. +pub fn unpremultiply_rgba8_in_place(pixels: &mut [u8]) -> Option { + if !pixels.len().is_multiple_of(4) { + return None; + } + let mut opaque = true; + for rgba in pixels.chunks_mut(4) { + let alpha = u32::from(rgba[3]); + if alpha == 0 { + rgba[0] = 0; + rgba[1] = 0; + rgba[2] = 0; + opaque = false; + continue; + } + opaque &= alpha == u32::from(u8::MAX); + if alpha == u32::from(u8::MAX) { + continue; + } + for value in rgba[..3].iter_mut() { + *value = ((u32::from(*value) * 255 + alpha / 2) / alpha) as u8; + } + } + Some(opaque) +} + pub fn flip_y_rgba8_in_place(pixels: &mut [u8], width: u32, height: u32) -> Option<()> { if !surface_matches_len(pixels, width, height) { return None; diff --git a/moli-canvas/src/recording.rs b/moli-canvas/src/recording.rs new file mode 100644 index 000000000..6faa4fce1 --- /dev/null +++ b/moli-canvas/src/recording.rs @@ -0,0 +1,446 @@ +//! Browser-independent ordered Canvas 2D recorder. +//! +//! A [`DrawRecording`] captures every ordinary drawing operation with its +//! frozen inputs (geometry, captured paint state, and immutable source image +//! snapshots) in call order. Executing it against a persistent +//! [`CanvasSurface`] reproduces the drawn result while batching contiguous +//! scene-expressible source-over operations (path fills/strokes, rectangles) +//! into fewer rasterizations, and preserving order through segmented internal +//! steps for destructive clears, image/text blits, and direct pixel writes. +//! +//! The recorder holds no page-layout or browser state; the browser adapter owns +//! the drawing state and current path and only hands frozen inputs here. +//! Executing/flushing must not reset the drawing state or current path — those +//! stay with the caller. `clear()` discards pending work. + +use std::sync::Arc; + +use anyrender::PaintScene; +use kurbo::{Affine, BezPath, Rect, Shape, Stroke}; +use moli_image::RgbaImage; +use peniko::{Color, Fill}; + +use crate::blit::{blit_draw_image_filtered_premul, blit_image_data_premul}; +use crate::surface::{CanvasSurface, CanvasSurfaceError}; +use crate::text::draw_text_premul; +use crate::types::{CanvasRect, DrawImageBlit, ScaleFilter}; + +/// Frozen stroke metrics that must survive the recording boundary unchanged. +#[derive(Clone, Debug)] +pub struct StrokeSpec { + pub width: f64, + pub cap: kurbo::Cap, + pub join: kurbo::Join, + pub miter_limit: f64, + pub dash_pattern: Vec, + pub dash_offset: f64, +} + +/// One ordered ordinary drawing operation with captured inputs. +#[derive(Clone, Debug)] +pub enum DrawOp { + /// An already-canvas-space path filled with a straight-alpha color. + FillPath { path: BezPath, color: [u8; 4] }, + /// A user-space path stroked with the given transform and frozen metrics. + StrokePath { + path: BezPath, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + }, + /// An axis-aligned rectangle filled with a straight-alpha color. + FillRect { rect: Rect, color: [u8; 4] }, + /// A rectangle stroked with the given transform and frozen metrics. + StrokeRect { + rect: Rect, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + }, + /// A destructive rectangle clear. + ClearRect { rect: Rect }, + /// A captured source image drawn into a destination rectangle. + DrawImage { + dest: Rect, + source: Arc, + blit: DrawImageBlit, + filter: ScaleFilter, + }, + /// Text rendered with the monochrome (font8x8) glyph helper. + Text { + text: String, + x: f64, + y: f64, + font: String, + color: [u8; 4], + }, + /// A raw pixel overwrite (does not apply ordinary paint state). + PutImageData { source: RgbaImage, dx: i32, dy: i32 }, +} + +/// Statistics from one [`DrawRecording::execute`] invocation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ExecutionStats { + /// Number of backend (surface) submissions produced. + pub flushes: u64, + /// Number of ordered direct-steps produced (clears / blits / text / writes). + pub direct_steps: u64, +} + +/// An ordered recording of ordinary drawing operations with frozen inputs. +#[derive(Clone, Debug, Default)] +pub struct DrawRecording { + ops: Vec, + /// Estimated retained storage bytes (for early-flush accounting). + estimated_bytes: usize, +} + +impl DrawRecording { + /// Creates an empty recording. + pub fn new() -> Self { + Self::default() + } + + /// Whether the recording has no pending operations. + pub fn is_empty(&self) -> bool { + self.ops.is_empty() + } + + /// Number of recorded logical operations. + pub fn op_count(&self) -> usize { + self.ops.len() + } + + /// Estimated storage bytes retained by pending operations. + pub fn estimated_bytes(&self) -> usize { + self.estimated_bytes + } + + /// Discards pending operations (reset semantics). Never affects the + /// caller's drawing state or current path. + pub fn clear(&mut self) { + self.ops.clear(); + self.estimated_bytes = 0; + } + + fn push(&mut self, op: DrawOp, bytes: usize) { + self.estimated_bytes = self.estimated_bytes.saturating_add(bytes); + self.ops.push(op); + } + + pub fn push_fill_path(&mut self, path: BezPath, color: [u8; 4]) { + let bytes = path.elements().len() * 24 + 4; + self.push(DrawOp::FillPath { path, color }, bytes); + } + + pub fn push_stroke_path( + &mut self, + path: BezPath, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + ) { + let bytes = path.elements().len() * 24 + 4; + self.push( + DrawOp::StrokePath { + path, + transform, + style, + color, + }, + bytes, + ); + } + + pub fn push_fill_rect(&mut self, rect: Rect, color: [u8; 4]) { + self.push(DrawOp::FillRect { rect, color }, 16 + 4); + } + + pub fn push_stroke_rect( + &mut self, + rect: Rect, + transform: Affine, + style: StrokeSpec, + color: [u8; 4], + ) { + self.push( + DrawOp::StrokeRect { + rect, + transform, + style, + color, + }, + 16 + 4, + ); + } + + pub fn push_clear_rect(&mut self, rect: Rect) { + self.push(DrawOp::ClearRect { rect }, 16); + } + + pub fn push_draw_image( + &mut self, + dest: Rect, + source: Arc, + blit: DrawImageBlit, + filter: ScaleFilter, + ) { + let bytes = source.byte_len() + 8; + self.push( + DrawOp::DrawImage { + dest, + source, + blit, + filter, + }, + bytes, + ); + } + + pub fn push_text(&mut self, text: String, x: f64, y: f64, font: String, color: [u8; 4]) { + let bytes = text.len() + font.len() + 16 + 4; + self.push( + DrawOp::Text { + text, + x, + y, + font, + color, + }, + bytes, + ); + } + + pub fn push_put_image_data(&mut self, source: RgbaImage, dx: i32, dy: i32) { + let bytes = source.byte_len() + 8; + self.push(DrawOp::PutImageData { source, dx, dy }, bytes); + } + + /// Executes the recorded operations against `surface` in call order. + /// + /// Contiguous scene-expressible source-over operations (path fills/strokes + /// and rectangles) are accumulated into one backend submission; destructive + /// clears, image/text blits, and direct pixel writes are executed as ordered + /// internal steps, so a mixed draw/clear/write sequence preserves ordering. + pub fn execute( + &self, + surface: &mut CanvasSurface, + ) -> Result { + let mut stats = ExecutionStats::default(); + let mut batch: Vec<&DrawOp> = Vec::new(); + + let is_scene_op = |op: &DrawOp| { + matches!( + op, + DrawOp::FillPath { .. } + | DrawOp::StrokePath { .. } + | DrawOp::FillRect { .. } + | DrawOp::StrokeRect { .. } + ) + }; + + for op in &self.ops { + if is_scene_op(op) { + batch.push(op); + continue; + } + if !batch.is_empty() { + flush_scene_batch(surface, &batch)?; + stats.flushes = stats.flushes.saturating_add(1); + batch.clear(); + } + execute_direct(surface, op)?; + stats.direct_steps = stats.direct_steps.saturating_add(1); + } + if !batch.is_empty() { + flush_scene_batch(surface, &batch)?; + stats.flushes = stats.flushes.saturating_add(1); + } + Ok(stats) + } +} + +fn flush_scene_batch( + surface: &mut CanvasSurface, + ops: &[&DrawOp], +) -> Result<(), CanvasSurfaceError> { + surface.render(|scene| { + for op in ops { + match op { + DrawOp::FillPath { path, color } => { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + to_color(*color), + None, + path, + ); + } + DrawOp::StrokePath { + path, + transform, + style, + color, + } => { + let stroke = to_stroke(style); + scene.stroke(&stroke, *transform, to_color(*color), None, path); + } + DrawOp::FillRect { rect, color } => { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + to_color(*color), + None, + rect, + ); + } + DrawOp::StrokeRect { + rect, + transform, + style, + color, + } => { + let stroke = to_stroke(style); + let path = rect.to_path(0.001); + scene.stroke(&stroke, *transform, to_color(*color), None, &path); + } + _ => unreachable!("scene batch only contains scene-expressible ops"), + } + } + }) +} + +fn execute_direct(surface: &mut CanvasSurface, op: &DrawOp) -> Result<(), CanvasSurfaceError> { + if surface.is_empty() { + return Ok(()); + } + match op { + DrawOp::ClearRect { rect } => { + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + clear_rect_premul(pixels, width, height, rect_to_canvas(*rect)); + } + DrawOp::DrawImage { + dest, + source, + blit, + filter, + } => { + let Some(blit_rect) = DrawImageBlit::new( + blit.source_x, + blit.source_y, + blit.source_width, + blit.source_height, + dest.x0, + dest.y0, + dest.x1 - dest.x0, + dest.y1 - dest.y0, + ) else { + return Ok(()); + }; + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + blit_draw_image_filtered_premul( + pixels, + width, + height, + &source.rgba, + source.width, + source.height, + blit_rect, + *filter, + ); + } + DrawOp::Text { + text, + x, + y, + font, + color, + } => { + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + draw_text_premul(pixels, width, height, text, *x, *y, font, *color); + } + DrawOp::PutImageData { source, dx, dy } => { + let width = surface.width(); + let height = surface.height(); + let pixels = surface.premutated_mut(); + blit_image_data_premul( + pixels, + width, + height, + &source.rgba, + source.width, + source.height, + *dx, + *dy, + 0, + 0, + source.width as i32, + source.height as i32, + ); + } + DrawOp::FillPath { .. } + | DrawOp::StrokePath { .. } + | DrawOp::FillRect { .. } + | DrawOp::StrokeRect { .. } => { + unreachable!("scene-expressible ops are batched, not direct") + } + } + surface.mark_dirty_and_invalidate_snapshot(); + Ok(()) +} + +fn to_color(rgba: [u8; 4]) -> Color { + Color::new([ + f64::from(rgba[0]) as f32 / 255.0, + f64::from(rgba[1]) as f32 / 255.0, + f64::from(rgba[2]) as f32 / 255.0, + f64::from(rgba[3]) as f32 / 255.0, + ]) +} + +fn to_stroke(style: &StrokeSpec) -> Stroke { + let mut stroke = Stroke::new(style.width); + stroke.join = style.join; + stroke.start_cap = style.cap; + stroke.end_cap = style.cap; + stroke.miter_limit = style.miter_limit; + stroke.dash_pattern = style.dash_pattern.iter().copied().collect(); + stroke.dash_offset = style.dash_offset; + stroke +} + +fn rect_to_canvas(rect: Rect) -> CanvasRect { + ( + rect.x0 as i32, + rect.y0 as i32, + rect.x1 as i32, + rect.y1 as i32, + ) +} + +/// Zeros pixels in the given rectangle of a premultiplied RGBA8 surface +/// (transparent black). This is O(rect area), not O(surface area). +fn clear_rect_premul(pixels: &mut [u8], width: u32, height: u32, rect: CanvasRect) { + let (left, top, right, bottom) = rect; + if left >= right || top >= bottom { + return; + } + let start_x = left.max(0).min(width as i32) as u32; + let start_y = top.max(0).min(height as i32) as u32; + let end_x = right.max(0).min(width as i32) as u32; + let end_y = bottom.max(0).min(height as i32) as u32; + let row_stride = width as usize * 4; + for y in start_y..end_y { + let row_start = y as usize * row_stride + start_x as usize * 4; + let row_end = row_start + (end_x - start_x) as usize * 4; + for byte in &mut pixels[row_start..row_end] { + *byte = 0; + } + } +} diff --git a/moli-canvas/src/rect.rs b/moli-canvas/src/rect.rs index e6bff36e2..a179776c1 100644 --- a/moli-canvas/src/rect.rs +++ b/moli-canvas/src/rect.rs @@ -298,6 +298,47 @@ pub fn paint_rect( } } +/// Composites a straight-alpha RGBA color over a premultiplied RGBA8 surface +/// within the given rectangle using source-over blending. The color channels +/// are premultiplied internally before compositing. +pub fn paint_rect_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + rect: CanvasRect, + straight_rgba: [u8; 4], +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) { + return; + } + let (left, top, right, bottom) = rect; + if left >= right || top >= bottom { + return; + } + let start_x = left.max(0).min(canvas_width as i32) as u32; + let start_y = top.max(0).min(canvas_height as i32) as u32; + let end_x = right.max(0).min(canvas_width as i32) as u32; + let end_y = bottom.max(0).min(canvas_height as i32) as u32; + let sa = straight_rgba[3] as u32; + if sa == 0 { + return; + } + let spr = (straight_rgba[0] as u32 * sa + 128) / 255; + let spg = (straight_rgba[1] as u32 * sa + 128) / 255; + let spb = (straight_rgba[2] as u32 * sa + 128) / 255; + let inv_sa = 255 - sa; + for y in start_y..end_y { + for x in start_x..end_x { + let index = ((y * canvas_width + x) * 4) as usize; + let d = &mut pixels[index..index + 4]; + d[0] = ((spr * 255 + d[0] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[1] = ((spg * 255 + d[1] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[2] = ((spb * 255 + d[2] as u32 * inv_sa + 128) / 255).min(255) as u8; + d[3] = ((sa * 255 + d[3] as u32 * inv_sa + 128) / 255).min(255) as u8; + } + } +} + fn canonical_hex_color(value: &str) -> Option { let [red, green, blue, alpha] = hex_color_rgba(value)?; if alpha == u8::MAX { diff --git a/moli-canvas/src/surface.rs b/moli-canvas/src/surface.rs new file mode 100644 index 000000000..5596aee0c --- /dev/null +++ b/moli-canvas/src/surface.rs @@ -0,0 +1,309 @@ +//! Authoritative Canvas pixel surface: ownership, flush, snapshot, readback, +//! and reset. +//! +//! This is the single writable pixel store for a Canvas. Its format is +//! **premultiplied RGBA8** (matching the Vello backend's native output); +//! conversion to straight RGBA8 happens only at the boundaries that publish +//! pixels (region readback, `ImageData`, encoding, page snapshots). +//! +//! The surface lazily materializes its buffer and a reusable rendering backend +//! on first flush, reuses both across successive flushes, keeps one immutable +//! published snapshot, and never retains a history of operations. + +use std::sync::Arc; + +use anyrender_vello_cpu::VelloCpuScenePainter; +use moli_image::RgbaImage; +use vello_cpu::CompositeMode; + +use crate::backend::VelloCpuBackend; +use crate::pixel::unpremultiply_rgba8_in_place; +use crate::types::byte_len; + +/// Largest edge dimension Vello CPU can address (its contexts use `u16`). +const MAX_VELLO_EDGE: u32 = u16::MAX as u32; + +/// Failure while provisioning or exposing a canvas surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CanvasSurfaceError { + /// The dimensions exceed the rasterizer or byte-length budget. + SurfaceTooLarge { width: u32, height: u32 }, + /// A snapshot or allocation could not be produced (resource limit). + AllocationFailed, +} + +impl std::fmt::Display for CanvasSurfaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CanvasSurfaceError::SurfaceTooLarge { width, height } => { + write!( + f, + "canvas surface {width}x{height} exceeds the supported budget" + ) + } + CanvasSurfaceError::AllocationFailed => write!(f, "canvas surface allocation failed"), + } + } +} + +impl std::error::Error for CanvasSurfaceError {} + +/// An authoritative, premultiplied-RGBA8 Canvas pixel surface. +pub struct CanvasSurface { + width: u32, + height: u32, + /// Premultiplied RGBA8, row major. Allocated lazily on first use. + pixels: Vec, + /// Reusable Vello CPU backend, created lazily on first flush. + backend: Option, + /// Immutable straight-alpha snapshot, cached so repeated clean reads + /// perform no further rasterization or conversion. + published: Option>, + dirty: bool, + /// Number of backend submissions (flushes) performed. + flush_count: u64, + /// Number of full-surface straight-alpha conversions performed. + snapshot_count: u64, +} + +impl CanvasSurface { + /// Creates a surface with the given dimensions. Storage and backend are + /// materialized lazily when content is first rendered. + pub fn new(width: u32, height: u32) -> Result { + byte_len(width, height).ok_or(CanvasSurfaceError::SurfaceTooLarge { width, height })?; + if width > MAX_VELLO_EDGE || height > MAX_VELLO_EDGE { + return Err(CanvasSurfaceError::SurfaceTooLarge { width, height }); + } + let len = byte_len(width, height).expect("validated byte length"); + Ok(Self { + width, + height, + pixels: vec![0; len], + backend: None, + published: None, + dirty: false, + flush_count: 0, + snapshot_count: 0, + }) + } + + /// The surface width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// The surface height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// Whether the surface has any materialized content (allocated pixels). + pub fn is_empty(&self) -> bool { + self.width == 0 || self.height == 0 + } + + /// The authoritative premultiplied-RGBA8 pixel buffer. + pub fn premultiplied(&self) -> &[u8] { + &self.pixels + } + + /// Mutable access to the premultiplied-RGBA8 pixel buffer for direct + /// pixel operations that already work in premultiplied space. + pub fn premutated_mut(&mut self) -> &mut [u8] { + &mut self.pixels + } + + /// Marks the surface dirty and invalidates the cached snapshot. Used by + /// direct pixel operations that modify the surface without going through + /// the Vello backend. + pub fn mark_dirty_and_invalidate_snapshot(&mut self) { + self.published = None; + self.dirty = true; + } + + /// Number of backend submissions (flushes) performed since reset. + pub fn flush_count(&self) -> u64 { + self.flush_count + } + + /// Number of full-surface straight-alpha conversions (snapshot creations) + /// performed since reset. Clean repeated reads via [`Self::snapshot`] reuse + /// the cached image and do not advance this counter. + pub fn snapshot_count(&self) -> u64 { + self.snapshot_count + } + + /// Whether a flush has occurred since the last observation. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Marks the surface as observed (no longer dirty) without touching pixels. + pub fn mark_clean(&mut self) { + self.dirty = false; + } + + /// Discards all pending and rendered content, restoring a transparent + /// surface. Reuses storage/backend where possible. + pub fn reset(&mut self) { + self.pixels.fill(0); + self.published = None; + self.dirty = true; + self.flush_count = 0; + self.snapshot_count = 0; + } + + /// Resizes the surface, resetting content and re-provisioning storage and + /// the backend when dimensions change (same-size assignment still clears). + /// Returns `Err` when the new dimensions exceed the budget; the previous + /// surface is left untouched in that case. + pub fn resize(&mut self, width: u32, height: u32) -> Result<(), CanvasSurfaceError> { + byte_len(width, height).ok_or(CanvasSurfaceError::SurfaceTooLarge { width, height })?; + if width > MAX_VELLO_EDGE || height > MAX_VELLO_EDGE { + return Err(CanvasSurfaceError::SurfaceTooLarge { width, height }); + } + let len = byte_len(width, height).expect("validated byte length"); + let same = self.width == width && self.height == height; + if !same { + self.pixels = vec![0; len]; + if let Some(mut backend) = self.backend.take() { + backend.resize(width, height); + self.backend = Some(backend); + } + } else { + // Same-size assignment still clears content per reset semantics. + self.pixels.fill(0); + } + self.width = width; + self.height = height; + self.published = None; + self.dirty = true; + self.flush_count = 0; + self.snapshot_count = 0; + Ok(()) + } + + /// Flushes a source-over batch of operations against the existing surface, + /// preserving prior content. The closure builds the scene against the Vello + /// scene painter (AnyRender [`PaintScene`]); it is executed against the + /// authoritative premultiplied buffer and reuses the backend across calls. + /// + /// On success the surface is marked dirty and any cached snapshot is + /// invalidated. On failure the surface is unchanged and not marked dirty, + /// so old pixels are never published as successful new content. + pub fn render(&mut self, draw: F) -> Result<(), CanvasSurfaceError> + where + F: FnOnce(&mut VelloCpuScenePainter), + { + self.render_composite(CompositeMode::SrcOver, draw) + } + + /// Flushes operations with destructive full overwrite semantics: the whole + /// destination is replaced (cleared) by the rendered scene, not composited + /// over existing content. Use for a proven full-canvas overwrite or a clear + /// that the source-over path cannot express. + pub fn render_replace(&mut self, draw: F) -> Result<(), CanvasSurfaceError> + where + F: FnOnce(&mut VelloCpuScenePainter), + { + self.render_composite(CompositeMode::Replace, draw) + } + + fn render_composite( + &mut self, + composite: CompositeMode, + draw: F, + ) -> Result<(), CanvasSurfaceError> + where + F: FnOnce(&mut VelloCpuScenePainter), + { + if self.is_empty() { + return Ok(()); + } + if self.backend.is_none() { + self.backend = Some(VelloCpuBackend::new(self.width, self.height)); + } + let backend = self.backend.as_mut().expect("backend provisioned"); + let buffer = &mut self.pixels; + if composite == CompositeMode::Replace { + buffer.fill(0); + } + backend.render(buffer, composite, draw); + self.published = None; + self.dirty = true; + self.flush_count = self.flush_count.saturating_add(1); + Ok(()) + } + + /// Destructively clears the whole surface to transparent without a raster + /// step. Invalidates any cached snapshot and marks the surface dirty. + pub fn clear(&mut self) { + self.pixels.fill(0); + self.published = None; + self.dirty = true; + self.flush_count = self.flush_count.saturating_add(1); + } + + /// An immutable straight-alpha snapshot of the whole surface, cached so + /// repeated clean observations perform no further rasterization. The + /// snapshot remains valid (unchanged) after subsequent drawing. + pub fn snapshot(&mut self) -> Result, CanvasSurfaceError> { + if let Some(published) = &self.published { + return Ok(published.clone()); + } + let mut straight = self.pixels.clone(); + unpremultiply_rgba8_in_place(&mut straight) + .expect("surface byte length is a multiple of four"); + let image = RgbaImage::try_new(self.width, self.height, straight) + .map_err(|_| CanvasSurfaceError::AllocationFailed)?; + let published = Arc::new(image); + self.published = Some(published.clone()); + self.snapshot_count = self.snapshot_count.saturating_add(1); + Ok(published) + } + + /// Copies a rectangular region out of the surface as straight RGBA8 into a + /// freshly allocated buffer. Out-of-canvas regions are filled transparent + /// (matching `getImageData` semantics). Only the visible intersection is + /// read, so a small region does not require a full-frame intermediate copy. + pub fn readback_region(&self, x: i32, y: i32, width: u32, height: u32) -> Vec { + let mut out = vec![0; (width as usize) * (height as usize) * 4]; + if self.is_empty() { + return out; + } + let src_row_stride = self.width as usize * 4; + let dst_row_stride = width as usize * 4; + for row in 0..height as usize { + let src_y = y + row as i32; + if src_y < 0 || src_y >= self.height as i32 { + continue; + } + for col in 0..width as usize { + let src_x = x + col as i32; + if src_x < 0 || src_x >= self.width as i32 { + continue; + } + let src_index = (src_y as usize) * src_row_stride + (src_x as usize) * 4; + let dst_index = row * dst_row_stride + col * 4; + let px = &self.pixels[src_index..src_index + 4]; + let dst = &mut out[dst_index..dst_index + 4]; + let alpha = u32::from(px[3]); + dst[3] = px[3]; + if alpha == 0 { + dst[0] = 0; + dst[1] = 0; + dst[2] = 0; + } else if alpha == u32::from(u8::MAX) { + dst[0] = px[0]; + dst[1] = px[1]; + dst[2] = px[2]; + } else { + dst[0] = ((u32::from(px[0]) * 255 + alpha / 2) / alpha) as u8; + dst[1] = ((u32::from(px[1]) * 255 + alpha / 2) / alpha) as u8; + dst[2] = ((u32::from(px[2]) * 255 + alpha / 2) / alpha) as u8; + } + } + } + out + } +} diff --git a/moli-canvas/src/text.rs b/moli-canvas/src/text.rs index 9085aa34a..750ee8fef 100644 --- a/moli-canvas/src/text.rs +++ b/moli-canvas/src/text.rs @@ -1,6 +1,6 @@ use font8x8::{BASIC_FONTS, UnicodeFonts}; -use crate::rect::paint_rect; +use crate::rect::{paint_rect, paint_rect_premul}; use crate::types::surface_matches_len; pub fn measure_text_width(text: &str, font: &str) -> f64 { @@ -87,3 +87,73 @@ fn draw_glyph( } } } + +/// Like [`draw_text`] but works directly on a premultiplied RGBA8 surface, +/// compositing each glyph with source-over blending in premultiplied space. +/// This avoids the full-surface unpremultiply/premultiply round-trip. +pub fn draw_text_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + text: &str, + x: f64, + y: f64, + font: &str, + rgba: [u8; 4], +) { + if !surface_matches_len(pixels, canvas_width, canvas_height) { + return; + } + let scale = text_scale(font); + let glyph_height = (8 * scale) as i32; + let mut cursor_x = x.round() as i32; + let top = y.round() as i32 - glyph_height + scale as i32; + for ch in text.chars() { + if let Some(glyph) = BASIC_FONTS.get(ch) { + draw_glyph_premul( + pixels, + canvas_width, + canvas_height, + glyph, + cursor_x, + top, + scale, + rgba, + ); + } + cursor_x += (8 * scale + scale) as i32; + } +} + +fn draw_glyph_premul( + pixels: &mut [u8], + canvas_width: u32, + canvas_height: u32, + glyph: [u8; 8], + origin_x: i32, + origin_y: i32, + scale: u32, + rgba: [u8; 4], +) { + for (row, bits) in glyph.into_iter().enumerate() { + for col in 0..8 { + if (bits & (1 << col)) == 0 { + continue; + } + let pixel_x = origin_x + ((7 - col) as u32 * scale) as i32; + let pixel_y = origin_y + (row as u32 * scale) as i32; + paint_rect_premul( + pixels, + canvas_width, + canvas_height, + ( + pixel_x, + pixel_y, + pixel_x + scale as i32, + pixel_y + scale as i32, + ), + rgba, + ); + } + } +} diff --git a/moli-canvas/tests/baseline_cost.rs b/moli-canvas/tests/baseline_cost.rs new file mode 100644 index 000000000..b753ed2ba --- /dev/null +++ b/moli-canvas/tests/baseline_cost.rs @@ -0,0 +1,125 @@ +//! Native (V8-free) cost model that makes the current Canvas 2D design's +//! structural cost visible. This is an M0 baseline artifact: it measures the +//! per-draw full-plane copy and format-conversion work the current renderer +//! does for every ordinary draw, so the pre-migration cost is reproducible +//! without V8, a Document, or a browser binary. +//! +//! Run with: +//! cargo test -p moli-canvas --test baseline_cost -- --nocapture +//! +//! The key evidence (bytes copied per draw, which scales with canvas area) is +//! computed arithmetically and asserted. Wall-clock timing is deliberately kept +//! to a small reduced matrix so this test stays fast inside the workspace check +//! suite; the full timing matrix is reported in +//! `moli-benchmark/fixtures/canvas/results/README.md`. + +use std::time::Instant; + +use moli_canvas::{ + Rgba8Rect, byte_len, copy_rgba8_rect, encode_data_url, premultiply_rgba8_in_place, +}; + +/// The three canvas areas named in the proposal's workload matrix, with the op +/// counts used for the (arithmetic) byte-cost evidence. +const SIZES: [(u32, u32, usize); 3] = [(256, 256, 100), (1024, 1024, 1000), (2048, 2048, 1000)]; + +/// A reduced matrix actually timed, so the test stays quick in debug builds. +const TIMED: [(u32, u32, usize, usize); 2] = [(256, 256, 100, 1), (1024, 1024, 100, 10)]; + +fn report(label: impl AsRef, rows: Vec<(String, String)>) { + eprintln!("--- {} ---", label.as_ref()); + for (key, value) in rows { + eprintln!("{key}: {value}"); + } +} + +#[test] +fn baseline_cost_draw_is_linear_in_canvas_area_not_paint_size() { + // The current `with_canvas_like_pixels_mut` path copies the entire backing + // view into a Vec, mutates it, and writes it back: two full-plane byte + // copies per ordinary draw, regardless of how small the painted shape is. + // This is the O(canvas area) per-draw cost the proposal targets. + for (width, height, ops) in SIZES { + let len = byte_len(width, height).expect("valid canvas byte len"); + let copied = len as u128 * 2 * ops as u128; + report( + format!("arithmetic cost {width}x{height} x{ops}"), + vec![ + ("bytes_per_plane".to_string(), len.to_string()), + ("full_copies_per_draw".to_string(), "2".to_string()), + ("bytes_copied_total".to_string(), copied.to_string()), + ], + ); + // The whole-plane copy must be exercised exactly once per op by the + // memcpy fast path, moving `len` bytes each time. + let surface = vec![0u8; len]; + let mut work = vec![0u8; len]; + copy_rgba8_rect( + &surface, + width, + height, + Rgba8Rect::new(0, 0, width, height).expect("full surface"), + &mut work, + width, + height, + 0, + 0, + ) + .expect("full copy fits"); + assert_eq!(work, surface, "full-plane copy must move every byte"); + } +} + +#[test] +fn baseline_cost_timing_is_reported_for_a_reduced_matrix() { + for (width, height, ops, encode_iters) in TIMED { + let len = byte_len(width, height).expect("valid canvas byte len"); + let surface = vec![0u8; len]; + let mut work = vec![0u8; len]; + + let copy_start = Instant::now(); + for _ in 0..ops { + copy_rgba8_rect( + &surface, + width, + height, + Rgba8Rect::new(0, 0, width, height).expect("full surface"), + &mut work, + width, + height, + 0, + 0, + ) + .expect("full copy fits"); + } + let copy_secs = copy_start.elapsed().as_secs_f64(); + + let convert_start = Instant::now(); + for _ in 0..ops { + let mut converted = work.clone(); + premultiply_rgba8_in_place(&mut converted).expect("mult of 4"); + } + let convert_secs = convert_start.elapsed().as_secs_f64(); + + let encode_start = Instant::now(); + for _ in 0..encode_iters { + let _ = encode_data_url(&surface, width, height); + } + let encode_secs = encode_start.elapsed().as_secs_f64(); + + report( + format!("timed {width}x{height} x{ops} (debug)"), + vec![ + ( + "full_copy_total_secs".to_string(), + format!("{copy_secs:.6}"), + ), + ( + "convert_pass_total_secs".to_string(), + format!("{convert_secs:.6}"), + ), + ("encode_total_secs".to_string(), format!("{encode_secs:.6}")), + ], + ); + } +} diff --git a/moli-canvas/tests/recording.rs b/moli-canvas/tests/recording.rs new file mode 100644 index 000000000..696d7a6d9 --- /dev/null +++ b/moli-canvas/tests/recording.rs @@ -0,0 +1,243 @@ +//! Native (V8-free) tests for the ordered Canvas recorder [`DrawRecording`]. +//! +//! These prove that all ordinary operations are captured with frozen inputs, +//! executed in call order against a persistent [`CanvasSurface`], batched into +//! fewer backend submissions where possible, and that ordering survives +//! destructive clears, direct pixel writes, and immutable source capture. + +use std::sync::Arc; + +use kurbo::{Affine, BezPath, Point, Rect}; +use moli_canvas::{CanvasSurface, DrawImageBlit, DrawRecording, ScaleFilter, StrokeSpec}; +use moli_image::RgbaImage; + +fn rect_path(x: f64, y: f64, w: f64, h: f64) -> BezPath { + let mut path = BezPath::new(); + path.move_to(Point::new(x, y)); + path.line_to(Point::new(x + w, y)); + path.line_to(Point::new(x + w, y + h)); + path.line_to(Point::new(x, y + h)); + path.close_path(); + path +} + +fn pixel(surface: &CanvasSurface, x: i32, y: i32) -> [u8; 4] { + let bytes = surface.readback_region(x, y, 1, 1); + [bytes[0], bytes[1], bytes[2], bytes[3]] +} + +#[test] +fn many_path_fills_batch_into_one_flush_with_one_observation() { + let mut surface = CanvasSurface::new(64, 64).unwrap(); + let mut rec = DrawRecording::new(); + for i in 0..50u32 { + rec.push_fill_path( + rect_path(f64::from(i % 40) + 2.0, f64::from(i % 30) + 2.0, 4.0, 4.0), + [255, 0, 0, 255], + ); + } + let before_snapshots = surface.snapshot_count(); + let stats = rec.execute(&mut surface).expect("recording executes"); + assert_eq!(stats.flushes, 1, "50 fills share one backend submission"); + assert_eq!(stats.direct_steps, 0, "no direct ops in a pure fill batch"); + assert_eq!(rec.op_count(), 50, "all ops are retained for replay/order"); + + let _snap = surface.snapshot().unwrap(); + assert_eq!( + surface.snapshot_count(), + before_snapshots + 1, + "one observation performs one full-image conversion" + ); + // An interior filled pixel is red. + assert_eq!(pixel(&surface, 5, 5), [255, 0, 0, 255]); +} + +#[test] +fn clear_segments_a_batch_and_preserves_draw_order() { + let mut surface = CanvasSurface::new(64, 64).unwrap(); + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 32.0, 32.0), [255, 0, 0, 255]); + rec.push_clear_rect(Rect::new(0.0, 0.0, 32.0, 32.0)); + rec.push_fill_rect(Rect::new(0.0, 0.0, 32.0, 32.0), [0, 255, 0, 255]); + + let stats = rec.execute(&mut surface).expect("recording executes"); + // The clear breaks the fill batch into a before/after pair. + assert_eq!( + stats.flushes, 2, + "the clear segments the scene batch into two submissions" + ); + assert_eq!(stats.direct_steps, 1, "the clear is an ordered direct step"); + // The final red-over-green ordering: the last fill is green and opaque, + // so the covered pixel is green. + assert_eq!(pixel(&surface, 16, 16), [0, 255, 0, 255]); + // The clear removed the first red and preserved the second fill's order. + assert_eq!(pixel(&surface, 40, 40), [0, 0, 0, 0]); +} + +#[test] +fn source_snapshot_is_immutable_across_later_mutation() { + let mut surface = CanvasSurface::new(32, 32).unwrap(); + // A source canvas-like image captured as an owned snapshot. + let mut src_pixels = vec![0u8; 8 * 8 * 4]; + for row in 0..8u32 { + for col in 0..8u32 { + let i = ((row * 8 + col) * 4) as usize; + src_pixels[i] = 255; // red + src_pixels[i + 1] = 0; + src_pixels[i + 2] = 0; + src_pixels[i + 3] = 255; + } + } + let source = Arc::new(RgbaImage::try_new(8, 8, src_pixels).expect("valid source")); + + let mut rec = DrawRecording::new(); + let blit = DrawImageBlit::new(0.0, 0.0, 8.0, 8.0, 0.0, 0.0, 8.0, 8.0).expect("valid blit"); + rec.push_draw_image( + Rect::new(0.0, 0.0, 8.0, 8.0), + source.clone(), + blit, + ScaleFilter::Nearest, + ); + // Mutating the backing source image after capture must not change the draw. + let _ = Arc::get_mut(&mut source.clone()).map(|img| { + for byte in img.rgba.iter_mut() { + *byte = 0; + } + }); + rec.execute(&mut surface).expect("recording executes"); + assert_eq!( + pixel(&surface, 4, 4), + [255, 0, 0, 255], + "captured source is stable" + ); +} + +#[test] +fn reset_discards_pending_operations() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [255, 0, 0, 255]); + assert!(!rec.is_empty()); + rec.clear(); + assert!(rec.is_empty()); + assert_eq!(rec.op_count(), 0); + rec.execute(&mut surface).expect("empty recording executes"); + assert_eq!( + pixel(&surface, 2, 2), + [0, 0, 0, 0], + "reset discards content" + ); +} + +#[test] +fn put_image_data_is_an_ordered_raw_overwrite() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + // Paint a translucent-ish background, then raw-overwrite a region via + // putImageData; the overwrite must not apply paint state. + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [0, 255, 0, 255]); + let mut img_pixels = vec![0u8; 2 * 2 * 4]; + img_pixels[0] = 255; // blue, opaque + img_pixels[1] = 0; + img_pixels[2] = 255; + img_pixels[3] = 255; + let image = RgbaImage::try_new(2, 2, img_pixels).expect("valid image"); + rec.push_put_image_data(image, 1, 1); + + let stats = rec.execute(&mut surface).expect("recording executes"); + assert_eq!( + stats.direct_steps, 1, + "putImageData is a direct ordered write" + ); + assert_eq!( + pixel(&surface, 1, 1), + [255, 0, 255, 255], + "raw overwrite wins" + ); + assert_eq!( + pixel(&surface, 5, 5), + [0, 255, 0, 255], + "background kept outside overwrite" + ); +} + +#[test] +fn put_image_data_translucent_over_opaque_is_a_raw_overwrite_not_a_blend() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + // Solid red background fills the whole surface. + let mut rec = DrawRecording::new(); + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [255, 0, 0, 255]); + // A translucent source pixel (alpha ~= 128) for putImageData. Spec: the + // destination is replaced exactly with the premultiplied source value -- + // it MUST NOT source-over composite over the red background. + let mut img_pixels = vec![0u8; 4]; + img_pixels[0] = 0; // blue, alpha 128 (straight) + img_pixels[1] = 0; + img_pixels[2] = 255; + img_pixels[3] = 128; + let image = RgbaImage::try_new(1, 1, img_pixels).expect("valid image"); + rec.push_put_image_data(image, 3, 3); + + rec.execute(&mut surface).expect("recording executes"); + // Recovered straight value must equal the source straight value exactly + // (128, not blended with red), proving raw overwrite semantics. + let px = pixel(&surface, 3, 3); + assert_eq!(px[3], 128, "source alpha preserved exactly"); + assert_eq!(px[2], 255, "source blue preserved exactly (no red blend)"); + assert_eq!(px[0], 0, "source red=0 preserved exactly (no red bg bleed)"); + // A neighbouring pixel not covered by putImageData keeps the background. + assert_eq!(pixel(&surface, 6, 6), [255, 0, 0, 255]); +} + +#[test] +fn estimated_bytes_accounts_for_pending_ops_and_resets_on_clear() { + let mut rec = DrawRecording::new(); + assert_eq!(rec.estimated_bytes(), 0, "empty recording retains nothing"); + + rec.push_fill_rect(Rect::new(0.0, 0.0, 8.0, 8.0), [255, 0, 0, 255]); + let after_fill = rec.estimated_bytes(); + assert!(after_fill > 0, "a push grows the byte accounting"); + + // A captured source image (drawImage/putImageData) adds its full pixel + // bytes, so the recorded budget reflects pinned image resources. + let mut img_pixels = vec![0u8; 64 * 64 * 4]; + img_pixels[3] = 255; + let image = RgbaImage::try_new(64, 64, img_pixels).expect("valid image"); + rec.push_put_image_data(image, 0, 0); + let after_image = rec.estimated_bytes(); + assert!( + after_image > after_fill, + "captured source bytes count toward the budget" + ); + + rec.clear(); + assert_eq!( + rec.estimated_bytes(), + 0, + "clear resets the recorded byte budget" + ); + assert!(rec.is_empty()); +} + +#[test] +fn stroke_path_carries_frozen_metrics_across_the_recording_boundary() { + let mut surface = CanvasSurface::new(32, 32).unwrap(); + let mut rec = DrawRecording::new(); + let style = StrokeSpec { + width: 3.0, + cap: kurbo::Cap::Round, + join: kurbo::Join::Round, + miter_limit: 10.0, + dash_pattern: Vec::new(), + dash_offset: 0.0, + }; + let path = rect_path(4.0, 4.0, 16.0, 16.0); + rec.push_stroke_path(path, Affine::IDENTITY, style, [0, 0, 255, 255]); + + let stats = rec.execute(&mut surface).expect("recording executes"); + assert_eq!(stats.flushes, 1, "stroke path is a scene batch op"); + // A point on the stroke perimeter is blue. + assert_eq!(pixel(&surface, 4, 15), [0, 0, 255, 255]); + // The interior is not filled. + assert_eq!(pixel(&surface, 12, 12), [0, 0, 0, 0]); +} diff --git a/moli-canvas/tests/surface_api.rs b/moli-canvas/tests/surface_api.rs new file mode 100644 index 000000000..61882676c --- /dev/null +++ b/moli-canvas/tests/surface_api.rs @@ -0,0 +1,285 @@ +//! Native (V8-free) tests for the Canvas surface/backend contract. +//! +//! These exercise the public [`CanvasSurface`] API through the real Vello CPU +//! backend, proving repeated rendering onto a persistent surface, immutable +//! snapshot isolation, reset, composition, pixel-format conversion, region +//! readback, and the failure/accounting contract. + +use std::sync::Arc; + +use anyrender::PaintScene; +use anyrender_vello_cpu::VelloCpuScenePainter; +use kurbo::{Affine, Rect}; +use moli_canvas::{CanvasSurface, CanvasSurfaceError}; +use peniko::{Color, Fill}; + +fn fill(scene: &mut VelloCpuScenePainter, x: f64, y: f64, w: f64, h: f64, color: Color) { + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + color, + None, + &Rect::new(x, y, x + w, y + h), + ); +} + +const RED: Color = Color::new([1.0, 0.0, 0.0, 1.0]); +const GREEN: Color = Color::new([0.0, 1.0, 0.0, 1.0]); + +/// Reads the straight-alpha RGBA8 pixel at (x, y) via a 1x1 region readback. +fn pixel(surface: &CanvasSurface, x: i32, y: i32) -> [u8; 4] { + let bytes = surface.readback_region(x, y, 1, 1); + [bytes[0], bytes[1], bytes[2], bytes[3]] +} + +#[test] +fn repeated_source_over_rendering_preserves_prior_content() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + surface + .render(|s| fill(s, 1.0, 1.0, 5.0, 5.0, RED)) + .unwrap(); + // A second source-over batch composes over the first, not replacing it. + surface + .render(|s| fill(s, 3.0, 3.0, 5.0, 5.0, GREEN)) + .unwrap(); + + // (2,2) is covered by both: red over which green was drawn at (3,3)+, + // so the interior at (2,2) is still pure red. + assert_eq!(pixel(&surface, 2, 2), [255, 0, 0, 255]); + // (5,5) is inside the green rect (green's alpha=255 overwrites red fully). + assert_eq!(pixel(&surface, 5, 5), [0, 255, 0, 255]); + // Snapshots reflect the cumulative content. + let snap = surface.snapshot().unwrap(); + assert_eq!( + snap.rgba[((2 * 16 + 2) * 4)..((2 * 16 + 2) * 4 + 4)], + [255, 0, 0, 255] + ); + assert_eq!( + snap.rgba[((5 * 16 + 5) * 4)..((5 * 16 + 5) * 4 + 4)], + [0, 255, 0, 255] + ); +} + +#[test] +fn snapshot_isolation_and_clean_repeated_reads() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + surface + .render(|s| fill(s, 1.0, 1.0, 5.0, 5.0, RED)) + .unwrap(); + let first = surface.snapshot().unwrap(); + + // Drawing more does not mutate the already-published snapshot. + surface + .render(|s| fill(s, 8.0, 8.0, 5.0, 5.0, GREEN)) + .unwrap(); + assert_eq!( + first.rgba[(2 * 16 + 2) * 4 + 3], + 255, + "old snapshot is unchanged where it had content" + ); + // The old snapshot has no green at the new rectangle area. + assert_eq!( + first.rgba[(10 * 16 + 10) * 4 + 1], + 0, + "old snapshot predates the green fill" + ); + + // Clean repeated reads produce the same cached snapshot (no re-conversion). + let second = surface.snapshot().unwrap(); + let third = surface.snapshot().unwrap(); + assert_eq!(second.rgba, third.rgba); + assert!( + Arc::ptr_eq(&second, &third), + "clean repeated reads reuse the cached snapshot" + ); + + // The surface and the new snapshot both have the green. + let fresh = surface.snapshot().unwrap(); + assert_eq!(fresh.rgba[(10 * 16 + 10) * 4], 0); + assert_eq!(fresh.rgba[(10 * 16 + 10) * 4 + 1], 255); + assert_eq!(fresh.rgba[(10 * 16 + 10) * 4 + 3], 255); +} + +#[test] +fn clear_between_draws_preserves_ordering() { + let mut surface = CanvasSurface::new(16, 16).unwrap(); + surface + .render(|s| fill(s, 0.0, 0.0, 16.0, 16.0, RED)) + .unwrap(); + // Destructive clear removes everything. + surface.clear(); + assert_eq!( + pixel(&surface, 5, 5), + [0, 0, 0, 0], + "clear empties the surface" + ); + surface + .render(|s| fill(s, 2.0, 2.0, 4.0, 4.0, GREEN)) + .unwrap(); + assert_eq!( + pixel(&surface, 3, 3), + [0, 255, 0, 255], + "draw after clear lands correctly" + ); + assert_eq!( + pixel(&surface, 10, 10), + [0, 0, 0, 0], + "no stale red remains" + ); +} + +#[test] +fn reset_semantics_same_size_and_resize() { + let mut surface = CanvasSurface::new(10, 10).unwrap(); + surface + .render(|s| fill(s, 0.0, 0.0, 10.0, 10.0, RED)) + .unwrap(); + + // Same-size resize still clears per reset semantics and reuses allocation. + surface.resize(10, 10).unwrap(); + assert_eq!( + pixel(&surface, 5, 5), + [0, 0, 0, 0], + "same-size resize clears content" + ); + assert_eq!(surface.premultiplied().len(), 10 * 10 * 4); + + // Different-size resize resets dimensions and content. + surface + .render(|s| fill(s, 0.0, 0.0, 5.0, 5.0, GREEN)) + .unwrap(); + surface.resize(20, 4).unwrap(); + assert_eq!(surface.premultiplied().len(), 20 * 4 * 4); + assert_eq!( + pixel(&surface, 2, 2), + [0, 0, 0, 0], + "resized surface is empty" + ); + + // Zero-size is well-defined and does not panic on render/readback. + let mut zero = CanvasSurface::new(0, 0).unwrap(); + assert!(zero.is_empty()); + zero.render(|_| {}).unwrap(); + assert_eq!(zero.readback_region(0, 0, 4, 4), vec![0; 4 * 4 * 4]); +} + +#[test] +fn readback_region_clipping_and_independence() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + surface + .render(|s| fill(s, 1.0, 1.0, 4.0, 4.0, RED)) + .unwrap(); + + // Out-of-canvas parts are filled transparent; only the visible intersection is read. + let region = surface.readback_region(-2, 2, 6, 4); + assert_eq!(region.len(), 6 * 4 * 4); + // Pixel that lies within the red rect at (source 2,2) maps to (4,0) in region: + let idx = 4 * 4; + assert_eq!(region[idx..idx + 4], [255, 0, 0, 255]); + // Pixels outside the canvas (region column 0 and 1, x=-2,-1) are transparent. + assert_eq!(®ion[0..3], &[0, 0, 0]); + + // Readback returns an independent buffer; writing to it does not affect the surface. + let mut independent = surface.readback_region(0, 0, 8, 8); + independent[0] = 99; + assert_eq!(pixel(&surface, 0, 0), [0, 0, 0, 0]); +} + +#[test] +fn pixel_format_low_alpha_and_round_trip() { + let mut surface = CanvasSurface::new(8, 8).unwrap(); + // A translucent red (alpha ~= 51) leaves a low-alpha premultiplied channel. + surface + .render(|s| { + fill( + s, + 1.0, + 1.0, + 6.0, + 6.0, + Color::new([1.0, 0.0, 0.0, 51.0 / 255.0]), + ) + }) + .unwrap(); + let px = pixel(&surface, 3, 3); + assert_eq!(px[3], 51, "alpha is preserved straight"); + // Straight red = 255 within rounding at alpha 51. + assert!(px[0] >= 245, "low-alpha straight red stays near 255"); + + // Round-trip: a snapshot and a region read share the same straight conversion. + let snap = surface.snapshot().unwrap(); + let snap_px = &snap.rgba[((3 * 8 + 3) * 4)..((3 * 8 + 3) * 4 + 4)]; + assert_eq!(snap_px[3], 51); + assert_eq!(snap_px, &[px[0], px[1], px[2], px[3]]); + + // Fully transparent pixels normalize to transparent black on readback. + assert_eq!(pixel(&surface, 0, 0), [0, 0, 0, 0]); +} + +#[test] +fn invalid_or_oversized_surface_fails_without_mutating_existing() { + // Edge beyond Vello's u16 limit is rejected. + assert!(matches!( + CanvasSurface::new(u16::MAX as u32 + 1, 4), + Err(CanvasSurfaceError::SurfaceTooLarge { .. }) + )); + // Byte lengths beyond the platform budget are rejected. + assert!(CanvasSurface::new(u32::MAX, u32::MAX).is_err()); + // An oversized resize leaves the prior surface intact (no mutation). + let mut surface = CanvasSurface::new(8, 8).unwrap(); + surface + .render(|s| fill(s, 0.0, 0.0, 8.0, 8.0, RED)) + .unwrap(); + assert!(surface.resize(u16::MAX as u32 + 1, 1).is_err()); + assert_eq!(surface.width(), 8); + assert_eq!( + pixel(&surface, 4, 4), + [255, 0, 0, 255], + "failed resize preserves old content" + ); +} + +#[test] +fn deterministic_counters_prove_scheduling_and_clean_reads() { + let mut surface = CanvasSurface::new(64, 64).unwrap(); + // N draws against the persistent surface each submit one backend flush, but + // do not produce any full-image copies until an observation occurs. + for i in 0..100u32 { + let off = (i % 40) as f64; + surface + .render(move |s| fill(s, off, off, 4.0, 4.0, RED)) + .unwrap(); + } + assert_eq!(surface.flush_count(), 100, "each draw submits one flush"); + assert_eq!( + surface.snapshot_count(), + 0, + "no observation yet, no full-image copy" + ); + + // One observation performs a single full-image conversion. + let _snap = surface.snapshot().unwrap(); + assert_eq!(surface.snapshot_count(), 1); + + // A clean subsequent observation performs zero additional conversion work. + for _ in 0..10 { + let _again = surface.snapshot().unwrap(); + } + assert_eq!( + surface.snapshot_count(), + 1, + "clean repeated reads reuse the cached snapshot" + ); + + // A new draw invalidates the snapshot; the next observation converts once. + surface + .render(|s| fill(s, 50.0, 50.0, 4.0, 4.0, GREEN)) + .unwrap(); + assert_eq!( + surface.snapshot_count(), + 1, + "unobserved new content is not converted" + ); + let _after = surface.snapshot().unwrap(); + assert_eq!(surface.snapshot_count(), 2); +} diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index ebac85fd2..2fb2671d7 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -210,7 +210,8 @@ pub(crate) use self::broadcast_channel::{ pub(crate) use self::canvas::{ CanvasContextKind, attach_canvas_like_context_object, build_canvas_rendering_context_2d_object, build_offscreen_canvas_object, build_webgl_context_object, build_webgl2_context_object, - canvas_like_to_data_url, reset_html_canvas_backing_store_for_dimension_assignment, + canvas_like_to_data_url, flush_all_recordings, + reset_html_canvas_backing_store_for_dimension_assignment, }; #[cfg(test)] pub(crate) use self::constructors::finalize_dom_exception_realm_bindings; diff --git a/moli-renderer-v8/src/context_bootstrap/canvas.rs b/moli-renderer-v8/src/context_bootstrap/canvas.rs index 640f9b392..893a8319c 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas.rs @@ -192,6 +192,7 @@ mod image_bitmap; mod objects; mod offscreen; mod path; +mod recording_store; mod state; mod transform; mod webgl; @@ -249,6 +250,7 @@ pub(crate) use objects::{ pub(crate) use offscreen::{ offscreen_canvas_convert_to_blob_callback, offscreen_canvas_get_context_callback, }; +pub(crate) use recording_store::flush_all_recordings; pub(crate) use webgl::{ WEBGL_CONSTANTS, WEBGL2_CONSTANTS, webgl_boolean_callback, webgl_check_framebuffer_status_callback, webgl_create_buffer_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs index 584b5086e..4f50f0da5 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/backing_store.rs @@ -1,19 +1,49 @@ -use super::super::image_data::new_uint8_clamped_array_from_bytes; -use super::{OFFSCREEN_CANVAS_HEIGHT_SLOT, OFFSCREEN_CANVAS_WIDTH_SLOT}; +//! Authoritative per-canvas native pixel surface, replacing the V8 backing +//! array. +//! +//! A Canvas with a 2D context owns one [`moli_canvas::CanvasSurface`] that +//! holds its pixels as its single writable owner (premultiplied RGBA8, with +//! straight-alpha handled at the observation/`ImageData`/publication boundary). +//! The native surface is keyed by the canvas-like JS object through a +//! weak-keyed per-context registry, so its lifetime is reclaimed with the canvas +//! (GC) and with the isolate, mirroring `state.rs`. +//! +//! M4's ordered recorder executes all draw operations against the surface. +//! Scene-expressible ops (fills, strokes, rectangles) go through the Vello +//! backend; direct ops (ClearRect, DrawImage, Text, PutImageData) operate +//! directly on the premultiplied pixel buffer, avoiding the full-surface +//! unpremultiply/premultiply round-trip. + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + use crate::util::{get_private_object, get_private_value, set_private_value}; use crate::webidl; use crate::{ document_runtime::DomHandle, native_bridge::{JsContextHost, node_runtime_and_handle_from_object_or_detached}, }; -use moli_canvas::{byte_len as canvas_byte_len, encode_data_url}; +use moli_canvas::{CanvasSurface, encode_data_url}; use moli_webapi_declare::WebApiObject; -const CANVAS_BACKING_STORE_SLOT: &str = "__moliCanvasBackingStore"; +const CANVAS_SURFACE_ID_SLOT: &str = "__moliCanvasLiveSurfaceId"; const CANVAS_OWNER_SLOT: &str = "__moliCanvasOwner"; const CANVAS_HAS_CONTEXT_SLOT: &str = "__moliCanvasHasContext"; const CANVAS_2D_CONTEXT_SLOT: &str = "__moliCanvas2DContext"; +type SurfaceCell = Rc>>; +type SurfaceStore = Rc>; + +#[derive(Default)] +struct SurfaceRegistry { + next_id: u64, + entries: HashMap, +} + +struct SurfaceRegistryEntry { + _context: v8::Weak, + surface: SurfaceCell, +} + #[derive(WebApiObject)] #[webapi(interface = "Object")] struct CanvasContextOwnerDeclaration<'scope> { @@ -37,7 +67,7 @@ pub(crate) fn attach_canvas_like_context_object<'s>( CANVAS_HAS_CONTEXT_SLOT, v8::Boolean::new(scope, true).into(), ); - let _ = ensure_canvas_like_backing_store(scope, canvas); + let _ = canvas_surface_cell(scope, canvas); } pub(super) fn canvas_like_has_context<'s>( @@ -56,26 +86,20 @@ pub(crate) fn reset_canvas_like_backing_store<'s>( super::context2d::reset_canvas_context_state(scope, context); } let Some((width, height)) = canvas_like_dimensions(scope, canvas) else { - remove_html_canvas_pixels(scope, canvas); - return; - }; - let Some(len) = canvas_byte_len(width, height) else { - remove_html_canvas_pixels(scope, canvas); + remove_canvas_surface(scope, canvas); return; }; - let Some(bytes) = new_uint8_clamped_array_from_bytes(scope, vec![0; len]) else { - remove_html_canvas_pixels(scope, canvas); + let cell = canvas_surface_cell(scope, canvas); + let too_large = !materialize_surface(&cell, width, height); + if too_large { + remove_canvas_surface(scope, canvas); return; - }; - set_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT, bytes.into()); - replace_html_canvas_pixels(scope, canvas, width, height, vec![0; len]); -} - -pub(super) fn canvas_2d_context<'s>( - scope: &mut v8::PinScope<'s, '_>, - canvas: v8::Local<'s, v8::Object>, -) -> Option> { - get_private_object(scope, canvas, CANVAS_2D_CONTEXT_SLOT) + } + cell.borrow_mut() + .as_mut() + .expect("surface materialized") + .reset(); + publish_canvas_snapshot(scope, canvas); } pub(crate) fn reset_html_canvas_backing_store_for_dimension_assignment<'s>( @@ -97,7 +121,7 @@ pub(crate) fn reset_html_canvas_backing_store_for_dimension_assignment<'s>( let _ = unsafe { &mut *runtime_ptr }.remove_canvas_pixels(handle); return; }; - if get_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT).is_none() { + if !canvas_like_has_context(scope, canvas) { let _ = unsafe { &mut *runtime_ptr }.remove_canvas_pixels(handle); return; } @@ -119,64 +143,188 @@ pub(super) fn canvas_owner_from_context<'s>( get_private_object(scope, context, CANVAS_OWNER_SLOT) } -pub(super) fn with_canvas_like_pixels_mut<'s, F>( +pub(super) fn canvas_like_pixels_copy<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, - mutate: F, -) -> bool -where - F: FnOnce(&mut [u8], u32, u32), -{ - let Some((view, width, height)) = canvas_like_pixel_view(scope, canvas) else { - return false; - }; - let mut bytes = vec![0; view.byte_length()]; - let written = view.copy_contents(&mut bytes); - bytes.truncate(written); - mutate(&mut bytes, width, height); - if write_bytes_to_view(scope, view, &bytes).is_none() { - return false; +) -> Option<(Vec, u32, u32)> { + flush_canvas_recording(scope, canvas); + let (width, height) = canvas_like_dimensions(scope, canvas)?; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, width, height) { + return None; } - replace_html_canvas_pixels(scope, canvas, width, height, bytes); - true + let mut surface = cell.borrow_mut(); + let surface = surface.as_mut()?; + let snapshot = surface.snapshot().ok()?; + Some((snapshot.rgba.clone(), snapshot.width, snapshot.height)) } -pub(super) fn canvas_like_pixels_copy<'s>( +/// Reads a rectangular region from the canvas as straight RGBA8. Only the +/// requested intersection is read, so a 1×1 read from a 2048×2048 canvas +/// allocates only 4 bytes instead of 16 MiB. Out-of-canvas regions are filled +/// transparent (matching `getImageData` semantics). +pub(super) fn canvas_like_region_readback<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, -) -> Option<(Vec, u32, u32)> { - let (view, width, height) = canvas_like_pixel_view(scope, canvas)?; - let mut bytes = vec![0; view.byte_length()]; - let written = view.copy_contents(&mut bytes); - bytes.truncate(written); - Some((bytes, width, height)) + x: i32, + y: i32, + width: u32, + height: u32, +) -> Option> { + flush_canvas_recording(scope, canvas); + let (canvas_width, canvas_height) = canvas_like_dimensions(scope, canvas)?; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, canvas_width, canvas_height) { + return None; + } + let surface = cell.borrow(); + let surface = surface.as_ref()?; + Some(surface.readback_region(x, y, width, height)) } -fn canvas_like_pixel_view<'s>( +/// Flushes any pending recording for `canvas` against its surface, then publishes +/// the snapshot. This must be called before any pixel observation (getImageData, +/// toDataURL, drawImage from canvas source, page painting, screencast). +pub(super) fn flush_canvas_recording<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, -) -> Option<(v8::Local<'s, v8::Uint8ClampedArray>, u32, u32)> { - let (width, height) = canvas_like_dimensions(scope, canvas)?; - let view = ensure_canvas_like_backing_store(scope, canvas)?; - Some((view, width, height)) +) { + let Some(context) = canvas_2d_context(scope, canvas) else { + return; + }; + let recording = super::recording_store::canvas_recording_state(scope, context); + { + let rec = recording.borrow(); + if rec.is_empty() { + return; + } + } + // Release the recording borrow before calling canvas_like_dimensions() which + // may read JS properties and re-enter user code. A width getter that calls + // ctx.fillRect() once would trigger "RefCell already borrowed" and abort. + let (width, height) = match canvas_like_dimensions(scope, canvas) { + Some(dims) => dims, + None => { + recording.borrow_mut().clear(); + return; + } + }; + let cell = canvas_surface_cell(scope, canvas); + if !materialize_surface(&cell, width, height) { + recording.borrow_mut().clear(); + return; + } + { + let mut rec = recording.borrow_mut(); + let mut surface = cell.borrow_mut(); + let Some(surface) = surface.as_mut() else { + rec.clear(); + return; + }; + let _ = rec.execute(surface); + rec.clear(); + } + publish_canvas_snapshot(scope, canvas); } -fn ensure_canvas_like_backing_store<'s>( +pub(super) fn canvas_2d_context<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, -) -> Option> { - let (width, height) = canvas_like_dimensions(scope, canvas)?; - let expected_len = canvas_byte_len(width, height)?; - if let Some(existing) = get_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT) - .and_then(|value| v8::Local::::try_from(value).ok()) - && existing.byte_length() == expected_len +) -> Option> { + get_private_object(scope, canvas, CANVAS_2D_CONTEXT_SLOT) +} + +fn surface_store<'s>(scope: &mut v8::PinScope<'s, '_>) -> SurfaceStore { + if let Some(store) = scope.get_slot::() { + return store.clone(); + } + let store = SurfaceStore::default(); + scope.set_slot(store.clone()); + store +} + +/// Returns (or creates, once per canvas lifetime) the per-canvas surface cell. +pub(super) fn canvas_surface_cell<'s>( + scope: &mut v8::PinScope<'s, '_>, + canvas: v8::Local<'s, v8::Object>, +) -> SurfaceCell { + let store = surface_store(scope); + if let Some(id) = get_private_value(scope, canvas, CANVAS_SURFACE_ID_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|value| value.u64_value().0) { - return Some(existing); + return store + .borrow() + .entries + .get(&id) + .expect("a live canvas must retain its native surface") + .surface + .clone(); } - let bytes = new_uint8_clamped_array_from_bytes(scope, vec![0; expected_len])?; - set_private_value(scope, canvas, CANVAS_BACKING_STORE_SLOT, bytes.into()); - replace_html_canvas_pixels(scope, canvas, width, height, vec![0; expected_len]); - Some(bytes) + let id = { + let mut store = store.borrow_mut(); + store.next_id = store + .next_id + .checked_add(1) + .expect("canvas surface identity exhausted"); + store.next_id + }; + let weak_store = Rc::downgrade(&store); + let owner = v8::Weak::with_finalizer( + scope, + canvas, + Box::new(move |_| { + if let Some(store) = weak_store.upgrade() { + store.borrow_mut().entries.remove(&id); + } + }), + ); + let surface: SurfaceCell = Rc::new(RefCell::new(None)); + store.borrow_mut().entries.insert( + id, + SurfaceRegistryEntry { + _context: owner, + surface: surface.clone(), + }, + ); + let id = v8::BigInt::new_from_u64(scope, id); + set_private_value(scope, canvas, CANVAS_SURFACE_ID_SLOT, id.into()); + surface +} + +/// Materializes the surface cell at `width` x `height`, resizing (and resetting +/// content) only when the dimensions change. Same-size access preserves content. +/// Returns `false` when the dimensions exceed the budget, leaving no surface. +pub(super) fn materialize_surface(cell: &SurfaceCell, width: u32, height: u32) -> bool { + let mut guard = cell.borrow_mut(); + match guard.as_mut() { + Some(surface) => { + if surface.width() == width && surface.height() == height { + true + } else { + match surface.resize(width, height) { + Ok(()) => true, + Err(_) => { + *guard = None; + false + } + } + } + } + None => match CanvasSurface::new(width, height) { + Ok(surface) => { + *guard = Some(surface); + true + } + Err(_) => false, + }, + } +} + +fn remove_canvas_surface<'s>(scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>) { + let cell = canvas_surface_cell(scope, canvas); + *cell.borrow_mut() = None; + remove_html_canvas_pixels(scope, canvas); } fn html_canvas_identity<'s>( @@ -191,17 +339,49 @@ fn html_canvas_identity<'s>( .then_some((runtime_ptr, handle)) } -fn replace_html_canvas_pixels<'s>( +pub(super) fn publish_canvas_snapshot<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, - width: u32, - height: u32, - rgba: Vec, ) { let Some((runtime_ptr, handle)) = html_canvas_identity(scope, canvas) else { return; }; - let _ = unsafe { &mut *runtime_ptr }.replace_canvas_pixels(handle, width, height, rgba); + let cell = canvas_surface_cell(scope, canvas); + let Some(snapshot) = cell + .borrow_mut() + .as_mut() + .and_then(|surface| surface.snapshot().ok()) + else { + return; + }; + let _ = unsafe { &mut *runtime_ptr }.replace_canvas_pixels( + handle, + snapshot.width, + snapshot.height, + snapshot.rgba.clone(), + ); +} + +/// Marks a canvas dirty after a draw is recorded AND flushes its pending +/// recording early once the recorded bytes (including captured source images) +/// exceed the justified resource budget, mirroring Chromium's +/// `FlushIfRecordingLimitExceeded`. Combined here so every draw callback that +/// records an operation triggers both invalidation and the resource check. +pub(super) fn bump_canvas_visual_generation<'s>( + scope: &mut v8::PinScope<'s, '_>, + canvas: v8::Local<'s, v8::Object>, +) { + let Some((runtime_ptr, handle)) = html_canvas_identity(scope, canvas) else { + return; + }; + unsafe { &mut *runtime_ptr }.touch_canvas_visual_generation(handle); + let Some(context) = canvas_2d_context(scope, canvas) else { + return; + }; + let recording = super::recording_store::canvas_recording_state(scope, context); + if recording.borrow().estimated_bytes() > super::recording_store::MAX_PENDING_RECORDING_BYTES { + flush_canvas_recording(scope, canvas); + } } fn remove_html_canvas_pixels<'s>( @@ -214,12 +394,13 @@ fn remove_html_canvas_pixels<'s>( let _ = unsafe { &mut *runtime_ptr }.remove_canvas_pixels(handle); } -fn canvas_like_dimensions<'s>( +pub(super) fn canvas_like_dimensions<'s>( scope: &mut v8::PinScope<'s, '_>, canvas: v8::Local<'s, v8::Object>, ) -> Option<(u32, u32)> { - let width = canvas_like_dimension(scope, canvas, OFFSCREEN_CANVAS_WIDTH_SLOT, "width")?; - let height = canvas_like_dimension(scope, canvas, OFFSCREEN_CANVAS_HEIGHT_SLOT, "height")?; + let width = canvas_like_dimension(scope, canvas, super::OFFSCREEN_CANVAS_WIDTH_SLOT, "width")?; + let height = + canvas_like_dimension(scope, canvas, super::OFFSCREEN_CANVAS_HEIGHT_SLOT, "height")?; Some((width, height)) } @@ -236,20 +417,57 @@ fn canvas_like_dimension<'s>( Some(value.max(0.0).trunc() as u32) } -fn write_bytes_to_view<'s>( - scope: &mut v8::PinScope<'s, '_>, - view: v8::Local<'s, v8::Uint8ClampedArray>, - bytes: &[u8], -) -> Option<()> { - if view.byte_length() != bytes.len() { - return None; - } - let backing_store = view.buffer(scope)?; - let data = backing_store.data()?; - let ptr = data.as_ptr() as *mut u8; - let byte_offset = view.byte_offset(); - unsafe { - std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.add(byte_offset), bytes.len()); +#[cfg(test)] +mod tests { + use super::*; + + /// Native surface ownership is reclaimed with the canvas (GC) and with the + /// isolate, matching the path-state lifecycle in `state.rs`. + #[test] + fn canvas_surfaces_are_reclaimed_with_canvas_gc_and_isolate_destruction() { + moli_v8_test_util::ensure_v8(); + let mut isolate = v8::Isolate::new(Default::default()); + + let live_surface = { + let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, context); + // Two canvases own two independent native surfaces in one registry, + // with stable per-canvas identity. + let canvas_a = v8::Object::new(scope); + let cell_a = canvas_surface_cell(scope, canvas_a); + let canvas_b = v8::Object::new(scope); + let cell_b = canvas_surface_cell(scope, canvas_b); + assert_eq!(surface_store(scope).borrow().entries.len(), 2); + assert!(Rc::ptr_eq(&cell_a, &canvas_surface_cell(scope, canvas_a))); + assert!( + !Rc::ptr_eq(&cell_a, &cell_b), + "distinct canvases own distinct surfaces" + ); + Rc::downgrade(&cell_a) + }; + + // Both canvases went out of scope; a GC must drop their native surfaces. + isolate.low_memory_notification(); + assert!( + live_surface.upgrade().is_none(), + "GC must drop the unreachable canvas native surface" + ); + { + let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut scope.init(); + let store = scope + .get_slot::() + .expect("isolate slot retains registry"); + assert_eq!( + store.borrow().entries.len(), + 0, + "GC releases the native surface registry entries" + ); + } + + // Isolate teardown needs no explicit cleanup. + let _ = isolate; } - Some(()) } diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs index f6e75dfeb..1ff820927 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/context2d.rs @@ -1,7 +1,9 @@ use super::backing_store::{ - canvas_like_pixels_copy, canvas_owner_from_context, with_canvas_like_pixels_mut, + bump_canvas_visual_generation, canvas_like_pixels_copy, canvas_like_region_readback, + canvas_owner_from_context, }; use super::helpers::{canonical_canvas_fill_style, canvas_unrestricted_double_arg}; +use super::recording_store::canvas_recording_state; use super::state::canvas_path_state; use super::*; use crate::context_bootstrap::image_data::{ @@ -12,17 +14,15 @@ use crate::native_bridge::element::image_selected_source; use crate::util::{get_private_value, set_private_value}; use crate::webidl; use moli_canvas::{ - DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, ScaleFilter, blit_draw_image_filtered, - blit_image_data, byte_len, data_image_rgba8_pixels, draw_text, extract_image_data, - fill_style_rgba, measure_text_width, normalize_rect as canvas_normalize_rect, paint_rect, -}; -use moli_layout::{ - PaintBrush, PaintColor, PaintFragment, PaintLineCap, PaintLineJoin, PaintShape, PaintSnapshot, - PaintStroke, PaintTransform2D, PaintViewport, + DEFAULT_FILL_STYLE, DEFAULT_FONT, DrawImageBlit, ScaleFilter, StrokeSpec, byte_len, + data_image_rgba8_pixels, fill_style_rgba, measure_text_width, + normalize_rect as canvas_normalize_rect, }; use moli_webapi_declare::WebApiObject; use std::str::FromStr; +use kurbo::{BezPath, Rect as KurboRect}; + const DEFAULT_IMAGE_SMOOTHING_QUALITY: &str = "low"; const CANVAS_CONTEXT_LINE_DASH_SLOT: &str = "__moliCanvasContextLineDash"; @@ -34,6 +34,7 @@ pub(super) fn reset_canvas_context_state<'s>( let dash = v8::Array::new(scope, 0); set_private_value(scope, context, CANVAS_CONTEXT_LINE_DASH_SLOT, dash.into()); super::state::reset_canvas_path_state(scope, context); + super::recording_store::reset_canvas_recording(scope, context); } #[derive(WebApiObject)] @@ -708,12 +709,11 @@ pub(crate) fn canvas_context_fill_rect_callback<'s>( let Some(rect) = normalized_rect(scope, &args, "CanvasRenderingContext2D.fillRect") else { return; }; - let fill_style = context_string_slot(scope, args.this(), CANVAS_CONTEXT_FILL_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); - let color = fill_style_rgba(&fill_style); - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - paint_rect(pixels, width, height, rect, color); - }); + let color = recording_fill_color(scope, args.this()); + let recording = canvas_recording_state(scope, args.this()); + let kurbo_rect = i32_rect_to_kurbo(rect.0, rect.1, rect.2, rect.3); + recording.borrow_mut().push_fill_rect(kurbo_rect, color); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_clear_rect_callback<'s>( @@ -727,9 +727,10 @@ pub(crate) fn canvas_context_clear_rect_callback<'s>( let Some(rect) = normalized_rect(scope, &args, "CanvasRenderingContext2D.clearRect") else { return; }; - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - paint_rect(pixels, width, height, rect, [0, 0, 0, 0]); - }); + let recording = canvas_recording_state(scope, args.this()); + let kurbo_rect = i32_rect_to_kurbo(rect.0, rect.1, rect.2, rect.3); + recording.borrow_mut().push_clear_rect(kurbo_rect); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_rect_callback<'s>( @@ -1075,19 +1076,16 @@ pub(crate) fn canvas_context_fill_callback<'s>( return; }; let path_state = canvas_path_state(scope, args.this()); - let fragment = with_path_state(&path_state, |state| { + let recording = canvas_recording_state(scope, args.this()); + let color = recording_fill_color_with_alpha(scope, args.this()); + with_path_state(&path_state, |state| { if state.is_empty() || state.inverse_transform().is_none() { - return None; + return; } - Some(PaintFragment::Fill { - shape: PaintShape::Path(state.paint_path()), - brush: PaintBrush::Solid(context_fill_color(scope, args.this())), - transform: PaintTransform2D::IDENTITY, - }) + let bez = canvas_path_data_to_bez(&state.paint_path()); + recording.borrow_mut().push_fill_path(bez, color); }); - if let Some(fragment) = fragment { - rasterize_canvas_fragment(scope, canvas, fragment); - } + bump_canvas_visual_generation(scope, canvas); rv.set_undefined(); } @@ -1103,20 +1101,23 @@ pub(crate) fn canvas_context_stroke_callback<'s>( return; }; let path_state = canvas_path_state(scope, args.this()); - let fragment = with_path_state(&path_state, |state| { + let recording = canvas_recording_state(scope, args.this()); + let color = recording_stroke_color(scope, args.this()); + let style = recording_stroke_spec(scope, args.this()); + with_path_state(&path_state, |state| { if state.is_empty() { - return None; + return; } - Some(PaintFragment::Stroke(context_stroke( - scope, - args.this(), - state.stroke_path()?, - state.transform(), - ))) + let Some(stroke_data) = state.stroke_path() else { + return; + }; + let bez = canvas_path_data_to_bez(&stroke_data); + let transform = state.transform(); + recording + .borrow_mut() + .push_stroke_path(bez, transform, style, color); }); - if let Some(fragment) = fragment { - rasterize_canvas_fragment(scope, canvas, fragment); - } + bump_canvas_visual_generation(scope, canvas); rv.set_undefined(); } @@ -1149,18 +1150,22 @@ pub(crate) fn canvas_context_stroke_rect_callback<'s>( return; }; let path_state = canvas_path_state(scope, args.this()); + let recording = canvas_recording_state(scope, args.this()); + let color = recording_stroke_color(scope, args.this()); + let style = recording_stroke_spec(scope, args.this()); // strokeRect must not alter the current default path. - let path = with_path_state(&path_state, |state| { - let mut rect_path = super::path::Canvas2dPathState::default(); - rect_path.rect(x, y, width, height); + let transform = with_path_state(&path_state, |state| { state.inverse_transform()?; - Some((rect_path.paint_path(), state.transform())) + Some(state.transform()) }); - let Some((path, transform)) = path else { + let Some(transform) = transform else { return; }; - let stroke = context_stroke(scope, args.this(), path, transform); - rasterize_canvas_fragment(scope, canvas, PaintFragment::Stroke(stroke)); + let kurbo_rect = KurboRect::new(x, y, x + width, y + height); + recording + .borrow_mut() + .push_stroke_rect(kurbo_rect, transform, style, color); + bump_canvas_visual_generation(scope, canvas); rv.set_undefined(); } @@ -1419,24 +1424,12 @@ fn canvas_context_enum_string_assign<'s>( } fn with_path_state( - state: &std::cell::RefCell, - update: impl FnOnce(&mut super::path::Canvas2dPathState) -> T, + state: &std::cell::RefCell, + update: impl FnOnce(&mut moli_canvas::path::CanvasPath) -> T, ) -> T { update(&mut state.borrow_mut()) } -fn context_fill_color<'s>( - scope: &mut v8::PinScope<'s, '_>, - context: v8::Local<'s, v8::Object>, -) -> PaintColor { - let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); - color_with_global_alpha( - fill_style_rgba(&fill_style), - context_global_alpha(scope, context), - ) -} - fn context_global_alpha<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, @@ -1445,54 +1438,6 @@ fn context_global_alpha<'s>( .unwrap_or(DEFAULT_GLOBAL_ALPHA) } -fn color_with_global_alpha(rgba: [u8; 4], global_alpha: f64) -> PaintColor { - let alpha = (f64::from(rgba[3]) / 255.0 * global_alpha).clamp(0.0, 1.0) as f32; - PaintColor::new( - f64::from(rgba[0]) as f32 / 255.0, - f64::from(rgba[1]) as f32 / 255.0, - f64::from(rgba[2]) as f32 / 255.0, - alpha, - ) -} - -fn context_stroke<'s>( - scope: &mut v8::PinScope<'s, '_>, - context: v8::Local<'s, v8::Object>, - path: moli_layout::PaintPath, - transform: PaintTransform2D, -) -> PaintStroke { - let stroke_style = context_string_slot(scope, context, CANVAS_CONTEXT_STROKE_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_STROKE_STYLE.to_owned()); - let join = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_JOIN_SLOT).as_deref() { - Some("round") => PaintLineJoin::Round, - Some("bevel") => PaintLineJoin::Bevel, - _ => PaintLineJoin::Miter, - }; - let cap = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_CAP_SLOT).as_deref() { - Some("round") => PaintLineCap::Round, - Some("square") => PaintLineCap::Square, - _ => PaintLineCap::Butt, - }; - PaintStroke { - path, - color: color_with_global_alpha( - fill_style_rgba(&stroke_style), - context_global_alpha(scope, context), - ), - width: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_WIDTH_SLOT) - .unwrap_or(DEFAULT_LINE_WIDTH) as f32, - join, - start_cap: cap, - end_cap: cap, - miter_limit: context_number_slot(scope, context, CANVAS_CONTEXT_MITER_LIMIT_SLOT) - .unwrap_or(DEFAULT_MITER_LIMIT) as f32, - dash_pattern: context_line_dash(scope, context), - dash_offset: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_DASH_OFFSET_SLOT) - .unwrap_or(DEFAULT_LINE_DASH_OFFSET) as f32, - transform, - } -} - fn context_line_dash<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, @@ -1509,58 +1454,116 @@ fn context_line_dash<'s>( .collect() } -fn rasterize_canvas_fragment<'s>( +/// Returns the straight `[u8; 4]` color from the context's `fillStyle`, +/// premultiplied for Vello but WITHOUT applying `globalAlpha`. +/// Used by direct-pixel ops (fillRect, fillText, drawImage) which historically +/// did not composite through globalAlpha. +fn recording_fill_color<'s>( scope: &mut v8::PinScope<'s, '_>, - canvas: v8::Local<'s, v8::Object>, - fragment: PaintFragment, -) { - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - if width == 0 || height == 0 { - return; - } - let mut snapshot = PaintSnapshot::new( - PaintViewport::new(width, height, 1.0), - PaintColor::new(0.0, 0.0, 0.0, 0.0), - ); - snapshot.push_fragment(fragment); - if let Ok(raster) = moli_paint::raster_snapshot(&snapshot) - && raster.width == width - && raster.height == height - { - composite_rgba8_over(pixels, &raster.rgba); - } - }); + context: v8::Local<'s, v8::Object>, +) -> [u8; 4] { + let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) + .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); + fill_style_rgba(&fill_style) } -/// Composites `source` (premultiplied RGBA8) over `destination` (straight -/// RGBA8) using source-over. This is the format vello_cpu renders into, while -/// the canvas backing store is straight alpha. -fn composite_rgba8_over(destination: &mut [u8], source: &[u8]) { - for (dst, src) in destination.chunks_exact_mut(4).zip(source.chunks_exact(4)) { - let src_alpha = u32::from(src[3]); - if src_alpha == 0 { - continue; - } - let dst_alpha = u32::from(dst[3]); - if src_alpha == 255 { - dst.copy_from_slice(src); - continue; - } - let out_alpha = src_alpha + dst_alpha * (255 - src_alpha) / 255; - if out_alpha == 0 { - dst.copy_from_slice(&[0, 0, 0, 0]); - continue; - } - for channel in 0..3 { - let src_premultiplied = u32::from(src[channel]); - let dst_premultiplied = u32::from(dst[channel]) * dst_alpha / 255; - let out_premultiplied = src_premultiplied + dst_premultiplied * (255 - src_alpha) / 255; - dst[channel] = ((out_premultiplied * 255 + out_alpha / 2) / out_alpha) as u8; +/// Returns the straight `[u8; 4]` fill color with `globalAlpha` applied. +/// Used by path-based ops (fill(), stroke(), strokeRect) which composit through +/// `globalAlpha`. +fn recording_fill_color_with_alpha<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> [u8; 4] { + let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) + .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); + let rgba = fill_style_rgba(&fill_style); + apply_global_alpha(rgba, context_global_alpha(scope, context)) +} + +/// Returns the straight `[u8; 4]` stroke color with `globalAlpha` applied. +fn recording_stroke_color<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> [u8; 4] { + let stroke_style = context_string_slot(scope, context, CANVAS_CONTEXT_STROKE_STYLE_SLOT) + .unwrap_or_else(|| DEFAULT_STROKE_STYLE.to_owned()); + let rgba = fill_style_rgba(&stroke_style); + apply_global_alpha(rgba, context_global_alpha(scope, context)) +} + +/// Applies `globalAlpha` to a **straight** (non-premultiplied) RGBA8 color. +/// +/// Per the spec, `globalAlpha` multiplies the alpha component only; the straight +/// RGB channels are left unchanged. Scaling RGB here would darken the color +/// before Vello premultiplies it internally, so a red path at `globalAlpha=0.5` +/// would read back as `[128,0,0,128]` instead of `[255,0,0,128]`. +fn apply_global_alpha(rgba: [u8; 4], global_alpha: f64) -> [u8; 4] { + let a = global_alpha.clamp(0.0, 1.0); + [ + rgba[0], + rgba[1], + rgba[2], + (f64::from(rgba[3]) * a + 0.5) as u8, + ] +} + +/// Builds a [`StrokeSpec`] from the current context line state. +fn recording_stroke_spec<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> StrokeSpec { + let cap = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_CAP_SLOT).as_deref() { + Some("round") => kurbo::Cap::Round, + Some("square") => kurbo::Cap::Square, + _ => kurbo::Cap::Butt, + }; + let join = match context_string_slot(scope, context, CANVAS_CONTEXT_LINE_JOIN_SLOT).as_deref() { + Some("round") => kurbo::Join::Round, + Some("bevel") => kurbo::Join::Bevel, + _ => kurbo::Join::Miter, + }; + let dash_pattern: Vec = context_line_dash(scope, context) + .into_iter() + .map(|v| v as f64) + .collect(); + StrokeSpec { + width: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_WIDTH_SLOT) + .unwrap_or(DEFAULT_LINE_WIDTH), + cap, + join, + miter_limit: context_number_slot(scope, context, CANVAS_CONTEXT_MITER_LIMIT_SLOT) + .unwrap_or(DEFAULT_MITER_LIMIT), + dash_pattern, + dash_offset: context_number_slot(scope, context, CANVAS_CONTEXT_LINE_DASH_OFFSET_SLOT) + .unwrap_or(DEFAULT_LINE_DASH_OFFSET), + } +} + +/// Converts a `(left, top, right, bottom)` tuple (as returned by +/// `normalize_rect`) to a kurbo `Rect`. +fn i32_rect_to_kurbo(left: i32, top: i32, right: i32, bottom: i32) -> KurboRect { + KurboRect::new(left as f64, top as f64, right as f64, bottom as f64) +} + +/// Converts native `CanvasPathData` elements into a kurbo `BezPath`. +fn canvas_path_data_to_bez(data: &moli_canvas::path::CanvasPathData) -> BezPath { + use kurbo::PathEl; + let mut path = BezPath::new(); + for el in &data.elements { + match *el { + PathEl::MoveTo(p) => path.move_to(p), + PathEl::LineTo(p) => path.line_to(p), + PathEl::QuadTo(c, p) => path.quad_to(c, p), + PathEl::CurveTo(a, b, p) => path.curve_to(a, b, p), + PathEl::ClosePath => path.close_path(), } - dst[3] = out_alpha as u8; } + path } +/// Composites `source` (premultiplied RGBA8) over `destination` (straight +/// RGBA8) using source-over. This is the format vello_cpu renders into, while +/// the canvas backing store is straight alpha. pub(crate) fn canvas_context_is_point_in_path_callback( scope: &mut v8::PinScope<'_, '_>, _args: v8::FunctionCallbackArguments<'_>, @@ -1580,7 +1583,8 @@ pub(crate) fn canvas_context_fill_text_callback<'s>( let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - draw_canvas_context_text(scope, args.this(), canvas, &parsed.text, parsed.x, parsed.y); + draw_canvas_context_text(scope, args.this(), &parsed.text, parsed.x, parsed.y); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_stroke_text_callback<'s>( @@ -1594,25 +1598,24 @@ pub(crate) fn canvas_context_stroke_text_callback<'s>( let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - draw_canvas_context_text(scope, args.this(), canvas, &parsed.text, parsed.x, parsed.y); + draw_canvas_context_text(scope, args.this(), &parsed.text, parsed.x, parsed.y); + bump_canvas_visual_generation(scope, canvas); } fn draw_canvas_context_text<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, - canvas: v8::Local<'s, v8::Object>, text: &str, x: f64, y: f64, ) { - let fill_style = context_string_slot(scope, context, CANVAS_CONTEXT_FILL_STYLE_SLOT) - .unwrap_or_else(|| DEFAULT_FILL_STYLE.to_owned()); let font = context_string_slot(scope, context, CANVAS_CONTEXT_FONT_SLOT) .unwrap_or_else(|| DEFAULT_FONT.to_owned()); - let color = fill_style_rgba(&fill_style); - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - draw_text(pixels, width, height, text, x, y, &font, color); - }); + let color = recording_fill_color(scope, context); + let recording = canvas_recording_state(scope, context); + recording + .borrow_mut() + .push_text(text.to_owned(), x, y, font, color); } pub(crate) fn canvas_context_draw_image_callback<'s>( @@ -1648,18 +1651,22 @@ pub(crate) fn canvas_context_draw_image_callback<'s>( } else { ScaleFilter::Nearest }; - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - blit_draw_image_filtered( - pixels, - width, - height, - &source_pixels, - source_width, - source_height, - blit, - filter, - ); - }); + let recording = canvas_recording_state(scope, args.this()); + let source_image = moli_image::RgbaImage { + width: source_width, + height: source_height, + rgba: source_pixels, + }; + let dest = KurboRect::new( + blit.dest_x, + blit.dest_y, + blit.dest_x + blit.dest_width, + blit.dest_y + blit.dest_height, + ); + recording + .borrow_mut() + .push_draw_image(dest, std::sync::Arc::new(source_image), blit, filter); + bump_canvas_visual_generation(scope, canvas); } fn html_image_pixels_copy<'s>( @@ -1894,28 +1901,54 @@ pub(crate) fn canvas_context_put_image_data_callback<'s>( return; }; + if Some(bytes.len()) != byte_len(source_width, source_height) { + webidl::throw_index_size_error(scope); + return; + } + let (dirty_x, dirty_y, dirty_width, dirty_height) = if args.length() >= 7 { dirty_rect.unwrap_or((0, 0, 0, 0)) } else { (0, 0, source_width as i32, source_height as i32) }; - let _ = with_canvas_like_pixels_mut(scope, canvas, |pixels, width, height| { - blit_image_data( - pixels, - width, - height, - &bytes, - source_width, - source_height, - dx, - dy, - dirty_x, - dirty_y, - dirty_width, - dirty_height, - ); - }); + // Clip the dirty rect to the source bounds before recording. + let sx = dirty_x.max(0).min(source_width as i32); + let sy = dirty_y.max(0).min(source_height as i32); + let ex = dirty_x + .max(0) + .saturating_add(dirty_width.max(0)) + .min(source_width as i32) + .max(sx); + let ey = dirty_y + .max(0) + .saturating_add(dirty_height.max(0)) + .min(source_height as i32) + .max(sy); + let clipped_width = (ex - sx) as u32; + let clipped_height = (ey - sy) as u32; + if clipped_width == 0 || clipped_height == 0 { + return; + } + let clipped = clip_image_data_to_dirty( + &bytes, + source_width, + source_height, + dirty_x, + dirty_y, + dirty_width, + dirty_height, + ); + let source_image = moli_image::RgbaImage { + width: clipped_width, + height: clipped_height, + rgba: clipped, + }; + let recording = canvas_recording_state(scope, args.this()); + recording + .borrow_mut() + .push_put_image_data(source_image, dx, dy); + bump_canvas_visual_generation(scope, canvas); } pub(crate) fn canvas_context_get_image_data_callback<'s>( @@ -1943,18 +1976,7 @@ pub(crate) fn canvas_context_get_image_data_callback<'s>( let source_x = parsed.sx; let source_y = parsed.sy; let bytes = if let Some(canvas) = canvas_owner_from_context(scope, args.this()) { - canvas_like_pixels_copy(scope, canvas) - .map(|(pixels, canvas_width, canvas_height)| { - extract_image_data( - &pixels, - canvas_width, - canvas_height, - source_x, - source_y, - width, - height, - ) - }) + canvas_like_region_readback(scope, canvas, source_x, source_y, width, height) .unwrap_or_else(|| blank_image_data(width, height)) } else { blank_image_data(width, height) @@ -2093,3 +2115,43 @@ fn normalized_draw_image_args<'s>( fn blank_image_data(width: u32, height: u32) -> Vec { vec![0; byte_len(width, height).unwrap_or(0)] } + +/// Clips ImageData bytes to the dirty rect intersected with the source bounds, +/// returning the clipped RGBA8 bytes. +fn clip_image_data_to_dirty( + bytes: &[u8], + source_width: u32, + source_height: u32, + dirty_x: i32, + dirty_y: i32, + dirty_width: i32, + dirty_height: i32, +) -> Vec { + let src_w = source_width as i32; + let src_h = source_height as i32; + let sx = dirty_x.max(0).min(src_w); + let sy = dirty_y.max(0).min(src_h); + let left = sx; + let top = sy; + let right = (dirty_x.max(0)) + .checked_add(dirty_width.max(0)) + .unwrap_or(src_w) + .min(src_w) + .max(left); + let bottom = (dirty_y.max(0)) + .checked_add(dirty_height.max(0)) + .unwrap_or(src_h) + .min(src_h) + .max(top); + let w = (right - left).max(0) as usize; + let h = (bottom - top).max(0) as usize; + let mut out = Vec::with_capacity(w * h * 4); + for row in top as usize..top as usize + h { + let offset = (row * source_width as usize + left as usize) * 4; + let end = offset + w * 4; + if end <= bytes.len() { + out.extend_from_slice(&bytes[offset..end]); + } + } + out +} diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/path.rs b/moli-renderer-v8/src/context_bootstrap/canvas/path.rs index b65e477a5..7c3630de7 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/path.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/path.rs @@ -1,483 +1,3 @@ -//! Canvas 2D current-path geometry, owned by the context's native state. - -use moli_layout::{ - LayoutPoint, LayoutRect, LayoutTransform2D, PaintPath, PaintPathElement, PaintRect, -}; - -/// A Bezier path in the same flat form as a kurbo path: one `MoveTo` starts a -/// subpath, `Close` ends it, and a new `MoveTo` begins the next one. -#[derive(Clone, Debug)] -pub(crate) struct Canvas2dPathState { - // Default-path geometry is already in canvas coordinates. The current - // transform only affects newly recorded commands, never earlier elements. - elements: Vec, - current: (f64, f64), - current_subpath_start: (f64, f64), - has_subpath: bool, - just_closed: bool, - transform: LayoutTransform2D, -} - -impl Default for Canvas2dPathState { - fn default() -> Self { - Self { - elements: Vec::new(), - current: (0.0, 0.0), - current_subpath_start: (0.0, 0.0), - has_subpath: false, - just_closed: false, - transform: LayoutTransform2D::IDENTITY, - } - } -} - -const TWO_PI: f64 = std::f64::consts::TAU; - -impl Canvas2dPathState { - pub(super) fn begin_path(&mut self) { - self.elements.clear(); - self.current = (0.0, 0.0); - self.current_subpath_start = (0.0, 0.0); - self.has_subpath = false; - self.just_closed = false; - } - - pub(super) fn move_to(&mut self, x: f64, y: f64) { - if ![x, y].into_iter().all(f64::is_finite) { - return; - } - let (x, y) = self.map_point(x, y); - self.elements.push(PaintPathElement::MoveTo(point(x, y))); - self.current = (x, y); - self.current_subpath_start = (x, y); - self.has_subpath = true; - self.just_closed = false; - } - - /// Reopens a closed subpath; callers establish the initial point for an empty path. - fn ensure_open_subpath(&mut self) -> bool { - if !self.has_subpath { - return false; - } - if self.just_closed { - self.elements.push(PaintPathElement::MoveTo(point( - self.current.0, - self.current.1, - ))); - self.current_subpath_start = self.current; - self.just_closed = false; - } - true - } - - pub(super) fn line_to(&mut self, x: f64, y: f64) { - if ![x, y].into_iter().all(f64::is_finite) { - return; - } - if !self.ensure_open_subpath() { - self.move_to(x, y); - return; - } - let (x, y) = self.map_point(x, y); - self.elements.push(PaintPathElement::LineTo(point(x, y))); - self.current = (x, y); - } - - pub(super) fn quadratic_curve_to(&mut self, cpx: f64, cpy: f64, x: f64, y: f64) { - if ![cpx, cpy, x, y].into_iter().all(f64::is_finite) { - return; - } - if !self.ensure_open_subpath() { - self.move_to(cpx, cpy); - } - let (cpx, cpy) = self.map_point(cpx, cpy); - let (x, y) = self.map_point(x, y); - self.elements - .push(PaintPathElement::QuadTo(point(cpx, cpy), point(x, y))); - self.current = (x, y); - } - - pub(super) fn bezier_curve_to( - &mut self, - c1x: f64, - c1y: f64, - c2x: f64, - c2y: f64, - x: f64, - y: f64, - ) { - if ![c1x, c1y, c2x, c2y, x, y].into_iter().all(f64::is_finite) { - return; - } - if !self.ensure_open_subpath() { - self.move_to(c1x, c1y); - } - let (c1x, c1y) = self.map_point(c1x, c1y); - let (c2x, c2y) = self.map_point(c2x, c2y); - let (x, y) = self.map_point(x, y); - self.elements.push(PaintPathElement::CubicTo( - point(c1x, c1y), - point(c2x, c2y), - point(x, y), - )); - self.current = (x, y); - } - - pub(super) fn close_path(&mut self) { - if !self.has_subpath || self.just_closed { - return; - } - self.elements.push(PaintPathElement::Close); - self.current = self.current_subpath_start; - self.just_closed = true; - } - - pub(super) fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) { - if ![x, y, width, height].into_iter().all(f64::is_finite) - || self.inverse_transform().is_none() - { - return; - } - self.move_to(x, y); - self.line_to(x + width, y); - self.line_to(x + width, y + height); - self.line_to(x, y + height); - self.close_path(); - } - - /// Canvas arcs use positive angles clockwise in the screen's y-down space. - pub(super) fn arc( - &mut self, - x: f64, - y: f64, - radius: f64, - start: f64, - end: f64, - ccw: bool, - ) -> bool { - self.ellipse(x, y, radius, radius, 0.0, start, end, ccw) - } - - pub(super) fn ellipse( - &mut self, - x: f64, - y: f64, - radius_x: f64, - radius_y: f64, - rotation: f64, - start: f64, - end: f64, - ccw: bool, - ) -> bool { - if ![x, y, radius_x, radius_y, rotation, start, end] - .into_iter() - .all(f64::is_finite) - || radius_x < 0.0 - || radius_y < 0.0 - || self.inverse_transform().is_none() - { - return false; - } - let (start, sweep) = arc_angles(start, end, ccw); - self.append_arc(kurbo::Arc::new( - (x, y), - (radius_x, radius_y), - start, - sweep, - rotation.rem_euclid(TWO_PI), - )); - true - } - - pub(super) fn arc_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, radius: f64) -> bool { - if ![x1, y1, x2, y2, radius].into_iter().all(f64::is_finite) || radius < 0.0 { - return false; - } - if !self.has_subpath { - self.move_to(x1, y1); - return true; - } - let Some(inverse) = self.inverse_transform() else { - self.line_to(x1, y1); - return true; - }; - // arcTo constructs a circular arc in the current user coordinate - // system. The previous point was recorded under a possibly older CTM. - let p0 = inverse * kurbo::Point::from(self.current); - let p1 = kurbo::Point::new(x1, y1); - let p2 = kurbo::Point::new(x2, y2); - let incoming = p0 - p1; - let outgoing = p2 - p1; - if incoming.hypot() == 0.0 || outgoing.hypot() == 0.0 || radius == 0.0 { - self.line_to(x1, y1); - return true; - } - // Both rays originate at the corner. There is no radius clamp: the - // tangent points may lie beyond either of the supplied line segments. - let u = incoming / incoming.hypot(); - let v = outgoing / outgoing.hypot(); - let cross = u.cross(v); - if cross == 0.0 || !cross.is_finite() { - self.line_to(x1, y1); - return true; - } - let tangent_distance = radius * ((1.0 + u.dot(v).clamp(-1.0, 1.0)) / cross.abs()); - let tangent = p1 + u * tangent_distance; - let center = tangent + kurbo::Vec2::new(-u.y, u.x) * (radius * cross.signum()); - let end = p1 + v * tangent_distance; - let start_angle = (tangent - center).atan2(); - let end_angle = (end - center).atan2(); - let (start, sweep) = arc_angles(start_angle, end_angle, cross > 0.0); - self.append_arc(kurbo::Arc::new(center, (radius, radius), start, sweep, 0.0)); - true - } - - fn append_arc(&mut self, arc: kurbo::Arc) { - use kurbo::{PathEl, Shape}; - - // A relative floor also bounds subdivision for enormous but finite - // radii. It prevents resource usage growing without bound with radius. - let tolerance = 0.01_f64.max(arc.radii.x.max(arc.radii.y) / 4096.0); - for element in arc.path_elements(tolerance) { - match element { - PathEl::MoveTo(p) => self.line_to(p.x, p.y), - PathEl::CurveTo(a, b, p) => self.bezier_curve_to(a.x, a.y, b.x, b.y, p.x, p.y), - _ => unreachable!("kurbo arcs contain only a start point and cubic segments"), - } - } - } - - pub(super) fn set_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { - if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { - self.transform = LayoutTransform2D::new([a, b, c, d, e, f]); - } - } - - pub(super) fn reset_transform(&mut self) { - self.transform = LayoutTransform2D::IDENTITY; - } - - pub(super) fn translate(&mut self, x: f64, y: f64) { - self.concatenate_transform(1.0, 0.0, 0.0, 1.0, x, y); - } - - pub(super) fn scale(&mut self, x: f64, y: f64) { - self.concatenate_transform(x, 0.0, 0.0, y, 0.0, 0.0); - } - - pub(super) fn rotate(&mut self, radians: f64) { - let (sin, cos) = radians.sin_cos(); - self.concatenate_transform(cos, sin, -sin, cos, 0.0, 0.0); - } - - pub(super) fn concatenate_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { - if [a, b, c, d, e, f].into_iter().all(f64::is_finite) { - self.transform = self - .transform - .concatenate(LayoutTransform2D::new([a, b, c, d, e, f])); - } - } - - pub(super) fn transform(&self) -> LayoutTransform2D { - self.transform - } - - fn map_point(&self, x: f64, y: f64) -> (f64, f64) { - let p = kurbo::Affine::new(self.transform.coefficients) * kurbo::Point::new(x, y); - (p.x, p.y) - } - - pub(super) fn inverse_transform(&self) -> Option { - let transform = kurbo::Affine::new(self.transform.coefficients); - let determinant = transform.determinant(); - // A small nonzero scale is still invertible; do not use EPSILON as - // a singularity threshold (e.g. scale(1e-9,1e-9) is valid). - (determinant != 0.0 && determinant.is_finite()).then(|| transform.inverse()) - } - - /// Stroke metrics are in the *current* user space. Bring the frozen path - /// back to that space and let the painter transform the resulting stroke, - /// so anisotropic line widths, joins and dashes change without moving it. - pub(super) fn stroke_path(&self) -> Option { - let inverse = self.inverse_transform()?; - let map = |p: LayoutPoint| { - let p = inverse * kurbo::Point::new(f64::from(p.x), f64::from(p.y)); - point(p.x, p.y) - }; - let elements = self - .elements - .iter() - .map(|element| match *element { - PaintPathElement::MoveTo(p) => PaintPathElement::MoveTo(map(p)), - PaintPathElement::LineTo(p) => PaintPathElement::LineTo(map(p)), - PaintPathElement::QuadTo(a, p) => PaintPathElement::QuadTo(map(a), map(p)), - PaintPathElement::CubicTo(a, b, p) => { - PaintPathElement::CubicTo(map(a), map(b), map(p)) - } - PaintPathElement::Close => PaintPathElement::Close, - }) - .collect::>(); - Some(PaintPath { - bounds: path_bounds(&elements), - elements, - }) - } - - /// Builds an owned `PaintPath` with conservative local bounds. - pub(super) fn paint_path(&self) -> PaintPath { - PaintPath { - elements: self.elements.clone(), - bounds: path_bounds(&self.elements), - } - } - - pub(super) fn is_empty(&self) -> bool { - self.elements.is_empty() - } -} - -fn path_bounds(elements: &[PaintPathElement]) -> PaintRect { - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for element in elements { - match *element { - PaintPathElement::MoveTo(point) | PaintPathElement::LineTo(point) => { - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, point); - } - PaintPathElement::QuadTo(first, second) => { - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); - } - PaintPathElement::CubicTo(first, second, third) => { - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, first); - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, second); - expand(&mut min_x, &mut min_y, &mut max_x, &mut max_y, third); - } - PaintPathElement::Close => {} - } - } - if !min_x.is_finite() { - return LayoutRect::ZERO; - } - LayoutRect::new( - min_x as f32, - min_y as f32, - (max_x - min_x) as f32, - (max_y - min_y) as f32, - ) -} - -fn point(x: f64, y: f64) -> LayoutPoint { - LayoutPoint::new(x as f32, y as f32) -} - -fn expand(min_x: &mut f64, min_y: &mut f64, max_x: &mut f64, max_y: &mut f64, point: LayoutPoint) { - let x = f64::from(point.x); - let y = f64::from(point.y); - *min_x = (*min_x).min(x); - *min_y = (*min_y).min(y); - *max_x = (*max_x).max(x); - *max_y = (*max_y).max(y); -} - -/// Normalize in constant time, including opposite-sign finite angles whose -/// subtraction overflows. Never repeatedly add/subtract TAU: at large f64 -/// magnitudes that operation does not advance at all. -fn arc_angles(start: f64, end: f64, ccw: bool) -> (f64, f64) { - let difference = end - start; - let normalized_start = start.rem_euclid(TWO_PI); - let sweep = if !ccw && difference >= TWO_PI { - TWO_PI - } else if ccw && difference <= -TWO_PI { - -TWO_PI - } else if difference == 0.0 { - 0.0 - } else { - let remainder = if difference.is_finite() { - difference.rem_euclid(TWO_PI) - } else { - (end.rem_euclid(TWO_PI) - normalized_start).rem_euclid(TWO_PI) - }; - if ccw { - // Like Blink's AdjustEndAngle, preserve a whole turn for opposite- - // direction endpoints separated by an exact multiple of TAU. - if remainder == 0.0 && difference < 0.0 { - 0.0 - } else { - remainder - TWO_PI - } - } else if remainder == 0.0 && difference < 0.0 { - TWO_PI - } else { - remainder - } - }; - (normalized_start, sweep) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::f64::consts::FRAC_PI_2; - - #[test] - fn arc_angles_preserve_direction_full_turns_and_equal_endpoints() { - for (start, end, ccw, expected) in [ - (0.0, FRAC_PI_2, false, FRAC_PI_2), - (0.0, FRAC_PI_2, true, -3.0 * FRAC_PI_2), - (0.0, -FRAC_PI_2, true, -FRAC_PI_2), - (0.0, -FRAC_PI_2, false, 3.0 * FRAC_PI_2), - (0.0, TWO_PI, false, TWO_PI), - (0.0, -TWO_PI, true, -TWO_PI), - (0.0, TWO_PI, true, -TWO_PI), - (0.0, -TWO_PI, false, TWO_PI), - (2.0, 2.0, true, 0.0), - (2.0, 2.0, false, 0.0), - ] { - assert!((arc_angles(start, end, ccw).1 - expected).abs() < 1e-12); - } - } - - #[test] - fn finite_extreme_angles_produce_bounded_path_work() { - for start in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { - for end in [0.0, 1e20, -1e20, f64::MAX, -f64::MAX] { - for ccw in [false, true] { - let (angle, sweep) = arc_angles(start, end, ccw); - assert!(angle.is_finite() && (0.0..TWO_PI).contains(&angle)); - assert!(sweep.is_finite() && sweep.abs() <= TWO_PI); - assert!(if ccw { sweep <= 0.0 } else { sweep >= 0.0 }); - let mut state = Canvas2dPathState::default(); - assert!(state.arc(10.0, 10.0, 5.0, start, end, ccw)); - assert!(state.elements.len() <= 7); - } - } - } - } - - #[test] - fn arc_to_has_the_correct_tangent_and_endpoint() { - let mut state = Canvas2dPathState::default(); - state.move_to(0.0, 0.0); - assert!(state.arc_to(10.0, 0.0, 10.0, 10.0, 5.0)); - let PaintPathElement::LineTo(tangent) = state.elements[1] else { - panic!("arcTo must connect to its first tangent"); - }; - assert!((tangent.x - 5.0).abs() < 1e-5 && tangent.y.abs() < 1e-5); - assert!((state.current.0 - 10.0).abs() < 1e-5); - assert!((state.current.1 - 5.0).abs() < 1e-5); - } - - #[test] - fn rejected_arc_geometry_does_not_mutate_the_path() { - let mut state = Canvas2dPathState::default(); - for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] { - assert!(!state.arc(0.0, 0.0, invalid, 0.0, 1.0, false)); - assert!(!state.ellipse(0.0, 0.0, 5.0, invalid, 0.0, 0.0, 1.0, false)); - assert!(!state.arc_to(0.0, 0.0, 1.0, 1.0, invalid)); - assert!(state.is_empty()); - } - } -} +//! Empty module — the recording engine in `moli-canvas` handles path +//! conversion directly via kurbo `BezPath`, so this adapter is no longer +//! needed. diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs new file mode 100644 index 000000000..30f38a95b --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/canvas/recording_store.rs @@ -0,0 +1,161 @@ +//! Per-context ordered drawing recording. +//! +//! The isolate owns the table. Weak context handles remove entries on GC; +//! dropping the isolate also drops every remaining entry without needing GC. +//! This mirrors the pattern in `state.rs` for path state. + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use crate::util::{get_private_value, set_private_value}; +use moli_canvas::DrawRecording; + +const RECORDING_STATE_SLOT: &str = "__moliCanvasRecordingState"; + +/// Justified resource budget for one canvas's pending recording: when the +/// recorded operations (including captured source image bytes) exceed this, +/// the adapter flushes early so a long-running recording cannot retain +/// unbounded pinned storage. Mirrors Chromium's `FlushIfRecordingLimitExceeded`. +pub(super) const MAX_PENDING_RECORDING_BYTES: usize = 4 * 1024 * 1024; + +type RecordingStore = Rc>; + +#[derive(Default)] +struct Recordings { + next_id: u64, + entries: HashMap, +} + +struct RecordingEntry { + _context: v8::Weak, + recording: Rc>, +} + +fn recording_store<'s>(scope: &mut v8::PinScope<'s, '_>) -> RecordingStore { + if let Some(store) = scope.get_slot::() { + store.clone() + } else { + let store = RecordingStore::default(); + scope.set_slot(store.clone()); + store + } +} + +pub(super) fn canvas_recording_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) -> Rc> { + let store = recording_store(scope); + if let Some(id) = get_private_value(scope, context, RECORDING_STATE_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|value| value.u64_value().0) + { + return store + .borrow() + .entries + .get(&id) + .expect("a live context must retain its recording") + .recording + .clone(); + } + let id = { + let mut store = store.borrow_mut(); + store.next_id = store + .next_id + .checked_add(1) + .expect("canvas recording identity exhausted"); + store.next_id + }; + let weak_store = Rc::downgrade(&store); + let owner = v8::Weak::with_finalizer( + scope, + context, + Box::new(move |_| { + if let Some(store) = weak_store.upgrade() { + store.borrow_mut().entries.remove(&id); + } + }), + ); + let recording = Rc::new(RefCell::new(DrawRecording::new())); + store.borrow_mut().entries.insert( + id, + RecordingEntry { + _context: owner, + recording: recording.clone(), + }, + ); + let id = v8::BigInt::new_from_u64(scope, id); + set_private_value(scope, context, RECORDING_STATE_SLOT, id.into()); + recording +} + +pub(super) fn reset_canvas_recording<'s>( + scope: &mut v8::PinScope<'s, '_>, + context: v8::Local<'s, v8::Object>, +) { + let recording = canvas_recording_state(scope, context); + recording.borrow_mut().clear(); +} + +/// Flushes all live recordings against their surfaces. +/// +/// For each recording entry whose context handle is still alive, the recording +/// is executed against the associated canvas surface and the snapshot is +/// published. This must be called before any page-level paint (screencast, +/// screenshot, layout) that reads published canvas pixels. +pub(crate) fn flush_all_recordings<'s>(scope: &mut v8::PinScope<'s, '_>) { + let store = recording_store(scope); + let ids: Vec = { + let s = store.borrow(); + s.entries.keys().copied().collect() + }; + for id in ids { + let (context_obj, recording) = { + let s = store.borrow(); + let Some(entry) = s.entries.get(&id) else { + continue; + }; + let Some(context_obj) = entry._context.to_local(scope) else { + continue; + }; + (context_obj, entry.recording.clone()) + }; + // Check emptiness and release the recording borrow before calling + // canvas_like_dimensions(), which reads JS-visible width/height + // properties. A width getter that calls ctx.fillRect() once would + // trigger "RefCell already borrowed" and abort. + { + let rec = recording.borrow(); + if rec.is_empty() { + continue; + } + } + let Some(canvas) = super::backing_store::canvas_owner_from_context(scope, context_obj) + else { + recording.borrow_mut().clear(); + continue; + }; + let (width, height) = match super::backing_store::canvas_like_dimensions(scope, canvas) { + Some(dims) => dims, + None => { + recording.borrow_mut().clear(); + continue; + } + }; + let cell = super::backing_store::canvas_surface_cell(scope, canvas); + if !super::backing_store::materialize_surface(&cell, width, height) { + recording.borrow_mut().clear(); + continue; + } + { + let mut rec = recording.borrow_mut(); + let mut surface = cell.borrow_mut(); + let Some(surface) = surface.as_mut() else { + rec.clear(); + continue; + }; + let _ = rec.execute(surface); + rec.clear(); + } + super::backing_store::publish_canvas_snapshot(scope, canvas); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/state.rs b/moli-renderer-v8/src/context_bootstrap/canvas/state.rs index ca6c25d79..a94dede1d 100644 --- a/moli-renderer-v8/src/context_bootstrap/canvas/state.rs +++ b/moli-renderer-v8/src/context_bootstrap/canvas/state.rs @@ -5,8 +5,8 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; -use super::path::Canvas2dPathState; use crate::util::{get_private_value, set_private_value}; +use moli_canvas::path::CanvasPath; const PATH_STATE_SLOT: &str = "__moliCanvasPathState"; type StateStore = Rc>; @@ -19,13 +19,13 @@ struct CanvasStates { struct CanvasStateEntry { _context: v8::Weak, - state: Rc>, + state: Rc>, } pub(super) fn canvas_path_state<'s>( scope: &mut v8::PinScope<'s, '_>, context: v8::Local<'s, v8::Object>, -) -> Rc> { +) -> Rc> { let store = if let Some(store) = scope.get_slot::() { store.clone() } else { @@ -63,7 +63,7 @@ pub(super) fn canvas_path_state<'s>( } }), ); - let state = Rc::new(RefCell::new(Canvas2dPathState::default())); + let state = Rc::new(RefCell::new(CanvasPath::default())); store.borrow_mut().entries.insert( id, CanvasStateEntry { @@ -81,7 +81,7 @@ pub(super) fn reset_canvas_path_state<'s>( context: v8::Local<'s, v8::Object>, ) { if get_private_value(scope, context, PATH_STATE_SLOT).is_some() { - *canvas_path_state(scope, context).borrow_mut() = Canvas2dPathState::default(); + *canvas_path_state(scope, context).borrow_mut() = CanvasPath::default(); } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs b/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs index 8e3f536b4..5adc87724 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/canvas_resources.rs @@ -60,6 +60,10 @@ impl CanvasResourceStore { fn elements(&self) -> impl Iterator + '_ { self.pixels_by_element.keys().copied() } + + fn touch(&mut self, _element: DomHandle) { + self.visual_generation.bump(); + } } impl Default for CanvasResourceStore { @@ -89,6 +93,10 @@ impl super::JsContextHost { self.canvas_resources.remove(element) } + pub(crate) fn touch_canvas_visual_generation(&mut self, element: DomHandle) { + self.canvas_resources.touch(element); + } + pub(crate) fn canvas_pixels_for_layout( &self, element: DomHandle, @@ -135,4 +143,17 @@ mod tests { assert_eq!(store.retained_bytes, 0); assert!(!store.remove(element)); } + + #[test] + fn touch_advances_generation_for_unseen_canvas() { + let mut store = CanvasResourceStore::default(); + let before = store.visual_generation.current(); + let element = DomHandle::new(99); + store.touch(element); + let after = store.visual_generation.current(); + assert!( + after > before, + "touch() must bump generation even when canvas has no published snapshot" + ); + } } diff --git a/moli-renderer-v8/src/runtime/page_screenshot.rs b/moli-renderer-v8/src/runtime/page_screenshot.rs index 3d7efd78d..e79992c9a 100644 --- a/moli-renderer-v8/src/runtime/page_screenshot.rs +++ b/moli-renderer-v8/src/runtime/page_screenshot.rs @@ -189,6 +189,12 @@ impl PageVm { RendererScreenshotPurpose::Print { .. } => moli_action_window::ActionBarrier::Explicit, }; self.flush_page_action_window(barrier)?; + // Flush pending canvas 2D recordings so the paint pass observes + // freshly-published backing store pixels. + let _ = self.vm_mut().with_default_context_scope(|scope, _| { + crate::context_bootstrap::flush_all_recordings(scope); + Ok(()) + }); let paint_capture = request.paint_capture_request()?; let restore_media = if matches!(request.purpose, RendererScreenshotPurpose::Print { .. }) && self.emulated_media.media.is_none() @@ -238,6 +244,12 @@ impl PageVm { before_layout: impl FnOnce(), ) -> anyhow::Result { self.flush_page_action_window(moli_action_window::ActionBarrier::Screencast)?; + // Flush pending canvas 2D recordings so the visual-state token and the + // paint pass observe freshly-published backing store pixels. + let _ = self.vm_mut().with_default_context_scope(|scope, _| { + crate::context_bootstrap::flush_all_recordings(scope); + Ok(()) + }); if self.layout_policy == LayoutPolicy::Mock { return Ok(RendererCaptureScreencastFrameReply::LayoutDisabled); } diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 346dc071a..a8c41c5c3 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -2516,6 +2516,12 @@ impl ScriptVm { &mut moli_layout::LayoutPassResult, ) -> Result, ) -> Result, moli_layout::LayoutError> { + // Flush any pending canvas 2D recording batches so the layout pass + // sees up-to-date backing store pixels (screencast, screenshot, drawImage). + let _ = self.with_default_context_scope(|scope, _| { + crate::context_bootstrap::flush_all_recordings(scope); + Ok(()) + }); // Font-source reconciliation is a pre-pass lifecycle step. CSS image // URLs come back from the actual box-construction traversal below. // Once the guard is entered, layout performs no JS, event-loop, diff --git a/moli-renderer-v8/src/script_vm/context_scope.rs b/moli-renderer-v8/src/script_vm/context_scope.rs index 6fcd6b6e8..ca8b81286 100644 --- a/moli-renderer-v8/src/script_vm/context_scope.rs +++ b/moli-renderer-v8/src/script_vm/context_scope.rs @@ -57,7 +57,7 @@ impl ScriptVm { /// Like `with_context_scope_by_ptr`, this is deliberately body-only. Its /// name must not imply that returning from the Rust closure completes an /// HTML task or a protocol command. - pub(super) fn with_default_context_scope( + pub(crate) fn with_default_context_scope( &mut self, op: impl FnOnce(&mut v8::PinScope<'_, '_>, *mut JsContextHost) -> Result, ) -> Result { diff --git a/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs b/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs index d92619085..963b00e2b 100644 --- a/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs +++ b/moli-renderer-v8/src/script_vm/tests/canvas_arguments.rs @@ -175,3 +175,24 @@ fn canvas_hex_alpha_is_accepted_and_preserved_for_fill_and_stroke() { ); } } + +#[test] +fn canvas_global_alpha_scales_only_alpha_not_rgb_for_fill_stroke_stroke_rect() { + // A straight red path at globalAlpha 0.5 must keep RGB at (255,0,0) and + // halve only the alpha. Previously the RGB channels were also scaled, + // reading back [128,0,0,128] instead of [255,0,0,128]. + check( + r#" + ctx.fillStyle='red';ctx.strokeStyle='red';ctx.globalAlpha=0.5; + ctx.rect(0,0,10,10);ctx.fill();ctx.beginPath(); + ctx.lineWidth=2;ctx.moveTo(20,20);ctx.lineTo(40,20);ctx.stroke(); + ctx.strokeRect(60,5,20,20); + return JSON.stringify([ + Array.from(ctx.getImageData(5,5,1,1).data), + Array.from(ctx.getImageData(25,20,1,1).data), + Array.from(ctx.getImageData(60,12,1,1).data) + ]); + "#, + "[[255,0,0,128],[255,0,0,128],[255,0,0,128]]", + ); +}