From bbc301fe9854d5c8cf34ce2d11ddef5c720b9847 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 5 Apr 2026 00:19:46 +0900 Subject: [PATCH 1/3] feat(canvas): add HTML+CSS embed renderer with Chromium-aligned architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a self-contained `htmlcss` rendering engine that converts HTML+CSS to Skia Pictures for opaque embedding on the canvas (HTMLEmbedNode). ## Architecture Three-phase pipeline inspired by Chromium's Blink renderer: 1. **Collect** (Stylo DOM → StyledElement tree) — no Skia objects 2. **Layout** (StyledElement → Taffy → LayoutBox tree) — block/flex/grid 3. **Paint** (LayoutBox → Skia Picture) — backgrounds, borders, text ## HTMLEmbedNode New node type for rendering HTML as an opaque picture on the canvas. Registered across all cg match arms (schema, painter, layout, geometry, resources, scene_graph, cost_prediction, compositor). ## CSS Support - **Layout**: block (margin collapsing), flex, grid via Taffy; subpixel precision (disable_rounding) - **Box model**: width/height/min/max, padding, margin, border (all sides, solid/dashed/dotted), per-corner elliptical border-radius - **Background**: solid color, linear/radial/conic gradients, multi-layer - **Text**: font properties, text-decoration (bitfield — simultaneous underline + line-through), text-align, text-transform, white-space, letter/word-spacing - **Inline elements**: merged into single Paragraph with per-run styles; OpenBox/CloseBox placeholders for padding/border spacing (Chromium kOpenTag/kCloseTag model); InlineBoxDecoration with background, border-radius, border via get_rects_for_range() - **Lists**: ul/ol with disc/circle/square/decimal/lower-alpha/upper-alpha markers, nested list counters - **Effects**: opacity, visibility, overflow clipping, box-shadow (outer), mix-blend-mode ## grida-dev - Embed/Convert dialog (rfd) for HTML file drops - Resize support for HTMLEmbedNode - Content-height measurement via measure_content_height() ## Documentation - `docs/wg/feat-2d/htmlcss.md` — feature doc with full CSS property table - `docs/wg/research/chromium/blink-rendering-pipeline.md` — Chromium research: ComputedStyle groups, LayoutNG, inline layout, paint phases, list markers Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 558 ++++++++- crates/csscascade/src/cascade.rs | 2 + .../grida-canvas/examples/golden_htmlcss.rs | 111 ++ crates/grida-canvas/examples/tool_io_grida.rs | 1 + crates/grida-canvas/examples/tool_io_svg.rs | 1 + .../src/cache/compositor/promotion.rs | 2 + crates/grida-canvas/src/htmlcss/collect.rs | 1103 +++++++++++++++++ crates/grida-canvas/src/htmlcss/layout.rs | 524 ++++++++ crates/grida-canvas/src/htmlcss/mod.rs | 213 ++++ crates/grida-canvas/src/htmlcss/paint.rs | 579 +++++++++ crates/grida-canvas/src/htmlcss/style.rs | 449 +++++++ crates/grida-canvas/src/htmlcss/types.rs | 214 ++++ crates/grida-canvas/src/layout/engine.rs | 2 + crates/grida-canvas/src/layout/into_taffy.rs | 15 + crates/grida-canvas/src/lib.rs | 1 + crates/grida-canvas/src/node/factory.rs | 20 + crates/grida-canvas/src/node/scene_graph.rs | 12 + crates/grida-canvas/src/node/schema.rs | 79 ++ crates/grida-canvas/src/painter/geometry.rs | 14 + crates/grida-canvas/src/painter/layer.rs | 58 + crates/grida-canvas/src/painter/painter.rs | 89 ++ .../src/painter/painter_debug_node.rs | 3 + crates/grida-canvas/src/resources/mod.rs | 3 + .../src/runtime/cost_prediction.rs | 1 + crates/grida-canvas/src/runtime/scene.rs | 1 + crates/grida-dev/Cargo.toml | 1 + crates/grida-dev/src/bench/load_bench.rs | 1 + crates/grida-dev/src/editor/mutation.rs | 3 + crates/grida-dev/src/main.rs | 69 +- docs/wg/feat-2d/htmlcss.md | 202 +++ .../chromium/blink-rendering-pipeline.md | 301 +++++ docs/wg/research/chromium/index.md | 1 + .../test-html/L0/text-inline-elements.html | 73 +- fixtures/test-html/L0/text-lists.html | 119 ++ 34 files changed, 4763 insertions(+), 62 deletions(-) create mode 100644 crates/grida-canvas/examples/golden_htmlcss.rs create mode 100644 crates/grida-canvas/src/htmlcss/collect.rs create mode 100644 crates/grida-canvas/src/htmlcss/layout.rs create mode 100644 crates/grida-canvas/src/htmlcss/mod.rs create mode 100644 crates/grida-canvas/src/htmlcss/paint.rs create mode 100644 crates/grida-canvas/src/htmlcss/style.rs create mode 100644 crates/grida-canvas/src/htmlcss/types.rs create mode 100644 docs/wg/feat-2d/htmlcss.md create mode 100644 docs/wg/research/chromium/blink-rendering-pipeline.md create mode 100644 fixtures/test-html/L0/text-lists.html diff --git a/Cargo.lock b/Cargo.lock index ad7eade98f..b04cc5f9ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,6 +221,170 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.0.7", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.0.7", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.0.7", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.88" @@ -359,6 +523,28 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2 0.6.1", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "built" version = "0.7.7" @@ -928,6 +1114,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", + "libc", "objc2 0.6.1", ] @@ -1011,6 +1199,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "equator" version = "0.4.2" @@ -1063,6 +1278,27 @@ dependencies = [ "serde", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.0" @@ -1311,6 +1547,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1542,6 +1791,7 @@ dependencies = [ "math2", "raw-window-handle", "reqwest", + "rfd", "serde", "serde_json", "skia-safe", @@ -1617,6 +1867,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "html5ever" version = "0.36.1" @@ -2310,6 +2566,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2406,6 +2671,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -2551,7 +2829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -2567,6 +2845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", "objc2 0.6.1", "objc2-core-foundation", "objc2-core-graphics", @@ -2580,7 +2859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2592,7 +2871,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2604,7 +2883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2639,7 +2918,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2651,7 +2930,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2670,7 +2949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -2704,7 +2983,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -2717,7 +2996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2729,7 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2752,7 +3031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -2772,7 +3051,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2784,7 +3063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.9.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2870,6 +3149,16 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.0" @@ -2879,6 +3168,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3005,6 +3300,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3067,6 +3373,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -3505,6 +3817,30 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.1", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.1", + "objc2-app-kit 0.3.1", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rgb" version = "0.8.52" @@ -3794,6 +4130,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3839,6 +4186,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -4670,9 +5027,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "tracing-core" version = "0.1.33" @@ -4709,6 +5078,17 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "uluru" version = "3.1.0" @@ -4796,6 +5176,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.0.1" @@ -4840,6 +5226,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -5136,6 +5533,22 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -5145,6 +5558,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.1.1" @@ -5213,6 +5632,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -5465,7 +5893,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.9.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", @@ -5640,6 +6068,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.101", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.25" @@ -5785,3 +6274,44 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.101", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.101", + "winnow 0.7.13", +] diff --git a/crates/csscascade/src/cascade.rs b/crates/csscascade/src/cascade.rs index 7d1f72e036..69bf63b7a9 100644 --- a/crates/csscascade/src/cascade.rs +++ b/crates/csscascade/src/cascade.rs @@ -105,6 +105,8 @@ h6 { margin-block: 2.33em; } dd { margin-inline-start: 40px; } blockquote, figure { margin-inline: 40px; } ul, ol, menu { margin-block: 1em; padding-inline-start: 40px; } +ul, menu { list-style-type: disc; } +ol { list-style-type: decimal; } /* ---- headings ---- */ h1 { font-size: 2em; font-weight: bold; } diff --git a/crates/grida-canvas/examples/golden_htmlcss.rs b/crates/grida-canvas/examples/golden_htmlcss.rs new file mode 100644 index 0000000000..bd88bc888a --- /dev/null +++ b/crates/grida-canvas/examples/golden_htmlcss.rs @@ -0,0 +1,111 @@ +/// HTML+CSS renderer golden test tool. +/// +/// Renders HTML files to PNG for visual inspection. Output goes to a +/// temporary directory (printed to stderr) so generated images don't +/// bloat the repository. +/// +/// Usage: +/// cargo run -p cg --example golden_htmlcss -- [FILE_OR_DIR...] +/// +/// If no arguments given, renders built-in test fixtures. +/// If a directory is given, renders all .html/.htm files in it. +use cg::htmlcss; +use cg::resources::ByteStore; +use cg::runtime::font_repository::FontRepository; +use skia_safe::{surfaces, Color}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +fn fonts() -> FontRepository { + let mut repo = FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))); + repo.enable_system_fallback(); + repo +} + +fn render_to_png(html: &str, width: f32, name: &str, out_dir: &Path) { + let fonts = fonts(); + let picture = htmlcss::render(html, width, 600.0, &fonts).expect("render failed"); + let cull = picture.cull_rect(); + let w = cull.width().max(1.0) as i32; + let h = cull.height().max(1.0) as i32; + + let mut surface = surfaces::raster_n32_premul((w, h)).expect("surface"); + let canvas = surface.canvas(); + canvas.clear(Color::WHITE); + canvas.draw_picture(&picture, None, None); + + let image = surface.image_snapshot(); + let data = image + .encode(None, skia_safe::EncodedImageFormat::PNG, None) + .unwrap(); + let path = out_dir.join(format!("{name}.png")); + std::fs::write(&path, data.as_bytes()).unwrap(); + eprintln!(" {name}: {w}x{h} → {}", path.display()); +} + +fn render_html_file(path: &Path, out_dir: &Path) { + let html = std::fs::read_to_string(path).expect("failed to read HTML file"); + let name = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + render_to_png(&html, 600.0, &name, out_dir); +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + + // Output to system temp directory + let out_dir = std::env::temp_dir().join("grida-htmlcss-goldens"); + std::fs::create_dir_all(&out_dir).expect("failed to create output directory"); + eprintln!("Output: {}", out_dir.display()); + + if args.is_empty() { + // Render built-in test fixtures from fixtures/test-html/L0/ + let fixture_dir = PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/test-html/L0" + )); + if fixture_dir.is_dir() { + render_directory(&fixture_dir, &out_dir); + } else { + eprintln!("No fixture directory found at {}", fixture_dir.display()); + eprintln!("Pass HTML files as arguments instead."); + } + } else { + for arg in &args { + let path = PathBuf::from(arg); + if path.is_dir() { + render_directory(&path, &out_dir); + } else if path.is_file() { + render_html_file(&path, &out_dir); + } else { + eprintln!("Skipping {}: not a file or directory", path.display()); + } + } + } + + eprintln!("Done. Files in: {}", out_dir.display()); +} + +fn render_directory(dir: &Path, out_dir: &Path) { + let mut entries: Vec = std::fs::read_dir(dir) + .expect("failed to read directory") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| { + p.extension() + .map(|ext| ext == "html" || ext == "htm") + .unwrap_or(false) + }) + .collect(); + entries.sort(); + + eprintln!( + "Rendering {} HTML files from {}", + entries.len(), + dir.display() + ); + for path in &entries { + render_html_file(path, out_dir); + } +} diff --git a/crates/grida-canvas/examples/tool_io_grida.rs b/crates/grida-canvas/examples/tool_io_grida.rs index 4dfa2ddf9c..0855eca7a7 100644 --- a/crates/grida-canvas/examples/tool_io_grida.rs +++ b/crates/grida-canvas/examples/tool_io_grida.rs @@ -466,5 +466,6 @@ fn classify_node(node: &Node) -> &'static str { Node::AttributedText(_) => "attributed_text", Node::Error(_) => "error", Node::Markdown(_) => "markdown", + Node::HTMLEmbed(_) => "html_embed", } } diff --git a/crates/grida-canvas/examples/tool_io_svg.rs b/crates/grida-canvas/examples/tool_io_svg.rs index a02cd78ac9..16afbb7e0e 100644 --- a/crates/grida-canvas/examples/tool_io_svg.rs +++ b/crates/grida-canvas/examples/tool_io_svg.rs @@ -241,6 +241,7 @@ fn classify_node(node: &Node) -> &'static str { Node::Tray(_) => "tray", Node::Error(_) => "error", Node::Markdown(_) => "markdown", + Node::HTMLEmbed(_) => "html_embed", } } diff --git a/crates/grida-canvas/src/cache/compositor/promotion.rs b/crates/grida-canvas/src/cache/compositor/promotion.rs index fea7dc183b..24391ceb81 100644 --- a/crates/grida-canvas/src/cache/compositor/promotion.rs +++ b/crates/grida-canvas/src/cache/compositor/promotion.rs @@ -99,6 +99,7 @@ fn has_expensive_effects(layer: &PainterPictureLayer) -> bool { PainterPictureLayer::Text(text) => &text.effects, PainterPictureLayer::Vector(vec) => &vec.effects, PainterPictureLayer::Markdown(md) => &md.effects, + PainterPictureLayer::HtmlEmbed(h) => &h.effects, }; effects.has_expensive_effects() } @@ -112,6 +113,7 @@ fn has_context_dependent_effects(layer: &PainterPictureLayer) -> bool { PainterPictureLayer::Text(text) => &text.effects, PainterPictureLayer::Vector(vec) => &vec.effects, PainterPictureLayer::Markdown(md) => &md.effects, + PainterPictureLayer::HtmlEmbed(h) => &h.effects, }; effects.backdrop_blur.as_ref().is_some_and(|b| b.active) || effects.glass.as_ref().is_some_and(|g| g.active) diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs new file mode 100644 index 0000000000..3044212f7e --- /dev/null +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -0,0 +1,1103 @@ +//! Phase 1: Walk Stylo-styled DOM → `StyledElement` tree. +//! +//! Extracts resolved CSS properties from `ComputedValues` into plain Rust +//! structs. **No Skia objects are created here** — Stylo's global DOM slot +//! corrupts Skia objects built while `borrow_data()` borrows are active. + +use crate::cg::prelude::*; +// Re-import our own GradientStop over cg's +use super::style::GradientStop; + +use csscascade::adapter::{self, HtmlElement}; +use csscascade::dom::{DemoDom, DemoNodeData}; + +use style::color::{AbsoluteColor, ColorSpace}; +use style::dom::TElement; +use style::properties::ComputedValues; +use style::values::generics::font::LineHeight as StyloLineHeight; +use style::values::generics::length::GenericSize; +use style::values::specified::text::TextDecorationLine as StyloTextDecorationLine; + +use super::style::*; +use super::types; +use super::types::{CssLength, LineHeight, WhiteSpace}; + +/// Parse HTML, resolve CSS via Stylo, and build a `StyledElement` tree. +pub fn collect_styled_tree(html: &str) -> Result, String> { + use csscascade::cascade::CascadeDriver; + use style::thread_state::{self, ThreadState}; + + let _ = thread_state::initialize(ThreadState::LAYOUT); + + let dom = + DemoDom::parse_from_bytes(html.as_bytes()).map_err(|e| format!("HTML parse error: {e}"))?; + let mut driver = CascadeDriver::new(&dom); + let document = adapter::bootstrap_dom(dom); + driver.flush(document); + let _styled_count = driver.style_document(document); + + let root = document.root_element().map(|el| collect_element(el)); + Ok(root) +} + +// ─── Element collection ────────────────────────────────────────────── + +/// Ordinal counter state for ordered lists. +/// Mirrors Chromium's `ListItemOrdinal` which tracks per-item values. +struct ListCounter { + value: i32, +} + +/// Generate marker text for a list item. +/// +/// Mirrors Chromium's `ListMarker::MarkerText()` which uses `CounterStyle` +/// to produce the prefix (bullet character or formatted number). +fn generate_marker_text(lst: &T, ordinal: i32) -> Option { + // Stylo's ListStyleType wraps the property enum. + // Use debug format to identify the type since the enum may be generated. + // Stylo's servo-mode ListStyleType is a keyword enum. + // Use Debug format to match variants since the type is generated. + // + // Supported by Stylo (servo): disc, none, circle, square, decimal, + // lower-alpha, upper-alpha, disclosure-open, disclosure-closed, and + // various CJK/Indic scripts. + // + // NOT supported by Stylo (servo): lower-roman, upper-roman. + // These parse as invalid and fall back to `disc`. + let debug = format!("{:?}", lst); + + if debug.contains("None") { + return None; + } + + // Symbol markers (Chromium: ListStyleCategory::kSymbol) + if debug.contains("Disc") { + return Some("\u{2022} ".to_string()); // • + } + if debug.contains("Circle") { + return Some("\u{25E6} ".to_string()); // ◦ + } + if debug.contains("Square") { + return Some("\u{25AA} ".to_string()); // ▪ + } + + // Ordinal markers (Chromium: ListStyleCategory::kLanguage) + if debug.contains("Decimal") { + return Some(format!("{}. ", ordinal)); + } + if debug.contains("LowerAlpha") { + if ordinal >= 1 && ordinal <= 26 { + let ch = (b'a' + (ordinal - 1) as u8) as char; + return Some(format!("{}. ", ch)); + } + return Some(format!("{}. ", ordinal)); + } + if debug.contains("UpperAlpha") { + if ordinal >= 1 && ordinal <= 26 { + let ch = (b'A' + (ordinal - 1) as u8) as char; + return Some(format!("{}. ", ch)); + } + return Some(format!("{}. ", ordinal)); + } + if debug.contains("LowerRoman") { + return Some(format!("{}. ", to_roman(ordinal).to_lowercase())); + } + if debug.contains("UpperRoman") { + return Some(format!("{}. ", to_roman(ordinal))); + } + + // Default fallback: disc bullet + Some("\u{2022} ".to_string()) +} + +/// Convert an integer to Roman numeral string. +fn to_roman(mut n: i32) -> String { + if n <= 0 { + return n.to_string(); + } + let values = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + let mut result = String::new(); + for &(val, sym) in &values { + while n >= val { + result.push_str(sym); + n -= val; + } + } + result +} + +fn collect_element(element: HtmlElement) -> StyledElement { + collect_element_with_counter(element, &mut None) +} + +fn collect_element_with_counter( + element: HtmlElement, + list_counter: &mut Option, +) -> StyledElement { + let tag = element.local_name_string(); + + let data = element.borrow_data(); + let style = data + .as_ref() + .map(|d| d.styles.primary().clone()) + .unwrap_or_else(|| panic!("Element {tag} has no style data")); + + let mut el = extract_style(&tag, &style); + + // Strip root element margins + if tag == "html" || tag == "body" { + el.margin = CssEdgeInsets { + top: CssLength::Px(0.0), + right: CssLength::Px(0.0), + bottom: CssLength::Px(0.0), + left: CssLength::Px(0.0), + }; + } + + // List marker generation (Chromium: ::marker pseudo-element) + // For display:list-item, generate marker text and prepend to content. + let display = style.clone_display(); + let is_list_item = display.is_list_item(); + + // Initialize counter for
    /
      elements + let mut child_counter: Option = if tag == "ol" { + // Check for start attribute via Stylo — defaults to 1 + // Stylo doesn't expose HTML attributes directly, but the UA stylesheet + // + author CSS handle `counter-reset`. We default to 1. + Some(ListCounter { value: 1 }) + } else if tag == "ul" || tag == "menu" { + Some(ListCounter { value: 0 }) // unordered, counter not used for numbering + } else { + None + }; + + // Use parent's counter if this is a list item + let marker_prefix = if is_list_item { + let list_style = style.get_list(); + let lst = list_style.clone_list_style_type(); + + // Get ordinal from parent counter + let ordinal = if let Some(ref mut counter) = list_counter { + let val = counter.value; + counter.value += 1; + val + } else { + 1 + }; + + generate_marker_text(&lst, ordinal) + } else { + None + }; + + // Collect children, merging consecutive inline content into InlineGroups + let dom = adapter::dom(); + let node_data = dom.node(element.node_id()); + + let mut pending_inline: Vec = Vec::new(); + let parent_text_align = el.font.text_align; + let parent_font = el.font.clone(); + let parent_color = el.color; + let parent_white_space = el.font.white_space; + + // Inject list marker as first inline content (Chromium: ::marker pseudo-element) + if let Some(marker) = marker_prefix { + pending_inline.push(InlineRunItem::Text(TextRun { + text: marker, + font: parent_font.clone(), + color: parent_color, + decoration: None, + })); + } + + for child_id in &node_data.children { + let child_node = dom.node(*child_id); + match &child_node.data { + DemoNodeData::Text(text) => { + let processed = process_whitespace(text, parent_white_space); + if !processed.is_empty() { + pending_inline.push(InlineRunItem::Text(TextRun { + text: processed, + font: parent_font.clone(), + color: parent_color, + decoration: None, + })); + } + } + DemoNodeData::Element(_) => { + let child_el = HtmlElement::from_node_id(*child_id); + let child = collect_element_with_counter(child_el, &mut child_counter); + if child.display == types::Display::None { + continue; + } + + if child.display == types::Display::Inline + || child.display == types::Display::InlineBlock + { + // Flatten inline element's content into the pending items + // (Chromium: InlineItemsBuilder flattens DOM → kOpenTag/kText/kCloseTag) + collect_inline_items(&child, &mut pending_inline); + } else { + // Block child — flush pending inline content first + flush_inline_group(&mut pending_inline, parent_text_align, &mut el.children); + el.children.push(StyledNode::Element(child)); + } + } + _ => {} // comments, doctypes + } + } + + // Flush any trailing inline content + flush_inline_group(&mut pending_inline, parent_text_align, &mut el.children); + + el +} + +/// Recursively flatten an inline element's content into text runs. +/// Collect inline items from an inline element, mirroring Chromium's +/// `InlineItemsBuilder` which flattens the DOM tree into a sequence of +/// `kOpenTag`, `kText`, `kCloseTag` items. +fn collect_inline_items(el: &StyledElement, items: &mut Vec) { + let deco = build_inline_decoration(el); + + // Chromium: HandleOpenTag() — emit inline-start spacing + if let Some(ref d) = deco { + let inline_start = d.padding_inline + d.border.map_or(0.0, |b| b.width); + items.push(InlineRunItem::OpenBox { + inline_size: inline_start, + decoration: d.clone(), + }); + } + + for child in &el.children { + match child { + StyledNode::Text(run) => { + items.push(InlineRunItem::Text(TextRun { + text: run.text.clone(), + font: el.font.clone(), + color: el.color, + decoration: None, // decoration is on the OpenBox/CloseBox, not the text + })); + } + StyledNode::Element(child_el) => { + collect_inline_items(child_el, items); + } + StyledNode::InlineGroup(group) => { + for item in &group.items { + match item { + InlineRunItem::Text(run) => { + items.push(InlineRunItem::Text(TextRun { + text: run.text.clone(), + font: el.font.clone(), + color: el.color, + decoration: None, + })); + } + other => items.push(other.clone()), + } + } + } + } + } + + // Chromium: HandleCloseTag() — emit inline-end spacing + if let Some(ref d) = deco { + let inline_end = d.padding_inline + d.border.map_or(0.0, |b| b.width); + items.push(InlineRunItem::CloseBox { + inline_size: inline_end, + }); + } +} + +/// Build an `InlineBoxDecoration` from an inline element's CSS properties. +/// +/// Mirrors Chromium's `ComputeOpenTagResult()` in `LineBreaker` which checks +/// `style.HasBorder() || style.MayHavePadding() || style.MayHaveMargin()` +/// for any inline element. This is purely CSS-driven — not tag-specific. +/// +/// Returns `None` if the element has no visual box decoration. +fn build_inline_decoration(el: &StyledElement) -> Option { + let bg = el.background.first().and_then(|l| match l { + BackgroundLayer::Solid(c) if c.a > 0 => Some(*c), + _ => None, + }); + + // Chromium: ComputeBordersForInline() — check if any border side has width > 0 + let has_border = el.border.top.width > 0.0 + || el.border.right.width > 0.0 + || el.border.bottom.width > 0.0 + || el.border.left.width > 0.0; + + // Simplified to uniform border (CSS inline borders are typically uniform + // for , etc.) + let border = if has_border { + let side = if el.border.top.width > 0.0 { + el.border.top + } else if el.border.left.width > 0.0 { + el.border.left + } else if el.border.bottom.width > 0.0 { + el.border.bottom + } else { + el.border.right + }; + Some(side) + } else { + None + }; + + let radius = el.border_radius.max_radius(); + + // Chromium: ComputeLinePadding() → inline/block axis padding + let padding_inline = el.padding.left.max(el.padding.right); + let padding_block = el.padding.top.max(el.padding.bottom); + + // Chromium: style.HasBorder() || style.MayHavePadding() + // (we skip margin check — inline margins are rare and not yet supported) + let has_decoration = bg.is_some() + || border.is_some() + || radius > 0.0 + || padding_inline > 0.0 + || padding_block > 0.0; + + if !has_decoration { + return None; + } + + Some(InlineBoxDecoration { + background: bg, + border, + border_radius: radius, + padding_inline, + padding_block, + }) +} + +/// Flush pending inline items into an InlineGroup node. +/// Drops groups that are whitespace-only (inter-element whitespace in block flow). +fn flush_inline_group( + pending: &mut Vec, + text_align: TextAlign, + children: &mut Vec, +) { + if pending.is_empty() { + return; + } + let items = std::mem::take(pending); + + // Skip whitespace-only groups — these are inter-element whitespace + // (e.g. "\n " between
      and

      ) that should not create a block. + let all_whitespace = items.iter().all(|item| match item { + InlineRunItem::Text(r) => r.text.trim().is_empty(), + InlineRunItem::OpenBox { .. } | InlineRunItem::CloseBox { .. } => false, + }); + if all_whitespace { + return; + } + + children.push(StyledNode::InlineGroup(InlineGroup { items, text_align })); +} + +// ─── CSS property extraction ───────────────────────────────────────── + +fn extract_style(tag: &str, style: &ComputedValues) -> StyledElement { + let mut el = StyledElement { + tag: tag.to_string(), + ..StyledElement::default() + }; + + // Display + let display = style.clone_display(); + el.display = if display.is_none() { + types::Display::None + } else { + match (display.outside(), display.inside()) { + ( + style::values::specified::box_::DisplayOutside::Inline, + style::values::specified::box_::DisplayInside::Flow, + ) => types::Display::Inline, + ( + style::values::specified::box_::DisplayOutside::Inline, + style::values::specified::box_::DisplayInside::FlowRoot, + ) => types::Display::InlineBlock, + (_, style::values::specified::box_::DisplayInside::Flex) => types::Display::Flex, + (_, style::values::specified::box_::DisplayInside::Grid) => types::Display::Grid, + (_, style::values::specified::box_::DisplayInside::Table) => types::Display::Table, + (_, style::values::specified::box_::DisplayInside::TableRow) => { + types::Display::TableRow + } + (_, style::values::specified::box_::DisplayInside::TableCell) => { + types::Display::TableCell + } + _ => types::Display::Block, + } + }; + + // Visibility + { + use style::properties::longhands::visibility::computed_value::T as StyloVis; + el.visibility = match style.clone_visibility() { + StyloVis::Visible => types::Visibility::Visible, + StyloVis::Hidden => types::Visibility::Hidden, + StyloVis::Collapse => types::Visibility::Collapse, + }; + } + + // Opacity + el.opacity = style.get_effects().opacity; + + // Overflow + let bx = style.get_box(); + el.overflow_x = map_overflow(bx.overflow_x); + el.overflow_y = map_overflow(bx.overflow_y); + + // Position + { + use style::properties::longhands::position::computed_value::T as StyloPos; + let pos_val = bx.clone_position(); + el.position = if pos_val.is_absolutely_positioned() { + types::Position::Absolute + } else if pos_val == StyloPos::Relative { + types::Position::Relative + } else if pos_val == StyloPos::Fixed { + types::Position::Fixed + } else { + types::Position::Static + }; + } + + // Margin (may be auto or %) + el.margin = extract_css_margin(style); + + // Padding (resolved to px) + el.padding = extract_padding(style); + + // Border + el.border = extract_border(style); + + // Border radius + el.border_radius = extract_border_radius(style); + + // Dimensions + let pos = style.get_position(); + el.width = extract_size(&pos.width); + el.height = extract_size(&pos.height); + el.min_width = extract_size(&pos.min_width); + el.min_height = extract_size(&pos.min_height); + el.max_width = extract_max_size(&pos.max_width); + el.max_height = extract_max_size(&pos.max_height); + + // Inset (for positioned elements) + el.inset = extract_inset(style); + + // Background + el.background = extract_background(style); + + // Text color (inherited) + el.color = abs_color_to_cg(&style.get_inherited_text().color); + + // Font properties (inherited) + el.font = extract_font(style); + + // Blend mode + el.blend_mode = extract_blend_mode(style); + + // Flex container + { + use style::properties::longhands::flex_direction::computed_value::T as FlexDir; + use style::properties::longhands::flex_wrap::computed_value::T as FlexWr; + + el.flex_direction = match style.clone_flex_direction() { + FlexDir::Row => types::FlexDirection::Row, + FlexDir::RowReverse => types::FlexDirection::RowReverse, + FlexDir::Column => types::FlexDirection::Column, + FlexDir::ColumnReverse => types::FlexDirection::ColumnReverse, + }; + el.flex_wrap = match style.clone_flex_wrap() { + FlexWr::Nowrap => types::FlexWrap::Nowrap, + FlexWr::Wrap => types::FlexWrap::Wrap, + FlexWr::WrapReverse => types::FlexWrap::WrapReverse, + }; + // align-items + let ai = style.clone_align_items(); + let ai_flags = ai.0.value(); + use style::values::specified::align::AlignFlags; + el.align_items = match ai_flags { + f if f == AlignFlags::CENTER => types::AlignItems::Center, + f if f == AlignFlags::FLEX_START || f == AlignFlags::START => types::AlignItems::Start, + f if f == AlignFlags::FLEX_END || f == AlignFlags::END => types::AlignItems::End, + f if f == AlignFlags::BASELINE => types::AlignItems::Baseline, + _ => types::AlignItems::Stretch, + }; + // justify-content + let jc = style.clone_justify_content(); + let jc_flags = jc.primary().value(); + el.justify_content = match jc_flags { + f if f == AlignFlags::CENTER => types::JustifyContent::Center, + f if f == AlignFlags::FLEX_START || f == AlignFlags::START => { + types::JustifyContent::Start + } + f if f == AlignFlags::FLEX_END || f == AlignFlags::END => types::JustifyContent::End, + f if f == AlignFlags::SPACE_BETWEEN => types::JustifyContent::SpaceBetween, + f if f == AlignFlags::SPACE_AROUND => types::JustifyContent::SpaceAround, + f if f == AlignFlags::SPACE_EVENLY => types::JustifyContent::SpaceEvenly, + _ => types::JustifyContent::Start, + }; + // gap + use style::values::generics::length::LengthPercentageOrNormal; + let gap_to_px = + |gap: &style::values::computed::length::NonNegativeLengthPercentageOrNormal| -> f32 { + match gap { + LengthPercentageOrNormal::Normal => 0.0, + LengthPercentageOrNormal::LengthPercentage(lp) => { + lp.0.to_length().map(|l| l.px()).unwrap_or(0.0) + } + } + }; + el.row_gap = gap_to_px(&pos.row_gap); + el.column_gap = gap_to_px(&pos.column_gap); + } + + // Flex child + el.flex_grow = style.clone_flex_grow().0; + el.flex_shrink = style.clone_flex_shrink().0; + + el +} + +// ─── Helpers ───────────────────────────────────────────────────────── + +fn extract_padding(style: &ComputedValues) -> EdgeInsets { + let p = style.get_padding(); + let lp = |lp: &style::values::computed::NonNegativeLengthPercentage| -> f32 { + lp.0.to_length().map(|l| l.px()).unwrap_or(0.0) + }; + EdgeInsets { + top: lp(&p.padding_top), + right: lp(&p.padding_right), + bottom: lp(&p.padding_bottom), + left: lp(&p.padding_left), + } +} + +fn extract_css_margin(style: &ComputedValues) -> CssEdgeInsets { + fn extract_side(v: style::values::computed::Margin) -> CssLength { + if v.is_auto() { + return CssLength::Auto; + } + match v { + style::values::computed::Margin::LengthPercentage(lp) => { + if let Some(len) = lp.to_length() { + CssLength::Px(len.px()) + } else { + CssLength::Px(0.0) + } + } + _ => CssLength::Px(0.0), + } + } + CssEdgeInsets { + top: extract_side(style.clone_margin_top()), + right: extract_side(style.clone_margin_right()), + bottom: extract_side(style.clone_margin_bottom()), + left: extract_side(style.clone_margin_left()), + } +} + +fn extract_border(style: &ComputedValues) -> BorderBox { + let b = style.get_border(); + + let extract_side_style = + |bs: style::values::specified::border::BorderStyle| -> types::BorderStyle { + use style::values::specified::border::BorderStyle as BS; + match bs { + BS::None => types::BorderStyle::None, + BS::Solid => types::BorderStyle::Solid, + BS::Dashed => types::BorderStyle::Dashed, + BS::Dotted => types::BorderStyle::Dotted, + BS::Double => types::BorderStyle::Double, + BS::Groove => types::BorderStyle::Groove, + BS::Ridge => types::BorderStyle::Ridge, + BS::Inset => types::BorderStyle::Inset, + BS::Outset => types::BorderStyle::Outset, + _ => types::BorderStyle::None, + } + }; + + let extract_color = |color: &style::values::computed::Color| -> CGColor { + color + .as_absolute() + .map(|abs| abs_color_to_cg(abs)) + .unwrap_or(CGColor::BLACK) + }; + + BorderBox { + top: BorderSide { + width: b.border_top_width.to_f32_px(), + color: extract_color(&style.clone_border_top_color()), + style: extract_side_style(b.border_top_style), + }, + right: BorderSide { + width: b.border_right_width.to_f32_px(), + color: extract_color(&style.clone_border_right_color()), + style: extract_side_style(b.border_right_style), + }, + bottom: BorderSide { + width: b.border_bottom_width.to_f32_px(), + color: extract_color(&style.clone_border_bottom_color()), + style: extract_side_style(b.border_bottom_style), + }, + left: BorderSide { + width: b.border_left_width.to_f32_px(), + color: extract_color(&style.clone_border_left_color()), + style: extract_side_style(b.border_left_style), + }, + } +} + +fn extract_border_radius(style: &ComputedValues) -> CornerRadii { + let b = style.get_border(); + let lp = |lp: &style::values::computed::NonNegativeLengthPercentage| -> f32 { + lp.0.to_length().map(|l| l.px()).unwrap_or(0.0) + }; + CornerRadii { + tl_x: lp(&b.border_top_left_radius.0.width), + tl_y: lp(&b.border_top_left_radius.0.height), + tr_x: lp(&b.border_top_right_radius.0.width), + tr_y: lp(&b.border_top_right_radius.0.height), + br_x: lp(&b.border_bottom_right_radius.0.width), + br_y: lp(&b.border_bottom_right_radius.0.height), + bl_x: lp(&b.border_bottom_left_radius.0.width), + bl_y: lp(&b.border_bottom_left_radius.0.height), + } +} + +fn extract_size( + size: &GenericSize, +) -> CssLength { + match size { + GenericSize::LengthPercentage(lp) => { + if let Some(len) = lp.0.to_length() { + CssLength::Px(len.px()) + } else { + CssLength::Auto + } + } + _ => CssLength::Auto, + } +} + +fn extract_max_size( + size: &style::values::generics::length::GenericMaxSize< + style::values::computed::NonNegativeLengthPercentage, + >, +) -> CssLength { + use style::values::generics::length::GenericMaxSize; + match size { + GenericMaxSize::LengthPercentage(lp) => { + if let Some(len) = lp.0.to_length() { + CssLength::Px(len.px()) + } else { + CssLength::Auto + } + } + _ => CssLength::Auto, // None, MaxContent, MinContent, FitContent, etc. + } +} + +fn extract_inset(_style: &ComputedValues) -> CssEdgeInsets { + // TODO: extract top/right/bottom/left inset for positioned elements + CssEdgeInsets::default() +} + +fn extract_background(style: &ComputedValues) -> Vec { + use style::values::generics::image::{GenericGradient, GenericImage}; + + let bg = style.get_background(); + let mut layers: Vec = Vec::new(); + + // 1. Background color (bottom layer) + if let Some(abs) = bg.background_color.as_absolute() { + let c = abs_color_to_cg(abs); + if c.a > 0 { + layers.push(BackgroundLayer::Solid(c)); + } + } + + // 2. Background image layers (gradients on top) + for image in bg.background_image.0.iter() { + match image { + GenericImage::Gradient(gradient) => match gradient.as_ref() { + GenericGradient::Linear { + direction, items, .. + } => { + let stops = gradient_items_to_stops(items); + if stops.is_empty() { + continue; + } + let angle_deg = extract_gradient_angle(direction); + layers.push(BackgroundLayer::LinearGradient(LinearGradient { + angle_deg, + stops, + })); + } + GenericGradient::Radial { items, .. } => { + let stops = gradient_items_to_stops(items); + if stops.is_empty() { + continue; + } + layers.push(BackgroundLayer::RadialGradient(RadialGradient { stops })); + } + GenericGradient::Conic { items, .. } => { + let stops = conic_gradient_items_to_stops(items); + if stops.is_empty() { + continue; + } + layers.push(BackgroundLayer::ConicGradient(ConicGradient { stops })); + } + }, + _ => {} + } + } + + layers +} + +/// Extract the CSS gradient angle in degrees. +/// +/// CSS gradient angles: 0deg = to top, 90deg = to right (clockwise). +/// `LineDirection::Corner(h, v)` gives the target corner — "to bottom left" +/// is `Corner(Left, Bottom)`. +fn extract_gradient_angle(direction: &style::values::computed::image::LineDirection) -> f32 { + use style::values::computed::image::LineDirection; + use style::values::specified::position::{HorizontalPositionKeyword, VerticalPositionKeyword}; + + match direction { + LineDirection::Angle(angle) => angle.degrees(), + LineDirection::Vertical(v) => match v { + VerticalPositionKeyword::Top => 0.0, + VerticalPositionKeyword::Bottom => 180.0, + }, + LineDirection::Horizontal(h) => match h { + HorizontalPositionKeyword::Left => 270.0, + HorizontalPositionKeyword::Right => 90.0, + }, + LineDirection::Corner(h, v) => { + // CSS corner gradients: map target corner to CSS angle + match (h, v) { + (HorizontalPositionKeyword::Right, VerticalPositionKeyword::Top) => 45.0, + (HorizontalPositionKeyword::Right, VerticalPositionKeyword::Bottom) => 135.0, + (HorizontalPositionKeyword::Left, VerticalPositionKeyword::Bottom) => 225.0, + (HorizontalPositionKeyword::Left, VerticalPositionKeyword::Top) => 315.0, + } + } + } +} + +/// Convert Stylo gradient items to GradientStops. +fn gradient_items_to_stops( + items: &[style::values::generics::image::GenericGradientItem< + style::values::computed::Color, + style::values::computed::LengthPercentage, + >], +) -> Vec { + use style::values::generics::image::GenericGradientItem; + + let mut raw: Vec<(Option, CGColor)> = Vec::new(); + for item in items { + match item { + GenericGradientItem::SimpleColorStop(color) => { + let c = color + .as_absolute() + .map(|a| abs_color_to_cg(a)) + .unwrap_or(CGColor::TRANSPARENT); + raw.push((None, c)); + } + GenericGradientItem::ComplexColorStop { color, position } => { + let offset = position.to_percentage().map(|p| p.0); + let c = color + .as_absolute() + .map(|a| abs_color_to_cg(a)) + .unwrap_or(CGColor::TRANSPARENT); + raw.push((offset, c)); + } + GenericGradientItem::InterpolationHint(_) => {} + } + } + auto_distribute_stops(&mut raw); + raw.into_iter() + .map(|(o, c)| GradientStop { + offset: o.unwrap_or(0.0), + color: c, + }) + .collect() +} + +/// Convert conic-gradient items to GradientStops. +fn conic_gradient_items_to_stops( + items: &[style::values::generics::image::GenericGradientItem< + style::values::computed::Color, + style::values::computed::AngleOrPercentage, + >], +) -> Vec { + use style::values::computed::AngleOrPercentage; + use style::values::generics::image::GenericGradientItem; + + let mut raw: Vec<(Option, CGColor)> = Vec::new(); + for item in items { + match item { + GenericGradientItem::SimpleColorStop(color) => { + let c = color + .as_absolute() + .map(|a| abs_color_to_cg(a)) + .unwrap_or(CGColor::TRANSPARENT); + raw.push((None, c)); + } + GenericGradientItem::ComplexColorStop { color, position } => { + let offset = match position { + AngleOrPercentage::Percentage(p) => Some(p.0), + AngleOrPercentage::Angle(a) => Some(a.degrees() / 360.0), + }; + let c = color + .as_absolute() + .map(|a| abs_color_to_cg(a)) + .unwrap_or(CGColor::TRANSPARENT); + raw.push((offset, c)); + } + GenericGradientItem::InterpolationHint(_) => {} + } + } + auto_distribute_stops(&mut raw); + raw.into_iter() + .map(|(o, c)| GradientStop { + offset: o.unwrap_or(0.0), + color: c, + }) + .collect() +} + +/// Auto-distribute gradient stop offsets (first=0, last=1, gaps interpolated). +fn auto_distribute_stops(raw: &mut [(Option, CGColor)]) { + let n = raw.len(); + if n == 0 { + return; + } + if raw[0].0.is_none() { + raw[0].0 = Some(0.0); + } + if raw[n - 1].0.is_none() { + raw[n - 1].0 = Some(1.0); + } + + let mut i = 0; + while i < n { + if raw[i].0.is_some() { + i += 1; + continue; + } + let start = i - 1; + let mut end = i + 1; + while end < n && raw[end].0.is_none() { + end += 1; + } + let s = raw[start].0.unwrap(); + let e = raw[end].0.unwrap(); + let count = (end - start) as f32; + for j in (start + 1)..end { + raw[j].0 = Some(s + (j - start) as f32 / count * (e - s)); + } + i = end + 1; + } +} + +fn extract_font(style: &ComputedValues) -> FontProps { + let font = style.get_font(); + let inherited_text = style.get_inherited_text(); + + let mut props = FontProps::default(); + + props.size = font.font_size.computed_size().px(); + props.weight = FontWeight(font.font_weight.value() as u32); + props.italic = font.font_style == style::values::computed::FontStyle::ITALIC; + + props.families = font + .font_family + .families + .iter() + .map(|f| { + use style::values::computed::font::SingleFontFamily; + match f { + SingleFontFamily::FamilyName(name) => name.name.to_string(), + SingleFontFamily::Generic(_) => "system-ui".to_string(), + } + }) + .collect(); + + props.line_height = match &font.line_height { + StyloLineHeight::Normal => LineHeight::Normal, + StyloLineHeight::Number(n) => LineHeight::Number(n.0), + StyloLineHeight::Length(len) => LineHeight::Px(len.0.px()), + }; + + // Letter/word spacing + if let Some(len) = inherited_text.letter_spacing.0.to_length() { + props.letter_spacing = len.px(); + } + props.word_spacing = inherited_text + .word_spacing + .to_length() + .map(|l| l.px()) + .unwrap_or(0.0); + + // Text align + use style::values::specified::text::TextAlignKeyword; + props.text_align = match inherited_text.text_align { + TextAlignKeyword::Start | TextAlignKeyword::Left | TextAlignKeyword::MozLeft => { + TextAlign::Left + } + TextAlignKeyword::End | TextAlignKeyword::Right | TextAlignKeyword::MozRight => { + TextAlign::Right + } + TextAlignKeyword::Center | TextAlignKeyword::MozCenter => TextAlign::Center, + TextAlignKeyword::Justify => TextAlign::Justify, + }; + + // Text transform + { + use style::values::specified::text::TextTransformCase; + let tt = style.clone_text_transform(); + let case = tt.case(); + props.text_transform = if case == TextTransformCase::Uppercase { + TextTransform::Uppercase + } else if case == TextTransformCase::Lowercase { + TextTransform::Lowercase + } else if case == TextTransformCase::Capitalize { + TextTransform::Capitalize + } else { + TextTransform::None + }; + } + + // Text decoration (bitfield — multiple can be active simultaneously) + let td_line = style.clone_text_decoration_line(); + props.decoration_underline = td_line.intersects(StyloTextDecorationLine::UNDERLINE); + props.decoration_overline = td_line.intersects(StyloTextDecorationLine::OVERLINE); + props.decoration_line_through = td_line.intersects(StyloTextDecorationLine::LINE_THROUGH); + + // White-space (decomposed into collapse + wrap in modern CSS/Stylo) + { + use style::properties::longhands::text_wrap_mode::computed_value::T as TWM; + use style::properties::longhands::white_space_collapse::computed_value::T as WSC; + let collapse = style.clone_white_space_collapse(); + let wrap = style.clone_text_wrap_mode(); + props.white_space = match (collapse, wrap) { + (WSC::Preserve, TWM::Nowrap) => WhiteSpace::Pre, + (WSC::Preserve, TWM::Wrap) => WhiteSpace::PreWrap, + (WSC::PreserveBreaks, TWM::Wrap) => WhiteSpace::PreLine, + (WSC::Collapse, TWM::Nowrap) => WhiteSpace::Nowrap, + _ => WhiteSpace::Normal, + }; + } + + props +} + +fn extract_blend_mode(style: &ComputedValues) -> BlendMode { + use style::properties::longhands::mix_blend_mode::computed_value::T as MixBlend; + match style.get_effects().mix_blend_mode { + MixBlend::Normal => BlendMode::Normal, + MixBlend::Multiply => BlendMode::Multiply, + MixBlend::Screen => BlendMode::Screen, + MixBlend::Overlay => BlendMode::Overlay, + MixBlend::Darken => BlendMode::Darken, + MixBlend::Lighten => BlendMode::Lighten, + MixBlend::ColorDodge => BlendMode::ColorDodge, + MixBlend::ColorBurn => BlendMode::ColorBurn, + MixBlend::HardLight => BlendMode::HardLight, + MixBlend::SoftLight => BlendMode::SoftLight, + MixBlend::Difference => BlendMode::Difference, + MixBlend::Exclusion => BlendMode::Exclusion, + MixBlend::Hue => BlendMode::Hue, + MixBlend::Saturation => BlendMode::Saturation, + MixBlend::Color => BlendMode::Color, + MixBlend::Luminosity => BlendMode::Luminosity, + _ => BlendMode::Normal, + } +} + +fn map_overflow(ov: style::values::specified::box_::Overflow) -> types::Overflow { + use style::values::specified::box_::Overflow as OV; + match ov { + OV::Visible => types::Overflow::Visible, + OV::Hidden => types::Overflow::Hidden, + OV::Scroll => types::Overflow::Scroll, + OV::Auto => types::Overflow::Auto, + OV::Clip => types::Overflow::Clip, + } +} + +fn abs_color_to_cg(color: &AbsoluteColor) -> CGColor { + let srgb = color.to_color_space(ColorSpace::Srgb); + CGColor::from_rgba( + (srgb.components.0.clamp(0.0, 1.0) * 255.0) as u8, + (srgb.components.1.clamp(0.0, 1.0) * 255.0) as u8, + (srgb.components.2.clamp(0.0, 1.0) * 255.0) as u8, + (srgb.alpha.clamp(0.0, 1.0) * 255.0) as u8, + ) +} + +fn process_whitespace(text: &str, ws: WhiteSpace) -> String { + match ws { + WhiteSpace::Pre | WhiteSpace::PreWrap => text.to_string(), + WhiteSpace::PreLine => { + // Collapse spaces/tabs but keep newlines + let mut result = String::with_capacity(text.len()); + let mut prev_was_space = false; + for ch in text.chars() { + if ch == '\n' { + result.push('\n'); + prev_was_space = false; + } else if ch.is_whitespace() { + if !prev_was_space { + result.push(' '); + prev_was_space = true; + } + } else { + result.push(ch); + prev_was_space = false; + } + } + result + } + _ => { + // Normal / Nowrap: collapse all whitespace + let mut result = String::with_capacity(text.len()); + let mut prev_was_space = false; + for ch in text.chars() { + if ch.is_whitespace() { + if !prev_was_space { + result.push(' '); + prev_was_space = true; + } + } else { + result.push(ch); + prev_was_space = false; + } + } + result + } + } +} diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs new file mode 100644 index 0000000000..88315416fe --- /dev/null +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -0,0 +1,524 @@ +//! Phase 2: `StyledElement` tree → Taffy layout → `LayoutBox` tree. +//! +//! Builds a Taffy tree from the styled IR, runs CSS layout (block flow, +//! flexbox, grid), and produces positioned boxes with resolved dimensions. + +use crate::cg::prelude::CGColor; +use crate::runtime::font_repository::FontRepository; + +use skia_safe::font_style; +use skia_safe::textlayout::{FontCollection, ParagraphBuilder, ParagraphStyle, TextStyle}; + +use taffy::style::{Dimension, LengthPercentage, LengthPercentageAuto}; +use taffy::{AvailableSpace, NodeId as TaffyNodeId, TaffyTree}; + +use super::style::*; +use super::types; + +// ─── Output types ──────────────────────────────────────────────────── + +/// A positioned box after layout. +#[derive(Debug)] +pub struct LayoutBox<'a> { + pub style: &'a StyledElement, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub children: Vec>, +} + +/// A positioned node — either a box, text, or inline group. +#[derive(Debug)] +pub enum LayoutNode<'a> { + Box(LayoutBox<'a>), + Text { + run: &'a TextRun, + x: f32, + y: f32, + width: f32, + }, + InlineGroup { + group: &'a InlineGroup, + x: f32, + y: f32, + width: f32, + }, +} + +// ─── Layout computation ────────────────────────────────────────────── + +/// Run layout on a `StyledElement` tree and produce a positioned `LayoutBox` tree. +pub fn compute_layout<'a>( + root: &'a StyledElement, + available_width: f32, + fonts: &FontRepository, +) -> LayoutBox<'a> { + let font_collection = fonts.font_collection(); + let mut taffy: TaffyTree = TaffyTree::new(); + // Disable rounding — subpixel precision avoids text wrapping artifacts + // where Taffy rounds a box width down by 1px, making text that barely + // fits on one line wrap to two lines. + taffy.disable_rounding(); + + // Build Taffy tree + let taffy_root = build_taffy_node(&mut taffy, root, font_collection); + + // Run layout with text measurement callback + let fc = font_collection.clone(); + let _ = taffy.compute_layout_with_measure( + taffy_root, + taffy::Size { + width: AvailableSpace::Definite(available_width), + height: AvailableSpace::MaxContent, + }, + |known_dimensions, available_space, _node_id, context, _style| { + text_measure_func(known_dimensions, available_space, context, &fc) + }, + ); + + // Extract results + extract_layout(&taffy, taffy_root, root, 0.0, 0.0) +} + +/// Compute just the content height (without building a full LayoutBox tree). +pub fn compute_content_height( + root: &StyledElement, + available_width: f32, + fonts: &FontRepository, +) -> f32 { + let font_collection = fonts.font_collection(); + let mut taffy: TaffyTree = TaffyTree::new(); + taffy.disable_rounding(); + let taffy_root = build_taffy_node(&mut taffy, root, font_collection); + let fc = font_collection.clone(); + let _ = taffy.compute_layout_with_measure( + taffy_root, + taffy::Size { + width: AvailableSpace::Definite(available_width), + height: AvailableSpace::MaxContent, + }, + |known_dimensions, available_space, _node_id, context, _style| { + text_measure_func(known_dimensions, available_space, context, &fc) + }, + ); + let layout = taffy.layout(taffy_root).unwrap(); + layout.size.height +} + +// ─── Taffy tree construction ───────────────────────────────────────── + +fn build_taffy_node( + taffy: &mut TaffyTree, + el: &StyledElement, + fonts: &FontCollection, +) -> TaffyNodeId { + let style = element_to_taffy_style(el); + + // Build child nodes + let mut child_ids: Vec = Vec::new(); + + for child in &el.children { + match child { + StyledNode::Element(child_el) => { + if child_el.display != types::Display::None { + child_ids.push(build_taffy_node(taffy, child_el, fonts)); + } + } + StyledNode::Text(run) => { + let leaf_style = taffy::Style { + display: taffy::Display::Block, + ..taffy::Style::default() + }; + let text_node = taffy + .new_leaf_with_context( + leaf_style, + TextMeasure { + items: vec![InlineRunItem::Text(run.clone())], + }, + ) + .unwrap(); + child_ids.push(text_node); + } + StyledNode::InlineGroup(group) => { + let leaf_style = taffy::Style { + display: taffy::Display::Block, + ..taffy::Style::default() + }; + let text_node = taffy + .new_leaf_with_context( + leaf_style, + TextMeasure { + items: group.items.clone(), + }, + ) + .unwrap(); + child_ids.push(text_node); + } + } + } + + taffy.new_with_children(style, &child_ids).unwrap() +} + +/// Taffy context for text/inline leaf nodes. Stores inline items so the +/// measure function can build a Skia Paragraph with placeholders at any +/// available width. +/// +/// Mirrors Chromium's `InlineNode::ItemsData()` — the flat list of items +/// used by `LineBreaker` for measurement and line breaking. +#[derive(Debug, Clone)] +struct TextMeasure { + items: Vec, +} + +/// Taffy measure callback — builds a Skia Paragraph at the given available +/// width and returns its intrinsic size. +fn text_measure_func( + known_dimensions: taffy::Size>, + available_space: taffy::Size, + context: Option<&mut TextMeasure>, + fonts: &FontCollection, +) -> taffy::Size { + let Some(ctx) = context else { + return taffy::Size::ZERO; + }; + + // If both dimensions are known, use them directly + if let (Some(w), Some(h)) = (known_dimensions.width, known_dimensions.height) { + return taffy::Size { + width: w, + height: h, + }; + } + + let max_width = match available_space.width { + AvailableSpace::Definite(w) => known_dimensions.width.unwrap_or(w), + AvailableSpace::MinContent => 0.0, + AvailableSpace::MaxContent => 100_000.0, + }; + + // Build Paragraph with placeholders for inline box spacing + // (Chromium: LineBreaker processes kOpenTag/kText/kCloseTag) + let ps = ParagraphStyle::new(); + let mut builder = ParagraphBuilder::new(&ps, fonts); + for item in &ctx.items { + match item { + InlineRunItem::Text(run) => { + let ts = build_skia_text_style(&run.font, &run.color); + builder.push_style(&ts); + builder.add_text(&run.text); + builder.pop(); + } + InlineRunItem::OpenBox { inline_size, .. } + | InlineRunItem::CloseBox { inline_size } => { + if *inline_size > 0.0 { + // Inject a placeholder that consumes inline space + // (Chromium: position_ += item_result->inline_size) + builder.add_placeholder(&skia_safe::textlayout::PlaceholderStyle::new( + *inline_size, + 0.01, // near-zero height — aligned to baseline + skia_safe::textlayout::PlaceholderAlignment::Baseline, + skia_safe::textlayout::TextBaseline::Alphabetic, + 0.0, + )); + } + } + } + } + let mut para = builder.build(); + para.layout(max_width); + + // Ceil intrinsic width to prevent subpixel-induced wrapping + let intrinsic_w = para.max_intrinsic_width().ceil(); + taffy::Size { + width: known_dimensions.width.unwrap_or(intrinsic_w.min(max_width)), + height: known_dimensions.height.unwrap_or(para.height()), + } +} + +/// Convert StyledElement to Taffy Style. +fn element_to_taffy_style(el: &StyledElement) -> taffy::Style { + taffy::Style { + display: match el.display { + types::Display::Flex => taffy::Display::Flex, + types::Display::Grid => taffy::Display::Grid, + types::Display::None => taffy::Display::None, + _ => taffy::Display::Block, + }, + position: match el.position { + types::Position::Absolute | types::Position::Fixed => taffy::Position::Absolute, + _ => taffy::Position::Relative, + }, + size: taffy::Size { + width: css_length_to_dim(el.width), + height: css_length_to_dim(el.height), + }, + min_size: taffy::Size { + width: css_length_to_dim(el.min_width), + height: css_length_to_dim(el.min_height), + }, + max_size: taffy::Size { + width: css_length_to_dim(el.max_width), + height: css_length_to_dim(el.max_height), + }, + margin: taffy::Rect { + top: css_length_to_lpa(el.margin.top), + right: css_length_to_lpa(el.margin.right), + bottom: css_length_to_lpa(el.margin.bottom), + left: css_length_to_lpa(el.margin.left), + }, + padding: taffy::Rect { + top: LengthPercentage::length(el.padding.top), + right: LengthPercentage::length(el.padding.right), + bottom: LengthPercentage::length(el.padding.bottom), + left: LengthPercentage::length(el.padding.left), + }, + border: taffy::Rect { + top: LengthPercentage::length(el.border.top.width), + right: LengthPercentage::length(el.border.right.width), + bottom: LengthPercentage::length(el.border.bottom.width), + left: LengthPercentage::length(el.border.left.width), + }, + inset: taffy::Rect { + top: css_length_to_lpa(el.inset.top), + right: css_length_to_lpa(el.inset.right), + bottom: css_length_to_lpa(el.inset.bottom), + left: css_length_to_lpa(el.inset.left), + }, + flex_direction: match el.flex_direction { + types::FlexDirection::Row => taffy::FlexDirection::Row, + types::FlexDirection::RowReverse => taffy::FlexDirection::RowReverse, + types::FlexDirection::Column => taffy::FlexDirection::Column, + types::FlexDirection::ColumnReverse => taffy::FlexDirection::ColumnReverse, + }, + flex_wrap: match el.flex_wrap { + types::FlexWrap::Nowrap => taffy::FlexWrap::NoWrap, + types::FlexWrap::Wrap => taffy::FlexWrap::Wrap, + types::FlexWrap::WrapReverse => taffy::FlexWrap::WrapReverse, + }, + align_items: Some(map_align_items(el.align_items)), + justify_content: Some(map_justify_content(el.justify_content)), + gap: taffy::Size { + width: LengthPercentage::length(el.column_gap), + height: LengthPercentage::length(el.row_gap), + }, + flex_grow: el.flex_grow, + flex_shrink: el.flex_shrink, + flex_basis: css_length_to_dim(el.flex_basis), + align_self: el.align_self.map(|a| match a { + types::AlignItems::Start => taffy::AlignSelf::FlexStart, + types::AlignItems::End => taffy::AlignSelf::FlexEnd, + types::AlignItems::Center => taffy::AlignSelf::Center, + types::AlignItems::Stretch => taffy::AlignSelf::Stretch, + types::AlignItems::Baseline => taffy::AlignSelf::Baseline, + }), + overflow: taffy::Point { + x: map_overflow(el.overflow_x), + y: map_overflow(el.overflow_y), + }, + ..taffy::Style::default() + } +} + +// ─── Conversion helpers ────────────────────────────────────────────── + +fn css_length_to_dim(len: types::CssLength) -> Dimension { + match len { + types::CssLength::Px(px) => Dimension::length(px), + types::CssLength::Percent(pct) => Dimension::percent(pct), + types::CssLength::Auto => Dimension::auto(), + } +} + +fn css_length_to_lpa(len: types::CssLength) -> LengthPercentageAuto { + match len { + types::CssLength::Px(px) => LengthPercentageAuto::length(px), + types::CssLength::Percent(pct) => LengthPercentageAuto::percent(pct), + types::CssLength::Auto => LengthPercentageAuto::auto(), + } +} + +fn map_align_items(a: types::AlignItems) -> taffy::AlignItems { + match a { + types::AlignItems::Start => taffy::AlignItems::FlexStart, + types::AlignItems::End => taffy::AlignItems::FlexEnd, + types::AlignItems::Center => taffy::AlignItems::Center, + types::AlignItems::Stretch => taffy::AlignItems::Stretch, + types::AlignItems::Baseline => taffy::AlignItems::Baseline, + } +} + +fn map_justify_content(j: types::JustifyContent) -> taffy::JustifyContent { + match j { + types::JustifyContent::Start => taffy::JustifyContent::FlexStart, + types::JustifyContent::End => taffy::JustifyContent::FlexEnd, + types::JustifyContent::Center => taffy::JustifyContent::Center, + types::JustifyContent::SpaceBetween => taffy::JustifyContent::SpaceBetween, + types::JustifyContent::SpaceAround => taffy::JustifyContent::SpaceAround, + types::JustifyContent::SpaceEvenly => taffy::JustifyContent::SpaceEvenly, + } +} + +fn map_overflow(ov: types::Overflow) -> taffy::Overflow { + match ov { + types::Overflow::Hidden | types::Overflow::Clip => taffy::Overflow::Clip, + types::Overflow::Scroll => taffy::Overflow::Scroll, + _ => taffy::Overflow::Visible, + } +} + +// ─── Layout extraction ─────────────────────────────────────────────── + +fn extract_layout<'a>( + taffy: &TaffyTree, + taffy_node: TaffyNodeId, + el: &'a StyledElement, + offset_x: f32, + offset_y: f32, +) -> LayoutBox<'a> { + let layout = taffy.layout(taffy_node).unwrap(); + let x = offset_x + layout.location.x; + let y = offset_y + layout.location.y; + let w = layout.size.width; + let h = layout.size.height; + + let taffy_children = taffy.children(taffy_node).unwrap(); + let mut children = Vec::new(); + + let mut child_idx = 0; + for styled_child in &el.children { + if child_idx >= taffy_children.len() { + break; + } + match styled_child { + StyledNode::Element(child_el) => { + if child_el.display == types::Display::None { + continue; + } + let child_layout = + extract_layout(taffy, taffy_children[child_idx], child_el, 0.0, 0.0); + children.push(LayoutNode::Box(child_layout)); + child_idx += 1; + } + StyledNode::Text(run) => { + let child_taffy = taffy.layout(taffy_children[child_idx]).unwrap(); + children.push(LayoutNode::Text { + run, + x: child_taffy.location.x, + y: child_taffy.location.y, + width: child_taffy.size.width, + }); + child_idx += 1; + } + StyledNode::InlineGroup(group) => { + let child_taffy = taffy.layout(taffy_children[child_idx]).unwrap(); + children.push(LayoutNode::InlineGroup { + group, + x: child_taffy.location.x, + y: child_taffy.location.y, + width: child_taffy.size.width, + }); + child_idx += 1; + } + } + } + + LayoutBox { + style: el, + x, + y, + width: w, + height: h, + children, + } +} + +// ─── Skia text style builder (for measurement) ─────────────────────── + +pub(crate) fn build_skia_text_style(font: &FontProps, color: &CGColor) -> TextStyle { + let mut ts = TextStyle::new(); + ts.set_font_size(font.size); + + let weight = font_style::Weight::from(font.weight.0 as i32); + let slant = if font.italic { + font_style::Slant::Italic + } else { + font_style::Slant::Upright + }; + ts.set_font_style(skia_safe::FontStyle::new( + weight, + font_style::Width::NORMAL, + slant, + )); + + // Map CSS generic families to platform-concrete names + let families: Vec = font + .families + .iter() + .map(|s| match s.as_str() { + "system-ui" | "-apple-system" | "BlinkMacSystemFont" => { + ".AppleSystemUIFont".to_string() + } + "sans-serif" => "Helvetica".to_string(), + "serif" => "Times".to_string(), + "monospace" => "Menlo".to_string(), + other => other.to_string(), + }) + .collect(); + let family_refs: Vec<&str> = families.iter().map(|s| s.as_str()).collect(); + if family_refs.is_empty() { + ts.set_font_families(&["Helvetica", "Arial"]); + } else { + ts.set_font_families(&family_refs); + } + + ts.set_color(skia_safe::Color::from_argb( + color.a, color.r, color.g, color.b, + )); + + match font.line_height { + types::LineHeight::Normal => { + ts.set_height_override(true); + ts.set_height(1.5); + } + types::LineHeight::Number(n) => { + ts.set_height_override(true); + ts.set_height(n); + } + types::LineHeight::Px(px) => { + ts.set_height_override(true); + ts.set_height(px / font.size); + } + } + + if font.letter_spacing != 0.0 { + ts.set_letter_spacing(font.letter_spacing); + } + if font.word_spacing != 0.0 { + ts.set_word_spacing(font.word_spacing); + } + + // Text decoration (bitfield — underline + line-through can both be active) + use skia_safe::textlayout; + let mut decoration = textlayout::TextDecoration::NO_DECORATION; + if font.decoration_underline { + decoration |= textlayout::TextDecoration::UNDERLINE; + } + if font.decoration_line_through { + decoration |= textlayout::TextDecoration::LINE_THROUGH; + } + if font.decoration_overline { + decoration |= textlayout::TextDecoration::OVERLINE; + } + if decoration != textlayout::TextDecoration::NO_DECORATION { + ts.set_decoration_type(decoration); + ts.set_decoration_style(textlayout::TextDecorationStyle::Solid); + ts.set_decoration_color(skia_safe::Color::from_argb( + color.a, color.r, color.g, color.b, + )); + ts.set_decoration_thickness_multiplier(1.0); + } + + ts +} diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs new file mode 100644 index 0000000000..627987b1af --- /dev/null +++ b/crates/grida-canvas/src/htmlcss/mod.rs @@ -0,0 +1,213 @@ +//! HTML+CSS → Skia Picture rendering engine. +//! +//! Three-phase pipeline: +//! 1. **Collect** (`collect.rs`) — Stylo DOM → `StyledElement` tree (plain Rust, no Skia) +//! 2. **Layout** (`layout.rs`) — `StyledElement` → Taffy → `LayoutBox` tree (positioned) +//! 3. **Paint** (`paint.rs`) — `LayoutBox` → Skia Picture +//! +//! # Thread Safety +//! +//! Uses Stylo's process-global DOM slot. Calls must be serialized externally. + +mod collect; +mod layout; +mod paint; +pub mod style; +pub mod types; + +use crate::runtime::font_repository::FontRepository; + +/// Render HTML+CSS to a Skia Picture. +pub fn render( + html: &str, + width: f32, + _height: f32, + fonts: &FontRepository, +) -> Result { + let root = collect::collect_styled_tree(html)?; + let Some(root) = root else { + // Empty document — return a minimal picture + let mut recorder = skia_safe::PictureRecorder::new(); + let bounds = skia_safe::Rect::from_wh(width, 1.0); + recorder.begin_recording(bounds, false); + return Ok(recorder + .finish_recording_as_picture(Some(&bounds)) + .expect("empty picture")); + }; + + let layout_root = layout::compute_layout(&root, width, fonts); + let content_height = layout_root.height; + + Ok(paint::paint_to_picture( + &layout_root, + width, + content_height, + fonts, + )) +} + +/// Measure the content height of HTML at the given width. +/// +/// Runs style resolution and Taffy layout but does not create a Skia Picture. +pub fn measure_content_height( + html: &str, + width: f32, + fonts: &FontRepository, +) -> Result { + let root = collect::collect_styled_tree(html)?; + let Some(root) = root else { + return Ok(0.0); + }; + Ok(layout::compute_content_height(&root, width, fonts)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resources::ByteStore; + use std::sync::{Arc, Mutex}; + + fn test_fonts() -> FontRepository { + FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))) + } + + #[test] + fn test_render_empty() { + let fonts = test_fonts(); + let pic = render("", 400.0, 300.0, &fonts); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_heading() { + let fonts = test_fonts(); + let pic = render("

      Hello

      ", 400.0, 300.0, &fonts).unwrap(); + assert!(pic.cull_rect().width() > 0.0); + } + + #[test] + fn test_render_with_style_block() { + let fonts = test_fonts(); + let pic = render( + "

      Blue

      ", + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_table() { + let fonts = test_fonts(); + let pic = render( + "
      AB
      ", + 600.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_flex() { + let fonts = test_fonts(); + let pic = render( + r#"
      A
      B
      "#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + #[test] + fn test_render_opacity() { + let fonts = test_fonts(); + let pic = render( + r#"

      Semi-transparent

      "#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + } + + /// Diagnostic: verify how Skia counts placeholder offsets for get_rects_for_range. + #[test] + fn test_placeholder_byte_offset() { + use skia_safe::textlayout::*; + + let mut fc = FontCollection::new(); + fc.set_default_font_manager(skia_safe::FontMgr::new(), None); + + let ps = ParagraphStyle::new(); + let mut builder = ParagraphBuilder::new(&ps, &fc); + let mut ts = TextStyle::new(); + ts.set_font_size(16.0); + ts.set_color(skia_safe::Color::BLACK); + + // Build: "abc" + placeholder(20px, 0.01 height) + "def" + builder.push_style(&ts); + builder.add_text("abc"); + builder.pop(); + builder.add_placeholder(&PlaceholderStyle::new( + 20.0, + 0.01, + PlaceholderAlignment::Baseline, + TextBaseline::Alphabetic, + 0.0, + )); + builder.push_style(&ts); + builder.add_text("def"); + builder.pop(); + + let mut para = builder.build(); + para.layout(500.0); + + // Verify: "def" starts at offset 4 (abc=3 + placeholder=1) + let rects_def = + para.get_rects_for_range(4..7, RectHeightStyle::Tight, RectWidthStyle::Tight); + assert!(!rects_def.is_empty(), "Should find rects for 'def' at 4..7"); + let def_left = rects_def[0].rect.left; + assert!( + def_left > 25.0, + "def should be after abc+placeholder, got left={def_left}" + ); + + // Verify: placeholder occupies 1 offset position + // offset 3 = the placeholder itself (zero-height rect) + let rects_placeholder = + para.get_rects_for_range(3..4, RectHeightStyle::Tight, RectWidthStyle::Tight); + assert!( + !rects_placeholder.is_empty(), + "Placeholder should have a rect at offset 3..4" + ); + let ph_height = rects_placeholder[0].rect.height(); + assert!( + ph_height < 1.0, + "Placeholder rect should have near-zero height, got {ph_height}" + ); + } + + #[test] + fn test_measure_height() { + let fonts = test_fonts(); + let h = measure_content_height("

      Hello

      ", 400.0, &fonts).unwrap(); + assert!(h > 0.0, "Content height should be positive, got {h}"); + } + + #[test] + fn test_head_hidden() { + let fonts = test_fonts(); + let pic = render( + r#"

      V

      "#, + 400.0, + 300.0, + &fonts, + ); + assert!(pic.is_ok()); + let h = pic.unwrap().cull_rect().height(); + assert!(h > 0.0, "With head should have height, got {h}"); + } +} diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs new file mode 100644 index 0000000000..416b224aaf --- /dev/null +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -0,0 +1,579 @@ +//! Phase 3: `LayoutBox` tree → Skia Picture. +//! +//! Paint order follows Chromium's phases (simplified): +//! 1. **Background** — box-shadow (outer), background-color, border, box-shadow (inset) +//! 2. **Children** — recurse into child boxes and inline content +//! 3. **Outline** — (not yet implemented) +//! +//! Opacity, clipping, and visibility are handled via canvas save/restore. + +use crate::cg::prelude::*; +use crate::runtime::font_repository::FontRepository; + +use skia_safe::textlayout::{self, FontCollection, ParagraphBuilder, ParagraphStyle}; +use skia_safe::{Canvas, ClipOp, Color, Paint, PaintStyle, PictureRecorder, Rect}; + +use super::layout::{build_skia_text_style, LayoutBox, LayoutNode}; +use super::style::{ + BackgroundLayer, BorderSide, ConicGradient, GradientStop, InlineBoxDecoration, InlineGroup, + InlineRunItem, LinearGradient, RadialGradient, StyledElement, TextRun, +}; +use super::types; + +/// Paint a `LayoutBox` tree into a Skia `Picture`. +pub fn paint_to_picture( + root: &LayoutBox, + width: f32, + height: f32, + fonts: &FontRepository, +) -> skia_safe::Picture { + let font_collection = fonts.font_collection(); + let mut recorder = PictureRecorder::new(); + let bounds = Rect::from_wh(width, height.max(1.0)); + let canvas = recorder.begin_recording(bounds, false); + + paint_box(canvas, root, font_collection); + + // Marker rect so Skia preserves the cull rect + { + let mut p = Paint::default(); + p.set_color(Color::TRANSPARENT); + canvas.draw_rect(Rect::from_wh(width, height.max(1.0)), &p); + } + + let cull = Rect::from_xywh(0.0, 0.0, width, height); + recorder + .finish_recording_as_picture(Some(&cull)) + .expect("Failed to finish recording HTML picture") +} + +// ─── Recursive box painter (Chromium: BoxFragmentPainter) ──────────── + +fn paint_box(canvas: &Canvas, layout: &LayoutBox, fonts: &FontCollection) { + let style = layout.style; + + // Visibility check (Chromium: early return in PaintObject) + if style.visibility == types::Visibility::Hidden + || style.visibility == types::Visibility::Collapse + { + return; + } + + let x = layout.x; + let y = layout.y; + let w = layout.width; + let h = layout.height; + + // ── Save state for opacity / clip ── + let needs_layer = style.opacity < 1.0; + let needs_clip = style.overflow_x != types::Overflow::Visible + || style.overflow_y != types::Overflow::Visible; + + canvas.save(); + canvas.translate((x, y)); + + if needs_layer { + let mut layer_paint = Paint::default(); + layer_paint.set_alpha((style.opacity * 255.0) as u8); + let bounds = Rect::from_xywh(0.0, 0.0, w, h); + let layer_rec = skia_safe::canvas::SaveLayerRec::default() + .paint(&layer_paint) + .bounds(&bounds); + canvas.save_layer(&layer_rec); + } + + if needs_clip { + canvas.clip_rect(Rect::from_xywh(0.0, 0.0, w, h), ClipOp::Intersect, true); + } + + // ── Phase 1: Background (Chromium: kBlockBackground) ── + // Order: outer box-shadow → background-color → border → inset box-shadow + paint_box_shadow_outer(canvas, style, w, h); + paint_background(canvas, style, w, h); + paint_borders(canvas, style, w, h); + paint_box_shadow_inset(canvas, style, w, h); + + // ── Phase 2: Children (Chromium: kForeground for inlines, recurse for blocks) ── + for child in &layout.children { + match child { + LayoutNode::Box(child_box) => { + paint_box(canvas, child_box, fonts); + } + LayoutNode::Text { + run, + x: tx, + y: ty, + width: tw, + } => { + paint_text(canvas, run, *tx, *ty, *tw, fonts); + } + LayoutNode::InlineGroup { + group, + x: gx, + y: gy, + width: gw, + } => { + paint_inline_group(canvas, group, *gx, *gy, *gw, fonts); + } + } + } + + // ── Restore ── + if needs_layer { + canvas.restore(); // layer + } + canvas.restore(); // translate +} + +// ─── Background painting (Chromium: BoxPainterBase::PaintFillLayers) ── + +fn paint_background(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { + if style.background.is_empty() { + return; + } + + let rect = Rect::from_xywh(0.0, 0.0, w, h); + let r = &style.border_radius; + + for layer in &style.background { + let mut paint = Paint::default(); + paint.set_style(PaintStyle::Fill); + paint.set_anti_alias(true); + + match layer { + BackgroundLayer::Solid(c) => { + if c.a == 0 { + continue; + } + paint.set_color(Color::from_argb(c.a, c.r, c.g, c.b)); + } + BackgroundLayer::LinearGradient(grad) => { + if let Some(shader) = build_linear_gradient_shader(grad, w, h) { + paint.set_shader(shader); + } else { + continue; + } + } + BackgroundLayer::RadialGradient(grad) => { + if let Some(shader) = build_radial_gradient_shader(grad, w, h) { + paint.set_shader(shader); + } else { + continue; + } + } + BackgroundLayer::ConicGradient(grad) => { + if let Some(shader) = build_conic_gradient_shader(grad, w, h) { + paint.set_shader(shader); + } else { + continue; + } + } + } + + if r.is_zero() { + canvas.draw_rect(rect, &paint); + } else { + let mut rrect = skia_safe::RRect::new(); + rrect.set_rect_radii(rect, &r.to_skia_radii()); + canvas.draw_rrect(rrect, &paint); + } + } +} + +// ─── Gradient shaders ──────────────────────────────────────────────── + +use skia_safe::gradient_shader::{Gradient, GradientColors, Interpolation}; +use skia_safe::scalar; + +fn build_gradient_data(stops: &[GradientStop]) -> (Vec, Vec) { + let colors: Vec = stops + .iter() + .map(|s| { + skia_safe::Color4f::new( + s.color.r as f32 / 255.0, + s.color.g as f32 / 255.0, + s.color.b as f32 / 255.0, + s.color.a as f32 / 255.0, + ) + }) + .collect(); + let positions: Vec = stops.iter().map(|s| s.offset).collect(); + (colors, positions) +} + +fn make_gradient<'a>(colors: &'a [skia_safe::Color4f], positions: &'a [f32]) -> Gradient<'a> { + Gradient::new( + GradientColors::new(colors, Some(positions), skia_safe::TileMode::Clamp, None), + Interpolation::default(), + ) +} + +fn build_linear_gradient_shader( + grad: &LinearGradient, + w: f32, + h: f32, +) -> Option { + let (colors, positions) = build_gradient_data(&grad.stops); + if colors.len() < 2 { + return None; + } + + // CSS: 0deg = to top, 90deg = to right. Convert to start/end points. + let rad = grad.angle_deg.to_radians(); + let sin = rad.sin(); + let cos = rad.cos(); + let cx = w / 2.0; + let cy = h / 2.0; + // Half-length covers the box diagonal + let half_len = (w * sin.abs() + h * cos.abs()) / 2.0; + let p1 = skia_safe::Point::new(cx - sin * half_len, cy + cos * half_len); + let p2 = skia_safe::Point::new(cx + sin * half_len, cy - cos * half_len); + + let gradient = make_gradient(&colors, &positions); + skia_safe::shaders::linear_gradient((p1, p2), &gradient, None) +} + +fn build_radial_gradient_shader( + grad: &RadialGradient, + w: f32, + h: f32, +) -> Option { + let (colors, positions) = build_gradient_data(&grad.stops); + if colors.len() < 2 { + return None; + } + + let matrix = skia_safe::Matrix::scale((w, h)); + let gradient = make_gradient(&colors, &positions); + skia_safe::shaders::radial_gradient( + (skia_safe::Point::new(0.5, 0.5), 0.5 as scalar), + &gradient, + Some(&matrix), + ) +} + +fn build_conic_gradient_shader(grad: &ConicGradient, w: f32, h: f32) -> Option { + let (colors, positions) = build_gradient_data(&grad.stops); + if colors.len() < 2 { + return None; + } + + let matrix = skia_safe::Matrix::scale((w, h)); + let gradient = make_gradient(&colors, &positions); + skia_safe::shaders::sweep_gradient( + skia_safe::Point::new(0.5, 0.5), + (0.0 as scalar, 360.0 as scalar), + &gradient, + Some(&matrix), + ) +} + +// ─── Border painting (Chromium: BoxBorderPainter::PaintBorder) ─────── + +fn paint_borders(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { + let b = &style.border; + + if b.top.width > 0.0 && b.top.style != types::BorderStyle::None { + let paint = border_paint(&b.top); + let by = b.top.width / 2.0; + canvas.draw_line((0.0, by), (w, by), &paint); + } + + if b.bottom.width > 0.0 && b.bottom.style != types::BorderStyle::None { + let paint = border_paint(&b.bottom); + let by = h - b.bottom.width / 2.0; + canvas.draw_line((0.0, by), (w, by), &paint); + } + + if b.left.width > 0.0 && b.left.style != types::BorderStyle::None { + let paint = border_paint(&b.left); + let bx = b.left.width / 2.0; + canvas.draw_line((bx, 0.0), (bx, h), &paint); + } + + if b.right.width > 0.0 && b.right.style != types::BorderStyle::None { + let paint = border_paint(&b.right); + let bx = w - b.right.width / 2.0; + canvas.draw_line((bx, 0.0), (bx, h), &paint); + } +} + +fn border_paint(side: &BorderSide) -> Paint { + let mut paint = Paint::default(); + paint.set_color(Color::from_argb( + side.color.a, + side.color.r, + side.color.g, + side.color.b, + )); + paint.set_stroke_width(side.width); + paint.set_style(PaintStyle::Stroke); + paint.set_anti_alias(true); + + match side.style { + types::BorderStyle::Dashed => { + let dash_len = side.width * 3.0; + if let Some(effect) = skia_safe::PathEffect::dash(&[dash_len, dash_len], 0.0) { + paint.set_path_effect(effect); + } + } + types::BorderStyle::Dotted => { + if let Some(effect) = skia_safe::PathEffect::dash(&[side.width, side.width], 0.0) { + paint.set_path_effect(effect); + } + paint.set_stroke_cap(skia_safe::paint::Cap::Round); + } + _ => {} + } + + paint +} + +// ─── Box shadow (Chromium: BoxPainterBase::PaintNormalBoxShadow / PaintInsetBoxShadow) ── + +fn paint_box_shadow_outer(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) { + for shadow in &style.box_shadow { + if shadow.inset { + continue; + } + let mut paint = Paint::default(); + paint.set_color(Color::from_argb( + shadow.color.a, + shadow.color.r, + shadow.color.g, + shadow.color.b, + )); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + if shadow.blur > 0.0 { + paint.set_mask_filter(skia_safe::MaskFilter::blur( + skia_safe::BlurStyle::Normal, + shadow.blur / 2.0, + false, + )); + } + + let shadow_rect = Rect::from_xywh( + shadow.offset_x - shadow.spread, + shadow.offset_y - shadow.spread, + w + shadow.spread * 2.0, + h + shadow.spread * 2.0, + ); + + let r = &style.border_radius; + if r.is_zero() { + canvas.draw_rect(shadow_rect, &paint); + } else { + let mut rrect = skia_safe::RRect::new(); + rrect.set_rect_radii(shadow_rect, &r.to_skia_radii()); + canvas.draw_rrect(rrect, &paint); + } + } +} + +fn paint_box_shadow_inset(_canvas: &Canvas, style: &StyledElement, _w: f32, _h: f32) { + for shadow in &style.box_shadow { + if !shadow.inset { + continue; + } + // TODO: inset box shadows require clipping to the box bounds + // and drawing the shadow inside. This is more complex than outer + // shadows and requires a save/clip/draw/restore pattern. + let _ = shadow; + } +} + +// ─── Text painting (Chromium: TextPainter) ─────────────────────────── + +fn paint_text(canvas: &Canvas, run: &TextRun, x: f32, y: f32, width: f32, fonts: &FontCollection) { + let mut ps = ParagraphStyle::new(); + let align = match run.font.text_align { + TextAlign::Left => textlayout::TextAlign::Left, + TextAlign::Right => textlayout::TextAlign::Right, + TextAlign::Center => textlayout::TextAlign::Center, + TextAlign::Justify => textlayout::TextAlign::Justify, + }; + ps.set_text_align(align); + + let mut builder = ParagraphBuilder::new(&ps, fonts); + let ts = build_skia_text_style(&run.font, &run.color); + builder.push_style(&ts); + builder.add_text(&run.text); + + let mut para = builder.build(); + para.layout(width); + para.paint(canvas, (x, y)); +} + +/// Paint an inline group as a single multi-run Skia Paragraph. +/// +/// Three-step approach mirroring Chromium's inline painting pipeline: +/// 1. Build the Paragraph with placeholders at OpenBox/CloseBox boundaries +/// (Chromium: `LineBreaker::HandleOpenTag`/`HandleCloseTag` add inline_size) +/// 2. For items with decorations, use `get_rects_for_range()` to find their +/// physical rects, then paint box decorations (Chromium: `InlineBoxPainter`) +/// 3. Paint the paragraph text on top (Chromium: `TextPainter`) +fn paint_inline_group( + canvas: &Canvas, + group: &InlineGroup, + x: f32, + y: f32, + width: f32, + fonts: &FontCollection, +) { + use skia_safe::textlayout::{ + PlaceholderAlignment, PlaceholderStyle, RectHeightStyle, RectWidthStyle, TextBaseline, + }; + + let mut ps = ParagraphStyle::new(); + let align = match group.text_align { + TextAlign::Left => textlayout::TextAlign::Left, + TextAlign::Right => textlayout::TextAlign::Right, + TextAlign::Center => textlayout::TextAlign::Center, + TextAlign::Justify => textlayout::TextAlign::Justify, + }; + ps.set_text_align(align); + + let mut builder = ParagraphBuilder::new(&ps, fonts); + + // Track paragraph offset for get_rects_for_range(). + // Each Skia placeholder occupies exactly 1 position in the offset space + // (verified empirically — see test_placeholder_byte_offset). + // Text occupies its byte length. + const PLACEHOLDER_OFFSET: usize = 1; + + struct DecoRange { + range_start: usize, + range_end: usize, + deco: InlineBoxDecoration, + } + let mut deco_stack: Vec<(usize, InlineBoxDecoration)> = Vec::new(); + let mut deco_ranges: Vec = Vec::new(); + let mut offset: usize = 0; + + for item in &group.items { + match item { + InlineRunItem::Text(run) => { + let ts = build_skia_text_style(&run.font, &run.color); + builder.push_style(&ts); + builder.add_text(&run.text); + builder.pop(); + offset += run.text.len(); + } + InlineRunItem::OpenBox { + inline_size, + decoration, + } => { + if *inline_size > 0.0 { + builder.add_placeholder(&PlaceholderStyle::new( + *inline_size, + 0.01, + PlaceholderAlignment::Baseline, + TextBaseline::Alphabetic, + 0.0, + )); + offset += PLACEHOLDER_OFFSET; + } + // Record start AFTER the open placeholder + deco_stack.push((offset, decoration.clone())); + } + InlineRunItem::CloseBox { inline_size } => { + // Record end BEFORE the close placeholder + if let Some((start, deco)) = deco_stack.pop() { + deco_ranges.push(DecoRange { + range_start: start, + range_end: offset, + deco, + }); + } + if *inline_size > 0.0 { + builder.add_placeholder(&PlaceholderStyle::new( + *inline_size, + 0.01, + PlaceholderAlignment::Baseline, + TextBaseline::Alphabetic, + 0.0, + )); + offset += PLACEHOLDER_OFFSET; + } + } + } + } + + let mut para = builder.build(); + para.layout(width); + + // Pass 1: Paint inline box decorations (Chromium: InlineBoxPainter) + for deco_range in &deco_ranges { + if deco_range.range_start >= deco_range.range_end { + continue; + } + let rects = para.get_rects_for_range( + deco_range.range_start..deco_range.range_end, + RectHeightStyle::Tight, + RectWidthStyle::Tight, + ); + let deco = &deco_range.deco; + let border_w = deco.border.map_or(0.0, |b| b.width); + + for text_box in &rects { + let r = text_box.rect; + // The text rect from get_rects_for_range covers just the text. + // The decoration rect extends outward to include padding + border + // (the placeholders already pushed the text inward in the paragraph). + let deco_rect = Rect::from_xywh( + x + r.left - deco.padding_inline - border_w, + y + r.top - deco.padding_block, + r.width() + (deco.padding_inline + border_w) * 2.0, + r.height() + deco.padding_block * 2.0, + ); + + // Background fill + if let Some(bg) = deco.background { + let mut paint = Paint::default(); + paint.set_color(Color::from_argb(bg.a, bg.r, bg.g, bg.b)); + paint.set_style(PaintStyle::Fill); + paint.set_anti_alias(true); + if deco.border_radius > 0.0 { + canvas.draw_round_rect( + deco_rect, + deco.border_radius, + deco.border_radius, + &paint, + ); + } else { + canvas.draw_rect(deco_rect, &paint); + } + } + + // Border stroke + if let Some(border) = &deco.border { + if border.width > 0.0 { + let mut paint = Paint::default(); + paint.set_color(Color::from_argb( + border.color.a, + border.color.r, + border.color.g, + border.color.b, + )); + paint.set_stroke_width(border.width); + paint.set_style(PaintStyle::Stroke); + paint.set_anti_alias(true); + if deco.border_radius > 0.0 { + canvas.draw_round_rect( + deco_rect, + deco.border_radius, + deco.border_radius, + &paint, + ); + } else { + canvas.draw_rect(deco_rect, &paint); + } + } + } + } + } + + // Pass 2: Paint the paragraph text on top (Chromium: TextPainter) + para.paint(canvas, (x, y)); +} diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs new file mode 100644 index 0000000000..890c1ce734 --- /dev/null +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -0,0 +1,449 @@ +//! Styled element IR — resolved CSS properties as plain Rust structs. +//! +//! Property grouping is inspired by Chromium's `ComputedStyle` sub-structs +//! (`StyleBoxData`, `StyleSurroundData`, `StyleBackgroundData`, etc.) but +//! flattened into a single struct since our styled tree is short-lived. +//! +//! ## CG type reuse +//! +//! Types from `cg::prelude` are reused where they align 1:1 with CSS: +//! `CGColor`, `EdgeInsets`, `BlendMode`, `TextAlign`, `FontWeight`, +//! `TextTransform`, `TextDecorationLine`, `TextDecorationStyle`. + +use crate::cg::prelude::{ + BlendMode, CGColor, EdgeInsets, FontWeight, TextAlign, TextDecorationStyle, TextTransform, +}; + +use super::types::*; + +// ─── StyledElement ─────────────────────────────────────────────────── + +/// A styled HTML element with all CSS properties resolved. +/// +/// Groups follow Chromium's ComputedStyle organization: +/// - **Box model** — sizing, margin, padding, border (StyleBoxData + StyleSurroundData) +/// - **Background** — background-color, border-radius (StyleBackgroundData) +/// - **Text** — inherited text/font properties (StyleInheritedData) +/// - **Visual effects** — opacity, overflow, blend-mode, visibility, box-shadow (rare non-inherited) +/// - **Positioning** — position, inset, z-index, float (rare non-inherited) +/// - **Flex/Grid** — flex-direction, align-items, gap, etc. (rare non-inherited) +/// - **Children** — child nodes in DOM order +#[derive(Debug, Clone)] +pub struct StyledElement { + pub tag: String, + + // ── Display (StyleBoxData) ── + pub display: Display, + pub visibility: Visibility, + pub box_sizing: BoxSizing, + + // ── Box Model: sizing (StyleBoxData) ── + pub width: CssLength, + pub height: CssLength, + pub min_width: CssLength, + pub max_width: CssLength, + pub min_height: CssLength, + pub max_height: CssLength, + + // ── Box Model: spacing (StyleBoxData + StyleSurroundData) ── + pub margin: CssEdgeInsets, + pub padding: EdgeInsets, + pub border: BorderBox, + + // ── Background (StyleBackgroundData) ── + /// Background layers, bottom-to-top. May include solid color and/or gradients. + pub background: Vec, + pub border_radius: CornerRadii, + // TODO: background-image (url), background-position, background-size, background-repeat + + // ── Text / Font (StyleInheritedData — inherited through tree) ── + pub color: CGColor, + pub font: FontProps, + + // ── Visual Effects (rare non-inherited) ── + pub opacity: f32, + pub blend_mode: BlendMode, + pub overflow_x: Overflow, + pub overflow_y: Overflow, + pub box_shadow: Vec, + + // ── Transform (rare non-inherited) ── + // TODO: transform, transform_origin + + // ── Positioning (rare non-inherited) ── + pub position: Position, + pub inset: CssEdgeInsets, + pub z_index: Option, + pub float: Float, + pub clear: Clear, + + // ── Flex container (rare non-inherited) ── + pub flex_direction: FlexDirection, + pub flex_wrap: FlexWrap, + pub align_items: AlignItems, + pub justify_content: JustifyContent, + pub row_gap: f32, + pub column_gap: f32, + + // ── Flex child (rare non-inherited) ── + pub flex_grow: f32, + pub flex_shrink: f32, + pub flex_basis: CssLength, + pub align_self: Option, + + // ── Children ── + pub children: Vec, +} + +/// A node in the styled tree. +#[derive(Debug, Clone)] +pub enum StyledNode { + Element(StyledElement), + Text(TextRun), + /// Consecutive inline content (text + inline elements) merged into one + /// paragraph. Each run carries its own font/color — mapped to Skia + /// ParagraphBuilder push_style/pop per run. + InlineGroup(InlineGroup), +} + +/// Consecutive inline items merged into a single paragraph. +/// +/// Maps to Chromium's flat `InlineItem` list within an inline formatting +/// context. Items include text runs and open/close box markers that inject +/// spacing for inline element padding/border/margin. +#[derive(Debug, Clone)] +pub struct InlineGroup { + pub items: Vec, + pub text_align: TextAlign, +} + +/// An item within an inline formatting context. +/// +/// Mirrors Chromium's `InlineItem::InlineItemType`: +/// - `Text` → `kText` — a contiguous span of styled text +/// - `OpenBox` → `kOpenTag` — start of an inline box (adds inline-start spacing) +/// - `CloseBox` → `kCloseTag` — end of an inline box (adds inline-end spacing) +#[derive(Debug, Clone)] +pub enum InlineRunItem { + /// Text content with uniform styling. + Text(TextRun), + /// Start of an inline box — injects `inline_size` spacing before text. + /// Chromium: `HandleOpenTag()` → `position_ += margins.inline_start + borders.inline_start + padding.inline_start` + OpenBox { + /// Inline-start spacing = margin + border + padding (inline-start side). + inline_size: f32, + decoration: InlineBoxDecoration, + }, + /// End of an inline box — injects `inline_size` spacing after text. + /// Chromium: `HandleCloseTag()` → `ComputeInlineEndSize()` + CloseBox { + /// Inline-end spacing = padding + border + margin (inline-end side). + inline_size: f32, + }, +} + +// ─── Box Model Sub-types (StyleSurroundData) ───────────────────────── + +/// Edge insets that may contain `auto` or `%` values (for margin, inset). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CssEdgeInsets { + pub top: CssLength, + pub right: CssLength, + pub bottom: CssLength, + pub left: CssLength, +} + +/// Per-side border properties. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BorderSide { + pub width: f32, + pub color: CGColor, + pub style: BorderStyle, +} + +impl Default for BorderSide { + fn default() -> Self { + Self { + width: 0.0, + color: CGColor::BLACK, + style: BorderStyle::None, + } + } +} + +/// Four-sided border box. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct BorderBox { + pub top: BorderSide, + pub right: BorderSide, + pub bottom: BorderSide, + pub left: BorderSide, +} + +/// Per-corner border radii with separate x/y values (elliptical). +/// +/// CSS: `border-radius: 10px / 20px` → each corner has (rx=10, ry=20). +/// Skia: `RRect::set_rect_radii` takes `[Point; 4]` where each Point is (rx, ry). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CornerRadii { + pub tl_x: f32, + pub tl_y: f32, + pub tr_x: f32, + pub tr_y: f32, + pub br_x: f32, + pub br_y: f32, + pub bl_x: f32, + pub bl_y: f32, +} + +impl CornerRadii { + /// Create uniform circular radii (same x/y per corner). + pub fn uniform(tl: f32, tr: f32, br: f32, bl: f32) -> Self { + Self { + tl_x: tl, + tl_y: tl, + tr_x: tr, + tr_y: tr, + br_x: br, + br_y: br, + bl_x: bl, + bl_y: bl, + } + } + + pub fn is_zero(&self) -> bool { + self.tl_x == 0.0 + && self.tl_y == 0.0 + && self.tr_x == 0.0 + && self.tr_y == 0.0 + && self.br_x == 0.0 + && self.br_y == 0.0 + && self.bl_x == 0.0 + && self.bl_y == 0.0 + } + + /// Convert to Skia's `[Point; 4]` format for `RRect::set_rect_radii`. + pub fn to_skia_radii(&self) -> [skia_safe::Point; 4] { + [ + skia_safe::Point::new(self.tl_x, self.tl_y), + skia_safe::Point::new(self.tr_x, self.tr_y), + skia_safe::Point::new(self.br_x, self.br_y), + skia_safe::Point::new(self.bl_x, self.bl_y), + ] + } + + /// Max radius (for simplified single-value contexts like inline decoration). + pub fn max_radius(&self) -> f32 { + self.tl_x + .max(self.tl_y) + .max(self.tr_x.max(self.tr_y)) + .max(self.br_x.max(self.br_y)) + .max(self.bl_x.max(self.bl_y)) + } +} + +// ─── Visual Effects Sub-types ──────────────────────────────────────── + +/// CSS `box-shadow` value. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BoxShadow { + pub offset_x: f32, + pub offset_y: f32, + pub blur: f32, + pub spread: f32, + pub color: CGColor, + pub inset: bool, +} + +// ─── Background Sub-types (StyleBackgroundData) ───────────────────── + +/// A single background layer — solid color or gradient. +#[derive(Debug, Clone)] +pub enum BackgroundLayer { + Solid(CGColor), + LinearGradient(LinearGradient), + RadialGradient(RadialGradient), + ConicGradient(ConicGradient), +} + +/// CSS `linear-gradient()`. +#[derive(Debug, Clone)] +pub struct LinearGradient { + /// Angle in CSS degrees (0 = to top, 90 = to right, 180 = to bottom). + pub angle_deg: f32, + pub stops: Vec, +} + +/// CSS `radial-gradient()`. +#[derive(Debug, Clone)] +pub struct RadialGradient { + pub stops: Vec, + // TODO: shape (circle/ellipse), size, position +} + +/// CSS `conic-gradient()`. +#[derive(Debug, Clone)] +pub struct ConicGradient { + pub stops: Vec, + // TODO: from angle, at position +} + +/// A gradient color stop. +#[derive(Debug, Clone, Copy)] +pub struct GradientStop { + pub offset: f32, // 0.0 = start, 1.0 = end + pub color: CGColor, +} + +// ─── Text / Font Sub-types (StyleInheritedData) ────────────────────── + +/// Font and text styling properties (inherited through the tree). +/// +/// Maps to Chromium's `StyleInheritedData` + font-related rare inherited data. +#[derive(Debug, Clone)] +pub struct FontProps { + pub size: f32, + pub weight: FontWeight, + pub italic: bool, + pub families: Vec, + pub line_height: LineHeight, + pub letter_spacing: f32, + pub word_spacing: f32, + pub text_align: TextAlign, + pub text_transform: TextTransform, + /// Bitfield: multiple decorations can be active simultaneously. + /// CSS `text-decoration-line: underline line-through` sets both. + pub decoration_underline: bool, + pub decoration_overline: bool, + pub decoration_line_through: bool, + pub decoration_style: TextDecorationStyle, + pub decoration_color: Option, + pub white_space: WhiteSpace, + pub text_indent: f32, + pub text_overflow: TextOverflow, + pub vertical_align: VerticalAlign, + // TODO: word-break, overflow-wrap, tab-size +} + +impl Default for FontProps { + fn default() -> Self { + Self { + size: 16.0, + weight: FontWeight::REGULAR400, + italic: false, + families: vec!["system-ui".into(), "sans-serif".into()], + line_height: LineHeight::Normal, + letter_spacing: 0.0, + word_spacing: 0.0, + text_align: TextAlign::Left, + text_transform: TextTransform::None, + decoration_underline: false, + decoration_overline: false, + decoration_line_through: false, + decoration_style: TextDecorationStyle::Solid, + decoration_color: None, + white_space: WhiteSpace::Normal, + text_indent: 0.0, + text_overflow: TextOverflow::Clip, + vertical_align: VerticalAlign::Baseline, + } + } +} + +/// A text run — inline text content with inherited styling. +/// +/// Maps to Chromium's `InlineItem` of type `kText` — a contiguous span +/// of text with uniform styling within an inline formatting context. +/// +/// For inline elements with box decorations (``, ``, ``), +/// the run carries an `InlineBoxDecoration` with background, border, radius, +/// and padding. These are painted as separate rects using +/// `Paragraph::get_rects_for_range()` (Chromium: `InlineBoxPainter`). +#[derive(Debug, Clone)] +pub struct TextRun { + pub text: String, + pub font: FontProps, + pub color: CGColor, + /// Inline box decoration (background, border, border-radius, padding). + pub decoration: Option, +} + +/// Visual box decoration for an inline element — painted as a rect around +/// the text run's character range. +/// +/// Maps to Chromium's `InlineBoxState` box decoration data. In Chromium, +/// every inline element with non-zero `padding`, `border`, or `margin` +/// creates box fragments via `HandleOpenTag`/`HandleCloseTag` in +/// `LineBreaker`. The decoration (background, border, border-radius) is +/// painted by `InlineBoxPainter` at the fragment's physical rect. +/// +/// In our implementation, decoration rects are computed post-layout via +/// `Paragraph::get_rects_for_range()` and expanded by padding/border. +/// +/// **Chromium divergence:** Chromium treats inline padding as layout space +/// (shifts text position, consumes line width). We treat it as visual-only +/// (expands decoration rect outward, text is not inset). See research doc +/// `docs/wg/research/chromium/blink-rendering-pipeline.md`. +#[derive(Debug, Clone)] +pub struct InlineBoxDecoration { + /// Background color fill (CSS `background-color`). + pub background: Option, + /// Border stroke (simplified to uniform — CSS `border`). + /// Chromium: `ComputeBordersForInline()` in `LineBreaker`. + pub border: Option, + /// Border radius (CSS `border-radius`). Chromium: from `ComputedStyle`. + pub border_radius: f32, + /// Inline-axis padding (CSS `padding-left`/`padding-right`). + /// Chromium: `ComputeLinePadding()` — consumed as layout space. + /// Our impl: visual expansion of decoration rect only. + pub padding_inline: f32, + /// Block-axis padding (CSS `padding-top`/`padding-bottom`). + pub padding_block: f32, +} + +// ─── Defaults ──────────────────────────────────────────────────────── + +impl Default for StyledElement { + fn default() -> Self { + Self { + tag: String::new(), + display: Display::Block, + visibility: Visibility::Visible, + box_sizing: BoxSizing::default(), + width: CssLength::Auto, + height: CssLength::Auto, + min_width: CssLength::Auto, + max_width: CssLength::Auto, + min_height: CssLength::Auto, + max_height: CssLength::Auto, + margin: CssEdgeInsets::default(), + padding: EdgeInsets::zero(), + border: BorderBox::default(), + background: Vec::new(), + border_radius: CornerRadii::default(), + color: CGColor::BLACK, + font: FontProps::default(), + opacity: 1.0, + blend_mode: BlendMode::Normal, + overflow_x: Overflow::Visible, + overflow_y: Overflow::Visible, + box_shadow: Vec::new(), + position: Position::Static, + inset: CssEdgeInsets::default(), + z_index: None, + float: Float::None, + clear: Clear::None, + flex_direction: FlexDirection::default(), + flex_wrap: FlexWrap::default(), + align_items: AlignItems::default(), + justify_content: JustifyContent::default(), + row_gap: 0.0, + column_gap: 0.0, + flex_grow: 0.0, + flex_shrink: 1.0, + flex_basis: CssLength::Auto, + align_self: None, + children: Vec::new(), + } + } +} diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs new file mode 100644 index 0000000000..40dae4f911 --- /dev/null +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -0,0 +1,214 @@ +//! CSS-specific type enums that don't exist in `cg::types`. +//! +//! These model CSS semantics that have no direct equivalent in the design-tool +//! schema. Types that DO align 1:1 with cg (e.g. `BlendMode`, `TextAlign`, +//! `FontWeight`) are reused from `cg::prelude` instead. + +/// CSS `display` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Display { + #[default] + Block, + Inline, + InlineBlock, + Flex, + Grid, + Table, + TableRow, + TableCell, + None, +} + +/// CSS `visibility` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Visibility { + #[default] + Visible, + Hidden, + Collapse, +} + +/// CSS `overflow` property (per axis). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Overflow { + #[default] + Visible, + Hidden, + Clip, + Scroll, + Auto, +} + +/// CSS `position` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Position { + #[default] + Static, + Relative, + Absolute, + Fixed, +} + +/// CSS `border-style` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BorderStyle { + #[default] + None, + Solid, + Dashed, + Dotted, + Double, + Groove, + Ridge, + Inset, + Outset, +} + +/// CSS `box-sizing` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BoxSizing { + ContentBox, + #[default] + BorderBox, +} + +/// CSS `white-space` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WhiteSpace { + #[default] + Normal, + Nowrap, + Pre, + PreWrap, + PreLine, +} + +/// CSS `flex-direction` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlexDirection { + #[default] + Row, + RowReverse, + Column, + ColumnReverse, +} + +/// CSS `flex-wrap` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlexWrap { + #[default] + Nowrap, + Wrap, + WrapReverse, +} + +/// CSS `align-items` / `align-self` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AlignItems { + #[default] + Stretch, + Start, + End, + Center, + Baseline, +} + +/// CSS `justify-content` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum JustifyContent { + #[default] + Start, + End, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +/// A CSS length value. Percentages are kept as-is for Taffy to resolve. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum CssLength { + #[default] + Auto, + Px(f32), + Percent(f32), +} + +/// CSS line-height (inherited text property). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LineHeight { + Normal, + Number(f32), + Px(f32), +} + +impl Default for LineHeight { + fn default() -> Self { + Self::Normal + } +} + +/// CSS `float` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Float { + #[default] + None, + Left, + Right, +} + +/// CSS `clear` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Clear { + #[default] + None, + Left, + Right, + Both, +} + +/// CSS `text-overflow` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TextOverflow { + #[default] + Clip, + Ellipsis, +} + +/// CSS `vertical-align` property (inline-level). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VerticalAlign { + #[default] + Baseline, + Top, + Middle, + Bottom, + TextTop, + TextBottom, + Sub, + Super, +} + +/// CSS `list-style-type` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ListStyleType { + #[default] + Disc, + Circle, + Square, + Decimal, + DecimalLeadingZero, + LowerAlpha, + UpperAlpha, + LowerRoman, + UpperRoman, + None, +} + +/// CSS `list-style-position` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ListStylePosition { + #[default] + Outside, + Inside, +} diff --git a/crates/grida-canvas/src/layout/engine.rs b/crates/grida-canvas/src/layout/engine.rs index 05e908b335..bb252466b2 100644 --- a/crates/grida-canvas/src/layout/engine.rs +++ b/crates/grida-canvas/src/layout/engine.rs @@ -220,6 +220,7 @@ impl LayoutEngine { Node::Ellipse(n) => (n.size.width, n.size.height), Node::Image(n) => (n.size.width, n.size.height), Node::Markdown(n) => (n.size.width, n.size.height), + Node::HTMLEmbed(n) => (n.size.width, n.size.height), Node::Line(n) => (n.size.width, n.size.height), Node::Polygon(n) => { let rect = n.rect(); @@ -274,6 +275,7 @@ impl LayoutEngine { Node::Ellipse(n) => (n.transform.x(), n.transform.y()), Node::Image(n) => (n.transform.x(), n.transform.y()), Node::Markdown(n) => (n.transform.x(), n.transform.y()), + Node::HTMLEmbed(n) => (n.transform.x(), n.transform.y()), Node::Line(n) => (n.transform.x(), n.transform.y()), Node::Polygon(n) => (n.transform.x(), n.transform.y()), Node::RegularPolygon(n) => (n.transform.x(), n.transform.y()), diff --git a/crates/grida-canvas/src/layout/into_taffy.rs b/crates/grida-canvas/src/layout/into_taffy.rs index 9e4dc2829e..a3cbdf0c15 100644 --- a/crates/grida-canvas/src/layout/into_taffy.rs +++ b/crates/grida-canvas/src/layout/into_taffy.rs @@ -303,6 +303,7 @@ pub fn node_to_taffy_style(node: &Node, _graph: &SceneGraph, _node_id: &NodeId) Node::Tray(_) => grida_style_default(), Node::BooleanOperation(_) => grida_style_default(), Node::Markdown(n) => n.into(), + Node::HTMLEmbed(n) => n.into(), } } @@ -579,3 +580,17 @@ impl From<&crate::node::schema::MarkdownNodeRec> for Style { apply_layout_child(style, &node.layout_child, node.transform) } } + +/// Convert HTMLEmbedNodeRec to Taffy Style +impl From<&crate::node::schema::HTMLEmbedNodeRec> for Style { + fn from(node: &crate::node::schema::HTMLEmbedNodeRec) -> Self { + let style = Style { + size: Size { + width: Dimension::length(node.size.width), + height: Dimension::length(node.size.height), + }, + ..grida_style_default() + }; + apply_layout_child(style, &node.layout_child, node.transform) + } +} diff --git a/crates/grida-canvas/src/lib.rs b/crates/grida-canvas/src/lib.rs index 381bd0e8a7..bf483db085 100644 --- a/crates/grida-canvas/src/lib.rs +++ b/crates/grida-canvas/src/lib.rs @@ -7,6 +7,7 @@ pub mod fonts; pub mod helpers; pub mod hittest; pub mod html; +pub mod htmlcss; pub mod io; pub mod layout; pub mod node; diff --git a/crates/grida-canvas/src/node/factory.rs b/crates/grida-canvas/src/node/factory.rs index b3d131fe7a..16e31bcef5 100644 --- a/crates/grida-canvas/src/node/factory.rs +++ b/crates/grida-canvas/src/node/factory.rs @@ -398,4 +398,24 @@ impl NodeFactory { layout_child: None, } } + + /// Creates a new HTML embed node with default values + pub fn create_html_embed_node(&self) -> HTMLEmbedNodeRec { + HTMLEmbedNodeRec { + active: true, + opacity: Self::DEFAULT_OPACITY, + blend_mode: LayerBlendMode::default(), + effects: LayerEffects::default(), + mask: None, + transform: AffineTransform::identity(), + size: Size { + width: 800.0, + height: 600.0, + }, + corner_radius: RectangularCornerRadius::zero(), + html: String::new(), + fills: Paints::new([Self::default_solid_paint(Self::DEFAULT_COLOR)]), + layout_child: None, + } + } } diff --git a/crates/grida-canvas/src/node/scene_graph.rs b/crates/grida-canvas/src/node/scene_graph.rs index eb35f0f43d..dfe5e89819 100644 --- a/crates/grida-canvas/src/node/scene_graph.rs +++ b/crates/grida-canvas/src/node/scene_graph.rs @@ -265,6 +265,18 @@ pub fn extract_geo_data(node: &Node) -> NodeGeoData { ), rotation: 0.0, }, + Node::HTMLEmbed(n) => NodeGeoData { + schema_transform: n.transform, + schema_width: n.size.width, + schema_height: n.size.height, + kind: GeoNodeKind::Leaf, + render_bounds_inflation: compute_inflation_uniform( + 0.0, + StrokeAlign::Center, + &n.effects, + ), + rotation: 0.0, + }, _ => { // Leaf nodes: Rectangle, Ellipse, Image, RegularPolygon, // RegularStarPolygon, Line, Polygon, Path, Vector, Error. diff --git a/crates/grida-canvas/src/node/schema.rs b/crates/grida-canvas/src/node/schema.rs index 70f664dc89..de9ad0ac41 100644 --- a/crates/grida-canvas/src/node/schema.rs +++ b/crates/grida-canvas/src/node/schema.rs @@ -919,6 +919,7 @@ pub enum NodeTypeTag { BooleanOperation, Image, Markdown, + HTMLEmbed, } /// Compact, layer-relevant data extracted from a `Node` at construction time. @@ -1134,6 +1135,16 @@ pub fn extract_layer_core(node: &Node) -> NodeLayerCore { node_type: NodeTypeTag::Markdown, is_flex: false, }, + Node::HTMLEmbed(n) => NodeLayerCore { + active: n.active, + opacity: n.opacity, + blend_mode: n.blend_mode, + mask: n.mask, + clips_content: false, + has_effects: !n.effects.is_empty(), + node_type: NodeTypeTag::HTMLEmbed, + is_flex: false, + }, } } @@ -1157,6 +1168,7 @@ pub enum Node { BooleanOperation(BooleanPathOperationNodeRec), Image(ImageNodeRec), Markdown(MarkdownNodeRec), + HTMLEmbed(HTMLEmbedNodeRec), } // node trait @@ -1185,6 +1197,7 @@ impl NodeTrait for Node { Node::BooleanOperation(n) => n.active, Node::Image(n) => n.active, Node::Markdown(n) => n.active, + Node::HTMLEmbed(n) => n.active, } } } @@ -1209,6 +1222,7 @@ impl Node { Node::BooleanOperation(n) => n.mask, Node::Image(n) => n.mask, Node::Markdown(n) => n.mask, + Node::HTMLEmbed(n) => n.mask, Node::Error(_) => None, } } @@ -1235,6 +1249,7 @@ impl Node { Node::BooleanOperation(n) => n.opacity, Node::Image(n) => n.opacity, Node::Markdown(n) => n.opacity, + Node::HTMLEmbed(n) => n.opacity, } } @@ -1259,6 +1274,7 @@ impl Node { Node::BooleanOperation(_) => "Boolean", Node::Image(_) => "Image", Node::Markdown(_) => "Markdown", + Node::HTMLEmbed(_) => "HTMLEmbed", } } @@ -1285,6 +1301,7 @@ impl Node { Node::Image(_) => None, // Markdown renders its own content; background fills are separate Node::Markdown(n) => Some(&n.fills), + Node::HTMLEmbed(n) => Some(&n.fills), Node::Error(_) | Node::Group(_) | Node::Line(_) => None, } } @@ -1311,6 +1328,7 @@ impl Node { Node::BooleanOperation(n) => n.blend_mode, Node::Image(n) => n.blend_mode, Node::Markdown(n) => n.blend_mode, + Node::HTMLEmbed(n) => n.blend_mode, } } @@ -1336,6 +1354,7 @@ impl Node { Node::BooleanOperation(n) => Some(&n.effects), Node::Image(n) => Some(&n.effects), Node::Markdown(n) => Some(&n.effects), + Node::HTMLEmbed(n) => Some(&n.effects), } } @@ -2911,4 +2930,64 @@ impl NodeGeometryMixin for MarkdownNodeRec { } } +/// An opaque HTML+CSS embed rendered as a Skia Picture. +/// +/// Stores raw HTML (with optional ` + + +

      Unordered list

      +
        +
      • Alpha
      • +
      • Bravo
      • +
      • Charlie
      • +
      + +

      Ordered list

      +
        +
      1. First
      2. +
      3. Second
      4. +
      5. Third
      6. +
      + +

      Nested lists

      +
        +
      • Outer one +
          +
        • Inner alpha
        • +
        • Inner bravo
        • +
        +
      • +
      • Outer two +
          +
        1. Numbered inner
        2. +
        3. Numbered inner
        4. +
        +
      • +
      + +

      List with inline content

      +
        +
      • Item with bold and code
      • +
      • Item with link and emphasis
      • +
      + +

      Ordered start and type

      +
        +
      1. Five
      2. +
      3. Six
      4. +
      5. Seven
      6. +
      + +

      Custom bullets

      +
        +
      • Square bullet
      • +
      • Square bullet
      • +
      + +

      No bullets

      +
        +
      • No marker
      • +
      • No marker
      • +
      + +

      Inside position

      +
        +
      • Marker is inside the content flow and wraps with the text if the line is long enough to demonstrate wrapping behavior
      • +
      • Short item
      • +
      + +

      Lower-alpha

      +
        +
      1. Alpha item
      2. +
      3. Bravo item
      4. +
      5. Charlie item
      6. +
      + +

      Upper-roman

      +
        +
      1. Roman one
      2. +
      3. Roman two
      4. +
      5. Roman three
      6. +
      + + From 0850570affbd59e7fdb09b4fb9906410e4a2e4d1 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 5 Apr 2026 00:42:46 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(htmlcss):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20percentages,=20inline=20styling,=20min-content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix percentage width/height values being dropped to auto in extract_size and extract_max_size (use to_percentage() fallback) - Fix inline group flattening overwriting nested child run styling — preserve original font/color for nested inline elements like text - Fix box-sizing default to content-box per CSS spec (was border-box) - Fix MinContent measure returning 0 — use Skia's min_intrinsic_width() for correct word-break minimum width; MaxContent uses max_intrinsic_width() Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/grida-canvas/src/htmlcss/collect.rs | 19 ++++++------- crates/grida-canvas/src/htmlcss/layout.rs | 32 +++++++++++++--------- crates/grida-canvas/src/htmlcss/types.rs | 4 +-- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index 3044212f7e..bd43759a58 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -297,18 +297,11 @@ fn collect_inline_items(el: &StyledElement, items: &mut Vec) { collect_inline_items(child_el, items); } StyledNode::InlineGroup(group) => { + // Preserve child run styling — don't overwrite with parent's + // font/color. Nested inline elements (e.g. text) + // need to keep each run's specific styling intact. for item in &group.items { - match item { - InlineRunItem::Text(run) => { - items.push(InlineRunItem::Text(TextRun { - text: run.text.clone(), - font: el.font.clone(), - color: el.color, - decoration: None, - })); - } - other => items.push(other.clone()), - } + items.push(item.clone()); } } } @@ -692,6 +685,8 @@ fn extract_size( GenericSize::LengthPercentage(lp) => { if let Some(len) = lp.0.to_length() { CssLength::Px(len.px()) + } else if let Some(pct) = lp.0.to_percentage() { + CssLength::Percent(pct.0) } else { CssLength::Auto } @@ -710,6 +705,8 @@ fn extract_max_size( GenericMaxSize::LengthPercentage(lp) => { if let Some(len) = lp.0.to_length() { CssLength::Px(len.px()) + } else if let Some(pct) = lp.0.to_percentage() { + CssLength::Percent(pct.0) } else { CssLength::Auto } diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index 88315416fe..35cd256e2d 100644 --- a/crates/grida-canvas/src/htmlcss/layout.rs +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -192,12 +192,6 @@ fn text_measure_func( }; } - let max_width = match available_space.width { - AvailableSpace::Definite(w) => known_dimensions.width.unwrap_or(w), - AvailableSpace::MinContent => 0.0, - AvailableSpace::MaxContent => 100_000.0, - }; - // Build Paragraph with placeholders for inline box spacing // (Chromium: LineBreaker processes kOpenTag/kText/kCloseTag) let ps = ParagraphStyle::new(); @@ -213,11 +207,9 @@ fn text_measure_func( InlineRunItem::OpenBox { inline_size, .. } | InlineRunItem::CloseBox { inline_size } => { if *inline_size > 0.0 { - // Inject a placeholder that consumes inline space - // (Chromium: position_ += item_result->inline_size) builder.add_placeholder(&skia_safe::textlayout::PlaceholderStyle::new( *inline_size, - 0.01, // near-zero height — aligned to baseline + 0.01, skia_safe::textlayout::PlaceholderAlignment::Baseline, skia_safe::textlayout::TextBaseline::Alphabetic, 0.0, @@ -227,12 +219,26 @@ fn text_measure_func( } } let mut para = builder.build(); - para.layout(max_width); - // Ceil intrinsic width to prevent subpixel-induced wrapping - let intrinsic_w = para.max_intrinsic_width().ceil(); + // Layout at large width first to get intrinsic measurements + para.layout(100_000.0); + let max_intrinsic = para.max_intrinsic_width().ceil(); + let min_intrinsic = para.min_intrinsic_width().ceil(); + + // Determine layout width from available space + let layout_width = match available_space.width { + AvailableSpace::Definite(w) => known_dimensions.width.unwrap_or(w), + AvailableSpace::MinContent => min_intrinsic, + AvailableSpace::MaxContent => max_intrinsic, + }; + + // Re-layout at actual width for correct line breaking and height + para.layout(layout_width); + taffy::Size { - width: known_dimensions.width.unwrap_or(intrinsic_w.min(max_width)), + width: known_dimensions + .width + .unwrap_or(max_intrinsic.min(layout_width)), height: known_dimensions.height.unwrap_or(para.height()), } } diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs index 40dae4f911..d475b987fa 100644 --- a/crates/grida-canvas/src/htmlcss/types.rs +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -64,11 +64,11 @@ pub enum BorderStyle { Outset, } -/// CSS `box-sizing` property. +/// CSS `box-sizing` property. Initial value: `content-box` per spec. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BoxSizing { - ContentBox, #[default] + ContentBox, BorderBox, } From ef8e03dd7cf598ba7d7fb5275e389daa77156cbd Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 5 Apr 2026 01:14:33 +0900 Subject: [PATCH 3/3] fix(htmlcss): serialize tests to avoid Stylo thread-safety panic in CI Stylo uses a process-global DOM slot that is not thread-safe. When tests run in parallel (CI), concurrent access to bootstrap_dom() causes "Why are we here?" panics in Stylo's traversal.rs. Add a static Mutex guard to all htmlcss tests that use Stylo to serialize their execution. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/grida-canvas/src/htmlcss/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/grida-canvas/src/htmlcss/mod.rs b/crates/grida-canvas/src/htmlcss/mod.rs index 627987b1af..a24b6616eb 100644 --- a/crates/grida-canvas/src/htmlcss/mod.rs +++ b/crates/grida-canvas/src/htmlcss/mod.rs @@ -67,12 +67,18 @@ mod tests { use crate::resources::ByteStore; use std::sync::{Arc, Mutex}; + /// Stylo uses a process-global DOM slot that is not thread-safe. + /// All htmlcss tests must be serialized to avoid concurrent access. + /// We also share this with the `html` module's tests via crate-level visibility. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + fn test_fonts() -> FontRepository { FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))) } #[test] fn test_render_empty() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render("", 400.0, 300.0, &fonts); assert!(pic.is_ok()); @@ -80,6 +86,7 @@ mod tests { #[test] fn test_render_heading() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render("

      Hello

      ", 400.0, 300.0, &fonts).unwrap(); assert!(pic.cull_rect().width() > 0.0); @@ -87,6 +94,7 @@ mod tests { #[test] fn test_render_with_style_block() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render( "

      Blue

      ", @@ -99,6 +107,7 @@ mod tests { #[test] fn test_render_table() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render( "
      AB
      ", @@ -111,6 +120,7 @@ mod tests { #[test] fn test_render_flex() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render( r#"
      A
      B
      "#, @@ -123,6 +133,7 @@ mod tests { #[test] fn test_render_opacity() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render( r#"

      Semi-transparent

      "#, @@ -192,6 +203,7 @@ mod tests { #[test] fn test_measure_height() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let h = measure_content_height("

      Hello

      ", 400.0, &fonts).unwrap(); assert!(h > 0.0, "Content height should be positive, got {h}"); @@ -199,6 +211,7 @@ mod tests { #[test] fn test_head_hidden() { + let _guard = TEST_LOCK.lock().unwrap(); let fonts = test_fonts(); let pic = render( r#"

      V

      "#,