diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e41d41..8b0d24fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,85 @@ in the README). ## [Unreleased] +### Added +- The versioned Tailwind-core manifest (`tailwind_profile="core-v1"`): + `chart.to_png(style_source="native_cascade", tailwind_profile="core-v1")` + resolves the core utilities natively — the full default color palette + (vendored from tailwindcss@3.4.17 with provenance, never hand-typed + hexes), the spacing/type/weight/radius/border/tracking scales in their + published rem/em/px values, resolved through the cascade's own font-size + chain. Project Tailwind builds ride `stylesheets=`; unmatched utilities + are reported, never guessed. +- Axis chrome joins static-export styling (parity phase 2): `axis_line` and + `tick_mark` accept the shared box vocabulary (background, border, radius, + offset shadow, opacity) in SVG, PNG and PDF, `tick_label` and `axis_title` + additionally draw padded boxes with the axis gutters growing to fit, and a + rotated y-title box lowers to PDF-legal pre-rotated geometry. Emission is + strictly declaration-gated (unstyled bytes are untouched), one shared + producer feeds both writers and the declared snapshot (per-tick + qualifiers + geometry), and `axis_band` is recorded as navigation-gated + live chrome rather than drawn. The preflight, capability registry and + matrix moved in the same change. +- The mount-free native cascade (optional `xy-cascade` extension, ~3.8 MB + cdylib built from the same workspace): `chart.to_png(custom_css=css, + style_source="native_cascade")` resolves classes and self-contained author + CSS natively — Lightning CSS parsing; class/slot/descendant selectors, + `!important`/specificity/order, custom properties with fallbacks, + `em`/`rem` against the font-size cascade, `prefers-color-scheme` — and + exports with no browser in the path. Every out-of-profile construct is + reported through the compatibility modes (warn by default, refusal in + strict), never guessed. `scripts/cascade_differential_smoke.py` pins the + cascade against a live Chromium oracle. +- Live style capture and snapshot-fed export (wire protocol v13). A mounted + chart's `await chart.capture_style_snapshot()` returns the browser's + resolved cascade as a validated `ResolvedStyleSnapshot` (the client + captures every rendered slot's allowlisted computed properties after + fonts/layout settle; replies are validated through the schema at the + boundary). Passing it to `to_png(style_snapshot=...)` / `to_svg` / + `to_image` / `write_image` makes the native writers reproduce what the + browser resolved — host theme, classes, dark mode — with no browser in + the export path; `compatibility="strict"` passes with a snapshot where it + refuses without one. Standalone documents expose the same capture as + `window.xy.captureStyleSnapshot`, and + `scripts/style_capture_smoke.py` pins the browser-oracle loop end to end. +- The declared-styling resolver (`xy.styling.declared`): the writers' slot + view now derives from the same pass that builds the interned + `ResolvedStyleSnapshot`, byte-equivalently, with the legend's em-domain + values as a named, tested residue. +- The renderer-neutral styling IR: `xy.styling.resolved` defines the + versioned, interned `ResolvedStyleSnapshot` (schema v1 — concrete values + only, declarations deduped, instances referencing them by index), with a + generated TypeScript mirror (`js/src/14_style_snapshot.ts`) that the test + suite pins to the Python schema. Wire shape and reserved message names: + `spec/design/wire-protocol.md` §8; nothing rides the wire yet, so + `PROTOCOL_VERSION` is unchanged. +- Every chart-, figure-, and module-level image-export API (`to_png`, + `to_svg`, `to_image`, `write_image`, `export.write_images`) accepts + `compatibility=` — facet-grid exports deliberately do not yet (their + per-panel preflight is tracked in the migration document): `"legacy"` + (default — + behavior unchanged), `"warn"` (one `StyleCompatibilityWarning` naming each + declaration the export would drop), or `"strict"` + (`StyleCompatibilityError` before emission, preflight report attached). + Modes never re-route an explicit engine; `"lossless"` is reserved and + rejected until preflight routing exists. The default flips only on the + published schedule in `spec/process/style-compatibility-migration.md` + (warn in 0.0.7, strict in 0.1.0, legacy removed in 0.2.0). +- `chart.style_compatibility_report(target=..., engine=..., custom_css=...)`: + a report-only export preflight that routes every declared slot style into + `survives`, `native-subset` (naming the kept and lost properties per + format family), `browser-only`, or `state-gated`, mirrors the export + path's refusals, and short-circuits in constant time for charts with no + class or per-slot declarations. The programmatic answer to + `spec/api/export.md` §9. +- The capability registry now tags every chrome slot with an + **applicability** — present in a clean static export, or gated by an + export state (`hover`, `selection`, `crosshair`, `modebar`, `view`) — and + the generated capability matrices gained the *applicable in* column and + applicable-slot counts (24 static / 24 state-gated of 48). Live-only + chrome a static file never contains is no longer counted as missing from + static export parity. + ## [0.0.5] - 2026-07-31 ### Added diff --git a/Cargo.lock b/Cargo.lock index 8afdf0f5..dd4242c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,18 +8,112 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -29,6 +123,124 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-color" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa233e1dcd9c13a5d3e3a8a2c0f5a727bac380398345dbcb31db4597edc86b" +dependencies = [ + "cssparser", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fdeflate" version = "0.3.7" @@ -48,6 +260,197 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lightningcss" +version = "1.0.0-alpha.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d31b760f96e8fdfe1d0c295e4bf76c503d6f15d2d470d53bd8cd1f7aa8c7d934" +dependencies = [ + "ahash 0.8.12", + "bitflags", + "const-str", + "cssparser", + "cssparser-color", + "dashmap", + "data-encoding", + "getrandom 0.3.4", + "indexmap", + "itertools", + "lazy_static", + "lightningcss-derive", + "parcel_selectors", + "parcel_sourcemap", + "pastey", + "pathdiff", + "rayon", + "serde", + "serde-content", + "smallvec", +] + +[[package]] +name = "lightningcss-derive" +version = "1.0.0-alpha.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -58,6 +461,160 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + +[[package]] +name = "parcel_selectors" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05f71e01edca03d245ab0a9f7ce13a974ceb79baaae8faf2ba0b11de6b90913" +dependencies = [ + "bitflags", + "cssparser", + "log", + "phf 0.11.3", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "parcel_sourcemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" +dependencies = [ + "base64-simd", + "data-url", + "rkyv", + "serde", + "serde_json", + "vlq", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.18.1" @@ -71,15 +628,467 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref", +] + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vlq" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "xy-cascade" +version = "0.1.0" +dependencies = [ + "lightningcss", + "parcel_selectors", + "serde", + "serde_json", +] + [[package]] name = "xy-core" version = "0.1.0" dependencies = [ "png", ] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 1a2946b4..2a133907 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,11 @@ +# Workspace: the core stays the root package; `cascade` is the optional +# style-cascade extension (its own cdylib, loaded lazily and only when a +# native-cascade export is requested — the §33 install budget and the +# import-weight contract are measured with both, but the core never links it). +[workspace] +members = [".", "cascade"] +default-members = [".", "cascade"] + [package] name = "xy-core" version = "0.1.0" diff --git a/cascade/Cargo.toml b/cascade/Cargo.toml new file mode 100644 index 00000000..21fe83c9 --- /dev/null +++ b/cascade/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "xy-cascade" +version = "0.1.0" +edition = "2021" +description = "Optional mount-free CSS cascade for xy: Lightning CSS parsing plus a profile-scoped resolver over the synthetic chart DOM" +license = "Apache-2.0" + +[lib] +name = "xy_cascade" +crate-type = ["cdylib"] + +# Lightning CSS does the real parsing/normalization; the matcher/cascade over +# the synthetic slot DOM is profile-scoped and lives here. Everything outside +# the published profile is reported, never guessed (§28). Pinned exactly: +# the resolver's output is part of a deterministic export path. +[dependencies] +lightningcss = "=1.0.0-alpha.72" +parcel_selectors = "=0.28.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/cascade/src/lib.rs b/cascade/src/lib.rs new file mode 100644 index 00000000..a3052437 --- /dev/null +++ b/cascade/src/lib.rs @@ -0,0 +1,78 @@ +//! Mount-free CSS cascade for xy (wire-protocol §8, migration doc "native +//! cascade"): Lightning CSS parses and normalizes author stylesheets; this +//! crate matches them against the synthetic chart DOM and cascades to +//! concrete per-node declarations — the values the ResolvedStyleSnapshot +//! carries and the native writers consume. +//! +//! Profile-scoped on purpose. The published profile is: style rules; +//! class / `[data-xy-slot=…]` attribute / universal / `:root` simple +//! selectors with descendant and child combinators; `@media +//! (prefers-color-scheme: …)`; custom properties with `var()` fallbacks; +//! `em`/`rem` lengths (resolved against the font-size cascade); the +//! inherited text properties. Everything else — other at-rules, pseudo +//! classes/elements, sibling combinators, percentage lengths — lands in the +//! reply's `unsupported` list with the reason, never in a guessed value +//! (§28). The Python side surfaces that list through the preflight. +//! +//! C ABI: JSON in, JSON out, one free function. The boundary stays this +//! narrow so the extension is loadable lazily and replaceable wholesale +//! (the plan's Stylo endgame swaps the resolver, not the contract). + +mod resolve; + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +pub const CASCADE_ABI_VERSION: u32 = 1; + +#[no_mangle] +pub extern "C" fn xy_cascade_abi_version() -> u32 { + CASCADE_ABI_VERSION +} + +/// Resolve `css` against the synthetic document `doc` (JSON; see +/// `resolve::Document`). Writes a malloc'd JSON reply to `out`/`out_len`; +/// returns 0 on success, 1 when the reply is `{"error": …}`, 2 on a caught +/// panic (reply is a static error JSON). The caller frees the reply with +/// `xy_cascade_free` in every case. +/// +/// # Safety +/// `css_ptr`/`doc_ptr` must reference `css_len`/`doc_len` readable bytes; +/// `out`/`out_len` must be writable. +#[no_mangle] +pub unsafe extern "C" fn xy_cascade_resolve( + css_ptr: *const u8, + css_len: usize, + doc_ptr: *const u8, + doc_len: usize, + out: *mut *mut u8, + out_len: *mut usize, +) -> i32 { + let css = std::slice::from_raw_parts(css_ptr, css_len); + let doc = std::slice::from_raw_parts(doc_ptr, doc_len); + let result = catch_unwind(AssertUnwindSafe(|| resolve::resolve_json(css, doc))); + let (code, payload) = match result { + Ok(Ok(json)) => (0, json), + Ok(Err(message)) => ( + 1, + serde_json::to_string(&serde_json::json!({ "error": message })) + .unwrap_or_else(|_| "{\"error\":\"unserializable error\"}".to_string()), + ), + Err(_) => (2, "{\"error\":\"panic in xy_cascade_resolve\"}".to_string()), + }; + let boxed = payload.into_bytes().into_boxed_slice(); + let len = boxed.len(); + *out = Box::into_raw(boxed) as *mut u8; + *out_len = len; + code +} + +/// Free a reply produced by `xy_cascade_resolve`. +/// +/// # Safety +/// `ptr`/`len` must be exactly a pair returned through `out`/`out_len`. +#[no_mangle] +pub unsafe extern "C" fn xy_cascade_free(ptr: *mut u8, len: usize) { + if !ptr.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); + } +} diff --git a/cascade/src/resolve.rs b/cascade/src/resolve.rs new file mode 100644 index 00000000..a16e265c --- /dev/null +++ b/cascade/src/resolve.rs @@ -0,0 +1,733 @@ +//! The profile-scoped resolver: parse → match → cascade → concrete values. +//! +//! Correctness order inside one node's cascade, mirroring CSS: importance, +//! then specificity, then source order. No layers in the profile (an +//! `@layer` is reported). Custom properties resolve after the cascade with +//! inheritance; `em`/`rem` resolve after font-size does; the inherited text +//! properties flow parent-to-child last, so a child's own cascade always +//! outranks what it inherits. + +use std::collections::BTreeMap; + +use lightningcss::printer::PrinterOptions; +use lightningcss::properties::Property; +use lightningcss::rules::CssRule; +use lightningcss::selector::{Component, Selector}; +use lightningcss::stylesheet::{ParserOptions, StyleSheet}; +use lightningcss::traits::ToCss; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +struct Document { + env: Env, + nodes: Vec, +} + +#[derive(Deserialize)] +struct Env { + #[serde(default = "default_scheme")] + color_scheme: String, + #[serde(default = "default_root_font")] + root_font_size: f64, +} + +fn default_scheme() -> String { + "light".to_string() +} + +fn default_root_font() -> f64 { + 16.0 +} + +#[derive(Deserialize)] +struct Node { + slot: String, + #[serde(default)] + classes: Vec, + /// Index into `nodes`; parents precede children. `null` for the root. + parent: Option, +} + +#[derive(Serialize, Deserialize)] +struct Reply { + nodes: Vec, + unsupported: Vec, +} + +#[derive(Serialize, Deserialize)] +struct NodeReply { + slot: String, + declarations: BTreeMap, +} + +/// Text properties that inherit down the synthetic tree — the CSS inherited +/// set restricted to the snapshot vocabulary the writers consume. +const INHERITED: &[&str] = &[ + "color", + "font-family", + "font-size", + "font-style", + "font-weight", + "letter-spacing", + "line-height", + "text-align", +]; + +/// One matched declaration awaiting cascade. +struct Candidate { + specificity: u32, + order: u32, + important: bool, + value: String, +} + +fn better(a: &Candidate, b: &Candidate) -> bool { + (a.important, a.specificity, a.order) > (b.important, b.specificity, b.order) +} + +/// A parsed profile selector: compounds right-to-left, combinator before +/// each non-subject compound (descendant or child only). +struct ProfileSelector { + /// (compound, child_combinator_to_previous) — subject compound first. + compounds: Vec<(Vec, bool)>, + specificity: u32, +} + +enum Simple { + Class(String), + SlotAttr(String), + Root, + Universal, +} + +pub fn resolve_json(css: &[u8], doc: &[u8]) -> Result { + let css = std::str::from_utf8(css).map_err(|e| format!("stylesheet is not UTF-8: {e}"))?; + let doc: Document = serde_json::from_slice(doc).map_err(|e| format!("document JSON: {e}"))?; + for (i, node) in doc.nodes.iter().enumerate() { + if let Some(p) = node.parent { + if p >= i { + return Err(format!( + "node {i} ({}) references parent {p}; parents must precede children", + node.slot + )); + } + } + } + + let mut unsupported: Vec = Vec::new(); + let sheet = StyleSheet::parse(css, ParserOptions::default()) + .map_err(|e| format!("stylesheet parse error: {e}"))?; + + // node -> property -> best candidate; custom properties cascade the + // same way but resolve later. + let n = doc.nodes.len(); + let mut winners: Vec> = (0..n).map(|_| BTreeMap::new()).collect(); + let mut order: u32 = 0; + collect_rules( + &sheet.rules.0, + &doc, + &mut winners, + &mut order, + &mut unsupported, + true, + ); + + // Custom-property chains: parent map extended by own winners. + let mut custom: Vec> = Vec::with_capacity(n); + for (i, node) in doc.nodes.iter().enumerate() { + let mut map = node.parent.map(|p| custom[p].clone()).unwrap_or_default(); + for (name, cand) in winners[i].iter() { + if name.starts_with("--") { + map.insert(name.clone(), cand.value.trim().to_string()); + } + } + custom.push(map); + } + + // Substitute var(), resolve font-size chain, then remaining lengths, + // then inheritance. + let mut resolved: Vec> = Vec::with_capacity(n); + let mut font_sizes: Vec = Vec::with_capacity(n); + for (i, node) in doc.nodes.iter().enumerate() { + let parent_font = node + .parent + .map(|p| font_sizes[p]) + .unwrap_or(doc.env.root_font_size); + let mut out: BTreeMap = BTreeMap::new(); + // font-size first: em/rem lengths on this node resolve against it. + let own_font = match winners[i].get("font-size") { + Some(cand) => match substituted(&cand.value, &custom[i]) { + Ok(value) => { + match resolve_length(&value, parent_font, doc.env.root_font_size, parent_font) { + Ok(px) => { + out.insert("font-size".into(), fmt_px(px)); + px + } + Err(why) => { + unsupported.push(format!( + "{}: font-size: {} — {}", + node.slot, cand.value, why + )); + parent_font + } + } + } + Err(why) => { + unsupported.push(format!("{}: font-size — {}", node.slot, why)); + parent_font + } + }, + None => parent_font, + }; + font_sizes.push(own_font); + + for (name, cand) in winners[i].iter() { + if name == "font-size" || name.starts_with("--") { + continue; + } + let value = match substituted(&cand.value, &custom[i]) { + Ok(v) => v, + Err(why) => { + unsupported.push(format!("{}: {} — {}", node.slot, name, why)); + continue; + } + }; + match concrete_value(&value, own_font, doc.env.root_font_size) { + Ok(v) => { + out.insert(name.clone(), v); + } + Err(why) => { + unsupported.push(format!("{}: {}: {} — {}", node.slot, name, value, why)); + } + } + } + resolved.push(out); + } + + // Inheritance, top-down; a node's own cascade already sits in `resolved`. + for i in 0..n { + if let Some(p) = doc.nodes[i].parent { + for &prop in INHERITED { + if !resolved[i].contains_key(prop) { + if let Some(v) = resolved[p].get(prop).cloned() { + resolved[i].insert(prop.to_string(), v); + } + } + } + } + } + + let reply = Reply { + nodes: doc + .nodes + .iter() + .zip(resolved) + .map(|(node, declarations)| NodeReply { + slot: node.slot.clone(), + declarations, + }) + .collect(), + unsupported, + }; + serde_json::to_string(&reply).map_err(|e| format!("reply serialization: {e}")) +} + +fn collect_rules( + rules: &[CssRule], + doc: &Document, + winners: &mut [BTreeMap], + order: &mut u32, + unsupported: &mut Vec, + media_active: bool, +) { + for rule in rules { + match rule { + CssRule::Style(style) => { + let decls: Vec<(String, String, bool)> = declaration_texts(style, unsupported); + for selector in &style.selectors.0 { + let profile = match profile_selector(selector) { + Ok(p) => p, + Err(why) => { + unsupported.push(format!( + "selector `{}` — {}", + selector_text(selector), + why + )); + continue; + } + }; + if !media_active { + continue; + } + for (i, _node) in doc.nodes.iter().enumerate() { + if matches(&profile, doc, i) { + for (name, value, important) in &decls { + let cand = Candidate { + specificity: profile.specificity, + order: *order, + important: *important, + value: value.clone(), + }; + match winners[i].get(name.as_str()) { + Some(existing) if !better(&cand, existing) => {} + _ => { + winners[i].insert(name.clone(), cand); + } + } + } + } + } + } + *order += 1; + } + CssRule::Media(media) => { + let query = media + .query + .to_css_string(PrinterOptions::default()) + .unwrap_or_default(); + match scheme_match(&query, &doc.env.color_scheme) { + Some(active) => collect_rules( + &media.rules.0, + doc, + winners, + order, + unsupported, + media_active && active, + ), + None => unsupported.push(format!( + "@media {query} — only (prefers-color-scheme: …) is in the profile" + )), + } + } + other => { + let text = other + .to_css_string(PrinterOptions::default()) + .unwrap_or_else(|_| "".into()); + let head: String = text.chars().take(60).collect(); + unsupported.push(format!("rule `{head}` — outside the style/media profile")); + } + } + } +} + +fn declaration_texts( + style: &lightningcss::rules::style::StyleRule, + unsupported: &mut Vec, +) -> Vec<(String, String, bool)> { + let mut out = Vec::new(); + let mut push = |prop: &Property, important: bool, unsupported: &mut Vec| { + let name = prop.property_id().name().to_string(); + match prop.value_to_css_string(PrinterOptions::default()) { + Ok(value) => out.push((name, value, important)), + Err(e) => unsupported.push(format!("declaration {name} — unserializable: {e}")), + } + }; + for prop in &style.declarations.declarations { + push(prop, false, unsupported); + } + for prop in &style.declarations.important_declarations { + push(prop, true, unsupported); + } + out +} + +fn selector_text(selector: &Selector) -> String { + selector + .to_css_string(PrinterOptions::default()) + .unwrap_or_else(|_| "".into()) +} + +/// Lower a parsed selector into the profile, or say exactly why not. +fn profile_selector(selector: &Selector) -> Result { + use lightningcss::selector::Combinator; + + let mut compounds: Vec<(Vec, bool)> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut child_next = false; + let mut iter = selector.iter(); + loop { + for component in &mut iter { + match component { + Component::Class(name) => current.push(Simple::Class(name.to_string())), + Component::AttributeInNoNamespace { + local_name, + operator, + value, + .. + } => { + if local_name.as_ref() != "data-xy-slot" { + return Err(format!( + "attribute selector [{local_name}] — only [data-xy-slot=…] is in the profile" + )); + } + if !matches!( + operator, + parcel_selectors::attr::AttrSelectorOperator::Equal + ) { + return Err("only [data-xy-slot=\"…\"] equality is in the profile".into()); + } + current.push(Simple::SlotAttr(value.to_string())); + } + Component::ExplicitUniversalType => current.push(Simple::Universal), + Component::Root => current.push(Simple::Root), + other => { + return Err(format!( + "component {:?} — outside the class/slot/universal/:root profile", + component_kind(other) + )); + } + } + } + compounds.push((std::mem::take(&mut current), child_next)); + match iter.next_sequence() { + Some(Combinator::Descendant) => child_next = false, + Some(Combinator::Child) => child_next = true, + Some(other) => { + return Err(format!( + "combinator {other:?} — only descendant and child are in the profile" + )) + } + None => break, + } + } + Ok(ProfileSelector { + compounds, + specificity: selector.specificity(), + }) +} + +fn component_kind(component: &Component) -> &'static str { + match component { + Component::ID(..) => "#id", + Component::LocalName(..) => "type selector", + Component::NonTSPseudoClass(..) => "pseudo-class", + Component::PseudoElement(..) => "pseudo-element", + Component::Negation(..) => ":not()", + Component::Is(..) => ":is()", + Component::Where(..) => ":where()", + _ => "unsupported selector component", + } +} + +fn compound_matches(compound: &[Simple], doc: &Document, index: usize) -> bool { + let node = &doc.nodes[index]; + compound.iter().all(|simple| match simple { + Simple::Class(name) => node.classes.iter().any(|c| c == name), + Simple::SlotAttr(slot) => node.slot == *slot, + Simple::Root => node.parent.is_none(), + Simple::Universal => true, + }) +} + +fn matches(profile: &ProfileSelector, doc: &Document, index: usize) -> bool { + // Subject compound first, then walk ancestors per combinator. + let mut compounds = profile.compounds.iter(); + let Some((subject, _)) = compounds.next() else { + return false; + }; + if !compound_matches(subject, doc, index) { + return false; + } + let mut position = index; + for (compound, child) in compounds { + if *child { + match doc.nodes[position].parent { + Some(p) if compound_matches(compound, doc, p) => position = p, + _ => return false, + } + } else { + let mut cursor = doc.nodes[position].parent; + loop { + match cursor { + Some(p) => { + if compound_matches(compound, doc, p) { + position = p; + break; + } + cursor = doc.nodes[p].parent; + } + None => return false, + } + } + } + } + true +} + +/// `(prefers-color-scheme: X)` → Some(X == env); anything else → None. +fn scheme_match(query: &str, scheme: &str) -> Option { + let q: String = query + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + match q.as_str() { + "(prefers-color-scheme:dark)" => Some(scheme == "dark"), + "(prefers-color-scheme:light)" => Some(scheme == "light"), + _ => None, + } +} + +/// Substitute var() with the node's custom-property chain, fallbacks +/// honored, to a fixpoint with a depth cap. +fn substituted(value: &str, custom: &BTreeMap) -> Result { + let mut text = value.to_string(); + for _ in 0..8 { + let Some(at) = text.find("var(") else { + return Ok(text); + }; + let body_start = at + 4; + let mut depth = 1usize; + let mut end = None; + for (offset, ch) in text[body_start..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = Some(body_start + offset); + break; + } + } + _ => {} + } + } + let end = end.ok_or_else(|| "unbalanced var(".to_string())?; + let body = &text[body_start..end]; + let (name, fallback) = match body.find(',') { + Some(comma) => (body[..comma].trim(), Some(body[comma + 1..].trim())), + None => (body.trim(), None), + }; + let replacement = match custom.get(name) { + Some(v) => v.clone(), + None => fallback + .map(str::to_string) + .ok_or_else(|| format!("var({name}) has no value and no fallback"))?, + }; + text.replace_range(at..=end, &replacement); + } + Err("var() nesting exceeded the resolver's depth cap".into()) +} + +fn fmt_px(px: f64) -> String { + // Round to 1/10000 px and trim zeros: 0.025em * 14px is 0.35px, not the + // binary-float tail — computed values compare stringly downstream. + let rounded = (px * 10000.0).round() / 10000.0; + if (rounded - rounded.round()).abs() < 1e-9 { + format!("{}px", rounded.round() as i64) + } else { + let text = format!("{rounded:.4}"); + format!("{}px", text.trim_end_matches('0').trim_end_matches('.')) + } +} + +/// Resolve one bare length. `own_font` is the em base (parent font for the +/// font-size property itself, own font for everything else). +fn resolve_length( + value: &str, + em_base: f64, + rem_base: f64, + _parent_font: f64, +) -> Result { + let v = value.trim(); + if let Some(number) = v.strip_suffix("px") { + return number + .trim() + .parse::() + .map_err(|_| format!("unparseable px length `{v}`")); + } + if let Some(number) = v.strip_suffix("rem") { + return number + .trim() + .parse::() + .map(|n| n * rem_base) + .map_err(|_| format!("unparseable rem length `{v}`")); + } + if let Some(number) = v.strip_suffix("em") { + return number + .trim() + .parse::() + .map(|n| n * em_base) + .map_err(|_| format!("unparseable em length `{v}`")); + } + if let Ok(number) = v.parse::() { + return Ok(number); + } + Err("not a px/em/rem length".into()) +} + +/// Make one declaration value concrete: resolve em/rem tokens against the +/// font-size cascade; refuse percentages outside color functions and +/// anything still cascade-dependent. Values with no length tokens pass +/// through as Lightning CSS normalized them. +fn concrete_value(value: &str, own_font: f64, rem_base: f64) -> Result { + let lowered = value.to_ascii_lowercase(); + if lowered.contains("calc(") || lowered.contains("env(") || lowered.contains("attr(") { + return Err("still depends on a cascade/environment the writers do not have".into()); + } + if lowered == "inherit" || lowered == "initial" || lowered == "unset" || lowered == "revert" { + return Err("cascade keyword".into()); + } + // Token-wise em/rem resolution; % outside color functions is refused. + let masked = mask_color_functions(&lowered); + if masked.contains('%') { + return Err("percentage length — resolved lengths are px (percentages are accepted only as rgb()/hsl() color components)".into()); + } + let mut out = String::with_capacity(value.len()); + let mut token = String::new(); + let flush = |token: &mut String, out: &mut String| -> Result<(), String> { + if token.is_empty() { + return Ok(()); + } + let t = std::mem::take(token); + let lower = t.to_ascii_lowercase(); + if lower.ends_with("em") && lower[..lower.len() - 2].parse::().is_ok() + || lower.ends_with("rem") && lower[..lower.len() - 3].parse::().is_ok() + { + let px = resolve_length(&lower, own_font, rem_base, own_font)?; + out.push_str(&fmt_px(px)); + } else { + out.push_str(&t); + } + Ok(()) + }; + for ch in value.chars() { + if ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')' { + flush(&mut token, &mut out)?; + out.push(ch); + } else { + token.push(ch); + } + } + flush(&mut token, &mut out)?; + Ok(out) +} + +/// Blank out rgb()/rgba()/hsl()/hsla() bodies so their % components do not +/// trip the percentage refusal — the same exemption the Python schema makes. +fn mask_color_functions(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let rest = &text[i..]; + let head = ["rgba(", "rgb(", "hsla(", "hsl("] + .iter() + .find(|h| rest.starts_with(**h)); + if let Some(h) = head { + let start = i + h.len(); + if let Some(close) = text[start..].find(')') { + out.push_str(h); + out.push(')'); + i = start + close + 1; + continue; + } + } + out.push(text[i..].chars().next().unwrap()); + i += text[i..].chars().next().unwrap().len_utf8(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc() -> String { + serde_json::json!({ + "env": {"color_scheme": "dark", "root_font_size": 16.0}, + "nodes": [ + {"slot": "root", "classes": ["dark"], "parent": null}, + {"slot": "chrome", "classes": [], "parent": 0}, + {"slot": "tick_label", "classes": ["smoke-tick"], "parent": 1}, + {"slot": "legend", "classes": [], "parent": 1}, + ] + }) + .to_string() + } + + fn run(css: &str) -> Reply { + let json = resolve_json(css.as_bytes(), doc().as_bytes()).unwrap(); + serde_json::from_str(&json).unwrap() + } + + fn decls<'r>(reply: &'r Reply, slot: &str) -> &'r BTreeMap { + &reply + .nodes + .iter() + .find(|n| n.slot == slot) + .unwrap() + .declarations + } + + #[test] + fn class_and_slot_selectors_cascade_by_specificity_and_order() { + let reply = run(".smoke-tick { color: rgb(7, 89, 133); }\n\ + [data-xy-slot=\"tick_label\"] { color: red; font-weight: 600; }\n\ + .dark .smoke-tick { color: rgb(1, 2, 3); }"); + let d = decls(&reply, "tick_label"); + // Two class selectors (0,2,0) beat one attribute (0,1,0); later of + // equal specificity wins. Lightning CSS normalizes color spellings + // (rgb -> #hex); the differential smoke compares colors parsed, not + // as strings, for exactly this reason. + assert_eq!(d["color"], "#010203"); + assert_eq!(d["font-weight"], "600"); + } + + #[test] + fn important_outranks_specificity() { + let reply = run("[data-xy-slot=\"tick_label\"] { color: red !important; }\n\ + .dark .smoke-tick { color: blue; }"); + assert_eq!(decls(&reply, "tick_label")["color"], "red"); + } + + #[test] + fn var_chain_inherits_and_falls_back() { + let reply = run(":root { --fg: rgb(9, 9, 9); }\n\ + .smoke-tick { color: var(--fg); background: var(--missing, rgb(4, 5, 6)); }"); + let d = decls(&reply, "tick_label"); + assert_eq!(d["color"], "#090909"); + assert_eq!(d["background"], "#040506"); // fallbacks normalize like any color + } + + #[test] + fn em_resolves_against_the_font_size_cascade() { + let reply = run(":root { font-size: 20px; }\n\ + [data-xy-slot=\"tick_label\"] { font-size: 0.5em; letter-spacing: 0.1em; }"); + let d = decls(&reply, "tick_label"); + // font-size em uses the PARENT size (root 20px -> 10px); other + // lengths use the node's OWN resolved size (10px -> 1px). + assert_eq!(d["font-size"], "10px"); + assert_eq!(d["letter-spacing"], "1px"); + } + + #[test] + fn media_scheme_gates_and_inheritance_flows() { + let reply = run( + "@media (prefers-color-scheme: dark) { :root { color: rgb(8, 8, 8); } }\n\ + @media (prefers-color-scheme: light) { :root { color: rgb(7, 7, 7); } }", + ); + // env is dark; color inherits to every descendant. + assert_eq!(decls(&reply, "legend")["color"], "#080808"); + } + + #[test] + fn out_of_profile_constructs_are_reported_not_guessed() { + let reply = run(".smoke-tick:hover { color: red; }\n\ + @keyframes spin { from { opacity: 0; } }\n\ + .smoke-tick { width: 50%; }"); + assert!(decls(&reply, "tick_label").get("color").is_none()); + assert_eq!(reply.unsupported.len(), 3, "{:?}", reply.unsupported); + } + + #[test] + fn child_combinator_requires_direct_parent() { + let reply = run( + "[data-xy-slot=\"root\"] > [data-xy-slot=\"tick_label\"] { color: red; }\n\ + [data-xy-slot=\"chrome\"] > [data-xy-slot=\"tick_label\"] { font-weight: 700; }", + ); + let d = decls(&reply, "tick_label"); + assert!(d.get("color").is_none(), "root is not the direct parent"); + assert_eq!(d["font-weight"], "700"); + } +} diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index 77043be3..b89545a6 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -85,16 +85,12 @@ implementation. The bullets below are the boundaries that page's rows imply. - **Styling does not survive every export path equally, and the boundary is published rather than left to be discovered.** Mark, axis, and chart-level `style=` reach all three renderers. Per-slot `styles={slot: {...}}` reaches - them for the nine slots that name chrome a static file contains — `title`, - `axis_title`, `tick_label`, the three legend slots, and the three colorbar - slots — carrying `font-size`, `font-weight`, `font-style`, `font-family`, - `letter-spacing`, `opacity`, and the text paint. The remaining slots are live - chrome (`tooltip*`, `modebar*`, `crosshair_*`, `selection`, `badge*`) with - nothing in a file to paint, and `class_names={slot: "..."}` cannot apply in a - file at all: a class selects a rule out of a stylesheet an exported file does - not have. The native raster's baked atlas is one face, so PNG/JPEG/WebP honor - a slot's size and paint but not its typeface. `xy.colorbar(style=...)` still - has no native channel — use the `colorbar_*` slots. The full matrix is + them for the seventeen slots that name chrome a static file contains — the box + slots `root`, `chrome`, `canvas` and `title` (background, border, radius, + opacity; `chrome` is background/opacity only), `annotation_label` with the + same box subset under its own `style=`, `annotation_layer` (opacity + + background) and `labels` (container defaults), plus `axis_title`, + `tick_label`, the three legend slots, and the three colorbar slots carrying [Static export §9](https://github.com/reflex-dev/xy/blob/main/spec/api/export.md), pinned by `tests/test_export_style_survival.py`. - Native PNG cannot apply author `custom_css`, and neither can native SVG, PDF, diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 59558079..b9db104c 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -12,7 +12,7 @@ and *does the change survive where I need it*. This page answers both from the registry the implementation is checked against. - **11** mark style properties across **21** mark kinds, drawn by all three renderers. -- **48** stable chrome slots for CSS and Tailwind in the browser. +- **48** stable chrome slots for CSS and Tailwind in the browser — **23** of them in a clean static export, **25** gated by an interaction or view state. - **1** way to add a mark kind XY does not ship, without forking it. ## Mark style properties @@ -54,69 +54,85 @@ raster and vector writers have no cascade, so per-slot styling is a browser mechanism; put anything that must survive export in the chart-level `style=` token bag or in mark and axis `style=`, which every renderer reads. -| slot | browser | native raster | native vector | -|---|---|---|---| -| `root` | full | partial | partial | -| `title` | full | partial | partial | -| `chrome` | full | none | none | -| `canvas` | full | none | none | -| `annotation_layer` | full | none | none | -| `labels` | full | none | none | -| `legend` | full | partial | partial | -| `legend_title` | full | partial | partial | -| `legend_item` | full | none | none | -| `legend_swatch` | full | none | none | -| `legend_label` | full | partial | partial | -| `colorbar` | full | partial | partial | -| `colorbar_bar` | full | none | none | -| `colorbar_extension` | full | none | none | -| `colorbar_line` | full | none | none | -| `colorbar_tick` | full | partial | partial | -| `colorbar_minor_tick` | full | none | none | -| `colorbar_title` | full | partial | partial | -| `tooltip` | full | none | none | -| `tooltip_title` | full | none | none | -| `tooltip_row` | full | none | none | -| `tooltip_label` | full | none | none | -| `tooltip_value` | full | none | none | -| `modebar` | full | none | none | -| `modebar_drag_handle` | full | none | none | -| `modebar_control_group` | full | none | none | -| `modebar_separator` | full | none | none | -| `modebar_button` | full | none | none | -| `modebar_icon` | full | none | none | -| `modebar_zoom_value` | full | none | none | -| `modebar_indicator` | full | none | none | -| `modebar_selection_icon` | full | none | none | -| `modebar_menu` | full | none | none | -| `modebar_menu_separator` | full | none | none | -| `modebar_menu_icon` | full | none | none | -| `modebar_menu_label` | full | none | none | -| `modebar_history_controls` | full | none | none | -| `selection` | full | none | none | -| `crosshair_x` | full | none | none | -| `crosshair_y` | full | none | none | -| `badge` | full | none | none | -| `badge_item` | full | none | none | -| `axis_band` | full | none | none | -| `axis_line` | full | none | none | -| `tick_mark` | full | none | none | -| `tick_label` | full | partial | partial | -| `axis_title` | full | partial | partial | -| `annotation_label` | full | none | none | +The *applicable in* column says which export contains the slot at all: a clean +static export has no tooltip, modebar, crosshair, selection overlay, or +reduction badge, so styling one is not lost in such a file — it simply is not +there. Ask `chart.style_compatibility_report(target=...)` for the per-chart +answer: it routes every declared style for a target and names anything that +would not survive, before any bytes exist. + +| slot | applicable in | browser | native raster | native vector | +|---|---|---|---|---| +| `root` | clean static | full | partial | partial | +| `title` | clean static | full | partial | partial | +| `chrome` | clean static | full | partial | partial | +| `canvas` | clean static | full | partial | partial | +| `annotation_layer` | clean static | full | partial | partial | +| `labels` | clean static | full | partial | partial | +| `legend` | clean static | full | partial | partial | +| `legend_title` | clean static | full | partial | partial | +| `legend_item` | clean static | full | partial | partial | +| `legend_swatch` | clean static | full | partial | partial | +| `legend_label` | clean static | full | partial | partial | +| `colorbar` | clean static | full | partial | partial | +| `colorbar_bar` | clean static | full | none | none | +| `colorbar_extension` | clean static | full | none | none | +| `colorbar_line` | clean static | full | none | none | +| `colorbar_tick` | clean static | full | partial | partial | +| `colorbar_minor_tick` | clean static | full | none | none | +| `colorbar_title` | clean static | full | partial | partial | +| `tooltip` | hover state | full | none | none | +| `tooltip_title` | hover state | full | none | none | +| `tooltip_row` | hover state | full | none | none | +| `tooltip_label` | hover state | full | none | none | +| `tooltip_value` | hover state | full | none | none | +| `modebar` | modebar state | full | none | none | +| `modebar_drag_handle` | modebar state | full | none | none | +| `modebar_control_group` | modebar state | full | none | none | +| `modebar_separator` | modebar state | full | none | none | +| `modebar_button` | modebar state | full | none | none | +| `modebar_icon` | modebar state | full | none | none | +| `modebar_zoom_value` | modebar state | full | none | none | +| `modebar_indicator` | modebar state | full | none | none | +| `modebar_selection_icon` | modebar state | full | none | none | +| `modebar_menu` | modebar state | full | none | none | +| `modebar_menu_separator` | modebar state | full | none | none | +| `modebar_menu_icon` | modebar state | full | none | none | +| `modebar_menu_label` | modebar state | full | none | none | +| `modebar_history_controls` | modebar state | full | none | none | +| `selection` | selection state | full | none | none | +| `crosshair_x` | crosshair state | full | none | none | +| `crosshair_y` | crosshair state | full | none | none | +| `badge` | view state | full | none | none | +| `badge_item` | view state | full | none | none | +| `axis_band` | navigation state | full | none | none | +| `axis_line` | clean static | full | partial | partial | +| `tick_mark` | clean static | full | partial | partial | +| `tick_label` | clean static | full | partial | partial | +| `axis_title` | clean static | full | partial | partial | +| `annotation_label` | clean static | full | partial | partial | ### Notes -- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export. -- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`legend`** (via `styles={'legend': ...} / xy.legend(style=...) / --chart-legend-bg`) — The frame box. Both spellings and the theme token now converge on one merged declaration block before the writers see it, so what agrees in the browser agrees in a PNG. `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em` are honored; an explicit background paints opaque, as it does in the browser. -- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. -- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`root`** (via `styles={'root': ...} / chart style=`) — Box slot: both writers honor background, border (color/width/style, dashed/dotted as dash arrays), symmetric border-radius, opacity and fill-opacity through the shared chrome-box lowering (`xy._chromebox.lower_box`); everything it cannot draw is a named loss in the preflight, never silent (§28). The root box is the figure patch: its fill replaces the `theme(background=)` token when both are set (same element, one background property, matching the browser), and an export `background=` override silences it (`_svg.apply_export_background` is the one precedence definition). box-shadow would fall outside the canvas and is a named loss; text properties have no root text to style. The chart-level `style=` token bag still reaches every renderer. +- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. The title also takes the full box vocabulary (`_svg.SLOT_BOX_PROPS`): a box under the text, sized to the measured block plus padding, with the title band growing to fit. Per-entry `xy.title(style=...)` box properties are native-only (KNOWN_RENDERER_DIVERGENCES `title_entry_box_allowlist`). +- **`chrome`** (via `styles={'chrome': ...}`) — Background and opacity only (parity plan §8 flag G): one full-canvas backdrop above the root and plot fills, below the grid. The rest of the box vocabulary is a named preflight loss, and the browser's own stacking of this slot against titles diverges by design (KNOWN_RENDERER_DIVERGENCES `chrome_slot_title_stacking`). +- **`canvas`** (via `styles={'canvas': ...}`) — Box slot: both writers honor background, border (color/width/style, dashed/dotted as dash arrays), symmetric border-radius, opacity and fill-opacity through the shared chrome-box lowering (`xy._chromebox.lower_box`); everything it cannot draw is a named loss in the preflight, never silent (§28). Painted at the above-grid seam, so a canvas background hides the grid exactly as the browser's marks canvas does; border-radius clips the marks through a dedicated clipPath in SVG/PDF and opacity rides the marks group there. The raster display list clips rectangles only and has no group compositing, so border-radius and opacity are named raster losses (`_svg.SLOT_BOX_RASTER_UNSUPPORTED`) until the rounded-clip opcode lands. An export `background=` override silences a canvas background like the plot token. +- **`annotation_layer`** (via `styles={'annotation_layer': ...}`) — The annotation-shape overlay. `opacity` dims every annotation shape as a group (never the labels, which live in the labels container): SVG/PDF as real group opacity on a ``, raster folded into each shape's RGBA because the display list has no group compositing — overlapping translucent shapes double-blend there, a recorded approximation (§28). `background` paints under the shapes, plot-clipped; the live overlay is full-bleed, a divergence recorded in KNOWN_RENDERER_DIVERGENCES. Everything else stays browser-only. +- **`labels`** (via `styles={'labels': ...}`) — The label container. Its color is the default under the live chain `var(--chart-text, inherit)` for every contained text (tick labels, axis titles, annotation labels): the theme token wins, then the container color, then the writer default — the axis's own colors and the specific slots stay narrower and win. Typography folds under the contained slots exactly where the live stylesheet leaves the property un-ruled (font-size/weight cascade into tick labels only; style/family/letter-spacing into all three). `background` paints full-bleed under the axis rules and every label text, the live order; the residual sibling stacking difference is in KNOWN_RENDERER_DIVERGENCES. `opacity` rides the SVG label group (vector-only); live it also dims the contained axis rules and the container background — recorded here rather than approximated. +- **`legend`** (via `styles={'legend': ...} / xy.legend(style=...) / --chart-legend-bg`) — The frame box, drawn through the shared chrome-box lowering (`xy._chromebox.lower_box`) in both writers. All three sources converge on one merged declaration before the writers see it, in the CSS and the camelCase spelling alike, so what agrees in the browser agrees in a PNG: `background`, `border-color`/`border-width`/`border-style`, `border-radius` (the authored value, not a pinned 4), `box-shadow`, `opacity`, `--xy-legend-frame-alpha`, and `padding`/`row-gap`/`gap` in resolved px or the legend's historical `em`. Padding and row-gap resize the frame in the exports, in pyplot's anchored-legend room reservation and in its best-location scoring together — one geometry, four consumers. An explicit background paints opaque, as it does in the browser, and `background: transparent` drops the frame entirely (Matplotlib `frameon=False`). A `box-shadow` carrying blur or spread draws the writers' offset-rect approximation and records the blur as a named loss (§28); the frame's alpha dims its border with it, matching the single translucent element the browser paints. +- **`legend_title`** (via `styles={'legend_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`legend_item`** (via `styles={'legend_item': ...}`) — The per-row cell of the legend, one instance per visible entry, drawn under that row's swatch and label and over the frame and title. Box vocabulary only (`_svg.SLOT_BOX_PROPS`): the row has no text of its own, and its size comes from the legend layout, so `padding` is refused rather than accepted and ignored. +- **`legend_swatch`** (via `styles={'legend_swatch': ...}`) — The handle cell of a legend row. On a patch entry the swatch IS the patch, so a declared background or border wins over the trace's own paint (browser precedence: the slot rule is applied after the per-entry paint variables) and a declared border-radius replaces the historical `rx=2`; on a marker or line entry the box paints behind the handle, which keeps its own ink. Box vocabulary only, padding excluded for the same reason as `legend_item`. +- **`legend_label`** (via `styles={'legend_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar`** (via `styles={'colorbar': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar_tick`** (via `styles={'colorbar_tick': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`colorbar_title`** (via `styles={'colorbar_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. +- **`axis_line`** (via `styles={'axis_line': ...}`) — Spines as boxes when box properties are declared: background, border (color/width/style, dashed/dotted as dash arrays), symmetric border-radius, offset box-shadow (blur/spread recorded unrepresentable), opacity and fill-opacity. The spine keeps its axis_color ink unless the slot declares a background (an explicit transparent erases it, as in the browser). Writers center the box on the plot edge where the unstyled stroke ran; the browser insets right/bottom spines (see KNOWN_RENDERER_DIVERGENCES). Polar spines stay strokes — the browser shares the limit (DIV spines cannot express a circle). +- **`tick_mark`** (via `styles={'tick_mark': ...}`) — Tick marks as boxes when box properties are declared: background, border (color/width/style, dashed/dotted as dash arrays), symmetric border-radius, offset box-shadow (blur/spread recorded unrepresentable), opacity and fill-opacity. Geometry is the centered stroke's own coverage — the same pixels as the browser's rect. Marks exist only where an axis authors tick_length > 0; a zero-length tick draws nothing (and casts no shadow) — the preflight carries the note rather than a length being invented. tick_color stays the narrower paint selector; polar has no cartesian tick marks (recorded). +- **`tick_label`** (via `styles={'tick_label': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. Additionally a per-label box: background, border (color/width/style, dashed/dotted as dash arrays), symmetric border-radius, offset box-shadow (blur/spread recorded unrepresentable), opacity and fill-opacity, with padding growing the axis gutters so the box stays on the canvas (cartesian; the polar label ring keeps its flat 30px allowance). Box geometry is measured with the writers' DejaVu metrics, so an authored font-family renders its own glyphs inside a DejaVu-measured box (recorded misfit); letter-spacing is likewise outside the gutter measurement. On the raster writer a declared opacity reaches the box, not the glyphs (the atlas blit has no alpha channel). +- **`axis_title`** (via `styles={'axis_title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps any declared family onto the base-14 Helvetica faces (regular/bold/oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster atlas carries regular, bold and italic faces, so font-size, the paint, font-weight and font-style survive there too — font-family, letter-spacing and opacity remain vector-only rather than silently approximated. Properties outside the subset stay browser-only. Additionally a per-title box: background, border (color/width/style, dashed/dotted as dash arrays), symmetric border-radius, offset box-shadow (blur/spread recorded unrepresentable), opacity and fill-opacity; a rotated y-title box is pre-rotated to a polygon (radius 0) or an arc path (radius > 0), staying inside the PDF closed subset. The axis's own label_* keys win per property over the slot (label_color, label_font_family/style/weight); font-size runs the other way — the slot's font-size wins over label_size (pre-existing, documented in spec/api/styling.md). DejaVu-measured box vs authored-family text and raster box-not-glyph opacity are recorded exactly as for tick_label. +- **`annotation_label`** (via `styles={'annotation_label': ...}`) — The per-slot text subset plus the shared chrome-box model (`xy._svg.SLOT_BOX_PROPS`): background, border — with solid/dashed/dotted lowered to a dash pattern and other border styles drawn solid and recorded (§28) — border-radius, CSS 1-4 value padding, offset box-shadow (blur/spread recorded unrepresentable), and whole-label opacity. The annotation's own `style=` is the narrower selector and wins per property group, matching the browser's slot-then-inline order. em font sizes resolve against the label's own 11px default. Vertical (rotation 90/270) labels keep only size and paint in SVG — a pre-existing limit of the rotated text path. ## Extension points @@ -136,6 +152,14 @@ an undocumented difference reads as a bug; a documented one is a contract. | what | browser | svg | native png | visible when | |---|---|---|---|---| | Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle | +| A styled `chrome` slot background against the title text and the plot fill | the chrome canvas is appended AFTER the title divs (js/src/50_chartview.ts) and its CSS background paints under its own bitmap, so the backdrop covers titles and sits below --chart-bg | one rect between the backgrounds and the grid group: above the root and plot fills, below every grid line and all chrome text | same seam as SVG (after the plot fill, before the plot clip) | styles={'chrome': {'background': ...}} overlaps a title, or is combined with a --chart-bg plot fill | +| Box styling authored on a per-entry title `style=` (not the title slot) | dropped: the client copies only color/font-family/font-size/font-style/font-weight from an entry's style onto the title div (js/src/50_chartview.ts entry-style allowlist) | honored: `_title_metrics` merges entry style over the slot, box included | honored, same merge (the two writers share the title placement) | xy.title(style={'background': ...}) or another per-entry box property | +| The annotation_layer slot's background extent | full-bleed (the overlay canvas is inset:0 over the whole chart) | plot rect, inside the marks clip (the only seam above traces and below shapes) | plot rect, under the active marks clip (same seam as SVG) | styles={'annotation_layer': {'background': ...}} is declared | +| Where the labels-container background sits among its siblings (flag D) | over the chart title (the container is a later DOM sibling), under the axis rules and label texts it contains | under the axis rules and label texts (the resolved flag-D order), and under the title/legend/colorbar chrome, which joins later | same as SVG: filled after the marks, before the chrome text phase | styles={'labels': {'background': ...}} on a chart with a title, legend or colorbar | +| How the annotation_layer slot's opacity composites overlapping shapes | group opacity: the overlay canvas is dimmed once as a whole | group opacity on the wrapping , PDF-legal, same as live | folded into each shape's RGBA (no group compositing opcode): overlapping translucent shapes double-blend | the slot declares opacity below 1 over overlapping annotation shapes | +| How a legend slot's `opacity` composites its box | group opacity: the element and its children fade once, together | `opacity` on the box element, PDF-legal, same as live | premultiplied into the box's own RGBA (the display list has no group-compositing opcode), so a translucent frame does not also fade the swatches and labels drawn over it, and overlapping translucent boxes double-blend | styles={'legend'|'legend_item'|'legend_swatch': {'opacity': <1}} | +| Whether the legend frame's alpha also dims its border (flag B) | one translucent element: the border fades with the fill | `stroke-opacity` carries the frame alpha, matching live | the same alpha folded into the border RGBA | the default grey frame, or --xy-legend-frame-alpha below 1 | +| Where an axis spine's box sits relative to the plot edge | right/bottom spines inset by their own width (DIVs laid inside the box) | centered on the plot edge, where the unstyled stroke has always run | centered on the plot edge (same shared box producer as SVG) | axis_width above ~2px, or a styled axis_line box under a magnifier | For what is still alpha, see [Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/). diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 526d659e..a942756f 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -358,20 +358,27 @@ apply it with. Rather than leave that to be discovered, it is a contract: | --- | --- | --- | --- | | mark / axis `style=` | yes | yes | yes | | chart-level `style=` (design tokens) | yes | yes | yes | -| `styles={slot: {...}}` | yes, all 48 slots | text subset, 9 slots | text subset, 9 slots | +| `styles={slot: {...}}` | yes, all 48 slots | text/box subset, 12 slots | text/box subset, 12 slots | | `class_names={slot: "..."}` | yes, all 48 slots | dropped | dropped | | `custom_css=` | yes | raises | raises | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | | `xy.colorbar(style=...)` | yes | dropped | dropped | -A per-slot `styles=` block reaches a file for the nine slots that name chrome a -file actually contains — `title`, `axis_title`, `tick_label`, the three legend -slots and the three colorbar slots — carrying `font-size`, `font-weight`, -`font-style`, `font-family`, `letter-spacing`, `opacity` and the text paint. -The rest are live-only chrome (`tooltip*`, `modebar*`, `crosshair_*`, -`selection`, `badge*`) with nothing in a file to paint. The native raster's -baked atlas is one face, so it honors a slot's size and paint and leaves the -typeface properties to the vector writers. +A per-slot `styles=` block reaches a file for the twelve slots that name +chrome a file actually contains. The box slots `root`, `chrome` and `canvas` +carry background, border, symmetric border-radius and opacity (`chrome` is +background/opacity only; the raster cannot round-clip or group-fade the +marks, so a `canvas` radius/opacity survives SVG/PDF and is a named loss on +PNG). The text slots — `title`, `axis_title`, `tick_label`, the three legend +slots and the three colorbar slots — carry `font-size`, `font-weight`, +`font-style`, `font-family`, `letter-spacing`, `opacity` and the text paint, +and the title additionally draws a box under its text (padding and border +included, with the title band growing to fit). The rest are live-only chrome +(`tooltip*`, `modebar*`, `crosshair_*`, `selection`, `badge*`) with nothing +in a file to paint. The raster atlas carries regular, bold and italic faces +but no family axis, so it honors a slot's size, paint, weight and style and +leaves `font-family`, `letter-spacing` and text opacity to the vector +writers. The `class_names` row is dropped rather than raising: raising would break every native export of a chart that carries Tailwind classes for its live view, which diff --git a/hatch_build.py b/hatch_build.py index 901621fe..0cea6631 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -180,6 +180,22 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: build_data.setdefault("force_include", {})[str(native_src)] = ( f"xy/_native_lib/{lib_name}" ) + # The optional cascade extension builds in the same workspace + # cargo invocation (default-members) and lands beside the core. + # Ship it when present; a wheel without it still imports and + # exports — only style_source="native_cascade" raises, with the + # build instruction (§28: optional is announced, not silent). + cascade_name = lib_name.replace("xy_core", "xy_cascade") + cascade_src = native_src.parent / cascade_name + if cascade_src.is_file(): + build_data["force_include"][str(cascade_src)] = f"xy/_native_lib/{cascade_name}" + else: + print( + "xy: wheel built WITHOUT the optional xy-cascade extension " + f"({cascade_src} not found); style_source='native_cascade' " + "will raise with the build instruction.", + file=sys.stderr, + ) else: # No toolchain / build skipped: ship a pure-Python wheel (the JS # client is included via committed package data). There is no NumPy diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 9f1482eb..9a17e9c2 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -46,7 +46,7 @@ // v12: polar angular axes carry `sector`/`grid_shape`, and radial axes carry // `hole`/`r_origin`. A v11 client would accept those fields but silently draw // full circular, centre-origin geometry. -export const PROTOCOL = 12; +export const PROTOCOL = 13; // Every GL buffer field a built trace — or a drill / sample-overlay clone of // one — can own. Teardown reads this list instead of a hand-kept subset: the diff --git a/js/src/14_style_snapshot.ts b/js/src/14_style_snapshot.ts new file mode 100644 index 00000000..4f175541 --- /dev/null +++ b/js/src/14_style_snapshot.ts @@ -0,0 +1,105 @@ +// @generated by scripts/gen_style_snapshot_types.py — do not edit by hand. +// +// The TypeScript mirror of `python/xy/styling/resolved.py` (schema v1). +// Concrete values only: no var()/calc(), no relative units — the Python +// side rejects them at construction and the capture side must never +// produce them. Wire shape: spec/design/wire-protocol.md §8. + +export const STYLE_SNAPSHOT_VERSION = 1 as const; + +export const STYLE_SNAPSHOT_PAINT_PROPERTIES = [ + "color", + "fill", + "background", + "background-image", + "opacity", + "fill-opacity", + "stroke", + "stroke-opacity", + "stroke-width", + "border-color", + "border-style", + "border-width", + "border-radius", + "box-shadow", +] as const; + +export const STYLE_SNAPSHOT_TYPOGRAPHY_PROPERTIES = [ + "font-family", + "font-size", + "font-style", + "font-weight", + "letter-spacing", + "line-height", + "text-align", + "xy-rotation", +] as const; + +export const STYLE_SNAPSHOT_LAYOUT_PROPERTIES = [ + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "gap", + "width", + "height", + "max-width", + "max-height", + "transform", + "clip-path", +] as const; + +export const STYLE_SNAPSHOT_EFFECT_PROPERTIES = [ + "filter", + "mix-blend-mode", + "isolation", + "mask", +] as const; + +export const STYLE_SNAPSHOT_PROPERTIES = [ + ...STYLE_SNAPSHOT_PAINT_PROPERTIES, + ...STYLE_SNAPSHOT_TYPOGRAPHY_PROPERTIES, + ...STYLE_SNAPSHOT_LAYOUT_PROPERTIES, + ...STYLE_SNAPSHOT_EFFECT_PROPERTIES, +] as const; + +export type StyleSnapshotProperty = (typeof STYLE_SNAPSHOT_PROPERTIES)[number]; + +/** One interned declaration: resolved property -> concrete value. */ +export type ResolvedDeclaration = Partial< + Record +>; + +/** One styled slot occurrence; `d` indexes the snapshot's declarations. + * Instance keys are one-letter on the wire because instances are the + * part that repeats with chart density (spec §8). */ +export interface StyleSnapshotInstance { + /** slot name (a `data-xy-slot` value) */ + s: string; + /** declaration index into `declarations` */ + d: number; + /** stable identity beyond the slot name, e.g. ["y", "major", "3"] */ + q?: readonly string[]; + /** resolved box in CSS px: [x, y, w, h] */ + g?: readonly [number, number, number, number]; + /** drawn text, when the slot has any */ + c?: string; +} + +export interface StyleSnapshotEnvironment { + width: number; + height: number; + dpr: number; + color_scheme: "light" | "dark"; +} + +export interface ResolvedStyleSnapshot { + version: 1; + style_epoch: number; + environment: StyleSnapshotEnvironment; + tokens: Record; + states: readonly string[]; + unrepresentable: readonly string[]; + declarations: readonly ResolvedDeclaration[]; + instances: readonly StyleSnapshotInstance[]; +} diff --git a/js/src/16_style_capture.ts b/js/src/16_style_capture.ts new file mode 100644 index 00000000..ac51eec5 --- /dev/null +++ b/js/src/16_style_capture.ts @@ -0,0 +1,161 @@ +// Browser computed-style capture: the live cascade as a ResolvedStyleSnapshot. +// +// The client-side half of the two-resolver architecture (wire-protocol §8): +// walk every rendered `data-xy-slot` element, read the allowlisted computed +// properties (concrete used values by definition — no var(), no relative +// units survive getComputedStyle), intern identical declarations, and emit +// the same payload shape `python/xy/styling/resolved.py` validates on +// arrival. Never called from the hover or animation path: the kernel invokes +// it only for an explicit `style_snapshot_request`, after fonts and layout +// settle (54_kernel.ts). + +import type { ResolvedStyleSnapshot, StyleSnapshotInstance } from "./14_style_snapshot"; +import { STYLE_SNAPSHOT_PROPERTIES, STYLE_SNAPSHOT_VERSION } from "./14_style_snapshot"; + +// Computed values that carry no styling information for a slot: recording +// them would bloat declarations without distinguishing anything (interning +// still works, but the payload budget is finite and "none" says nothing). +// Fully-transparent background is the browser's spelling of "unpainted", +// which is exactly the writers' default — absence, not a declaration. +const SKIP_VALUES = new Set(["", "none", "normal", "auto", "rgba(0, 0, 0, 0)"]); + +// Schema properties whose computed form is a shorthand serialization: the +// computed `background` carries position/size tokens (`0% 0% / auto`) that +// are serialization sugar, not styling — the schema's concreteness gate +// rightly refuses them (the smoke caught exactly this). Capture reads the +// concrete longhand and records it under the schema's name. +const CAPTURE_SOURCE: Record = { background: "background-color" }; + +// SVG presentation properties compute on EVERY element — an HTML tick label +// reports `fill: rgb(0, 0, 0)` (the SVG initial paint) it never uses. The +// writers prefer `fill` over `color`, so capturing that phantom black would +// outrank the element's real text color (the smoke caught exactly this). +// These properties are real styling only on SVG elements. +const SVG_ONLY_PROPERTIES = new Set([ + "fill", + "fill-opacity", + "stroke", + "stroke-opacity", + "stroke-width", +]); + +// Chart tokens ride the snapshot's token bag; they live as custom +// properties on the chart root. +const TOKEN_PREFIXES = ["--chart-", "--xy-"]; + +function num(value: number): number { + // Geometry to 1/100 px: sub-centipixel noise is rendering jitter, and + // stable payload bytes matter more (canonical-ordering contract). + return Math.round(value * 100) / 100; +} + +export function captureStyleSnapshot( + root: HTMLElement, + opts?: { styleEpoch?: number; states?: readonly string[] }, +): ResolvedStyleSnapshot { + const doc = root.ownerDocument; + const win = doc.defaultView; + if (!win) throw new Error("style capture needs a live window"); + const rootRect = root.getBoundingClientRect(); + const rootStyle = win.getComputedStyle(root); + + const declarations: Record[] = []; + const index = new Map(); + const instances: StyleSnapshotInstance[] = []; + const perSlotCount = new Map(); + + const elements = root.querySelectorAll("[data-xy-slot]"); + for (const el of elements) { + const slot = el.dataset.xySlot; + if (!slot) continue; + // Only chrome the document is actually rendering: a hidden tooltip or a + // closed modebar menu has no boxes, and a clean capture must not invent + // state-gated chrome that is not on screen. + if (el.getClientRects().length === 0) continue; + const style = win.getComputedStyle(el); + const isSvg = el instanceof win.SVGElement; + const decl: Record = {}; + for (const prop of STYLE_SNAPSHOT_PROPERTIES) { + if (!isSvg && SVG_ONLY_PROPERTIES.has(prop)) continue; + const value = style.getPropertyValue(CAPTURE_SOURCE[prop] ?? prop).trim(); + if (!value || SKIP_VALUES.has(value)) continue; + decl[prop] = value; + } + const key = JSON.stringify( + Object.keys(decl) + .sort() + .map((k) => [k, decl[k]]), + ); + let at = index.get(key); + if (at === undefined) { + at = declarations.length; + index.set(key, at); + declarations.push(decl); + } + const nth = perSlotCount.get(slot) ?? 0; + perSlotCount.set(slot, nth + 1); + const rect = el.getBoundingClientRect(); + const instance: StyleSnapshotInstance = { + s: slot, + d: at, + q: [String(nth)], + g: [ + num(rect.left - rootRect.left), + num(rect.top - rootRect.top), + num(rect.width), + num(rect.height), + ], + }; + if (el.childElementCount === 0) { + const text = (el.textContent ?? "").trim(); + if (text) (instance as { c?: string }).c = text.slice(0, 200); + } + instances.push(instance); + } + + const tokens: Record = {}; + for (const prefix of TOKEN_PREFIXES) { + // Inline root tokens are the authored/theme values the client applied; + // computed custom properties are not enumerable, so read the ones the + // chart itself declares on its root style attribute. + for (const name of Array.from(root.style)) { + if (name.startsWith(prefix)) { + const value = rootStyle.getPropertyValue(name).trim(); + if (value) tokens[name] = value; + } + } + } + + const dpr = win.devicePixelRatio || 1; + const scheme = rootStyle.colorScheme.includes("dark") ? "dark" : "light"; + return { + version: STYLE_SNAPSHOT_VERSION, + style_epoch: opts?.styleEpoch ?? 0, + environment: { + width: num(rootRect.width), + height: num(rootRect.height), + dpr, + color_scheme: scheme as "light" | "dark", + }, + tokens, + states: opts?.states ?? [], + unrepresentable: [], + declarations, + instances, + }; +} + +// Layout/fonts settle barrier for capture: fonts.ready, then two macrotask +// ticks. setTimeout rather than requestAnimationFrame — headless capture +// hosts throttle rAF on unfocused pages, and a capture must settle there +// exactly like it settles in a notebook. +export async function styleCaptureSettled(doc: Document): Promise { + try { + await doc.fonts?.ready; + } catch { + // A document without FontFaceSet still captures; there is nothing to + // wait for. + } + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 7e8f2297..6bb44f38 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -706,6 +706,13 @@ export class ChartView { // `_positionTitles` caps the element at this same width, so what the DOM // wraps is exactly what is reserved here. this._titleWrapWidth = Math.max(40, this.size.w - authoredLeft - baseRight); + // A declared title box (padding/border on the title slot) grows the DOM + // element, so the band must reserve the extra height or the box pokes + // above the canvas. Mirrors `_title_box_extent` in python/xy/_svg.py — + // the two must agree or native and browser disagree on plot.y. Slot-level + // only: per-entry box declarations are native-only by the entry-style + // allowlist below (a recorded divergence, KNOWN_RENDERER_DIVERGENCES). + const titleBoxExtent = this._titleBoxExtent(); const titleRoom = this._titleEntries().reduce((room, entry) => { const authoredSize = Number.parseFloat(entry.style?.["font-size"]); const titleFontSize = Number.isFinite(authoredSize) @@ -716,8 +723,8 @@ export class ChartView { ).h; const pad = Number.isFinite(Number(entry.pad)) ? Number(entry.pad) : 8; const candidate = entry.automatic_y !== false - ? Math.max(compact ? 26 : 30, measured + pad) - : (Number(entry.y ?? 1) >= 1 ? measured + pad : 0); + ? Math.max(compact ? 26 : 30, measured + titleBoxExtent + pad) + : (Number(entry.y ?? 1) >= 1 ? measured + titleBoxExtent + pad : 0); return Math.max(room, candidate); }, 0); this._titleRoom = titleRoom; @@ -1032,6 +1039,60 @@ export class ChartView { return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } + // Vertical px the title slot's declared box adds around its text block: + // padding plus a paintable border, above and below. Mirrors + // `_title_box_extent` in python/xy/_svg.py (same rules, including the + // native model's "border counts only with an explicit border-color, which + // implies the 1px chrome border" — an out-of-model spelling like a + // colorless `border-style` reserves nothing in either renderer's layout). + // Zero whenever the slot declares no box, so unstyled layout cannot move. + _titleBoxExtent() { + const styles = this.spec.dom?.styles; + const raw = styles && typeof styles === "object" ? styles.title : null; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return 0; + const decl: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof key !== "string") continue; + if (typeof value !== "string" && typeof value !== "number") continue; + decl[this._stylePropertyName(key)] = value; + } + const px = (value) => { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + const m = /^\s*(-?\d+(?:\.\d+)?)(?:px)?\s*$/.exec(String(value ?? "")); + return m ? Number.parseFloat(m[1]) : null; + }; + let top = px(decl["padding-top"]); + let bottom = px(decl["padding-bottom"]); + if ((top == null || bottom == null) && decl.padding != null) { + const parts = String(decl.padding).trim().split(/\s+/).map(px); + if (parts.length >= 1 && parts.length <= 4 && parts.every((p) => p != null)) { + if (top == null) top = parts[0]; + if (bottom == null) bottom = parts.length > 2 ? parts[2] : parts[0]; + } + } + let width = px(decl["border-width"]); + let hasColor = decl["border-color"] != null; + let style = decl["border-style"] != null ? String(decl["border-style"]).toLowerCase() : null; + if (typeof decl.border === "string") { + const styles_ = ["solid", "dashed", "dotted", "none", "hidden", "double", + "groove", "ridge", "inset", "outset"]; + for (const token of decl.border.split(/\s+/).filter(Boolean)) { + const tokenPx = px(token); + if (tokenPx != null && width == null) width = tokenPx; + else if (styles_.includes(token.toLowerCase()) && style == null) style = token.toLowerCase(); + else hasColor = true; + } + } + let border = 0; + if (hasColor && style !== "none" && style !== "hidden") { + // An absent or zero width with a declared color is the implied 1px + // chrome border (lower_box's rule); a negative width paints nothing. + if (width == null || width === 0) border = 1; + else if (width > 0) border = width; + } + return (top ?? 0) + (bottom ?? 0) + 2 * border; + } + _xAxisRoom(side, plotWidth) { let room = 0; for (const axis of Object.values(this.axes || {})) { diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 638bc502..e2979781 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -1,5 +1,6 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; +import { captureStyleSnapshot, styleCaptureSettled } from "./16_style_capture"; import { parseColor } from "./20_theme"; import { lodAggregateStands, lodAggregateStepWindow, lodApplyDensityUpdate, lodApplyDrill, @@ -908,6 +909,30 @@ Object.assign(ChartView.prototype, { if (msg.op === "reset") this._navReset(msg.axes); } else if (msg.type === "selection_rows") { this._applyRowsSelection(msg, buffers); + } else if (msg.type === "style_snapshot_request") { + this._replyStyleSnapshot(msg); + } + }, + + // Capture the live cascade and reply (wire-protocol §8). Explicitly + // requested only — never on the hover or animation path — and settled + // first: fonts.ready plus two macrotask ticks, so the values are the + // document's, not a mid-layout frame's. Errors reply as errors: a request + // must never dangle a kernel-side future (§28: the outcome is said). + async _replyStyleSnapshot(msg) { + try { + await styleCaptureSettled(this.root.ownerDocument); + if (this._destroyed) return; + const snapshot = captureStyleSnapshot(this.root, { + styleEpoch: typeof msg.style_epoch === "number" ? msg.style_epoch : 0, + }); + this.comm.send({ type: "style_snapshot", request_id: msg.request_id, snapshot }); + } catch (err) { + this.comm.send({ + type: "style_snapshot", + request_id: msg.request_id, + error: String((err && (err as Error).message) || err), + }); } }, diff --git a/js/src/60_entries.ts b/js/src/60_entries.ts index 0c780c99..34418278 100644 --- a/js/src/60_entries.ts +++ b/js/src/60_entries.ts @@ -97,4 +97,9 @@ export function renderStandalone(el, spec, arrayBuffer) { // these directly; the IIFE bundle (static/standalone.js) exposes the same // namespace as `window.xy`. export { decodeFrame, ChartView, MARK_KINDS, markOf }; +// The capture half of wire-protocol §8, public so a standalone document (no +// kernel, no comm) can produce the same snapshot a widget request does — +// which is also what lets the browser-oracle smoke validate the payload +// against the Python schema without a notebook in the loop. +export { captureStyleSnapshot, styleCaptureSettled } from "./16_style_capture"; export default { render, decodeFrame }; diff --git a/python/xy/_chromebox.py b/python/xy/_chromebox.py new file mode 100644 index 00000000..5e517bb3 --- /dev/null +++ b/python/xy/_chromebox.py @@ -0,0 +1,546 @@ +"""The shared chrome-box model: one lowering, two emitters, zero drift. + +Static-chrome parity gives every applicable slot a real box (background, +border, radius, shadow, opacity) in both native writers. The three box +drawers that existed before this module — the legend frame, the annotation +text box, and the background composition — were each written twice and +drifted twice (hard-coded shadow constants, different radius geometry); +this module is the single lowering from a resolved declaration to a +`ChromeBox`, consumed by `_svg._slot_box_svg` and `_raster._emit_slot_box` +so a box drawn in one writer cannot mean something else in the other. + +Contract notes, each load-bearing: + +- Values are resolved px/colors (the snapshot contract); the lowering + parses spellings, it never resolves cascades. +- `radius` is one symmetric `rx` clamped to `min(w, h) / 2` — PDF accepts + `rx` and rejects `ry` (probe-verified), so asymmetric corner radii are + outside the model until a path lowering exists. +- `shadow` is the offset-rect approximation this codebase has always drawn + (no blur primitive exists in the raster opcodes and PDF rejects + `filter`); a blur/spread request is recorded in `unrepresentable`, never + silently blurless (§28). +- `border-style` lowers `dashed`/`dotted` to dash arrays scaled by width — + the same construction both writers already use for data strokes; `double` + and friends are recorded unrepresentable. +- A rotated box (`angle` about `(cx, cy)`) is lowered by the emitters to + pre-rotated geometry — a polygon when `radius == 0`, a path with circular + arcs when `radius > 0` — because the PDF closed subset accepts no + `transform` on ``. One lowering repo-wide (plan flag E). +- A zero-area box emits nothing at all — no fill, no border, and explicitly + no shadow (a `tick_length: 0` tick must not cast one). +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field, replace +from typing import Any, Optional + +__all__ = [ + "ChromeBox", + "box_at", + "box_padding", + "box_room", + "box_template", + "expand_box_shorthands", + "lower_box", + "padding_sides", + "parse_padding", + "rotate_points", + "text_box", +] + + +def rotate_points( + points: list[tuple[float, float]], angle: float, cx: float, cy: float +) -> list[tuple[float, float]]: + """Points rotated `angle` degrees (screen space, y down) about `(cx, cy)`. + + The one rotation both emitters use to pre-rotate a posed box — the PDF + subset accepts no transform on shapes, and the raster display list has no + transform primitive, so rotation happens here, once, in Python. + """ + radians = math.radians(angle) + cos, sin = math.cos(radians), math.sin(radians) + return [ + (cx + (px - cx) * cos - (py - cy) * sin, cy + (px - cx) * sin + (py - cy) * cos) + for px, py in points + ] + + +_LENGTH_RE = re.compile(r"^\s*(-?\d+(?:\.\d+)?)(?:px)?\s*$") + +_BORDER_STYLES = frozenset( + {"solid", "dashed", "dotted", "none", "hidden", "double", "groove", "ridge", "inset", "outset"} +) + + +@dataclass(frozen=True) +class ChromeBox: + """One slot instance's resolved box, ready for either writer. + + `qualifiers` is the instance's stable identity beyond the slot name + (`SlotInstance.qualifiers` carrier — e.g. `("x", "major", "bottom", "3")` + for a tick mark). `angle`/`cx`/`cy` is the rotation pose (an axis + y-title); zero for most slots. + """ + + slot: str + x: float + y: float + w: float + h: float + fill: Optional[str] = None # resolved CSS color text, writer-parsed + fill_opacity: float = 1.0 + border_color: Optional[str] = None + border_width: float = 0.0 + border_dash: Optional[tuple[float, ...]] = None + #: Stroke alpha, separate from `fill_opacity`, because the legend frame + #: has always dimmed its border with the frame's own alpha: the default + #: grey frame is a single translucent element, border included, and + #: matching the browser means the border fades with it rather than + #: staying opaque over a 0.08 fill (plan §8 flag B — the coupling was an + #: open question and this is the answer: it survives). + border_opacity: float = 1.0 + radius: float = 0.0 + shadow: Optional[tuple[float, float, str]] = None # (dx, dy, color) + opacity: float = 1.0 + # (top, right, bottom, left) px. Consumed by the layout/room functions + # that size the box around its content — never by the emitters, which + # draw exactly the (x, y, w, h) they are given. + padding: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + #: Pyplot text-bbox compatibility: the pre-parity SVG emitter serialized + #: `stroke=... stroke-width=...` on every bbox rect, including the inert + #: `stroke="none" stroke-width="0"` of a borderless box, and existing + #: pyplot output is byte-pinned (§0.5). An adapter folding that emitter + #: onto this model sets `(paint, width)` here so the SVG writer keeps + #: those exact attributes when no active border exists; the raster twin + #: ignores it, because the raster writer never painted a zero-width + #: border. New chrome boxes leave it None. + explicit_stroke: Optional[tuple[str, float]] = None + unrepresentable: tuple[str, ...] = field(default_factory=tuple) + qualifiers: tuple[str, ...] = () + angle: float = 0.0 + cx: float = 0.0 + cy: float = 0.0 + #: True when the declaration carried a `background`/`background-color` + #: key at all — an explicit `transparent` (fill None, declared True) must + #: not fall back to a slot's default ink the way an absent key does. + fill_declared: bool = False + + @property + def paints_anything(self) -> bool: + if self.w <= 0.0 or self.h <= 0.0: + return False + return self.fill is not None or (self.border_color is not None and self.border_width > 0) + + +def parse_padding(value: Any) -> Optional[tuple[float, float, float, float]]: + """CSS `padding` shorthand as `(top, right, bottom, left)` px, or None. + + Accepts 1-4 px lengths with the standard CSS expansion. The existing + annotation-box parsers read tokens [0]/[1] only, which silently misreads + a 4-value shorthand — this is the one correct expansion every box + consumer shares. + """ + if isinstance(value, (int, float)) and not isinstance(value, bool): + v = float(value) + return (v, v, v, v) + if not isinstance(value, str): + return None + tokens = value.split() + if not 1 <= len(tokens) <= 4: + return None + sides = [_px(token) for token in tokens] + if any(side is None for side in sides): + return None + vals = [float(side) for side in sides if side is not None] + if len(vals) == 1: + vals *= 4 + elif len(vals) == 2: + vals = [vals[0], vals[1], vals[0], vals[1]] + elif len(vals) == 3: + vals = [vals[0], vals[1], vals[2], vals[1]] + return (vals[0], vals[1], vals[2], vals[3]) + + +def padding_sides(declaration: Optional[dict[str, Any]]) -> tuple[float, float, float, float]: + """A declaration's padding as `(top, right, bottom, left)` px. + + The `padding` shorthand seeds all four sides; a `padding-*` longhand + overrides its side regardless of dict order (documented precedence — the + resolver has no declaration order to honor). + """ + if not declaration: + return (0.0, 0.0, 0.0, 0.0) + sides = list(parse_padding(declaration.get("padding")) or (0.0, 0.0, 0.0, 0.0)) + for index, prop in enumerate( + ("padding-top", "padding-right", "padding-bottom", "padding-left") + ): + longhand = _px(declaration.get(prop)) + if longhand is not None: + sides[index] = longhand + return (sides[0], sides[1], sides[2], sides[3]) + + +def _border_width(declaration: dict[str, Any]) -> float: + """The border width lower_box would draw — one rule, shared with rooms.""" + width = _px(declaration.get("border-width")) or 0.0 + style = str(declaration.get("border-style", "solid") or "solid").strip().lower() + if style in ("none", "hidden"): + return 0.0 + if declaration.get("border-color") is not None and width == 0.0: + return 1.0 # the 1px chrome-border default, mirroring lower_box + return width + + +def box_room(declaration: Optional[dict[str, Any]]) -> tuple[float, float, float, float]: + """Outward growth `(top, right, bottom, left)` a declared box adds around + its content: per-side padding plus the border width. The layout/room + functions consume this; the emitters never do (geometry already carries + it).""" + if not declaration: + return (0.0, 0.0, 0.0, 0.0) + top, right, bottom, left = padding_sides(declaration) + border = _border_width(declaration) + return (top + border, right + border, bottom + border, left + border) + + +def box_template(slot: str, declaration: dict[str, Any]) -> ChromeBox: + """The declaration lowered once, geometry-free — the interning half. + + A dense axis draws one declaration N times; parsing it per instance is + the cost `styling/resolved.py`'s interning design exists to avoid. The + huge placeholder geometry keeps the radius unclamped so `box_at` can + clamp per instance. + """ + return lower_box(slot, declaration, x=0.0, y=0.0, w=1e18, h=1e18) + + +def box_at( + template: ChromeBox, + x: float, + y: float, + w: float, + h: float, + *, + qualifiers: tuple[str, ...] = (), + angle: float = 0.0, + cx: float = 0.0, + cy: float = 0.0, + fallback_fill: Optional[str] = None, +) -> ChromeBox: + """One instance of an interned template at a concrete geometry. + + `fallback_fill` is the slot's default ink (an axis spine's `axis_color`); + it applies only when the declaration never mentioned a background — an + explicit `transparent` stays unpainted, exactly as in the browser. + """ + fill = template.fill + if fill is None and not template.fill_declared and fallback_fill is not None: + fill = fallback_fill + radius = max(0.0, min(template.radius, w / 2.0, h / 2.0)) if w > 0.0 and h > 0.0 else 0.0 + return replace( + template, + x=float(x), + y=float(y), + w=float(w), + h=float(h), + fill=fill, + radius=radius, + qualifiers=tuple(str(q) for q in qualifiers), + angle=float(angle), + cx=float(cx), + cy=float(cy), + ) + + +def text_box( + template: ChromeBox, + pads: tuple[float, float, float, float], + *, + x: float, + y: float, + anchor: str, + block: Any, + angle: float = 0.0, + qualifiers: tuple[str, ...] = (), +) -> ChromeBox: + """The box around one text block, in the writers' shared metrics. + + `(x, y)` is the text anchor (first-line baseline), `anchor` the SVG + vocabulary (`start`/`middle`/`end`), `block` a `_textblock.TextBlock`. + Padding is applied in text-local space; a rotated label's box rotates + with the text about the anchor — the emitters lower that pose to + PDF-legal pre-rotated geometry. Both writers build tick-label and + axis-title boxes through here, so their geometry cannot drift (the + metrics are the writers' DejaVu tables; an authored font-family renders + other glyphs inside a DejaVu-measured box — recorded misfit, §28). + """ + pad_t, pad_r, pad_b, pad_l = pads + if anchor == "middle": + x0 = x - block.width / 2.0 + elif anchor == "end": + x0 = x - block.width + else: + x0 = x + y0 = y - block.ascent + height = block.ascent + block.descent + (block.line_count - 1) * block.line_step + return box_at( + template, + x0 - pad_l, + y0 - pad_t, + block.width + pad_l + pad_r, + height + pad_t + pad_b, + qualifiers=qualifiers, + angle=angle, + cx=x, + cy=y, + ) + + +def _px(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + m = _LENGTH_RE.match(value) + if m: + return float(m.group(1)) + return None + + +def _opacity(value: Any, fallback: float = 1.0) -> float: + try: + out = float(value) + except (TypeError, ValueError): + return fallback + return min(1.0, max(0.0, out)) + + +def _border_dash(style: str, width: float) -> Optional[tuple[float, ...]]: + if style == "dashed": + return (3.7 * width, 1.6 * width) + if style == "dotted": + return (width, width) + return None + + +def _parse_shadow(value: str) -> tuple[Optional[tuple[float, float, str]], Optional[str]]: + """`box-shadow` to the offset-rect model, or the reason it cannot be. + + Accepted: ` [color]` and ` 0 [0] [color]` (explicit + zero blur/spread). Anything with real blur/spread, insets, or multiple + shadows is unrepresentable in the offset-rect model. + """ + text = value.strip() + if not text or text == "none": + return None, None + # Multiple shadows split on top-level commas; a color's own commas + # (rgba(0, 0, 0, .2)) live inside parens and must not count. + depth = 0 + for ch in text: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif ch == "," and depth == 0: + return None, "multiple shadows" + if "inset" in text: + return None, "inset shadow" + parts: list[str] = [] + depth = 0 + token = "" + for ch in text: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if ch.isspace() and depth == 0: + if token: + parts.append(token) + token = "" + else: + token += ch + if token: + parts.append(token) + lengths: list[float] = [] + color = "" + for part in parts: + px = _px(part) + if px is not None and not color: + lengths.append(px) + else: + color = f"{color} {part}".strip() + if len(lengths) < 2: + return None, "shadow needs dx and dy" + if any(v != 0.0 for v in lengths[2:4]): + return None, "shadow blur/spread (no blur primitive in the native writers)" + return (lengths[0], lengths[1], color or "rgba(0, 0, 0, 0.22)"), None + + +def _padding_sides(value: Any) -> Optional[tuple[float, float, float, float]]: + """A CSS `padding` shorthand as (top, right, bottom, left), or None.""" + if isinstance(value, (int, float)) and not isinstance(value, bool): + px = float(value) + return (px, px, px, px) + if not isinstance(value, str): + return None + parts = [_px(token) for token in value.split()] + if not 1 <= len(parts) <= 4 or any(px is None for px in parts): + return None + sides = [float(px) for px in parts if px is not None] + top = sides[0] + right = sides[1] if len(sides) > 1 else top + bottom = sides[2] if len(sides) > 2 else top + left = sides[3] if len(sides) > 3 else right + return (top, right, bottom, left) + + +def expand_box_shorthands(declaration: dict[str, Any]) -> dict[str, Any]: + """Split the box shorthands (`border`, `padding`) into their longhands. + + Same policy as the cascade extension's `_expand_shorthands`: an explicit + longhand beside the shorthand wins (it is the narrower author intent). + A shorthand this parser cannot split — an em `padding`, a `border` with + no recognizable parts — is passed through untouched so nothing declared + disappears; the consumer decides what an unexpanded value means. + """ + out: dict[str, Any] = {} + for prop, value in declaration.items(): + if prop == "padding": + sides = _padding_sides(value) + if sides is not None: + for name, side in zip( + ("padding-top", "padding-right", "padding-bottom", "padding-left"), + sides, + strict=True, + ): + if name not in declaration: + out.setdefault(name, side) + continue + if prop == "border" and isinstance(value, str): + width: Optional[float] = None + style: Optional[str] = None + color_parts: list[str] = [] + for token in value.split(): + token_px = _px(token) + if token_px is not None and width is None: + width = token_px + elif token.lower() in _BORDER_STYLES and style is None: + style = token.lower() + else: + color_parts.append(token) + if width is not None or style is not None or color_parts: + if width is not None and "border-width" not in declaration: + out.setdefault("border-width", width) + if style is not None and "border-style" not in declaration: + out.setdefault("border-style", style) + if color_parts and "border-color" not in declaration: + out.setdefault("border-color", " ".join(color_parts)) + continue + out[prop] = value + return out + + +def box_padding(declaration: dict[str, Any]) -> tuple[float, float, float, float]: + """A declaration's resolved padding as (top, right, bottom, left) px. + + Longhands win over the shorthand; anything unparsable (em values stay + the legend writers' domain until P4) contributes zero rather than a + guess. + """ + expanded = expand_box_shorthands(declaration) + top, right, bottom, left = ( + float(_px(expanded.get(name)) or 0.0) + for name in ("padding-top", "padding-right", "padding-bottom", "padding-left") + ) + return (top, right, bottom, left) + + +def lower_box( + slot: str, + declaration: dict[str, Any], + *, + x: float, + y: float, + w: float, + h: float, +) -> ChromeBox: + """Lower one resolved declaration onto a geometry rectangle. + + Reads only the box vocabulary; text properties ride the existing text + emitters. Every request the model cannot draw lands in + `unrepresentable` with its reason — the preflight and the tests read + that list, so nothing rounds to silence. + """ + unrepresentable: list[str] = [] + declaration = expand_box_shorthands(declaration) + + fill_declared = "background" in declaration or "background-color" in declaration + fill = declaration.get("background") + if fill is None: + fill = declaration.get("background-color") + if isinstance(fill, str) and ("gradient(" in fill or "url(" in fill): + unrepresentable.append(f"background {fill!r} (box gradients land with the effect phase)") + fill = None + if fill is not None and not isinstance(fill, str): + fill = str(fill) + if isinstance(fill, str) and fill.strip().lower() in ("", "none", "transparent"): + fill = None + + border_width = _px(declaration.get("border-width")) or 0.0 + border_color = declaration.get("border-color") + border_style = str(declaration.get("border-style", "solid") or "solid").strip().lower() + if border_color is not None and border_width == 0.0: + border_width = 1.0 # CSS medium is 3px, but chrome borders here have always drawn 1px + if border_style in ("none", "hidden"): + border_color, border_width = None, 0.0 + dash = None + elif border_style in ("solid", "dashed", "dotted"): + dash = _border_dash(border_style, max(border_width, 1.0)) + else: + unrepresentable.append(f"border-style {border_style!r}") + dash = None + if border_color is not None: + border_color = str(border_color) + + radius = _px(declaration.get("border-radius")) or 0.0 + if ( + isinstance(declaration.get("border-radius"), str) + and " " in str(declaration["border-radius"]).strip() + ): + unrepresentable.append( + "asymmetric border-radius (PDF accepts symmetric rx only; path lowering pending)" + ) + radius = 0.0 + radius = max(0.0, min(radius, w / 2.0, h / 2.0)) + + shadow = None + raw_shadow = declaration.get("box-shadow") + if isinstance(raw_shadow, str): + shadow, why = _parse_shadow(raw_shadow) + if why is not None: + unrepresentable.append(f"box-shadow: {why}") + + return ChromeBox( + slot=slot, + x=float(x), + y=float(y), + w=float(w), + h=float(h), + fill=fill, + fill_opacity=_opacity(declaration.get("fill-opacity"), 1.0), + border_color=border_color, + border_width=border_width, + border_dash=dash, + radius=radius, + shadow=shadow, + opacity=_opacity(declaration.get("opacity"), 1.0), + padding=box_padding(declaration), + unrepresentable=tuple(unrepresentable), + fill_declared=fill_declared, + ) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 4c0f92cc..b66fd079 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -12,7 +12,10 @@ import warnings from collections.abc import Mapping, Sequence from os import PathLike -from typing import Any, Optional, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Optional, TypeAlias, Union + +if TYPE_CHECKING: + from .styling.preflight import StyleCompatibilityReport import numpy as np @@ -2219,19 +2222,65 @@ def _repr_html_(self) -> str: """Notebook HTML repr isolated from the host document's styles.""" return export.notebook_iframe(self.to_html(), width=self.width, height=self.height) + def style_compatibility_report( + self, + target: str = "png", + *, + engine: Optional[export.Engine | str] = None, + custom_css: Optional[str] = None, + ) -> StyleCompatibilityReport: + """Route every declared style for one export target, without exporting. + + The programmatic answer to export.md §9: which styling sources this + figure carries, how each styled slot routes under `target`/`engine`, + and exactly what would not survive — including the export path's own + refusals, mirrored rather than re-decided. Report-only: computing it + never changes an export.""" + from .styling.preflight import preflight + + return preflight(self, target=target, engine=engine, custom_css=custom_css) + def to_svg( self, path: Optional[str | PathLike[str]] = None, *, width: Optional[int] = None, height: Optional[int] = None, + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> str: """Static SVG (_svg.py): a pure-Python render of the same decimated payload the browser client consumes — resolution-independent, tiny (screen-bounded regardless of source size), and dependency-free. - `width`/`height` override the figure's pixel size.""" + `width`/`height` override the figure's pixel size. `compatibility` + stages the styling contract: "warn" surfaces any declaration this + vector export would drop, "strict" refuses to drop one.""" from . import _svg + if style_source not in ("declared", "native_cascade"): + raise ValueError( + f'style_source must be "declared" or "native_cascade", got {style_source!r}' + ) + if style_source == "native_cascade": + if style_snapshot is not None: + raise ValueError( + "style_snapshot and style_source='native_cascade' are two " + "sources for the same values; pass one" + ) + # Classes-only here: to_svg carries no custom_css parameter, so + # author stylesheets ride to_image("svg", custom_css=..., + # style_source="native_cascade") instead. + style_snapshot = export._cascade_snapshot( + self, None, compatibility, tuple(stylesheets), tailwind_profile + ) + if style_snapshot is not None: + snapshot = export._coerce_style_snapshot(style_snapshot) + with export._snapshot_styles(self, snapshot): + return _svg.to_svg(self, path, width=width, height=height) + export._enforce_compatibility(self, "svg", "native", None, compatibility) return _svg.to_svg(self, path, width=width, height=height) def to_png( @@ -2246,6 +2295,11 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Static PNG (export.py). `engine=Engine.default` paints the decimated payload with the built-in Rust rasterizer — no browser, @@ -2267,6 +2321,11 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, ) def to_image( @@ -2283,13 +2342,19 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes (export.py). `engine=Engine.auto` is deterministic — the browser-free native path for every format, Chromium only when `custom_css` needs a real CSS engine. See `export.to_image` for the format, quality, and background - policies.""" + policies, and `compatibility=` ("legacy"/"warn"/"strict") for the + staged styling contract.""" return export.to_image( self, format, @@ -2303,6 +2368,11 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, ) def write_image( @@ -2320,6 +2390,11 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Atomic file export with extension-inferred format (export.py): .png/.jpg/.jpeg/.webp/.svg/.pdf, plus .html routing to `to_html`.""" @@ -2337,6 +2412,11 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, ) def memory_report(self) -> dict[str, Any]: diff --git a/python/xy/_pdf.py b/python/xy/_pdf.py index c4dc813e..6011c184 100644 --- a/python/xy/_pdf.py +++ b/python/xy/_pdf.py @@ -17,12 +17,29 @@ (``fill-opacity``/``stroke-opacity``/``opacity`` and rgba() color alpha) become deduplicated ExtGStates (/ca /CA). The generator only ever emits the default nonzero winding rule, so even-odd variants are never produced. -- Text stays text: BT/Tf/Tm/Tj/ET with the base-14 Helvetica family - (weight >= 600 selects Helvetica-Bold) in WinAnsiEncoding, using the - standard AFM width tables so ``text-anchor="middle"/"end"`` offsets come - from real metrics. Characters outside WinAnsi are replaced with "?" - (``cp1252`` + ``errors="replace"``) — a deterministic, locale-independent - substitution policy. +- Text stays text: BT/Tf/Tm/Tj/ET with the base-14 Helvetica family — four + faces: weight >= 600 selects the bold face, ``font-style: italic|oblique`` + the oblique one — in WinAnsiEncoding, using the standard AFM width tables + so ``text-anchor="middle"/"end"`` offsets come from real metrics. + ``letter-spacing`` becomes ``Tc`` (reset after each run — text state + persists past ET), with the anchor width counting the len-1 inter-glyph + gaps ``Tc`` actually renders. ``opacity`` on text multiplies into the + ExtGState alpha alongside fill-opacity and the paint's own alpha. A + declared ``font-family`` is accepted and maps to the Helvetica family: + deterministic and metrically exact for the anchor math, recorded here + rather than silent (§28) — exact custom faces arrive with the font + registry. ``dominant-baseline`` has no PDF equivalent (text is always set + from the alphabetic baseline) and lowers to a baseline SHIFT along the + run's perpendicular: ``central``/``middle`` by 0.35em, ``hanging`` by + 0.72em, ``text-after-edge`` by -0.21em. ``stroke``/``stroke-width``/ + ``stroke-opacity`` on text become text render mode 2 (fill then stroke) + with the ordinary stroke graphics state, reset to mode 0 after each run + because text state persists past ET. Both exist for the glyph and + mathtext markers the SVG writer draws as outlined ````; before they + were accepted, any chart carrying one — including its legend entry — + raised instead of exporting. Characters outside WinAnsi are replaced with + "?" (``cp1252`` + ``errors="replace"``) — a deterministic, + locale-independent substitution policy. - ```` becomes an axial shading (/ShadingType 2; exponential function for 2 stops, stitching for more) painted inside the gradient geometry's clip; per-stop alpha becomes a luminosity soft mask. @@ -202,7 +219,24 @@ def _rgba(css: str) -> tuple[float, float, float, float]: "polyline": frozenset({"points"}) | _PAINT_ATTRS, "polygon": frozenset({"points"}) | _PAINT_ATTRS, "text": frozenset( - {"x", "y", "transform", "text-anchor", "font-size", "font-weight", "fill", "fill-opacity"} + { + "x", + "y", + "transform", + "text-anchor", + "dominant-baseline", + "font-size", + "font-weight", + "font-style", + "font-family", + "letter-spacing", + "opacity", + "fill", + "fill-opacity", + "stroke", + "stroke-width", + "stroke-opacity", + } ), "tspan": frozenset({"x", "y"}), "image": frozenset({"x", "y", "width", "height", "preserveAspectRatio", "style", "href"}), @@ -678,8 +712,20 @@ def _set_stroke_params( # -- resource registration --------------------------------------------- - def _font(self, bold: bool) -> str: - base = "Helvetica-Bold" if bold else "Helvetica" + def _font(self, bold: bool, italic: bool = False) -> str: + # The four Helvetica base-14 faces. Any declared font-family maps to + # this family for now — deterministic, and the anchor math stays + # exact because _text_width_px speaks Helvetica metrics. The mapping + # is recorded in the module contract note, not silent (§28); exact + # custom faces arrive with the font registry. + if bold and italic: + base = "Helvetica-BoldOblique" + elif bold: + base = "Helvetica-Bold" + elif italic: + base = "Helvetica-Oblique" + else: + base = "Helvetica" if base not in self.fonts: num = self.pdf.reserve() name = f"F{len(self.fonts) + 1}" @@ -1063,9 +1109,37 @@ def _render_text(self, el: ET.Element, state: _State) -> None: _check_attrs(el, "text", _ALLOWED_ATTRS["text"]) font_size = _float(el.get("font-size"), state.font_size, "font-size") bold = _weight(el.get("font-weight"), state.font_weight) >= 600 + # font-style selects the oblique base-14 face; font-family is + # accepted and maps to the Helvetica family (module contract note) — + # the attribute must not be a crash, because the SVG writer emits it + # for any declared family and PDF inherits SVG's markup. + italic = str(el.get("font-style", "")).strip().lower() in ("italic", "oblique") + # The writer emits letter-spacing either as a bare number (author + # gave a number; normalized to px) or as an "Npx" string passed + # verbatim — both are px by the styling contract. + spacing_raw = str(el.get("letter-spacing") or "0").strip().removesuffix("px") + letter_spacing = _float(spacing_raw, 0.0, "letter-spacing") anchor = el.get("text-anchor", "start") if anchor not in ("start", "middle", "end"): _unsupported(f"text-anchor {anchor!r}") + # PDF has no baseline-alignment mode: text is always set from its + # alphabetic baseline, so a `dominant-baseline` lowers to a shift of + # that baseline. `central`/`middle` centre the glyph box on the + # anchor, which the SVG writer uses for glyph legend markers and for + # authored mathtext markers — the attribute reached the whitelist + # and raised, so those charts could not export to PDF at all. + # 0.35em is the usual cap-height/2 for the base-14 faces. + baseline = str(el.get("dominant-baseline", "")).strip().lower() + if baseline in ("", "auto", "alphabetic"): + baseline_shift = 0.0 + elif baseline in ("central", "middle"): + baseline_shift = 0.35 * font_size + elif baseline in ("hanging", "text-before-edge"): + baseline_shift = 0.72 * font_size + elif baseline in ("text-after-edge", "ideographic"): + baseline_shift = -0.21 * font_size + else: + _unsupported(f"dominant-baseline {baseline!r}") fill = self._resolve_paint(el.get("fill", state.fill)) if fill is None or fill[0] != "solid": _unsupported("text fill paint") @@ -1073,8 +1147,29 @@ def _render_text(self, el: ET.Element, state: _State) -> None: ca = ( state.opacity * _float(el.get("fill-opacity"), state.fill_opacity, "fill-opacity") + * _float(el.get("opacity"), 1.0, "opacity") * alpha ) + # An outlined glyph. The SVG writer strokes `` for glyph and + # mathtext markers, so a chart using one could not reach PDF at all + # while the same marker exported to SVG and PNG. PDF expresses it as + # text render mode 2 (fill, then stroke) with the ordinary stroke + # graphics state; mode 1 when there is nothing to fill. + stroke_rgb: Optional[tuple[float, float, float]] = None + stroke_alpha = 1.0 + stroke_width = _float(el.get("stroke-width"), 0.0, "stroke-width") + stroke_paint = self._resolve_paint(el.get("stroke")) + if stroke_paint is not None and stroke_width > 0.0: + if stroke_paint[0] != "solid": + _unsupported("text stroke paint") + s_red, s_green, s_blue, s_alpha = stroke_paint[1] + stroke_rgb = (s_red, s_green, s_blue) + stroke_alpha = ( + state.opacity + * _float(el.get("stroke-opacity"), 1.0, "stroke-opacity") + * _float(el.get("opacity"), 1.0, "opacity") + * s_alpha + ) angle = 0.0 center: Optional[tuple[float, float]] = None @@ -1110,7 +1205,7 @@ def _render_text(self, el: ET.Element, state: _State) -> None: (_float(el.get("x"), 0.0, "x"), _float(el.get("y"), 0.0, "y"), el.text or "") ) - font_name = self._font(bold) + font_name = self._font(bold, italic) theta = math.radians(angle) cos_t, sin_t = math.cos(theta), math.sin(theta) for x, y, s in runs: @@ -1124,20 +1219,41 @@ def _render_text(self, el: ET.Element, state: _State) -> None: cx + cos_t * (x - cx) - sin_t * (y - cy), cy + sin_t * (x - cx) + cos_t * (y - cy), ) + # Letter-spacing widens every advance, including after the last + # glyph in SVG's model — anchor math must match what Tc renders, + # which spaces between glyphs only, so count len-1 gaps. width = _text_width_px(data, font_size, bold) + if letter_spacing: + width += letter_spacing * max(0, len(data) - 1) dx = -width / 2.0 if anchor == "middle" else (-width if anchor == "end" else 0.0) - tx = x + dx * cos_t - ty = y + dx * sin_t - self._set_gs(ca, ca) + # The baseline shift is perpendicular to the text direction, so + # it rotates with the run exactly as the anchor offset does. + tx = x + dx * cos_t + baseline_shift * sin_t + ty = y + dx * sin_t + baseline_shift * cos_t + self._set_gs(ca, stroke_alpha if stroke_rgb is not None else ca) self._set_fill_rgb((red, green, blue)) + if stroke_rgb is not None: + self._set_stroke_rgb(stroke_rgb) + self.ops.append(f"{_f(stroke_width)} w") # Tm un-flips the top-level y flip so glyphs render upright; the # rotation is the SVG angle (clockwise in screen space). self.ops.append("BT") self.ops.append(f"/{font_name} {_f(font_size)} Tf") + if stroke_rgb is not None: + self.ops.append("2 Tr") + if letter_spacing: + # Text state persists past ET; reset below so a spaced run + # never leaks into a later unspaced one. + self.ops.append(f"{_f(letter_spacing)} Tc") self.ops.append( f"{_f(cos_t)} {_f(sin_t)} {_f(sin_t)} {_f(-cos_t)} {_f(tx)} {_f(ty)} Tm" ) self.ops.append(f"{_pdf_string(data)} Tj") + if letter_spacing: + self.ops.append("0 Tc") + if stroke_rgb is not None: + # Text state persists past ET, exactly like Tc above. + self.ops.append("0 Tr") self.ops.append("ET") # -- images ------------------------------------------------------------- diff --git a/python/xy/_raster.py b/python/xy/_raster.py index bb341b27..dc54c94d 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -22,6 +22,8 @@ from . import _paint, _png, _scene, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes +from ._chromebox import box_at, box_template, lower_box, padding_sides, text_box +from ._chromebox import rotate_points as _chromebox_rotate from ._svg import ( _AXIS, _AXIS_GRID_DASHES, @@ -41,7 +43,6 @@ _axis_tick_label_sides, _axis_tick_label_strategy, _axis_tick_sides, - _box_corner_radius, _colorbar_right_axis_room, _colormap_stops, _column, @@ -49,9 +50,12 @@ _css, _decode_title_geometry, _density_column, - _estimated_text_width, + _has_box_declaration, _heatmap_rgba_grid, + _layer_opacity, _legend_layout, + _legend_patch_box, + _legend_text_slot_box, _lut, _physical_density_alpha, _PolarProjection, @@ -62,23 +66,33 @@ _step_arrays, _tick_label_anchor, _title_entries, - _title_metrics, affine_fast_path, annotation_label_placement, + annotation_style_with_slot, + annotation_text_box, apply_export_background, + axis_chrome_boxes, axis_ticks, hexbin_ring, layout, + legacy_title_placement, legend_clip_rect, + legend_frame_box, legend_items, legend_options_with_slot, + legend_text_align, minor_axis_ticks, polar_heatmap_rgba, polar_tick_label_layout, polar_wedge_points, + slot_box_declaration, slot_font_size, + slot_in_labels_container, slot_styles, slot_text_color, + tick_span, + title_box, + title_placement, warp_grid_rgba, ) @@ -770,6 +784,52 @@ def _round_rect_pts( return pts +def _emit_slot_box(cmd: "_Cmd", box: Any) -> None: + """One chrome box in the raster display list — `_slot_box_svg`'s twin. + + Slot opacity folds into every RGBA (the display list has no group + compositing); rounded corners go through `_round_rect_pts`; the border + strokes closed so the final edge is not silently dropped. A posed box + (`angle != 0`) is pre-rotated in Python — `cmd.fill` takes any polygon + and the display list has no transform primitive — the raster half of the + repo's one rotated-box lowering (plan flag E). Emits nothing for a box + that paints nothing — including any zero-area box, which casts no shadow + either — the unstyled-bytes gate. + """ + if box.w <= 0.0 or box.h <= 0.0: + return + if not box.paints_anything and box.shadow is None: + return + + def pts(x: float, y: float) -> list[tuple[float, float]]: + # (x0, y0, x1, y1) corners, like every other _rect_pts caller — the + # box carries a width/height pair, not a far corner. + if box.radius > 0: + shape = _round_rect_pts(x, y, x + box.w, y + box.h, box.radius) + else: + shape = _rect_pts(x, y, x + box.w, y + box.h) + if box.angle: + shape = _chromebox_rotate(shape, box.angle, box.cx, box.cy) + return shape + + if box.shadow is not None: + dx, dy, color = box.shadow + cmd.fill(pts(box.x + dx, box.y + dy), _parse_color(color, opacity=box.opacity)) + if box.fill is not None: + cmd.fill( + pts(box.x, box.y), + _parse_color(box.fill, opacity=box.opacity * box.fill_opacity), + ) + if box.border_color is not None and box.border_width > 0: + cmd.stroke( + pts(box.x, box.y), + box.border_width, + _parse_color(box.border_color, opacity=box.opacity * box.border_opacity), + closed=True, + dash=list(box.border_dash) if box.border_dash else None, + ) + + def _grad_line( space: str, direction: str, @@ -886,13 +946,23 @@ def _emit_polar_tick_labels( r_color: tuple[int, ...], hide_theta: bool, hide_r: bool, + tick_label_slot: Optional[dict[str, Any]] = None, ) -> None: """Emit polar tick labels as display-list text, from the shared placement. Placement lives in `_svg.polar_tick_label_layout`; this is only the sink, so the two exporters cannot drift on rim offsets, quadrant anchors or the - radial spoke angle. + radial spoke angle. `tick_label_slot` carries the chart-wide slot: its + font emphasis routes to the atlas faces here exactly as on the cartesian + path (the P0.2 contract — this sink was the one emitter it had missed), + and its declared box rides `text_box` like every other label. """ + slot = tick_label_slot or {} + italic, bold = _native_font_emphasis( + {"font_style": slot.get("font-style"), "font_weight": slot.get("font-weight", 400)} + ) + box_tmpl = box_template("tick_label", slot) if _has_box_declaration(slot) else None + box_pads = padding_sides(slot) angular, radial = polar_tick_label_layout( polar, theta_values, @@ -908,6 +978,19 @@ def _emit_polar_tick_labels( ) for placed, paint in ((angular, theta_color), (radial, r_color)): for item in placed: + if box_tmpl is not None: + _emit_slot_box( + cmd, + text_box( + box_tmpl, + box_pads, + x=item.x, + y=item.y, + anchor=item.anchor, + block=_textblock.measure(item.text, item.size), + angle=item.spin, + ), + ) cmd.text( item.x, item.y, @@ -918,6 +1001,8 @@ def _emit_polar_tick_labels( paint, item.text, angle=item.spin, + italic=italic, + bold=bold, ) @@ -947,23 +1032,53 @@ def render_raster( cmd = _Cmd(scale) dom_style = (spec.get("dom") or {}).get("style") or {} + slots = slot_styles(spec) # Figure patch (mpl figure.facecolor): `theme(background=)` lands on the # root element's CSS background, painted over the whole canvas so the # margins match the browser. Gradients stay browser-only (skipped). figure_background = _solid_color(dom_style.get("background")) + # The root slot's box (parity plan §3.4), mirroring the SVG writer: + # declaration-gated, and when declared it IS the figure patch (one DOM + # element, one `background` property), so the theme paint becomes the box + # fill when the slot declares chrome but no fill of its own. + # `apply_export_background` already silenced the slot fill under an + # export `background=` override — the single precedence definition. + root_declaration = slot_box_declaration(slots.get("root") or {}, "root") + root_box = None + if root_declaration: + figure_css = _solid_paint(dom_style.get("background")) + if figure_css is not None and not any( + prop in root_declaration for prop in ("background", "background-color") + ): + root_declaration = {"background": figure_css, **root_declaration} + root_box = lower_box("root", root_declaration, x=0.0, y=0.0, w=width, h=height) + # The fused PNG path initializes its native canvas white, avoiding a second # full-frame memory pass. Raw RGBA callers still receive an explicit fill — - # skipped when an opaque figure background would fully cover it anyway + # skipped when an opaque full-bleed paint would fully cover it anyway # (a translucent one keeps the white underlay to composite over, matching - # the browser's white host page). - if not fast_png and (figure_background is None or figure_background[3] < 255): + # the browser's white host page). A ROUNDED root box breaks full-bleed + # coverage — its corners must show the underlay, never uninitialized + # pixels — so the skip only fires for a square, fully-opaque patch. + if root_box is not None: + root_fill = ( + _parse_color(root_box.fill, opacity=root_box.opacity * root_box.fill_opacity) + if root_box.fill is not None + else None + ) + covers = root_fill is not None and root_fill[3] >= 255 and root_box.radius == 0.0 + else: + covers = figure_background is not None and figure_background[3] >= 255 + if not fast_png and not covers: cmd.fill( _rect_pts(0, 0, width, height), _parse_color(spec.get("canvas_background", "#ffffff")), ) - if figure_background is not None: + if root_box is not None: + _emit_slot_box(cmd, root_box) + elif figure_background is not None: cmd.fill(_rect_pts(0, 0, width, height), figure_background) # Static exports honor the same axes background token as HTML/SVG. This @@ -971,11 +1086,13 @@ def render_raster( # is the Figure patch, composed above (or by pyplot's grid exporter). An # unset token keeps the plot rect transparent when a figure background is # present — matching the browser, where the root shows through — and - # falls back to the classic white fill otherwise. + # falls back to the classic white fill otherwise. A painted root box + # counts as a figure background here: the browser shows it through the + # transparent plot rect, so the white fallback must not bury it. plot_css = _css(dom_style.get("--chart-bg"), "") if plot_css: plot_background = _parse_color(plot_css) - elif figure_background is None: + elif figure_background is None and (root_box is None or root_box.fill is None): plot_background = _parse_color("#ffffff") else: plot_background = None @@ -985,6 +1102,17 @@ def render_raster( plot_background, ) + # The chrome slot (parity plan §3.5, background/opacity only): above the + # root and plot backgrounds, below the grid — before the plot clip so it + # covers the full canvas exactly like the browser's inset:0 chrome + # canvas. Its stacking against the title divs diverges by design and is + # recorded in `KNOWN_RENDERER_DIVERGENCES` (`chrome_slot_title_stacking`). + chrome_declaration = slot_box_declaration(slots.get("chrome") or {}, "chrome") + if chrome_declaration: + _emit_slot_box( + cmd, lower_box("chrome", chrome_declaration, x=0.0, y=0.0, w=width, h=height) + ) + xt, xlab, xstep = axis_ticks(xa, plot["w"], True) yt, ylab, ystep = axis_ticks(ya, plot["h"], False) xmt, ymt = minor_axis_ticks(xa), minor_axis_ticks(ya) @@ -1053,12 +1181,55 @@ def render_raster( dash=_AXIS_GRID_DASHES.get(str(ystyle.get("grid_dash", "solid"))), ) + # The canvas slot (parity plan §3.6) paints at the ABOVE-grid seam, + # inside the plot clip and before every trace: the browser's marks canvas + # is a separate element over the chrome canvas, so a declared canvas + # background HIDES the grid — deliberately NOT the below-grid --chart-bg + # anchor, the paint-order trap this family exists to avoid. The display + # list clips rectangles only and has no group compositing, so a declared + # `border-radius` (rounded content clip) and `opacity` (fading marks + # already drawn by later commands) are raster losses the preflight names + # (`_svg.SLOT_BOX_RASTER_UNSUPPORTED`) — stripped here rather than + # half-approximated on the box alone. + canvas_declaration = slot_box_declaration(slots.get("canvas") or {}, "canvas") + if canvas_declaration: + canvas_declaration = { + prop: value + for prop, value in canvas_declaration.items() + if prop not in ("border-radius", "opacity") + } + if canvas_declaration: + _emit_slot_box( + cmd, + lower_box( + "canvas", + canvas_declaration, + x=plot["x"], + y=plot["y"], + w=plot["w"], + h=plot["h"], + ), + ) + # Grid/frame chrome is drawn before the shaped clip. Marks then share one # analytic annular-sector clip in the native painter, matching SVG's # polar clipPath without flattening every mark at the boundary. if polar is not None: cmd.polar_clip(polar) + # Hoisted above the annotation passes: both phases read the annotation + # slots, and the chrome text below reads the rest of the same mapping. + slots = slot_styles(spec) + labels_slot = slots.get("labels") or {} + # The live chain for every text in the labels container is + # `color: var(--chart-text, inherit)`: the theme token wins, the + # container's own declared color is the inherited fallback, then the + # writer default. Title/legend/colorbar are siblings and keep + # `default_text`. + label_text_default = ( + _css(dom_style.get("--chart-text"), "") or slot_text_color(labels_slot, "") or _TEXT + ) + spec_palette: Sequence[str] = spec.get("palette") or DEFAULT_PALETTE for palette_i, t in enumerate(spec["traces"]): style = t.get("style") or {} @@ -1102,10 +1273,30 @@ def render_raster( elif all(k in t for k in ("x0", "x1", "y0", "y1")): _emit_rects(cmd, t, blob, cols, trace_sx, trace_sy, style, color, plot, polar) - _emit_annotations(cmd, spec.get("annotations") or [], sx, sy, plot, width, height, polar=polar) + _emit_annotations( + cmd, + spec.get("annotations") or [], + sx, + sy, + plot, + width, + height, + polar=polar, + slots=slots, + ) # Chrome (unclipped): baselines, labels, title, legend. cmd.clip(0, 0, width, height) + # labels-container box (flag D, resolved): the background paints UNDER + # the baselines and every label text — the live DOM's order, where the + # spine/tick rules are children of the labels container. Painted before + # the annotation text phase because the raster draws those labels ahead + # of the spines (a pre-existing phase-order difference from SVG). + labels_paint = labels_slot.get("background", labels_slot.get("background-color")) + if labels_paint is not None: + _emit_slot_box( + cmd, lower_box("labels", {"background": labels_paint}, x=0, y=0, w=width, h=height) + ) # Text annotations are unclipped like matplotlib Text (clip_on=False): # margin titles and edge labels may live outside the plot rectangle. _emit_annotations( @@ -1118,7 +1309,19 @@ def render_raster( height, phase="text", polar=polar, + slots=slots, ) + # Hoisted above the axis chrome: the axis_line/tick_mark box slots are + # read here (formerly assigned after the tick loops, which could never + # see them). Same resolver, same view — a pure reorder for unstyled + # charts. + slots = slot_styles(spec) + axis_line_boxed = polar is None and _has_box_declaration(slots.get("axis_line")) + tick_mark_boxed = polar is None and _has_box_declaration(slots.get("tick_mark")) + # One producer feeds both writers and the declared snapshot, so a styled + # spine or tick cannot mean different geometry in different outputs. + # Polar keeps stroke semantics (the browser shares the limit). + axis_boxes = axis_chrome_boxes(spec, slots) if (axis_line_boxed or tick_mark_boxed) else [] # "none" silences the whole axis chrome (sparklines); "off" hides only the # label text and keeps baselines and the axis title (mpl shared axes). frame_sides = spec.get("frame_sides") @@ -1155,64 +1358,63 @@ def render_raster( ) boundary = [*outer, *reversed(inner)] cmd.stroke([*boundary, boundary[0]], width_, paint) - if not hide_y or explicit_frame_sides: - if "left" in frame_sides: - cmd.stroke( - [(px0, py0), (px0, py1)], - float(ystyle.get("axis_width", 1)), - _parse_color(_css(ystyle.get("axis_color"), default_axis)), - ) - if "right" in frame_sides: - cmd.stroke( - [(px1, py0), (px1, py1)], - float(ystyle.get("axis_width", 1)), - _parse_color(_css(ystyle.get("axis_color"), default_axis)), - ) - if not hide_x or explicit_frame_sides: - if "top" in frame_sides: + if not axis_line_boxed: + if not hide_y or explicit_frame_sides: + if "left" in frame_sides: + cmd.stroke( + [(px0, py0), (px0, py1)], + float(ystyle.get("axis_width", 1)), + _parse_color(_css(ystyle.get("axis_color"), default_axis)), + ) + if "right" in frame_sides: + cmd.stroke( + [(px1, py0), (px1, py1)], + float(ystyle.get("axis_width", 1)), + _parse_color(_css(ystyle.get("axis_color"), default_axis)), + ) + if not hide_x or explicit_frame_sides: + if "top" in frame_sides: + cmd.stroke( + [(px0, py0), (px1, py0)], + float(xstyle.get("axis_width", 1)), + _parse_color(_css(xstyle.get("axis_color"), default_axis)), + ) + if "bottom" in frame_sides: + cmd.stroke( + [(px0, py1), (px1, py1)], + float(xstyle.get("axis_width", 1)), + _parse_color(_css(xstyle.get("axis_color"), default_axis)), + ) + for _axis_id, axis, _axis_scale in extra_x_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + edge = py0 if axis.get("side", "bottom") == "top" else py1 cmd.stroke( - [(px0, py0), (px1, py0)], - float(xstyle.get("axis_width", 1)), - _parse_color(_css(xstyle.get("axis_color"), default_axis)), + [(px0, edge), (px1, edge)], + float(axis_style.get("axis_width", 1)), + _parse_color(_css(axis_style.get("axis_color"), default_axis)), ) - if "bottom" in frame_sides: + for _axis_id, axis, _axis_scale in extra_y_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + edge = px1 if axis.get("side", "right") == "right" else px0 cmd.stroke( - [(px0, py1), (px1, py1)], - float(xstyle.get("axis_width", 1)), - _parse_color(_css(xstyle.get("axis_color"), default_axis)), + [(edge, py0), (edge, py1)], + float(axis_style.get("axis_width", 1)), + _parse_color(_css(axis_style.get("axis_color"), default_axis)), ) - for _axis_id, axis, _axis_scale in extra_x_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - edge = py0 if axis.get("side", "bottom") == "top" else py1 - cmd.stroke( - [(px0, edge), (px1, edge)], - float(axis_style.get("axis_width", 1)), - _parse_color(_css(axis_style.get("axis_color"), default_axis)), - ) - for _axis_id, axis, _axis_scale in extra_y_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - edge = px1 if axis.get("side", "right") == "right" else px0 - cmd.stroke( - [(edge, py0), (edge, py1)], - float(axis_style.get("axis_width", 1)), - _parse_color(_css(axis_style.get("axis_color"), default_axis)), - ) - - def tick_span(style: dict[str, Any]) -> tuple[float, float]: - length = max(0.0, float(style.get("tick_length", 0))) - direction = str(style.get("tick_direction", "out")) - if direction == "in": - return length, 0.0 - if direction == "inout": - return length / 2, length / 2 - return 0.0, length - - if not hide_x and polar is None: - inward, outward = tick_span(xmstyle) + else: + # The styled spines as boxes, centered on the plot edge exactly where + # the unstyled stroke ran (the browser insets right/bottom spines — + # recorded divergence in `KNOWN_RENDERER_DIVERGENCES`). + for box in axis_boxes: + if box.slot == "axis_line": + _emit_slot_box(cmd, box) + + if not tick_mark_boxed and not hide_x and polar is None: + inward, outward, tick_width = tick_span(xmstyle) side = xa.get("side", "bottom") edge = py0 if side == "top" else py1 for value in xmt: @@ -1224,10 +1426,10 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: ) cmd.stroke( [(x, y0), (x, y1)], - float(xmstyle.get("tick_width", 1)), + tick_width, _parse_color(_css(xmstyle.get("tick_color"), default_axis)), ) - inward, outward = tick_span(xstyle) + inward, outward, tick_width = tick_span(xstyle) for side in _axis_tick_sides(xa, is_x=True): edge = py0 if side == "top" else py1 for value in xt: @@ -1239,11 +1441,11 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: ) cmd.stroke( [(x, y0), (x, y1)], - float(xstyle.get("tick_width", 1)), + tick_width, _parse_color(_css(xstyle.get("tick_color"), default_axis)), ) - if not hide_y and polar is None: - inward, outward = tick_span(ymstyle) + if not tick_mark_boxed and not hide_y and polar is None: + inward, outward, tick_width = tick_span(ymstyle) side = ya.get("side", "left") edge = px1 if side == "right" else px0 for value in ymt: @@ -1255,10 +1457,10 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: ) cmd.stroke( [(x0, y), (x1, y)], - float(ymstyle.get("tick_width", 1)), + tick_width, _parse_color(_css(ymstyle.get("tick_color"), default_axis)), ) - inward, outward = tick_span(ystyle) + inward, outward, tick_width = tick_span(ystyle) for side in _axis_tick_sides(ya, is_x=False): edge = px1 if side == "right" else px0 for value in yt: @@ -1270,49 +1472,55 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float]: ) cmd.stroke( [(x0, y), (x1, y)], - float(ystyle.get("tick_width", 1)), + tick_width, _parse_color(_css(ystyle.get("tick_color"), default_axis)), ) - for axis_id, axis, axis_scale in extra_x_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - inward, outward = tick_span(axis_style) - for side in _axis_tick_sides(axis, is_x=True): - edge = py0 if side == "top" else py1 - for value in extra_x_ticks[axis_id][0]: - x = float(axis_scale(value)) - y0, y1 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - cmd.stroke( - [(x, y0), (x, y1)], - float(axis_style.get("tick_width", 1)), - _parse_color(_css(axis_style.get("tick_color"), default_axis)), - ) - for axis_id, axis, axis_scale in extra_y_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - inward, outward = tick_span(axis_style) - for side in _axis_tick_sides(axis, is_x=False): - edge = px1 if side == "right" else px0 - for value in extra_y_ticks[axis_id][0]: - y = float(axis_scale(value)) - x0, x1 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - cmd.stroke( - [(x0, y), (x1, y)], - float(axis_style.get("tick_width", 1)), - _parse_color(_css(axis_style.get("tick_color"), default_axis)), - ) - - slots = slot_styles(spec) + if not tick_mark_boxed: + for axis_id, axis, axis_scale in extra_x_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + inward, outward, tick_width = tick_span(axis_style) + for side in _axis_tick_sides(axis, is_x=True): + edge = py0 if side == "top" else py1 + for value in extra_x_ticks[axis_id][0]: + x = float(axis_scale(value)) + y0, y1 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + cmd.stroke( + [(x, y0), (x, y1)], + tick_width, + _parse_color(_css(axis_style.get("tick_color"), default_axis)), + ) + for axis_id, axis, axis_scale in extra_y_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + inward, outward, tick_width = tick_span(axis_style) + for side in _axis_tick_sides(axis, is_x=False): + edge = px1 if side == "right" else px0 + for value in extra_y_ticks[axis_id][0]: + y = float(axis_scale(value)) + x0, x1 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + cmd.stroke( + [(x0, y), (x1, y)], + tick_width, + _parse_color(_css(axis_style.get("tick_color"), default_axis)), + ) + if tick_mark_boxed: + # The styled tick marks, from the shared producer — the same boxes the + # SVG writer and the declared snapshot consume, at the stroke's own + # paint position in the display list. + for box in axis_boxes: + if box.slot == "tick_mark": + _emit_slot_box(cmd, box) def slot_paint(slot: str, fallback: str) -> tuple: """A slot's text paint, or the writer's own default.""" @@ -1328,15 +1536,32 @@ def emit_tick_labels( is_x: bool, ) -> None: axis_style = axis.get("style") or {} + tick_slot = slot_in_labels_container(slots, "tick_label") # The axis's own tick_label_color/tick_color is the narrower # selector and wins; the chart-wide slot fills in when it says nothing. axis_tick_paint = _css(axis_style.get("tick_label_color", axis_style.get("tick_color")), "") tick_color = ( _parse_color(axis_tick_paint) if axis_tick_paint - else slot_paint("tick_label", default_text) + else _parse_color(slot_text_color(tick_slot, "") or label_text_default) + ) + font_size = slot_font_size(tick_slot, _axis_tick_font_size(axis)) + tick_italic, tick_bold = _native_font_emphasis( + { + "font_style": tick_slot.get("font-style"), + "font_weight": tick_slot.get("font-weight", 400), + } + ) + # The slot's box, lowered once and stamped per label — the raster + # twin of the SVG writer's per- rect, from the same shared + # `text_box` metrics. + tick_label_slot = slots.get("tick_label") or {} + box_tmpl = ( + box_template("tick_label", tick_label_slot) + if _has_box_declaration(tick_label_slot) + else None ) - font_size = slot_font_size(slots.get("tick_label") or {}, _axis_tick_font_size(axis)) + box_pads = padding_sides(tick_label_slot) baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # side-derived default, matching the browser client and SVG export. @@ -1385,6 +1610,19 @@ def emit_tick_labels( anchor = ( _TEXT_ANCHOR_CODES[explicit_anchor] if explicit_anchor else default_anchor ) + if box_tmpl is not None: + _emit_slot_box( + cmd, + text_box( + box_tmpl, + box_pads, + x=x, + y=y, + anchor=("start", "middle", "end")[anchor], + block=block, + angle=float(item["angle"]), + ), + ) _emit_text_block( cmd, x, @@ -1394,6 +1632,8 @@ def emit_tick_labels( tick_color, item["text"], angle=float(item["angle"]), + italic=tick_italic, + bold=tick_bold, ) if polar is not None: @@ -1406,12 +1646,13 @@ def emit_tick_labels( ystep, xa, ya, - slot_font_size(slots.get("tick_label") or {}, _axis_tick_font_size(xa)), - slot_font_size(slots.get("tick_label") or {}, _axis_tick_font_size(ya)), - _polar_label_paint(xa, slot_paint, default_text), - _polar_label_paint(ya, slot_paint, default_text), + slot_font_size(slot_in_labels_container(slots, "tick_label"), _axis_tick_font_size(xa)), + slot_font_size(slot_in_labels_container(slots, "tick_label"), _axis_tick_font_size(ya)), + _polar_label_paint(xa, slot_paint, label_text_default), + _polar_label_paint(ya, slot_paint, label_text_default), hide_x or xa.get("tick_label_strategy") == "off", hide_y or ya.get("tick_label_strategy") == "off", + tick_label_slot=slots.get("tick_label") or {}, ) else: emit_tick_labels(xa, xlab, xstep, sx, is_x=True) @@ -1427,62 +1668,54 @@ def emit_tick_labels( # draw more lines than `title_room` reserved (see _svg._title_wrap_width). title_wrap_width = plot.get("title_wrap_width") if legacy_title: - title_slot = slots.get("title") or {} + # Anchor math shared with the SVG writer (`_svg.legacy_title_placement`): + # the last line keeps the historical single-line baseline, extra lines + # fill the reserved band above it — byte-identical when unstyled. + placement = legacy_title_placement(spec, plot, compact, width, title_wrap_width) title_italic, title_bold = _native_font_emphasis( { - "font_style": title_slot.get("font-style"), - "font_weight": title_slot.get("font-weight", 400), + "font_style": placement.style.get("font-style"), + "font_weight": placement.style.get("font-weight", 400), } ) - legacy_size = slot_font_size(title_slot, 14.0) - legacy_block = _textblock.measure(legacy_title, legacy_size, max_width=title_wrap_width) - # Lines run downward from the baseline, so lift the block by its trailing - # lines: the last line keeps the historical single-line baseline. A - # one-line title has no trailing lines and emits exactly as before. - legacy_trailing = (legacy_block.line_count - 1) * legacy_block.line_step + legacy_box = title_box(placement) + if legacy_box is not None: + _emit_slot_box(cmd, legacy_box) _emit_text_block( cmd, - width / 2, - plot["y"] - plot["top_axis_room"] - (10 if compact else 12) - legacy_trailing, + placement.x, + placement.baseline, 1, - legacy_size, + placement.size, slot_paint("title", default_text), - "\n".join(legacy_block.lines), + "\n".join(placement.block.lines), italic=title_italic, bold=title_bold, ) for title_entry in [] if legacy_title else _title_entries(spec): - title_style, title_size, title_block = _title_metrics(spec, title_entry, title_wrap_width) + placement = title_placement(spec, title_entry, plot, title_wrap_width) title_italic, title_bold = _native_font_emphasis( { - "font_style": title_style.get("font-style"), + "font_style": placement.style.get("font-style"), # 400 = Matplotlib's `axes.titleweight: normal`; the baked # atlas only has a bold face, so anything >= 600 rounds up to # it. Mirrors the SVG/browser title default. - "font_weight": title_style.get("font-weight", 400), + "font_weight": placement.style.get("font-weight", 400), } ) - trailing = (title_block.line_count - 1) * title_block.line_step - if title_entry.get("automatic_y", True): - title_anchor_y = plot["y"] - plot["top_axis_room"] - else: - title_anchor_y = plot["y"] + (1.0 - float(title_entry.get("y", 1.0))) * plot["h"] - loc = str(title_entry.get("loc", "center")) - title_x = { - "left": plot["x"], - "center": plot["x"] + plot["w"] / 2.0, - "right": plot["x"] + plot["w"], - }.get(loc, plot["x"] + plot["w"] / 2.0) + entry_box = title_box(placement) + if entry_box is not None: + _emit_slot_box(cmd, entry_box) _emit_text_block( cmd, - title_x, - title_anchor_y - float(title_entry.get("pad", 8.0)) - title_block.descent - trailing, - {"left": 0, "center": 1, "right": 2}.get(loc, 1), - title_size, - _parse_color(slot_text_color(title_style, default_text)), + placement.x, + placement.baseline, + {"start": 0, "middle": 1, "end": 2}[placement.anchor], + placement.size, + _parse_color(slot_text_color(placement.style, default_text)), # The wrapped lines, not the raw string: one long line inside a # two-line band is the clipping bug this reservation exists to stop. - "\n".join(title_block.lines), + "\n".join(placement.block.lines), italic=title_italic, bold=title_bold, ) @@ -1493,7 +1726,7 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: axis_style = axis.get("style") or {} geometry = _axis_label_geometry(axis, plot, is_x=is_x) anchor = {"start": 0, "middle": 1, "end": 2}[geometry["anchor"]] - axis_title_slot = slots.get("axis_title") or {} + axis_title_slot = slot_in_labels_container(slots, "axis_title") italic, bold = _native_font_emphasis( { "font_style": axis_style.get("label_font_style") @@ -1503,16 +1736,34 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ), } ) + font_size = slot_font_size(axis_title_slot, float(geometry["font_size"])) + if _has_box_declaration(axis_title_slot): + # The title's box before its text, from the same shared `text_box` + # metrics the SVG writer uses; a rotated y-title box is + # pre-rotated by the emitter (flag-E lowering, no transform + # primitive in the display list). + _emit_slot_box( + cmd, + text_box( + box_template("axis_title", axis_title_slot), + padding_sides(axis_title_slot), + x=float(geometry["x"]), + y=float(geometry["y"]), + anchor=str(geometry["anchor"]), + block=_textblock.measure(str(axis["label"]), font_size), + angle=float(geometry["angle"]), + ), + ) _emit_text_block( cmd, float(geometry["x"]), float(geometry["y"]), anchor, - slot_font_size(slots.get("axis_title") or {}, float(geometry["font_size"])), + font_size, ( _parse_color(_css(axis_style.get("label_color"), "")) if _css(axis_style.get("label_color"), "") - else slot_paint("axis_title", default_text) + else _parse_color(slot_text_color(axis_title_slot, "") or label_text_default) ), str(axis["label"]), angle=float(geometry["angle"]), @@ -1565,6 +1816,8 @@ def emit_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: spec_palette, slots.get("legend_label") or {}, slots.get("legend_title") or {}, + slots.get("legend_item") or {}, + slots.get("legend_swatch") or {}, ) if clipped_to_plot: cmd.clip(0, 0, width, height) @@ -1676,9 +1929,34 @@ def _emit_annotations( *, phase: str = "marks", polar: "Optional[_PolarProjection]" = None, + slots: Optional[dict[str, dict[str, Any]]] = None, ) -> None: px0, py0 = plot["x"], plot["y"] text_phase = phase == "text" + slots = slots or {} + # annotation_layer: the display list has no group compositing, so the + # declared layer opacity folds into every shape RGBA (overlapping + # translucent shapes double-blend — recorded divergence, §28) and never + # into the labels, which live in the labels container in the browser. + # The layer background is a plot-rect box under the shapes, inside the + # active marks clip — the geometry pinned against the browser's + # full-bleed overlay in KNOWN_RENDERER_DIVERGENCES. + layer = slots.get("annotation_layer") or {} + layer_alpha = 1.0 if text_phase else _layer_opacity(layer) + if not text_phase and layer: + layer_background = layer.get("background", layer.get("background-color")) + if layer_background is not None: + _emit_slot_box( + cmd, + lower_box( + "annotation_layer", + {"background": layer_background, "opacity": layer_alpha}, + x=plot["x"], + y=plot["y"], + w=plot["w"], + h=plot["h"], + ), + ) def point(x: float, y: float) -> tuple[float, float]: """Jointly project point-anchored geometry under polar coordinates.""" @@ -1691,9 +1969,9 @@ def point(x: float, y: float) -> tuple[float, float]: # Geometry (rules/bands/arrows/markers) draws in the clipped marks # pass; every label draws in the unclipped chrome pass, matching # matplotlib's Text and the client's DOM labels. - style = ann.get("style") or {} + style = annotation_style_with_slot(ann, slots) restore_plot_clip = False - color = _rgba(style.get("color"), "#667085", float(style.get("opacity", 1.0))) + color = _rgba(style.get("color"), "#667085", float(style.get("opacity", 1.0)) * layer_alpha) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) end = max(start, min(1.0, float(style.get("span_end", 1.0)))) if text_phase: @@ -1725,7 +2003,11 @@ def point(x: float, y: float) -> tuple[float, float]: x0, x1 = px0 + start * plot["w"], px0 + end * plot["w"] cmd.fill( _rect_pts(x0, y0, x1, y1), - _rgba(style.get("color"), "#64748b", float(style.get("opacity", 0.14))), + _rgba( + style.get("color"), + "#64748b", + float(style.get("opacity", 0.14)) * layer_alpha, + ), ) elif ann.get("kind") in ("arrow", "callout"): if _annotation_connector_unclipped(ann, sx, sy, plot, polar): @@ -1763,7 +2045,7 @@ def point(x: float, y: float) -> tuple[float, float]: elif ann.get("kind") == "marker": mx, my = point(float(ann["x"]), float(ann["y"])) if np.isfinite(mx) and np.isfinite(my): - alpha = float(style.get("opacity", 1.0)) + alpha = float(style.get("opacity", 1.0)) * layer_alpha stroke_w = float(style.get("stroke_width", 0.0)) cmd.point( mx, @@ -1882,42 +2164,25 @@ def _emit_text_box( font_size: float, anchor: int, ) -> None: - """Draw the bounded CSS approximation used by pyplot ``text(bbox=)``.""" - background = style.get("background") - border = str(style.get("border", "")) - if background is None and not border: - return - pad_parts = str(style.get("padding", "0")).split() + """Adapter: the pyplot ``text(bbox=)`` approximation over the shared box. - def px(value: str) -> float: - try: - return max(0.0, float(value.removesuffix("px"))) - except ValueError: - return 0.0 - - pad_y = px(pad_parts[0]) if pad_parts else 0.0 - pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y - text_width = _estimated_text_width(lines, font_size) - left = x - (text_width / 2 if anchor == 1 else text_width if anchor == 2 else 0.0) - pad_x - top = first_y - font_size * 0.8 - pad_y - right = left + text_width + pad_x * 2 - bottom = top + font_size + (len(lines) - 1) * line_height + pad_y * 2 - # `boxstyle="round"`/`round4` set border_radius, which the browser applies - # as CSS border-radius; round the same corners here or the exported box is - # square where the live one is not. - points = _round_rect_pts( - left, top, right, bottom, _box_corner_radius(style, right - left, bottom - top) + Geometry and declaration parsing live in `annotation_text_box` (one + lowering for both writers); the display list comes from `_emit_slot_box`. + A closed border stroke rasterizes pixel-identically to the old + repeat-the-first-point polyline (round cap == round join for the capsule + distance field). + """ + box = annotation_text_box( + style, + lines, + x, + first_y, + line_height, + font_size, + {0: "start", 1: "middle", 2: "end"}.get(anchor, "start"), ) - if background is not None: - cmd.fill(points, _parse_color(str(background))) - if border: - parts = border.split() - try: - width = max(0.0, float(parts[0].removesuffix("px"))) - except (IndexError, ValueError): - width = 1.0 - if width: - cmd.stroke(points + [points[0]], width, _parse_color(parts[-1])) + if box is not None: + _emit_slot_box(cmd, box) def _emit_area( @@ -3054,6 +3319,13 @@ def _emit_grid( cmd.image(dx, dy, dw, dh, w, h, rgba.tobytes(), nearest=kind == "heatmap") +#: SVG `text-anchor` to the display list's anchor byte (`src/raster.rs` +#: `text`: 0 = start, 1 = middle, 2 = end). Named so the legend's +#: `text-align` support cannot spell the mapping differently from the +#: writer it has to agree with. +_TEXT_ANCHORS: dict[str, int] = {"start": 0, "middle": 1, "end": 2} + + # Trace kinds whose legend entry is a short line sample (with dash) rather # than a marker glyph or a filled patch. _LEGEND_LINE_KINDS = frozenset({"line", "segments", "step", "stairs", "errorbar"}) @@ -3068,56 +3340,69 @@ def _emit_legend( palette: Sequence[str] = DEFAULT_PALETTE, label_slot: Optional[dict[str, Any]] = None, title_slot: Optional[dict[str, Any]] = None, + item_slot: Optional[dict[str, Any]] = None, + swatch_slot: Optional[dict[str, Any]] = None, ) -> None: label_slot = label_slot or {} title_slot = title_slot or {} - legend = _legend_layout(named, plot, options) + item_slot = item_slot or {} + swatch_slot = swatch_slot or {} + legend = _legend_layout(named, plot, options, title_slot, label_slot) if not legend["visible_count"]: # A plot too short for even one entry: no floating frame/title either. return - style_opts = legend["style"] - pad, handle, gap = legend["pad"], legend["handle"], legend["gap"] - line_h, ncols = legend["line_h"], legend["ncols"] + handle, gap = legend["handle"], legend["gap"] swatch_h = legend["swatch_h"] - title, title_h = legend["title"], legend["title_h"] - font_size, text_h = legend["font_size"], legend["text_h"] - column_offsets = legend["column_offsets"] - box_w, box_h = legend["box_w"], legend["box_h"] - x, y = legend["x"], legend["y"] - # frameon=False (background transparent) drops the box entirely (§ mpl parity). - if style_opts.get("background") != "transparent": - radius = 4.0 if style_opts.get("borderRadius") else 0.0 - frame_points = _round_rect_pts(x, y, x + box_w, y + box_h, radius) - if style_opts.get("boxShadow"): - shadow_points = _round_rect_pts(x + 2, y + 2, x + box_w + 2, y + box_h + 2, radius) - cmd.fill(shadow_points, (0, 0, 0, 55)) - # An explicit background is a paint, not a tint — see the matching note - # in `_svg._legend`; the two writers must agree. - frame_alpha = style_opts.get("--xy-legend-frame-alpha") - if frame_alpha is not None: - alpha = float(frame_alpha) - else: - alpha = 0.08 if style_opts.get("background") is None else 1.0 - background = style_opts.get("background") - frame = ( - _rgba(background, "#808080", alpha) - if background - else (128, 128, 128, round(255 * alpha)) - ) - cmd.fill(frame_points, frame) - border = _rgba(style_opts.get("borderColor"), "#cccccc", alpha) - # closed=True: the point list omits a repeated start point. Without it, - # the final edge is silently dropped for both square and rounded frames. - cmd.stroke(frame_points, 1.0, border, closed=True) + title = legend["title"] + text_h = legend["text_h"] + box_w = legend["box_w"] + x = legend["x"] + entries = legend["rows"] + # frameon=False (background transparent) drops the box entirely (§ mpl + # parity) — `legend_frame_box` returns None and is the one place that + # decision, the radius, the border and the shadow now live for both + # writers. + frame = legend_frame_box(legend) + if frame is not None: + _emit_slot_box(cmd, frame) if title: - cmd.text( + title_box = _legend_text_slot_box( + "legend_title", + title_slot, + x, + legend["y"] + legend["pad_top"], + box_w, + legend["title_h"], + ) + if title_box is not None: + _emit_slot_box(cmd, title_box) + title_italic, title_bold = _native_font_emphasis( + { + "font_style": title_slot.get("font-style"), + "font_weight": title_slot.get("font-weight", 400), + } + ) + title_anchor, title_x = legend_text_align( + title_slot, + x + legend["pad_left"], + max(0.0, box_w - legend["pad_left"] - legend["pad_right"]), + "middle", x + box_w / 2, - y + pad / 2 + font_size * 0.82, - 1, - slot_font_size(title_slot, font_size), + ) + cmd.text( + title_x, + legend["title_baseline"], + _TEXT_ANCHORS[title_anchor], + slot_font_size(title_slot, legend["font_size"]), _parse_color(slot_text_color(title_slot, text_color)), str(title), + italic=title_italic, + bold=title_bold, ) + item_tmpl = box_template("legend_item", item_slot) if _has_box_declaration(item_slot) else None + swatch_tmpl = ( + box_template("legend_swatch", swatch_slot) if _has_box_declaration(swatch_slot) else None + ) for i, t in enumerate(named[: legend["visible_count"]]): style = t.get("style") or {} color_str = _css( @@ -3125,10 +3410,29 @@ def _emit_legend( palette[i % len(palette)], ) c = _parse_color(color_str) - col, row = i % ncols, i // ncols - rx, ry = x + column_offsets[col], y + pad / 2 + title_h + row * line_h - hx0, hx1, cy = rx, rx + handle, ry + text_h / 2 + entry = entries[i] + ry = entry["y"] + hx0, hx1, cy = entry["swatch_x"], entry["swatch_x"] + handle, entry["handle_cy"] kind = t.get("kind") + qualifiers = (str(int(entry["row"])), str(int(entry["col"]))) + if item_tmpl is not None: + _emit_slot_box( + cmd, + box_at(item_tmpl, entry["x"], ry, entry["w"], entry["h"], qualifiers=qualifiers), + ) + patch_kind = kind != "scatter" and kind not in _LEGEND_LINE_KINDS + if swatch_tmpl is not None and not patch_kind: + _emit_slot_box( + cmd, + box_at( + swatch_tmpl, + entry["swatch_x"], + entry["swatch_y"], + handle, + swatch_h, + qualifiers=qualifiers, + ), + ) if kind == "scatter": _emit_legend_marker(cmd, style, (hx0 + hx1) / 2, cy, color_str) elif kind in _LEGEND_LINE_KINDS: @@ -3150,16 +3454,25 @@ def _emit_legend( if isinstance(marker, dict): _emit_legend_marker(cmd, marker, (hx0 + hx1) / 2, cy, color_str) else: - swatch_points = _rect_pts(hx0, cy - swatch_h / 2, hx1, cy + swatch_h / 2) - cmd.fill(swatch_points, c) - stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) - if style.get("stroke") is not None and stroke_width > 0.0: - cmd.stroke( - swatch_points, - stroke_width, - _rgba(style.get("stroke"), color_str), - closed=True, + if swatch_tmpl is not None: + # The patch IS the swatch — same lowering as the SVG twin, so + # the slot's paint and radius win over the trace's the same + # way in both writers. + _emit_slot_box( + cmd, + _legend_patch_box(swatch_tmpl, entry, handle, swatch_h, style, color_str), ) + else: + swatch_points = _rect_pts(hx0, cy - swatch_h / 2, hx1, cy + swatch_h / 2) + cmd.fill(swatch_points, c) + stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) + if style.get("stroke") is not None and stroke_width > 0.0: + cmd.stroke( + swatch_points, + stroke_width, + _rgba(style.get("stroke"), color_str), + closed=True, + ) hatch = style.get("hatch") if hatch: _emit_legend_hatch( @@ -3171,13 +3484,35 @@ def _emit_legend( str(hatch), _parse_color(_css(style.get("hatch_color"), "#222222")), ) + label_italic, label_bold = _native_font_emphasis( + { + "font_style": label_slot.get("font-style"), + "font_weight": label_slot.get("font-weight", 400), + } + ) + label_box = _legend_text_slot_box( + "legend_label", + label_slot, + entry["label_x"], + ry, + entry["label_w"], + text_h, + qualifiers=qualifiers, + ) + if label_box is not None: + _emit_slot_box(cmd, label_box) + label_anchor, label_x = legend_text_align( + label_slot, entry["label_x"], entry["label_w"], "start", hx1 + gap + ) cmd.text( - hx1 + gap, - ry + font_size * 0.82, - 0, - slot_font_size(label_slot, font_size), + label_x, + entry["label_baseline"], + _TEXT_ANCHORS[label_anchor], + slot_font_size(label_slot, legend["font_size"]), _parse_color(slot_text_color(label_slot, text_color)), legend["names"][i], + italic=label_italic, + bold=label_bold, ) @@ -3284,6 +3619,18 @@ def _emit_colorbar( title_paint = _parse_color(slot_text_color(title_slot, text_color)) tick_size = slot_font_size(tick_slot, COLORBAR_FONT_SIZE) tick_paint = _parse_color(slot_text_color(tick_slot, text_color)) + cb_title_italic, cb_title_bold = _native_font_emphasis( + { + "font_style": title_slot.get("font-style"), + "font_weight": title_slot.get("font-weight", 400), + } + ) + cb_tick_italic, cb_tick_bold = _native_font_emphasis( + { + "font_style": tick_slot.get("font-style"), + "font_weight": tick_slot.get("font-weight", 400), + } + ) from ._svg import _colorbar_tick_target, _fmt_log, _linear_ticks, _log_ticks, _lut orientation = options.get("orientation", "vertical") @@ -3452,6 +3799,8 @@ def tick_text(value: float) -> str: tick_size, tick_paint, tick_text(value), + italic=cb_tick_italic, + bold=cb_tick_bold, ) if options.get("label"): cmd.text( @@ -3461,6 +3810,8 @@ def tick_text(value: float) -> str: title_size, title_paint, str(options["label"]), + italic=cb_title_italic, + bold=cb_title_bold, ) else: tick_positions = ( @@ -3491,6 +3842,8 @@ def tick_text(value: float) -> str: tick_size, tick_paint, tick_text(value), + italic=cb_tick_italic, + bold=cb_tick_bold, ) # Matplotlib rotates a vertical colorbar's label 90° CCW and centers it # alongside the bar, outboard of the tick labels. The native glyph @@ -3508,6 +3861,8 @@ def tick_text(value: float) -> str: title_size, title_paint, str(options["label"]), + italic=cb_title_italic, + bold=cb_title_bold, ) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 0e5c5e1f..9a06c301 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -18,9 +18,11 @@ from __future__ import annotations import base64 +import dataclasses import hashlib import math import re +import threading from collections.abc import Callable, Sequence from datetime import UTC, datetime from itertools import pairwise @@ -31,6 +33,18 @@ from . import _fontmetrics, _native, _paint, _png, _textblock from ._arrowgeom import arrow_shapes as _arrow_shapes +from ._chromebox import ( + ChromeBox, + box_at, + box_padding, + box_room, + box_template, + lower_box, + padding_sides, + parse_padding, + rotate_points, +) +from ._chromebox import text_box as _chrome_text_box from .config import DEFAULT_PALETTE, polar_bar_segments @@ -1363,17 +1377,30 @@ def _px_size(value: Any, default: float) -> float: #: Slots the native writers style. Every one names chrome that a static file #: actually contains; the rest of `CHART_DOM_SLOTS` is live-only chrome #: (tooltip, modebar, crosshair, selection, badge) or a container with no -#: painted text of its own, and stays browser-only. +#: painted box or text of its own yet, and stays browser-only. `root`, +#: `chrome` and `canvas` joined with the static-chrome-parity P1 family +#: (spec/process/static-chrome-parity-plan-2026-08-04.md §3): they take the +#: box vocabulary (`SLOT_BOX_PROPS_BY_SLOT`), not the text subset. STATIC_STYLED_SLOTS: tuple[str, ...] = ( + "root", + "chrome", + "canvas", "title", + "axis_line", + "tick_mark", "axis_title", "tick_label", "legend", "legend_title", "legend_label", + "legend_item", + "legend_swatch", "colorbar", "colorbar_title", "colorbar_tick", + "annotation_label", + "annotation_layer", + "labels", ) @@ -1383,17 +1410,17 @@ def slot_styles(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: `chrome_styles` keeps whatever spelling the caller used (`font_size` and `font-size` both reach the browser, which sees the same declaration); the static writers match on property names, so they need one spelling. + + Since the resolved-style IR landed, this view is produced by the declared + resolver (`xy.styling.declared.resolve_declared`), which builds the + interned `ResolvedStyleSnapshot` from the same normalization in the same + pass — the writers keep reading the byte-exact authored view, and every + IR consumer reads the snapshot, so the two cannot disagree about what was + declared. """ - raw = (spec.get("dom") or {}).get("styles") or {} - out: dict[str, dict[str, Any]] = {} - for slot, decls in raw.items(): - if not isinstance(decls, dict): - continue - out[str(slot)] = { - (k if str(k).startswith("--") else str(k).replace("_", "-")): v - for k, v in decls.items() - } - return out + from .styling.declared import resolve_declared + + return resolve_declared(spec).slot_view() #: `styles={"legend": ...}` is CSS; `xy.legend(style=...)` reaches the writers @@ -1406,6 +1433,133 @@ def slot_styles(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: "row-gap": "rowGap", } +#: The legend frame's merged-declaration vocabulary, in every accepted +#: spelling — the box properties `legend_options_with_slot` folds and the +#: writers honor, per the capability-matrix legend note. The preflight +#: consumes this to route legend declarations at property level: a name in +#: neither this set nor the text subsets has no channel to a static file +#: and is a provable loss, not a qualified maybe. Owned by this module so +#: the writers and the report cannot drift apart. +#: +#: Both spellings of every property are listed because both reach the same +#: merged declaration: `styles={'legend': ...}` arrives kebab from the +#: declared resolver and `xy.legend(style=...)` arrives camelCase from the +#: browser's own vocabulary. Before P4 only the camelCase half of +#: `border-color`/`border-width`/`border-style` was honored, so the report +#: promised a channel the writers did not have — the set and the writers +#: now agree, which is what let `legend` leave the preflight's +#: `_CONDITIONAL_CHANNEL_SLOTS`. +LEGEND_BOX_PROPS: frozenset[str] = frozenset( + { + "background", + "background-color", + "box-shadow", + "boxShadow", + "border-radius", + "borderRadius", + "border-color", + "borderColor", + "border-width", + "borderWidth", + "border-style", + "borderStyle", + "opacity", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "gap", + "row-gap", + "rowGap", + "--xy-legend-frame-alpha", + } +) + +#: The chrome-box vocabulary the writers honor on a box-capable slot — the +#: declaration surface of `xy._chromebox.lower_box` plus the shorthands it +#: expands. Owned here (the `LEGEND_BOX_PROPS` pattern) so the writers, the +#: capability registry, and the preflight report cannot drift: preflight's +#: `_honored_props` consumes `SLOT_BOX_PROPS_BY_SLOT` directly. +#: Kebab spellings only — the declared resolver normalizes before any +#: writer reads a declaration; `border`/`padding` are the CSS shorthands the +#: writers split before `lower_box` reads the longhands, and both must be +#: named here or a chart declaring only a shorthand draws no box at all +#: (a second, `border`-less binding of this name used to shadow this one and +#: did exactly that to every slot gated on `_has_box_declaration`). +#: `padding`/`padding-*` are consumed by the layout/room functions, never by +#: the emitters — the geometry handed to a box already carries them. +SLOT_BOX_PROPS: frozenset[str] = frozenset( + { + "background", + "background-color", + "border", + "border-color", + "border-style", + "border-width", + "border-radius", + "box-shadow", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "opacity", + "fill-opacity", + } +) + +#: The padding spellings, split out so the per-slot subsets below can name +#: "the box minus padding" without restating five strings. +_SLOT_BOX_PADDING_PROPS: frozenset[str] = frozenset( + {"padding", "padding-top", "padding-right", "padding-bottom", "padding-left"} +) + +#: Which box-capable slot honors which subset, per the P1 decisions: +#: `title` takes the full box under its text; `root` and `canvas` take the +#: box minus `box-shadow` (a shadow on either falls outside the canvas — it +#: may never grow the export, so it is a named loss, §28) and minus padding +#: (their geometry is the canvas/plot rect, there is no content to pad); +#: `chrome` is background/opacity only (plan §8 flag G — the rest of its box +#: model is recorded unrepresentable rather than half-drawn). +SLOT_BOX_PROPS_BY_SLOT: dict[str, frozenset[str]] = { + "title": SLOT_BOX_PROPS, + "root": SLOT_BOX_PROPS - {"box-shadow"} - _SLOT_BOX_PADDING_PROPS, + "canvas": SLOT_BOX_PROPS - {"box-shadow"} - _SLOT_BOX_PADDING_PROPS, + "chrome": frozenset({"background", "background-color", "opacity"}), + # The legend's row and swatch cells are laid out by `_legend_layout`, + # which owns their pitch; padding on a cell would have to move that + # pitch, so it is not accepted rather than accepted and ignored. + "legend_item": SLOT_BOX_PROPS - _SLOT_BOX_PADDING_PROPS, + "legend_swatch": SLOT_BOX_PROPS - _SLOT_BOX_PADDING_PROPS, +} + +#: Box requests a slot's RASTER path cannot draw while the vector path can: +#: rounding the canvas needs a rounded content clip (the display list clips +#: rectangles only) and canvas opacity needs group compositing (the display +#: list has none) — both stay recorded losses on PNG/JPEG/WebP until the +#: `src/raster.rs` opcode lands (plan §1.3), never silent approximations. +SLOT_BOX_RASTER_UNSUPPORTED: dict[str, frozenset[str]] = { + "canvas": frozenset({"border-radius", "opacity"}), +} + + +def slot_box_declaration(style: dict[str, Any], slot: str) -> dict[str, Any]: + """The subset of a slot's declaration its box honors, or {} when the + declaration carries no box property at all — the emission gate every + box-capable slot shares, so unstyled output stays byte-identical.""" + honored = SLOT_BOX_PROPS_BY_SLOT.get(slot, SLOT_BOX_PROPS) + return {prop: value for prop, value in style.items() if prop in honored} + + +def _has_box_declaration(style: Optional[dict[str, Any]]) -> bool: + """Whether a slot declaration says anything the box model draws or + measures. The writers' box emission is strictly gated on this — an + absent/empty declaration must leave the unstyled bytes untouched.""" + if not style: + return False + return any(style.get(prop) is not None for prop in SLOT_BOX_PROPS) + def legend_options_with_slot(spec: dict[str, Any], options: dict[str, Any]) -> dict[str, Any]: """Fold the chart-level legend styling into a legend's own options, so every @@ -1464,6 +1618,17 @@ def slot_font_size(style: dict[str, Any], default: float) -> float: return _px_size(style.get("font-size"), default) if "font-size" in style else default +def _slot_letter_spacing(style: dict[str, Any]) -> float: + """A slot's `letter-spacing` in px, or 0. + + Only a resolved px length counts: an `em` spelling is the writer view's + own domain and the measurement has no font context to resolve it + against, so it contributes nothing here rather than a guess (the axis + family's qualified-not-folded ruling, plan §4 item 5). + """ + return _px_size(style.get("letter-spacing"), 0.0) + + def slot_text_attrs(style: dict[str, Any], **defaults: Any) -> str: """Extra SVG `` attributes for a slot's non-paint text properties. @@ -1497,6 +1662,12 @@ def apply_export_background(spec: dict[str, Any], background: Optional[str]) -> is what actually shows regardless of chart theme, instead of being buried under the theme paints. The plot token becomes "transparent" rather than the override color so translucent backgrounds composite exactly once. + The `root` and `canvas` slot backgrounds are part of that painted + backdrop (they target the same two elements the token pair does), so the + override silences them too — this function is the single definition of + the three-source precedence: export override > slot declaration > theme + token. Non-background box styling (a border, a radius) is chrome, not + backdrop, and survives the override. Shared by the raster exporter and (via SVG) the PDF exporter.""" if background is None: return @@ -1507,6 +1678,14 @@ def apply_export_background(spec: dict[str, Any], background: Optional[str]) -> if isinstance(style, dict): style.pop("background", None) style["--chart-bg"] = "transparent" + styles = dom.get("styles") + if isinstance(styles, dict): + for slot in ("root", "canvas"): + declaration = styles.get(slot) + if isinstance(declaration, dict): + for prop in list(declaration): + if str(prop).replace("_", "-") in ("background", "background-color"): + declaration.pop(prop) def _solid_paint(css: Any) -> Optional[str]: @@ -1795,6 +1974,132 @@ def _rounded_rect_path( return " ".join(s for s in p if s) +def _slot_box_paint_attrs(box: Any) -> str: + """A chrome box's geometry-free attribute tail, computed once per + declaration (the interned-attrs rule, `styling/resolved.py`): dense loops + format the paints once and reuse the string across N instances.""" + fill = _escape_attr(box.fill) if box.fill is not None else "none" + stroke = "" + if box.border_color is not None and box.border_width > 0: + stroke = ( + f' stroke="{_escape_attr(box.border_color)}" stroke-width="{_num(box.border_width)}"' + ) + if box.border_opacity < 1.0: + stroke += f' stroke-opacity="{_num(box.border_opacity)}"' + if box.border_dash: + dashes = " ".join(_num(v) for v in box.border_dash) + stroke += f' stroke-dasharray="{dashes}"' + elif box.explicit_stroke is not None: + paint, width = box.explicit_stroke + stroke = f' stroke="{_escape_attr(paint)}" stroke-width="{_num(width)}"' + fill_opacity = f' fill-opacity="{_num(box.fill_opacity)}"' if box.fill_opacity < 1.0 else "" + common_opacity = f' opacity="{_num(box.opacity)}"' if box.opacity < 1.0 else "" + return f' fill="{fill}"{fill_opacity}{stroke}{common_opacity}' + + +def _rotated_box_shape( + x: float, y: float, w: float, h: float, radius: float, angle: float, cx: float, cy: float +) -> str: + """A rotated box's geometry, pre-rotated to the PDF-legal lowering. + + The pinned repo-wide rule (plan flag E): `` when `radius == 0`, + `` with circular arcs when `radius > 0` — `` accepts no + `transform` in the PDF closed subset, and both replacements do accept + the shared paint attributes. Returns everything up to (not including) + the paint attribute tail. + """ + if radius <= 0.0: + corners = rotate_points([(x, y), (x + w, y), (x + w, y + h), (x, y + h)], angle, cx, cy) + points = " ".join(f"{_num(px)},{_num(py)}" for px, py in corners) + return f' str: + return f"{_num(p[i][0])} {_num(p[i][1])}" + + d = ( + f"M {pt(0)} L {pt(1)} A {rr} {rr} 0 0 1 {pt(2)} L {pt(3)} " + f"A {rr} {rr} 0 0 1 {pt(4)} L {pt(5)} A {rr} {rr} 0 0 1 {pt(6)} " + f"L {pt(7)} A {rr} {rr} 0 0 1 {pt(0)} Z" + ) + return f' str: + """One chrome box as SVG shapes — the writer half of `_chromebox`. + + Shadow first, then the background/border shape; every attribute exactly + once (the duplicate-attribute XML trap: parsers keep the first value + silently), always an explicit `fill` (a rect inside the labels group + would inherit the text paint otherwise), radius as symmetric `rx` only + (PDF rejects `ry`). A posed box (`angle != 0`) lowers to pre-rotated + geometry via `_rotated_box_shape`. Emits nothing for a box that paints + nothing — including any zero-area box, which casts no shadow either — + so the unstyled document stays byte-identical. + + `paint_attrs` is the optional interned tail from `_slot_box_paint_attrs` + for dense per-instance loops; it must have been built from a box sharing + this box's declaration. + """ + if box.w <= 0.0 or box.h <= 0.0: + return "" + if not box.paints_anything and box.shadow is None and box.explicit_stroke is None: + # `explicit_stroke` is the legacy accommodation: the pyplot text-bbox + # emitter always wrote a stroke pair, even the inert + # `stroke="none" stroke-width="0"` of a borderless box, and that + # output is byte-pinned (§0.5). + return "" + parts: list[str] = [] + common_opacity = f' opacity="{_num(box.opacity)}"' if box.opacity < 1.0 else "" + radius_attr = f' rx="{_num(box.radius)}"' if box.radius > 0 else "" + if box.shadow is not None: + dx, dy, color = box.shadow + shadow_attrs = f' fill="{_escape_attr(color)}"{common_opacity}' + if box.angle: + parts.append( + _rotated_box_shape( + box.x + dx, box.y + dy, box.w, box.h, box.radius, box.angle, box.cx, box.cy + ) + + shadow_attrs + + "/>" + ) + else: + parts.append( + f'" + ) + if paint_attrs is None: + paint_attrs = _slot_box_paint_attrs(box) + if box.angle: + parts.append( + _rotated_box_shape(box.x, box.y, box.w, box.h, box.radius, box.angle, box.cx, box.cy) + + paint_attrs + + "/>" + ) + else: + parts.append( + f'" + ) + return "".join(parts) + + def _poly_path(px: np.ndarray, py: np.ndarray) -> str: return _native.svg_poly_path(px, py) @@ -2190,25 +2495,53 @@ def _y_title_baseline( angle = float(axis.get("label_angle", 90.0)) shift = (ascent - descent) / 2 if abs(abs(angle) - 90.0) < 0.5 else 0.0 return plot["x"] + plot["w"] + 40.0 - shift + float(axis.get("label_offset", 0.0)) - tick_offset, tick_room = ( - _y_tick_label_room(axis, plot["h"]) - if "left" in _axis_tick_label_sides(axis, is_x=False) - else (0.0, 0.0) + # Declared tick_label/axis_title boxes widen this gutter; `layout()` + # stashes their outward extents on the plot record so emission-time + # callers agree with the reservation it made (zero/absent when unstyled — + # the historical arithmetic runs untouched). + tick_box = ( + plot.get("tick_label_box_t", 0.0), + plot.get("tick_label_box_r", 0.0), + plot.get("tick_label_box_b", 0.0), + plot.get("tick_label_box_l", 0.0), ) + if "left" in _axis_tick_label_sides(axis, is_x=False): + tick_offset, tick_room = ( + _y_tick_label_room(axis, plot["h"], tick_box) + if any(tick_box) + else _y_tick_label_room(axis, plot["h"]) + ) + else: + tick_offset, tick_room = 0.0, 0.0 gap = float(axis.get("label_offset", _Y_TITLE_TICK_GAP * font_size)) # For a -90 degree title, later lines move toward the plot. Pin the first # baseline so the whole block, not only line one, remains outside ticks. title_depth = descent + (block.line_count - 1) * block.line_step - return plot["x"] - tick_offset - tick_room - gap - title_depth + # A -90-degree title's plot-side box edge is its local bottom (descent + # side); shift the baseline outward by that extent so the box clears the + # tick labels. + return ( + plot["x"] - tick_offset - tick_room - gap - title_depth - plot.get("axis_title_box_b", 0.0) + ) -def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, float]: +def _y_tick_label_room( + axis: dict[str, Any], + plot_h: float, + box_out: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), +) -> tuple[float, float]: """(offset from the spine, widest tick-label extent) for a y axis, in px. Measured from the advance widths of the strings that will actually be drawn, using the same DejaVu metrics the Rust rasterizer blits (`src/font.rs`) — which is also Matplotlib's default face, so an advance measured here is the advance Matplotlib lays out. + + `box_out` is the tick_label slot's outward box growth (`_chromebox + .box_room`): a declared padding/border extends the drawn box past the + glyphs on the gutter side, and the reservation must cover it or the box + clips at the canvas edge. Zero — the default, and the only value the + unstyled path ever passes — reproduces the historical rooms exactly. """ if _axis_tick_label_strategy(axis) in {"none", "off"} or not _axis_text_paint_visible( axis, "tick_label_color", "tick_color" @@ -2224,6 +2557,10 @@ def _y_tick_label_room(axis: dict[str, Any], plot_h: float) -> tuple[float, floa # A rotated block trades its measured width for its full line-box # height about the pinned edge. room = max(room, _textblock.rotated_extent(block, angle)[0]) + if room > 0.0: + # The gutter-side box extent: right-side axes grow by the box's + # right edge, everything else by its left. + room += box_out[1] if axis.get("side") == "right" else box_out[3] # Match the SVG y-label placement below. A y label's anchored edge is # already the glyph-side edge, so unlike an x-label baseline it needs no # extra font-room term. @@ -2246,6 +2583,12 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: `spec/api/styling.md`, not silently fixed here. """ room = 0.0 + # Declared tick_label/axis_title boxes widen the gutter; both extents are + # zero on the unstyled path, which then runs the historical arithmetic + # (and the historical two-argument `_y_tick_label_room` call) untouched. + slots = slot_styles(spec) + tick_box = box_room(slots.get("tick_label")) + title_box = box_room(slots.get("axis_title")) for axis_id, axis in _axes_by_id(spec).items(): if not axis_id.startswith("y"): continue @@ -2253,7 +2596,14 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: left_title = axis.get("side", "left") != "right" if not left_labels and not left_title: continue - tick_offset, tick_room = _y_tick_label_room(axis, plot_h) if left_labels else (0.0, 0.0) + if left_labels: + tick_offset, tick_room = ( + _y_tick_label_room(axis, plot_h, tick_box) + if any(tick_box) + else _y_tick_label_room(axis, plot_h) + ) + else: + tick_offset, tick_room = 0.0, 0.0 title_visible = ( left_title and _has_outside_y_title(axis) @@ -2273,6 +2623,10 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: + block.ascent + block.descent + (block.line_count - 1) * block.line_step + # A quarter-turned title box spends its local top/bottom extents + # horizontally: both sides of the rotated box live in this gutter. + + title_box[0] + + title_box[2] + gap + tick_offset + tick_room, @@ -2280,13 +2634,20 @@ def _y_axis_left_room(spec: dict[str, Any], plot_h: float) -> float: return room -def _x_axis_title_room(axis: dict[str, Any]) -> float: +def _x_axis_title_room( + axis: dict[str, Any], + box_out: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), +) -> float: """Outward room needed by an outside x-axis title. ``_axis_label_geometry()`` positions x titles from their line-box top and converts that top to a static-text baseline. Measure the corresponding outer glyph edge here so tight/constrained layout does not stop at the historical 36/42 px band while the title itself extends past the canvas. + + `box_out` is the axis_title slot's outward box growth; the outward side + (top pad for a top axis, bottom pad otherwise) joins the reservation so + a padded title box stays on the canvas. Zero when unstyled. """ if not axis.get("label") or not _axis_text_paint_visible(axis, "label_color"): return 0.0 @@ -2301,7 +2662,7 @@ def _x_axis_title_room(axis: dict[str, Any]) -> float: if axis.get("side", "bottom") == "top": # outside_top = plot-top - 34; the baseline conversion then moves # 0.82em back toward the plot. - return _AXIS_TEXT_EDGE_PAD + 34.0 + offset - font_size * 0.82 + block.ascent + return _AXIS_TEXT_EDGE_PAD + 34.0 + offset - font_size * 0.82 + block.ascent + box_out[0] # outside_bottom = plot-bottom + 24; later lines move farther outward. return ( _AXIS_TEXT_EDGE_PAD @@ -2310,10 +2671,16 @@ def _x_axis_title_room(axis: dict[str, Any]) -> float: + font_size * 0.82 + (block.line_count - 1) * block.line_step + block.descent + + box_out[2] ) -def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: +def _x_tick_label_room( + axis: dict[str, Any], + plot_w: float, + tick_box: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), + title_box: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), +) -> float: """Outward room needed by the x axis's final tick-label set and title. The old 32/42 px bands only fit horizontal labels. Measure the strings and @@ -2321,15 +2688,23 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: is deliberately evaluated *after* collision policy, so ``auto`` reserves only labels it will draw while pyplot's ``preserve`` reserves all fixed locations. The same value is used by SVG and native PNG layout. + + `tick_box`/`title_box` are the tick_label/axis_title slots' outward box + growth: a declared padding/border must join the reservation, which also + means a declared tick box takes the measuring path where unstyled auto + ticks keep their historical flat band. Both default to zero and the + unstyled arithmetic is untouched. """ strategy = _axis_tick_label_strategy(axis) if strategy == "none": return 0.0 - title_room = _x_axis_title_room(axis) + title_room = _x_axis_title_room(axis, title_box) if strategy == "off" or not _axis_text_paint_visible(axis, "tick_label_color", "tick_color"): return title_room + boxed = any(tick_box) if ( - strategy == "auto" + not boxed + and strategy == "auto" and axis.get("tick_label_angle") is None and axis.get("tick_values") is None and axis.get("kind") != "category" @@ -2349,7 +2724,8 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: font_size = _axis_tick_font_size(axis) has_multiline_ticks = any(len(_textblock.split_lines(item["text"])) > 1 for item in items) if ( - not has_adaptive_layout + not boxed + and not has_adaptive_layout and not has_multiline_ticks and strategy == "auto" and axis.get("tick_label_angle") is None @@ -2369,17 +2745,26 @@ def _x_tick_label_room(axis: dict[str, Any], plot_w: float) -> float: else _axis_tick_label_offset(axis, 16.0, 0.8) ) rows = max(int(item.get("row", 0)) for item in items) - tick_room = _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + # The outward side of a declared label box: top pad above a top axis, + # bottom pad below a bottom one. + box_extent = tick_box[0] if side == "top" else tick_box[2] + tick_room = _AXIS_TEXT_EDGE_PAD + label_offset + rows * (font_size + 4.0) + extent + box_extent return max(title_room, tick_room) -def _x_tick_label_edge_rooms(axes: dict[str, dict[str, Any]], plot_w: float) -> tuple[float, float]: +def _x_tick_label_edge_rooms( + axes: dict[str, dict[str, Any]], + plot_w: float, + tick_box: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), +) -> tuple[float, float]: """Canvas-edge room needed by x tick labels that overhang the plot. A terminal tick label is centered on the end of the spine by default, so half its ink lives outside the plot rectangle. Matplotlib includes every visible tick-label bbox in ``Axes.get_tightbbox``; mirror that horizontal union here instead of relying on the compact layout's flat right gutter. + A declared tick_label box widens each label by its left/right extents + (`tick_box`; zero when unstyled). """ left = right = 0.0 for axis_id, axis in axes.items(): @@ -2435,22 +2820,28 @@ def _x_tick_label_edge_rooms(axes: dict[str, dict[str, Any]], plot_w: float) -> cosine, sine = math.cos(radians), math.sin(radians) rotated_x = [x * cosine - y * sine for x in (x0, x1) for y in (y0, y1)] position = float(item["pos"]) - left = max(left, _AXIS_TEXT_EDGE_PAD - position - min(rotated_x)) + left = max(left, _AXIS_TEXT_EDGE_PAD - position - min(rotated_x) + tick_box[3]) right = max( right, - _AXIS_TEXT_EDGE_PAD + position + max(rotated_x) - plot_w, + _AXIS_TEXT_EDGE_PAD + position + max(rotated_x) - plot_w + tick_box[1], ) return float(math.ceil(max(0.0, left))), float(math.ceil(max(0.0, right))) def _x_axis_rooms( - axes: dict[str, dict[str, Any]], plot_w: float, compact: bool + axes: dict[str, dict[str, Any]], + plot_w: float, + compact: bool, + tick_box: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), + title_box: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0), ) -> tuple[float, float, float]: """Shared ``(top, bottom, measured_bottom)`` x-axis bands. The fixed bottom band is metadata for colorbar placement. It must not override an explicit figure ``padding`` authored by pyplot unless rotated - or staggered labels actually require more room. + or staggered labels actually require more room. `tick_box`/`title_box` + carry the declared slot-box extents through to `_x_tick_label_room` + (zero when unstyled). """ top = 0.0 bottom = 0.0 @@ -2466,7 +2857,7 @@ def _x_axis_rooms( side_axis = {**axis, "side": side} if side != title_side: side_axis.pop("label", None) - measured = _x_tick_label_room(side_axis, plot_w) + measured = _x_tick_label_room(side_axis, plot_w, tick_box, title_box) if side == "top": top = max(top, 26.0 if compact else 32.0, measured) else: @@ -2536,15 +2927,139 @@ def _title_metrics( return style, size, _textblock.measure(entry["text"], size, max_width=wrap_width) +#: Anchor math for one drawn title, shared verbatim by the SVG writer, the +#: raster writer, and the box emitter: `x` and `baseline` (first line) are +#: where the text goes, `anchor` its SVG anchor name (`_TEXT_ANCHOR_CODES` +#: maps it for the raster op). +class TitlePlacement(NamedTuple): + style: dict[str, Any] + size: float + block: _textblock.TextBlock + x: float + baseline: float + anchor: str + + +def title_placement( + spec: dict[str, Any], + entry: dict[str, Any], + plot: dict[str, float], + wrap_width: float | None = None, +) -> TitlePlacement: + """One authored title entry's shared anchor math (plan §3.1). + + This is the hoist of the placement both writers used to duplicate — + change it here and both files move together, which is the property the + box emission relies on (a box drawn from one copy and text from another + would drift). + """ + style, size, block = _title_metrics(spec, entry, wrap_width) + trailing = (block.line_count - 1) * block.line_step + if entry.get("automatic_y", True): + anchor_y = plot["y"] - plot["top_axis_room"] + else: + anchor_y = plot["y"] + (1.0 - float(entry.get("y", 1.0))) * plot["h"] + baseline = anchor_y - float(entry.get("pad", 8.0)) - block.descent - trailing + loc = str(entry.get("loc", "center")) + x = { + "left": plot["x"], + "center": plot["x"] + plot["w"] / 2.0, + "right": plot["x"] + plot["w"], + }.get(loc, plot["x"] + plot["w"] / 2.0) + anchor = {"left": "start", "center": "middle", "right": "end"}.get(loc, "middle") + return TitlePlacement(style, size, block, x, baseline, anchor) + + +def legacy_title_placement( + spec: dict[str, Any], + plot: dict[str, float], + compact: bool, + width: float, + wrap_width: float | None = None, +) -> TitlePlacement: + """The legacy `spec['title']` band's anchor math, shared like the above. + + Kept as its own branch (not folded into `title_placement`) because its + offsets are the historical byte contract: centered on the CANVAS, not the + plot rect, with the 10/12 px compact gap instead of the entry `pad`. + """ + style = slot_styles(spec).get("title") or {} + size = slot_font_size(style, 14.0) + block = _textblock.measure(spec.get("title", ""), size, max_width=wrap_width) + trailing = (block.line_count - 1) * block.line_step + baseline = plot["y"] - plot["top_axis_room"] - (10 if compact else 12) - trailing + return TitlePlacement(style, size, block, width / 2.0, baseline, "middle") + + +def title_box(placement: TitlePlacement) -> Optional[ChromeBox]: + """The title's chrome box under its text, or None when nothing is declared. + + The box wraps the measured `TextBlock` — width is block width plus the + declared padding, NOT the wrap width (the browser box is the + shrink-to-fit div, js/src/50_chartview.ts `_positionTitles`) — and the + text itself does not move: padding extends outward from the block like + the annotation text box, and `_title_room` reserves the extra height so + the box clears the top axis room. Strictly declaration-gated: no box + property, no box, and unstyled bytes stay identical. + """ + declaration = slot_box_declaration(placement.style, "title") + if not declaration: + return None + block = placement.block + pad_top, pad_right, pad_bottom, pad_left = box_padding(declaration) + left = ( + placement.x + - {"start": 0.0, "middle": block.width / 2.0, "end": block.width}[placement.anchor] + ) + trailing = (block.line_count - 1) * block.line_step + # The block bottom in CSS line-box terms: the last baseline plus the + # descent — the same construction `_title_room` measures with. + bottom = placement.baseline + trailing + block.descent + return lower_box( + "title", + declaration, + x=left - pad_left, + y=bottom - block.height - pad_top, + w=block.width + pad_left + pad_right, + h=block.height + pad_top + pad_bottom, + ) + + +def _title_box_extent(style: dict[str, Any]) -> float: + """Vertical px a title's declared box adds above+below its text block. + + Mirrored by `_titleBoxExtent` in js/src/50_chartview.ts — the two must + agree or native and browser disagree on `plot.y`. Padding counts as + declared; the border counts only when it would paint under the + `lower_box` rules (an explicit color, or a width the implied 1px chrome + border rule gives one), exactly like the CSS computed width collapses + without a border style. + """ + declaration = slot_box_declaration(style, "title") + if not declaration: + return 0.0 + probe = lower_box("title", declaration, x=0.0, y=0.0, w=1e9, h=1e9) + border = ( + probe.border_width if probe.border_color is not None and probe.border_width > 0.0 else 0.0 + ) + return probe.padding[0] + probe.padding[2] + 2.0 * border + + def _title_room(spec: dict[str, Any], compact: bool, wrap_width: float | None = None) -> float: room = 0.0 for entry in _title_entries(spec): - _style, _size, block = _title_metrics(spec, entry, wrap_width) + style, _size, block = _title_metrics(spec, entry, wrap_width) + # A declared box grows the band exactly like the browser element + # grows: padding and paintable border, above and below the block + # (zero when unstyled, so the reservation bytes cannot move). + box_extent = _title_box_extent(style) pad = float(entry.get("pad", 8.0)) if entry.get("automatic_y", True): - candidate = max(26.0 if compact else 30.0, block.height + pad) + candidate = max(26.0 if compact else 30.0, block.height + box_extent + pad) else: - candidate = block.height + pad if float(entry.get("y", 1.0)) >= 1.0 else 0.0 + candidate = ( + block.height + box_extent + pad if float(entry.get("y", 1.0)) >= 1.0 else 0.0 + ) room = max(room, max(0.0, candidate)) return room @@ -2568,6 +3083,13 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: top = 6 if compact else 10 bottom = 36 if compact else 42 axes = _axes_by_id(spec) + # Declared tick_label/axis_title boxes grow the reservations below; both + # extents are zero for the (overwhelmingly common) unstyled chart, whose + # rooms then come out of the historical arithmetic byte-identically. + chrome_slots = slot_styles(spec) + tick_box = box_room(chrome_slots.get("tick_label")) + title_box = box_room(chrome_slots.get("axis_title")) + slot_boxed = any(tick_box) or any(title_box) # The first pass uses the authored/default horizontal allocation. A second # pass after the measured left gutter catches an auto-collision decision # whose final plot width changes the chosen label set. @@ -2576,8 +3098,10 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: # how many lines the title wraps into at this width. title_wrap_width = _title_wrap_width(width, left, right) title_room = _title_room(spec, compact, title_wrap_width) - top_axis_room, bottom_axis_room, measured_bottom_room = _x_axis_rooms( - axes, provisional_w, compact + top_axis_room, bottom_axis_room, measured_bottom_room = ( + _x_axis_rooms(axes, provisional_w, compact, tick_box, title_box) + if slot_boxed + else _x_axis_rooms(axes, provisional_w, compact) ) top += title_room top += top_axis_room @@ -2620,6 +3144,7 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: edge_left, edge_right = _x_tick_label_edge_rooms( axes, max(40.0, width - left - right), + tick_box, ) widened_left = max(left, edge_left) widened_right = max(right, edge_right) @@ -2632,7 +3157,11 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: measured_bottom = bottom_axis_room final_measured_bottom = measured_bottom_room else: - measured_top, measured_bottom, final_measured_bottom = _x_axis_rooms(axes, final_w, compact) + measured_top, measured_bottom, final_measured_bottom = ( + _x_axis_rooms(axes, final_w, compact, tick_box, title_box) + if slot_boxed + else _x_axis_rooms(axes, final_w, compact) + ) if measured_top > top_axis_room: top += measured_top - top_axis_room top_axis_room = measured_top @@ -2654,6 +3183,16 @@ def layout(spec: dict[str, Any]) -> tuple[int, int, bool, dict[str, float]]: "top_axis_room": top_axis_room, "bottom_axis_room": bottom_axis_room, } + if slot_boxed: + # Emission-time callers (`_y_title_baseline` via + # `_axis_label_geometry`) read the extents layout reserved with, so + # placement and reservation cannot disagree. Scalar entries keep the + # plot record's float value type; absent when unstyled. + plot["tick_label_box_t"] = tick_box[0] + plot["tick_label_box_r"] = tick_box[1] + plot["tick_label_box_b"] = tick_box[2] + plot["tick_label_box_l"] = tick_box[3] + plot["axis_title_box_b"] = title_box[2] if spec.get("coords") == "polar": _recut_polar_plot(spec, plot, width, height, compact) return width, height, compact, plot @@ -3072,6 +3611,27 @@ def _axis_tick_label_sides(axis: dict[str, Any], *, is_x: bool) -> list[str]: return [side for side in allowed if side in authored] +def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: + """`(inward, outward, width)` of an axis's tick marks, in px. + + The one reading of `tick_length`/`tick_direction`/`tick_width` shared by + the SVG writer, the raster writer, and the axis-chrome box producer — + formerly a literal near-duplicate closure in each writer (the 2-tuple + raster copy re-read `tick_width` at every call site). `tick_length` + defaults to 0: an axis that authors no tick geometry has no marks, and + the box producer deliberately does not invent a length for a styled + `tick_mark` slot (the preflight says so instead). + """ + length = max(0.0, float(style.get("tick_length", 0))) + direction = str(style.get("tick_direction", "out")) + width = float(style.get("tick_width", 1)) + if direction == "in": + return length, 0.0, width + if direction == "inout": + return length / 2, length / 2, width + return 0.0, length, width + + def _axis_tick_label_offset(axis: dict[str, Any], unstyled: float, font_room: float = 0.0) -> float: """Distance from the axis spine to a tick label's anchor point, in px. @@ -3801,8 +4361,14 @@ def _polar_tick_labels( hide_r: bool, ) -> None: """Emit polar tick labels as SVG text, from the shared placement.""" - slot = slots.get("tick_label") or {} + slot = slot_in_labels_container(slots, "tick_label") attrs = slot_text_attrs(slot) + # Same box hook as the cartesian labels; polar keeps its flat 30 px label + # ring (`_POLAR_LABEL_ROOM`), so a padded box spends that allowance + # rather than growing it — recorded in the capability note. + box_tmpl = box_template("tick_label", slot) if _has_box_declaration(slot) else None + box_pads = padding_sides(slot) + box_attrs = _slot_box_paint_attrs(box_tmpl) if box_tmpl is not None else "" def tick_color(axis: dict[str, Any]) -> str: """Axis tick_label_color/tick_color first, chart slot second. @@ -3837,6 +4403,21 @@ def tick_color(axis: dict[str, Any]) -> str: if item.spin else "" ) + if box_tmpl is not None: + labels.append( + _slot_box_svg( + _chrome_text_box( + box_tmpl, + box_pads, + x=item.x, + y=item.y, + anchor=item.anchor, + block=_textblock.measure(item.text, item.size), + angle=item.spin, + ), + box_attrs, + ) + ) labels.append( f' str: ) +#: Re-entrancy guard for `axis_chrome_boxes` (see its docstring). +_AXIS_BOX_GUARD = threading.local() + + +def axis_chrome_boxes( + spec: dict[str, Any], slots: Optional[dict[str, dict[str, Any]]] = None +) -> "list[ChromeBox]": + """Per-instance `ChromeBox`es for the axis-chrome box slots. + + One producer, three consumers — the SVG writer, the raster writer, and + the declared snapshot (`styling/declared.py`, which fills + `SlotInstance.qualifiers`/`geometry` from these records) — so a styled + spine or tick cannot mean different geometry in different outputs. + Returns `[]` unless `axis_line`/`tick_mark` declare box properties; the + writers keep their historical stroke loops for anything unstyled (the + unstyled-bytes gate). The declaration is lowered once per slot and + stamped per instance (`box_template`/`box_at` — the interning rule). + + Geometry decisions, each deliberate and pinned by golden tests: + + - Spines center on the plot edge exactly where the unstyled stroke ran. + The browser insets right/bottom spines by their own width + (`50_chartview.ts` `rule()` call sites); matching it unstyled would + break the byte pin, so the writers keep the centered geometry and the + divergence is recorded in `KNOWN_RENDERER_DIVERGENCES`. + - A tick box is the centered stroke's own coverage (`x - width/2`, span + `inward + outward`) — the same pixels the browser's rect occupies. + - `tick_length` defaults to 0 and no length is invented for a styled + slot: a zero-area box draws nothing (and casts no shadow); the + preflight carries the note. + - Polar charts return nothing: spines and ticks keep stroke semantics + there, and the browser shares the limit ("spines are + background-coloured DIVs and cannot express a circle"). + - The slot's `background` replaces the axis ink when declared (an + explicit `transparent` erases it, as in the browser); otherwise the + box keeps the axis's own `axis_color`/`tick_color` — the narrower + selector still wins the paint. + """ + if getattr(_AXIS_BOX_GUARD, "active", False): + # Re-entrant resolution: this producer's own `layout()` call resolves + # declarations (title metrics), whose snapshot enrichment would call + # back in. The nested result is discarded, so produce nothing. + return [] + if slots is None: + slots = slot_styles(spec) + line_slot = slots.get("axis_line") or {} + tick_slot = slots.get("tick_mark") or {} + line_boxed = _has_box_declaration(line_slot) + tick_boxed = _has_box_declaration(tick_slot) + if not line_boxed and not tick_boxed: + return [] + if spec.get("coords") == "polar": + return [] + _AXIS_BOX_GUARD.active = True + try: + return _axis_chrome_boxes_body(spec, slots, line_slot, tick_slot, line_boxed, tick_boxed) + finally: + _AXIS_BOX_GUARD.active = False + + +def _axis_chrome_boxes_body( + spec: dict[str, Any], + slots: dict[str, dict[str, Any]], + line_slot: dict[str, Any], + tick_slot: dict[str, Any], + line_boxed: bool, + tick_boxed: bool, +) -> "list[ChromeBox]": + _width, _height, _compact, plot = layout(spec) + xa, ya = spec["x_axis"], spec["y_axis"] + _x_scales, _y_scales, sx, sy, extra_x_axes, extra_y_axes = _axis_scales(spec, plot) + dom_style = (spec.get("dom") or {}).get("style") or {} + default_axis = _css(dom_style.get("--chart-axis"), _AXIS) + xstyle, ystyle = xa.get("style") or {}, ya.get("style") or {} + xmstyle, ymstyle = xa.get("minor_style") or {}, ya.get("minor_style") or {} + hide_x = xa.get("tick_label_strategy") == "none" + hide_y = ya.get("tick_label_strategy") == "none" + frame_sides = spec.get("frame_sides") + explicit_frame_sides = frame_sides is not None + if frame_sides is None: + frame_sides = [xa.get("side", "bottom"), ya.get("side", "left")] + boxes: list[ChromeBox] = [] + + if line_boxed: + line_template = box_template("axis_line", line_slot) + + def spine(axis_id: str, side: str, x: float, y: float, w: float, h: float, paint: str): + if w <= 0.0 or h <= 0.0: + return # a zero-width spine draws nothing; record no instance + boxes.append( + box_at( + line_template, + x, + y, + w, + h, + qualifiers=(axis_id, side), + fallback_fill=paint, + ) + ) + + if not hide_y or explicit_frame_sides: + width_ = float(ystyle.get("axis_width", 1)) + paint = _css(ystyle.get("axis_color"), default_axis) + for side, x in (("left", plot["x"]), ("right", plot["x"] + plot["w"])): + if side in frame_sides: + spine("y", side, x - width_ / 2.0, plot["y"], width_, plot["h"], paint) + if not hide_x or explicit_frame_sides: + width_ = float(xstyle.get("axis_width", 1)) + paint = _css(xstyle.get("axis_color"), default_axis) + for side, y in (("top", plot["y"]), ("bottom", plot["y"] + plot["h"])): + if side in frame_sides: + spine("x", side, plot["x"], y - width_ / 2.0, plot["w"], width_, paint) + for axis_id, axis, _axis_scale in extra_x_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + width_ = float(axis_style.get("axis_width", 1)) + side = axis.get("side", "bottom") + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + spine( + axis_id, + side, + plot["x"], + edge - width_ / 2.0, + plot["w"], + width_, + _css(axis_style.get("axis_color"), default_axis), + ) + for axis_id, axis, _axis_scale in extra_y_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + width_ = float(axis_style.get("axis_width", 1)) + side = axis.get("side", "right") + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + spine( + axis_id, + side, + edge - width_ / 2.0, + plot["y"], + width_, + plot["h"], + _css(axis_style.get("axis_color"), default_axis), + ) + + if tick_boxed: + tick_template = box_template("tick_mark", tick_slot) + xt = axis_ticks(xa, plot["w"], True)[0] + yt = axis_ticks(ya, plot["h"], False)[0] + xmt, ymt = minor_axis_ticks(xa), minor_axis_ticks(ya) + + def tick( + axis_id: str, + kind: str, + side: str, + index: int, + x: float, + y: float, + w: float, + h: float, + paint: str, + ): + if w <= 0.0 or h <= 0.0: + # tick_length 0 (the default): the mark does not exist, so no + # box, no shadow, and no snapshot instance — the preflight + # carries the note instead of a length being invented. + return + boxes.append( + box_at( + tick_template, + x, + y, + w, + h, + qualifiers=(axis_id, kind, side, str(index)), + fallback_fill=paint, + ) + ) + + if not hide_x: + inward, outward, tick_width = tick_span(xmstyle) + side = xa.get("side", "bottom") + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + paint = _css(xmstyle.get("tick_color"), default_axis) + for index, value in enumerate(xmt): + x = float(sx(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + tick( + "x", "minor", side, index, x - tick_width / 2.0, y1, tick_width, y2 - y1, paint + ) + inward, outward, tick_width = tick_span(xstyle) + paint = _css(xstyle.get("tick_color"), default_axis) + for side in _axis_tick_sides(xa, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for index, value in enumerate(xt): + x = float(sx(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + tick( + "x", + "major", + side, + index, + x - tick_width / 2.0, + y1, + tick_width, + y2 - y1, + paint, + ) + if not hide_y: + inward, outward, tick_width = tick_span(ymstyle) + side = ya.get("side", "left") + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + paint = _css(ymstyle.get("tick_color"), default_axis) + for index, value in enumerate(ymt): + y = float(sy(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + tick( + "y", "minor", side, index, x1, y - tick_width / 2.0, x2 - x1, tick_width, paint + ) + inward, outward, tick_width = tick_span(ystyle) + paint = _css(ystyle.get("tick_color"), default_axis) + for side in _axis_tick_sides(ya, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for index, value in enumerate(yt): + y = float(sy(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + tick( + "y", + "major", + side, + index, + x1, + y - tick_width / 2.0, + x2 - x1, + tick_width, + paint, + ) + for axis_id, axis, axis_scale in extra_x_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + inward, outward, tick_width = tick_span(axis_style) + paint = _css(axis_style.get("tick_color"), default_axis) + values = axis_ticks(axis, plot["w"], True)[0] + for side in _axis_tick_sides(axis, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for index, value in enumerate(values): + x = float(axis_scale(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + tick( + axis_id, + "major", + side, + index, + x - tick_width / 2.0, + y1, + tick_width, + y2 - y1, + paint, + ) + for axis_id, axis, axis_scale in extra_y_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + inward, outward, tick_width = tick_span(axis_style) + paint = _css(axis_style.get("tick_color"), default_axis) + values = axis_ticks(axis, plot["h"], False)[0] + for side in _axis_tick_sides(axis, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for index, value in enumerate(values): + y = float(axis_scale(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + tick( + axis_id, + "major", + side, + index, + x1, + y - tick_width / 2.0, + x2 - x1, + tick_width, + paint, + ) + return boxes + + def render_svg(spec: dict[str, Any], blob: bytes, *, id_prefix: str = "") -> str: spec = _decode_title_geometry(spec, blob) spec = _resolve_static_css_vars(spec) @@ -3900,6 +4792,15 @@ def ticks_for(axis: dict[str, Any], length_px: float) -> tuple[list[float], list default_axis = _css(dom_style.get("--chart-axis"), _AXIS) default_text = _css(dom_style.get("--chart-text"), _TEXT) slots = slot_styles(spec) + labels_slot = slots.get("labels") or {} + # The live chain for every text in the labels container is + # `color: var(--chart-text, inherit)`: the theme token wins, the + # container's own declared color is the inherited fallback, then the + # writer default. Title/legend/colorbar are siblings of the container + # and keep `default_text`. + label_text_default = ( + _css(dom_style.get("--chart-text"), "") or slot_text_color(labels_slot, "") or _TEXT + ) grid: list[str] = [] labels: list[str] = [] # "none" silences the whole axis chrome (sparklines); "off" hides only the @@ -3969,7 +4870,7 @@ def append_tick_labels( is_x: bool, ) -> None: axis_style = axis.get("style") or {} - slot = slots.get("tick_label") or {} + slot = slot_in_labels_container(slots, "tick_label") # The axis's own tick_label_color/tick_color is the narrower selector # and wins; the chart-wide slot fills in when the axis says nothing. color = escape( @@ -3977,10 +4878,17 @@ def append_tick_labels( axis_style.get("tick_label_color", axis_style.get("tick_color")), "", ) - or slot_text_color(slot, default_text) + or slot_text_color(slot, label_text_default) ) font_size = slot_font_size(slot, _axis_tick_font_size(axis)) slot_attrs = slot_text_attrs(slot) + # The slot's box, lowered once and stamped per label (interned attrs: + # one declaration string, N instances). Emitted immediately before + # each inside the labels group — the emitter always writes an + # explicit fill, or the rect would inherit the group's text paint. + box_tmpl = box_template("tick_label", slot) if _has_box_declaration(slot) else None + box_pads = padding_sides(slot) + box_attrs = _slot_box_paint_attrs(box_tmpl) if box_tmpl is not None else "" baseline_shift = _axis_tick_label_baseline_shift(axis) # An explicit tick_label_anchor (axis spec or style) overrides the # angle/side-derived default. Anchored labels rotate about the tick @@ -4035,6 +4943,21 @@ def append_tick_labels( transform = ( f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" ) + if box_tmpl is not None: + labels.append( + _slot_box_svg( + _chrome_text_box( + box_tmpl, + box_pads, + x=x, + y=y, + anchor=anchor, + block=block, + angle=angle, + ), + box_attrs, + ) + ) labels.append( f' str: legacy_title = spec.get("title") if not spec.get("title_options") else None title_wrap_width = plot.get("title_wrap_width") if legacy_title: - title_slot = slots.get("title") or {} - legacy_size = slot_font_size(title_slot, 14.0) - legacy_block = _textblock.measure(legacy_title, legacy_size, max_width=title_wrap_width) - # Wrapped lines run downward from the baseline, so lift the block by its - # trailing lines: the LAST line keeps the historical single-line baseline - # and the extra lines fill the room `_title_room` reserved above it. A - # one-line title has no trailing lines and is byte-identical to before. - legacy_trailing = (legacy_block.line_count - 1) * legacy_block.line_step - legacy_y = plot["y"] - plot["top_axis_room"] - (10 if compact else 12) - legacy_trailing - legacy_x = width / 2 - legacy_text = "\n".join(legacy_block.lines) - legacy_content = _text_block_content(legacy_text, legacy_x, legacy_block.line_step) + placement = legacy_title_placement(spec, plot, compact, width, title_wrap_width) + # Wrapped lines run downward from the baseline, so the shared anchor + # math lifts the block by its trailing lines: the LAST line keeps the + # historical single-line baseline and the extra lines fill the room + # `_title_room` reserved above it. A one-line title has no trailing + # lines and is byte-identical to before. + legacy_text = "\n".join(placement.block.lines) + legacy_content = _text_block_content(legacy_text, placement.x, placement.block.line_step) + legacy_box = title_box(placement) + if legacy_box is not None: + # The box rides `chrome` immediately before its text: under the + # title, above every earlier layer of the document. + chrome.append(_slot_box_svg(legacy_box)) chrome.append( - f'' + f'' f"{legacy_content}" ) for title_entry in [] if legacy_title else _title_entries(spec): - title_style, title_size, title_block = _title_metrics(spec, title_entry, title_wrap_width) + placement = title_placement(spec, title_entry, plot, title_wrap_width) # Matplotlib's `axes.titleweight`/`axes.labelweight` both default to # "normal", so chrome text stays at 400 unless a style or rcParam asks # for more. Keep this in step with the `title`/`axis_title` slot rules # in js/src/20_theme.ts and the raster defaults in _raster.py. - title_font_attrs = slot_text_attrs(title_style, font_weight="400") - trailing = (title_block.line_count - 1) * title_block.line_step - if title_entry.get("automatic_y", True): - title_anchor_y = plot["y"] - plot["top_axis_room"] - else: - title_anchor_y = plot["y"] + (1.0 - float(title_entry.get("y", 1.0))) * plot["h"] - title_y = ( - title_anchor_y - float(title_entry.get("pad", 8.0)) - title_block.descent - trailing - ) - loc = str(title_entry.get("loc", "center")) - title_x = { - "left": plot["x"], - "center": plot["x"] + plot["w"] / 2.0, - "right": plot["x"] + plot["w"], - }.get(loc, plot["x"] + plot["w"] / 2.0) - anchor = {"left": "start", "center": "middle", "right": "end"}.get(loc, "middle") - # `title_block.lines` is the wrapped set — drawing `entry["text"]` here + title_font_attrs = slot_text_attrs(placement.style, font_weight="400") + # `block.lines` is the wrapped set — drawing `entry["text"]` here # would put the whole title on one line inside a band reserved for two. title_content = _text_block_content( - "\n".join(title_block.lines), title_x, title_block.line_step + "\n".join(placement.block.lines), placement.x, placement.block.line_step ) + entry_box = title_box(placement) + if entry_box is not None: + chrome.append(_slot_box_svg(entry_box)) chrome.append( - f'' + f'fill="{escape(slot_text_color(placement.style, default_text))}">' f"{title_content}" ) @@ -4253,26 +5165,52 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: if not axis.get("label") or _axis_tick_label_strategy(axis) == "none": return axis_style = axis.get("style") or {} - slot = slots.get("axis_title") or {} + slot = slot_in_labels_container(slots, "axis_title") geometry = _axis_label_geometry(axis, plot, is_x=is_x) x, y = float(geometry["x"]), float(geometry["y"]) angle = float(geometry["angle"]) transform = f' transform="rotate({_num(angle)} {_num(x)} {_num(y)})"' if angle else "" - # The axis's own label_* keys are the narrower selector, so they win - # over the chart-wide slot; the slot supplies whatever they leave unset. - family = axis_style.get("label_font_family") - font_style = axis_style.get("label_font_style") - weight = axis_style.get("label_font_weight", 400) - paint = _css(axis_style.get("label_color"), "") or slot_text_color(slot, default_text) - font_attrs = (f' font-family="{_escape_attr(family)}"' if family is not None else "") + ( - f' font-style="{_escape_attr(font_style)}"' if font_style is not None else "" - ) - if not font_attrs: - font_attrs = slot_text_attrs(slot, font_weight=weight) - else: - font_attrs = f' font-weight="{_escape_attr(weight)}"' + font_attrs + # Per-property merge: the axis's own label_* keys are the narrower + # selector and win; the chart-wide slot supplies whatever they leave + # unset. The pre-parity branch dropped the slot's letter-spacing and + # opacity wholesale the moment the axis authored a family or style. + # letter-spacing/opacity have no axis spelling, so the slot always + # carries them. Documented exception (spec/api/styling.md): font-size + # runs the other way — the slot's font-size wins over label_size. + # `label_text_default`, not `default_text`: an axis title lives inside + # the labels container, so the container's declared color is its + # inherited fallback (the live `color: var(--chart-text, inherit)` + # chain). The tick labels above resolve through the same default. + paint = _css(axis_style.get("label_color"), "") or slot_text_color(slot, label_text_default) + merged = dict(slot) + for axis_key, prop in ( + ("label_font_family", "font-family"), + ("label_font_style", "font-style"), + ("label_font_weight", "font-weight"), + ): + if axis_style.get(axis_key) is not None: + merged[prop] = axis_style[axis_key] + font_attrs = slot_text_attrs(merged, font_weight=400) font_size = slot_font_size(slot, float(geometry["font_size"])) block = _textblock.measure(axis["label"], font_size) + if _has_box_declaration(slot): + # The title's box, from the same shared metrics both writers + # use. A rotated y-title box rides the flag-E lowering in the + # emitter (pre-rotated polygon / path-with-arcs), so it stays + # inside the PDF closed subset. + chrome.append( + _slot_box_svg( + _chrome_text_box( + box_template("axis_title", slot), + padding_sides(slot), + x=x, + y=y, + anchor=geometry["anchor"], + block=block, + angle=angle, + ) + ) + ) chrome.append( f' None: named = legend_items(spec["traces"], spec_palette) legend_label_slot = slots.get("legend_label") or {} legend_title_slot = slots.get("legend_title") or {} + legend_item_slot = slots.get("legend_item") or {} + legend_swatch_slot = slots.get("legend_swatch") or {} main_legend = spec.get("legend") or {} main_items = main_legend.get("items") or named if spec.get("show_legend", True) and main_items: @@ -4303,6 +5243,8 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: spec_palette, legend_label_slot, legend_title_slot, + legend_item_slot, + legend_swatch_slot, ) ) for extra in spec.get("extra_legends") or []: @@ -4318,6 +5260,8 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: spec_palette, legend_label_slot, legend_title_slot, + legend_item_slot, + legend_swatch_slot, ) ) if spec.get("colorbar"): @@ -4333,13 +5277,39 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: ) annotation_marks, unclipped_annotation_marks, annotation_labels = _annotation_svg( - spec.get("annotations") or [], sx, sy, plot, width, height, polar + spec.get("annotations") or [], sx, sy, plot, width, height, polar, slots ) marks.extend(annotation_marks) labels.extend(annotation_labels) # baselines above the marks, matching the client's overlay rules baselines = "" + # The axis_line/tick_mark box slots: when either declares box properties, + # its elements come from the shared producer (`axis_chrome_boxes` — the + # same boxes the raster writer and the declared snapshot consume) instead + # of the historical stroke loops below. Polar keeps stroke semantics in + # every renderer (the browser's spines are DIVs and cannot express a + # circle), so the flags stay off there and the loops run unchanged. + axis_line_boxed = polar is None and _has_box_declaration(slots.get("axis_line")) + tick_mark_boxed = polar is None and _has_box_declaration(slots.get("tick_mark")) + axis_boxes = axis_chrome_boxes(spec, slots) if (axis_line_boxed or tick_mark_boxed) else [] + + def _boxed_chrome_svg(slot_name: str) -> str: + # One paint-attr string per declaration, N instances (the interned- + # attrs rule): geometry varies per box, the paints do not — except + # through the per-axis ink fallback, which keys the tiny cache. + interned: dict[Optional[str], str] = {} + parts: list[str] = [] + for box in axis_boxes: + if box.slot != slot_name: + continue + attrs = interned.get(box.fill) + if attrs is None: + attrs = _slot_box_paint_attrs(box) + interned[box.fill] = attrs + parts.append(_slot_box_svg(box, attrs)) + return "".join(parts) + frame_sides = spec.get("frame_sides") explicit_frame_sides = frame_sides is not None if frame_sides is None: @@ -4367,57 +5337,53 @@ def append_axis_title(axis: dict[str, Any], *, is_x: bool) -> None: f'' ) - if not hide_y or explicit_frame_sides: - for side, x in (("left", plot["x"]), ("right", plot["x"] + plot["w"])): - if side in frame_sides: - baselines += ( - f'' - ) - if not hide_x or explicit_frame_sides: - for side, y in (("top", plot["y"]), ("bottom", plot["y"] + plot["h"])): - if side in frame_sides: - baselines += ( - f'' - ) - for _axis_id, axis, _axis_scale in extra_x_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - edge = plot["y"] if axis.get("side", "bottom") == "top" else plot["y"] + plot["h"] - baselines += ( - f'' - ) - for _axis_id, axis, _axis_scale in extra_y_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - edge = plot["x"] + plot["w"] if axis.get("side", "right") == "right" else plot["x"] - baselines += ( - f'' - ) - - def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: - length = max(0.0, float(style.get("tick_length", 0))) - direction = str(style.get("tick_direction", "out")) - if direction == "in": - return length, 0.0, float(style.get("tick_width", 1)) - if direction == "inout": - return length / 2, length / 2, float(style.get("tick_width", 1)) - return 0.0, length, float(style.get("tick_width", 1)) + if axis_line_boxed: + # Styled spines as boxes, at the strokes' own place in the paint + # order (below the labels group, above the marks). + baselines += _boxed_chrome_svg("axis_line") + else: + if not hide_y or explicit_frame_sides: + for side, x in (("left", plot["x"]), ("right", plot["x"] + plot["w"])): + if side in frame_sides: + baselines += ( + f'' + ) + if not hide_x or explicit_frame_sides: + for side, y in (("top", plot["y"]), ("bottom", plot["y"] + plot["h"])): + if side in frame_sides: + baselines += ( + f'' + ) + for _axis_id, axis, _axis_scale in extra_x_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + edge = plot["y"] if axis.get("side", "bottom") == "top" else plot["y"] + plot["h"] + baselines += ( + f'' + ) + for _axis_id, axis, _axis_scale in extra_y_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + edge = plot["x"] + plot["w"] if axis.get("side", "right") == "right" else plot["x"] + baselines += ( + f'' + ) - if not hide_x and polar is None: + if not tick_mark_boxed and not hide_x and polar is None: inward, outward, tick_width = tick_span(xmstyle) side = xa.get("side", "bottom") edge = plot["y"] if side == "top" else plot["y"] + plot["h"] @@ -4450,7 +5416,7 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'stroke="{escape(_css(xstyle.get("tick_color"), default_axis))}" ' f'stroke-width="{_num(tick_width)}"/>' ) - if not hide_y and polar is None: + if not tick_mark_boxed and not hide_y and polar is None: inward, outward, tick_width = tick_span(ymstyle) side = ya.get("side", "left") edge = plot["x"] + plot["w"] if side == "right" else plot["x"] @@ -4483,48 +5449,54 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'stroke="{escape(_css(ystyle.get("tick_color"), default_axis))}" ' f'stroke-width="{_num(tick_width)}"/>' ) - for axis_id, axis, axis_scale in extra_x_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - inward, outward, tick_width = tick_span(axis_style) - for side in _axis_tick_sides(axis, is_x=True): - edge = plot["y"] if side == "top" else plot["y"] + plot["h"] - for value in extra_x_ticks[axis_id][0]: - x = float(axis_scale(value)) - y1, y2 = ( - (edge - outward, edge + inward) - if side == "top" - else (edge - inward, edge + outward) - ) - baselines += ( - f'' - ) - for axis_id, axis, axis_scale in extra_y_axes: - if _axis_tick_label_strategy(axis) == "none": - continue - axis_style = axis.get("style") or {} - inward, outward, tick_width = tick_span(axis_style) - for side in _axis_tick_sides(axis, is_x=False): - edge = plot["x"] + plot["w"] if side == "right" else plot["x"] - for value in extra_y_ticks[axis_id][0]: - y = float(axis_scale(value)) - x1, x2 = ( - (edge - inward, edge + outward) - if side == "right" - else (edge - outward, edge + inward) - ) - baselines += ( - f'' - ) + if not tick_mark_boxed: + for axis_id, axis, axis_scale in extra_x_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + inward, outward, tick_width = tick_span(axis_style) + for side in _axis_tick_sides(axis, is_x=True): + edge = plot["y"] if side == "top" else plot["y"] + plot["h"] + for value in extra_x_ticks[axis_id][0]: + x = float(axis_scale(value)) + y1, y2 = ( + (edge - outward, edge + inward) + if side == "top" + else (edge - inward, edge + outward) + ) + baselines += ( + f'' + ) + for axis_id, axis, axis_scale in extra_y_axes: + if _axis_tick_label_strategy(axis) == "none": + continue + axis_style = axis.get("style") or {} + inward, outward, tick_width = tick_span(axis_style) + for side in _axis_tick_sides(axis, is_x=False): + edge = plot["x"] + plot["w"] if side == "right" else plot["x"] + for value in extra_y_ticks[axis_id][0]: + y = float(axis_scale(value)) + x1, x2 = ( + (edge - inward, edge + outward) + if side == "right" + else (edge - outward, edge + inward) + ) + baselines += ( + f'' + ) + else: + # Styled tick marks as boxes, at the strokes' own place in the paint + # order. Geometry parity is exact: a box at x - width/2 covers the + # same pixels as the centered stroke (and as the browser's rect). + baselines += _boxed_chrome_svg("tick_mark") - defs = f"{''.join(svg.defs)}" if svg.defs else "" + defs_parts = svg.defs # Figure patch + plot-rect backgrounds, mirroring the browser: the root # element's CSS `background` (theme(background=)) behind everything, then # the --chart-bg token over the plot rect only. Solid colors only — @@ -4537,7 +5509,24 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: if canvas_paint and canvas_paint not in ("transparent", "none"): backgrounds += f'' figure_background = _solid_paint(dom_style.get("background")) - if figure_background is not None: + # The root slot's box (parity plan §3.4). Declaration-gated: with no box + # declaration the legacy figure-patch rect below emits byte-identically. + # When declared, the box IS the figure patch — same element in the DOM, + # one `background` property — so the theme paint becomes the box fill + # whenever the slot declares chrome around it but no fill of its own. + # First-painted content, like the browser's border-below-descendants; + # `apply_export_background` already silenced the slot fill if an export + # `background=` override is in force (the one precedence definition). + root_declaration = slot_box_declaration(slots.get("root") or {}, "root") + root_box: Optional[ChromeBox] = None + if root_declaration: + if figure_background is not None and not any( + prop in root_declaration for prop in ("background", "background-color") + ): + root_declaration = {"background": figure_background, **root_declaration} + root_box = lower_box("root", root_declaration, x=0.0, y=0.0, w=width, h=height) + backgrounds += _slot_box_svg(root_box) + elif figure_background is not None: backgrounds += ( f'' ) @@ -4547,6 +5536,77 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'' ) + # labels-container box (flag D, resolved): the background paints UNDER + # the axis baselines and every label text, exactly where the live DOM + # puts it — the spine/tick rules are children of the labels container, + # so its background sits below them and above marks + annotation shapes. + # The residual stacking difference (live, the full-bleed container also + # covers the title/legend/colorbar, which are earlier/later siblings) is + # recorded in KNOWN_RENDERER_DIVERGENCES, never silent. + labels_background = "" + container_paint = labels_slot.get("background", labels_slot.get("background-color")) + if container_paint is not None: + labels_background = _slot_box_svg( + lower_box("labels", {"background": container_paint}, x=0, y=0, w=width, h=height) + ) + labels_opacity = _layer_opacity(labels_slot) + labels_group_attrs = f' opacity="{_num(labels_opacity)}"' if labels_opacity < 1.0 else "" + # The chrome slot (parity plan §3.5, background/opacity only): one rect + # above the root/plot backgrounds and below the grid — the browser's + # chrome canvas sits above the root element and holds the grid. Its DOM + # stacking against the title divs diverges by design and is recorded in + # `KNOWN_RENDERER_DIVERGENCES` (`chrome_slot_title_stacking`). + chrome_slot_backdrop = "" + chrome_declaration = slot_box_declaration(slots.get("chrome") or {}, "chrome") + if chrome_declaration: + chrome_slot_backdrop = _slot_box_svg( + lower_box("chrome", chrome_declaration, x=0.0, y=0.0, w=width, h=height) + ) + # The canvas slot (parity plan §3.6) paints at the ABOVE-grid seam: the + # browser's marks canvas is a separate element over the chrome canvas, so + # a canvas background HIDES the grid. Deliberately NOT the --chart-bg + # anchor, which paints below the grid — that paint-order trap is this + # family's central divergence. A declared radius clips the marks through + # a THIRD clipPath (never by mutating clip_id/marks_clip_id: the shared + # clip also bounds legends, and polar legends live outside the disc); + # declared opacity rides the wrapping group, PDF-legal on . + canvas_backdrop = "" + canvas_group_open = "" + canvas_group_close = "" + canvas_declaration = slot_box_declaration(slots.get("canvas") or {}, "canvas") + if canvas_declaration: + canvas_box = lower_box( + "canvas", + canvas_declaration, + x=plot["x"], + y=plot["y"], + w=plot["w"], + h=plot["h"], + ) + canvas_backdrop = _slot_box_svg(canvas_box) + canvas_group_attrs = "" + if canvas_box.radius > 0: + canvas_clip_id = svg.uid("clip") + defs_parts.append( + f'' + ) + canvas_group_attrs += f' clip-path="url(#{canvas_clip_id})"' + if canvas_box.opacity < 1.0: + canvas_group_attrs += f' opacity="{_num(canvas_box.opacity)}"' + if canvas_group_attrs: + canvas_group_open = f"" + canvas_group_close = "" + defs = f"{''.join(defs_parts)}" if defs_parts else "" # One flat join over the pieces rather than nested `join`s inside an # f-string: the mark list is the whole document for a per-point chart (tens # of MB at 100k markers), and joining it separately would materialize a @@ -4557,15 +5617,20 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: f'viewBox="0 0 {width} {height}" font-family="{_FONT}" font-size="11">', defs, backgrounds, + chrome_slot_backdrop, "", *grid, "", + canvas_group_open, + canvas_backdrop, f'', *marks, "", + canvas_group_close, *unclipped_annotation_marks, + labels_background, baselines, - f'', + f'', *labels, "", *chrome, @@ -4726,10 +5791,12 @@ def _annotation_svg( width: float, height: float, polar: "Optional[_PolarProjection]" = None, + slots: Optional[dict[str, dict[str, Any]]] = None, ) -> tuple[list[str], list[str], list[str]]: marks: list[str] = [] unclipped_marks: list[str] = [] labels: list[str] = [] + slots = slots or {} px0, py0 = plot["x"], plot["y"] def point(x: float, y: float) -> tuple[float, float]: @@ -4750,7 +5817,7 @@ def point(x: float, y: float) -> tuple[float, float]: return float(sx(x)), float(sy(y)) for ann in annotations: - style = ann.get("style") or {} + style = annotation_style_with_slot(ann, slots) color = escape(_css(style.get("color"), "#667085")) opacity = float(style.get("opacity", 1.0)) start = max(0.0, min(1.0, float(style.get("span_start", 0.0)))) @@ -4933,18 +6000,87 @@ def point(x: float, y: float) -> tuple[float, float]: + (f'fill-opacity="{_num(text_opacity)}" ' if text_opacity < 1 else "") + f'fill="{label_color}">{tspans}' ) - return marks, unclipped_marks, labels + wrapped_marks, wrapped_unclipped = _annotation_layer_wrap(marks, unclipped_marks, slots, plot) + return wrapped_marks, wrapped_unclipped, labels + + +def _layer_opacity(layer: dict[str, Any]) -> float: + """The annotation_layer slot's declared group opacity, clamped, 1 unset.""" + if "opacity" not in layer: + return 1.0 + try: + return min(1.0, max(0.0, float(layer["opacity"]))) + except (TypeError, ValueError): + return 1.0 + + +def _annotation_layer_wrap( + marks: list[str], + unclipped_marks: list[str], + slots: dict[str, Any], + plot: dict[str, float], +) -> tuple[list[str], list[str]]: + """Apply the annotation_layer slot to the annotation shape lists. + + Group opacity rides a `` (PDF-legal), wrapping the clipped tail and + the unclipped connectors alike — never the labels, which live in the + labels container in the browser too. The layer background is a + plot-rect chrome box painted UNDER the shapes, inside the marks clip: + the live overlay canvas is full-bleed (`inset:0`), but a full-bleed rect + cannot sit above the traces and below the shapes in either writer, so + the writers pin the plot-clipped geometry and the divergence is recorded + in `KNOWN_RENDERER_DIVERGENCES` rather than left to be discovered. + """ + layer = slots.get("annotation_layer") or {} + if not layer: + return marks, unclipped_marks + prefix: list[str] = [] + background = layer.get("background", layer.get("background-color")) + if background is not None: + box = lower_box( + "annotation_layer", + {"background": background, "opacity": _layer_opacity(layer)}, + x=plot["x"], + y=plot["y"], + w=plot["w"], + h=plot["h"], + ) + # The rect carries the folded opacity itself (it sits outside the + # group so the group cannot double-dim it). + rect = _slot_box_svg(box) + if rect: + prefix.append(rect) + opacity = _layer_opacity(layer) + if opacity < 1.0: + wrap = f'' + if marks: + marks = [wrap, *marks, ""] + if unclipped_marks: + unclipped_marks = [wrap, *unclipped_marks, ""] + return prefix + marks, unclipped_marks def _svg_font_attrs(style: dict[str, Any]) -> str: + """Annotation-label font attributes, each emitted exactly once. + + `letter_spacing` joined the tuple with the annotation_label slot channel + (it is in `SLOT_TEXT_PROPS`, and the merge translates the slot's kebab + spelling onto this vocabulary); a bare number is normalized to px the way + `slot_text_attrs` does for the other text slots. + """ attrs = [] for key, attribute in ( ("font_family", "font-family"), ("font_weight", "font-weight"), ("font_style", "font-style"), + ("letter_spacing", "letter-spacing"), ): - if style.get(key) is not None: - attrs.append(f' {attribute}="{escape(str(style[key]))}"') + value = style.get(key) + if value is None: + continue + if key == "letter_spacing" and not isinstance(value, str): + value = _num(_px_size(value, 0.0)) + attrs.append(f' {attribute}="{_escape_attr(value)}"') return "".join(attrs) @@ -4982,7 +6118,197 @@ def _svg_mathtext_spans(line: str, style: dict[str, Any], offset: int) -> str: return "".join(out) -def _svg_text_box( +#: Annotation vocabulary ↔ slot CSS vocabulary, defined exactly once (the +#: two-vocabulary collision is this family's named risk). Left: the kebab +#: property a `styles={'annotation_label': ...}` declaration carries after +#: resolver normalization; right: the pyplot-derived key the annotation's own +#: `style=` mapping (and both writers) have always used. Paint and opacity +#: are handled separately — their targets depend on the annotation kind. +_ANNOTATION_SLOT_TEXT_KEYS: tuple[tuple[str, str], ...] = ( + ("font-size", "font_size"), + ("font-weight", "font_weight"), + ("font-style", "font_style"), + ("font-family", "font_family"), + ("letter-spacing", "letter_spacing"), +) + +#: What a labels-container declaration cascades into an annotation label. +#: Exactly the text properties the live stylesheet leaves un-ruled on the +#: label div: `20_theme.ts` pins font-size (11px), font-weight (400) and the +#: color chain per label, so only these three inherit from the container. +_LABELS_CONTAINER_ANNOTATION_PROPS: tuple[str, ...] = ( + "font-style", + "font-family", + "letter-spacing", +) + +#: The annotation label's own stylesheet font size (`20_theme.ts`), the base +#: an em-valued slot font-size resolves against — the legend precedent: +#: relative units resolve in the slot's own unit domain, never silently drop. +_ANNOTATION_FONT_SIZE = 11.0 + +#: What a labels-container declaration cascades into each text slot living in +#: the container, per property. Read straight off the live stylesheet +#: (`20_theme.ts`): a `:where()` rule on the child beats inheritance from the +#: container even at zero specificity, so only the properties the child's +#: rule leaves unset inherit. tick_label has a color rule only; axis_title +#: pins font-size (12px) and font-weight (400); annotation_label pins both +#: plus line-height (`_LABELS_CONTAINER_ANNOTATION_PROPS`). +_LABELS_CONTAINER_TEXT_PROPS: dict[str, tuple[str, ...]] = { + "tick_label": ("font-size", "font-weight", "font-style", "font-family", "letter-spacing"), + "axis_title": ("font-style", "font-family", "letter-spacing"), +} + +#: The labels container's own inherited font size (`:where(.xy){font:12px…}`), +#: the base an em-valued labels-slot font-size resolves against. +_LABELS_CONTAINER_FONT_SIZE = 12.0 + + +def slot_in_labels_container(slots: dict[str, Any], name: str) -> dict[str, Any]: + """The effective declaration for a text slot inside the labels container. + + The labels-slot typography folds UNDER the specific slot (container < + specific slot < axis/annotation style — the cascade order the live DOM + gives inherited container styling). Color is deliberately absent: every + contained text resolves paint through `var(--chart-text, inherit)`, so + the container's color is a *default* below the theme token, threaded by + the writers as the label-text fallback instead of merged here. + """ + slot = slots.get(name) or {} + container = slots.get("labels") or {} + if not container: + return slot + inherited = { + prop: container[prop] for prop in _LABELS_CONTAINER_TEXT_PROPS[name] if prop in container + } + if not inherited: + return slot + if "font-size" in inherited: + inherited["font-size"] = _annotation_em(inherited["font-size"], _LABELS_CONTAINER_FONT_SIZE) + return {**inherited, **slot} + + +def _annotation_em(value: Any, base: float) -> Any: + """Resolve an em spelling against `base`; pass everything else through.""" + if isinstance(value, str) and value.strip().endswith("em"): + try: + return float(value.strip().removesuffix("em")) * base + except ValueError: + return value + return value + + +def annotation_style_with_slot(ann: dict[str, Any], slots: dict[str, Any]) -> dict[str, Any]: + """One annotation's effective label styling: slot folded UNDER `style=`. + + Mirrors the browser exactly: `_applySlot(d, "annotation_label")` runs + before the per-annotation inline styles (`51_annotations.ts`), so the + chart-wide declaration is the wider selector and the annotation's own + `style=` wins. The fold is per property GROUP, not per key, because the + two vocabularies overlap without matching one-to-one: + + - text paint: slot `fill`/`color` becomes `label_color`, yielding to the + annotation's own `label_color` OR `color` (the browser pins inline + color when either is present); + - `opacity`: the slot's is a label dimmer (the div's CSS opacity), so it + becomes `label_opacity` + the box opacity, yielding to the annotation's + own `label_opacity` — and, for `kind="text"`, to its own `opacity`, + which already acts as the label alpha; a shape-bearing annotation's + `opacity` is shape alpha and never collides; + - box groups (`background`, the border, `border_radius`, `padding`): + whichever vocabulary the annotation used wins the whole group; + - `box-shadow` and `fill-opacity` exist only in the slot vocabulary and + pass through. + + The labels-container slot sits under everything, restricted to the + properties that genuinely inherit in the live DOM + (`_LABELS_CONTAINER_ANNOTATION_PROPS`). + """ + style = ann.get("style") or {} + slot = slots.get("annotation_label") or {} + container = slots.get("labels") or {} + if not slot and not container: + return style + merged: dict[str, Any] = {} + for prop, key in _ANNOTATION_SLOT_TEXT_KEYS: + if prop in _LABELS_CONTAINER_ANNOTATION_PROPS and prop in container: + merged[key] = container[prop] + if prop in slot: + merged[key] = slot[prop] + if "font_size" in merged: + merged["font_size"] = _annotation_em(merged["font_size"], _ANNOTATION_FONT_SIZE) + paint = slot_text_color(slot, "") + if paint and "label_color" not in style and "color" not in style: + merged["label_color"] = paint + if ( + "opacity" in slot + and "label_opacity" not in style + and not (ann.get("kind") == "text" and "opacity" in style) + ): + merged["label_opacity"] = slot["opacity"] + merged["box-opacity"] = slot["opacity"] + if "background" not in style: + background = slot.get("background", slot.get("background-color")) + if background is not None: + merged["background"] = background + if "border" not in style: + for prop in ("border", "border-width", "border-color", "border-style"): + if prop in slot: + merged[prop] = slot[prop] + if "border_radius" not in style and "border-radius" in slot: + merged["border_radius"] = slot["border-radius"] + if "padding" not in style and "padding" in slot: + merged["padding"] = slot["padding"] + if "box-shadow" in slot: + merged["box-shadow"] = slot["box-shadow"] + if "fill-opacity" in slot: + merged["fill-opacity"] = slot["fill-opacity"] + return {**merged, **style} + + +def _css_box_padding(value: Any, font_size: float) -> tuple[float, float, float, float]: + """CSS `padding` as (top, right, bottom, left) px. + + The historical parsers read tokens [0]/[1] only, silently misreading + 4-value CSS (`declared top/RIGHT/bottom/left` became `vertical/ + horizontal`); this is the one correct read both writers now share. Em + tokens resolve against the label font size, like the browser; anything + unparseable stays 0, the failure mode the old parsers had. + """ + tokens = str(value).split() + + def length(token: str) -> float: + resolved = _annotation_em(token, font_size) + if isinstance(resolved, (int, float)): + return max(0.0, float(resolved)) + try: + return max(0.0, float(str(resolved).removesuffix("px"))) + except ValueError: + return 0.0 + + if not tokens: + return 0.0, 0.0, 0.0, 0.0 + sides = [length(token) for token in tokens[:4]] + if len(sides) == 1: + top = right = bottom = left = sides[0] + elif len(sides) == 2: + top = bottom = sides[0] + right = left = sides[1] + elif len(sides) == 3: + top, right, bottom = sides + left = right + else: + top, right, bottom, left = sides + return top, right, bottom, left + + +#: `border` shorthand middle tokens the writers lower to a dash pattern. +#: Any other style keyword passes through to `lower_box`, which keeps the +#: solid border the old emitters drew and records the approximation (§28). +_BORDER_STYLE_TOKENS = frozenset({"solid", "dashed", "dotted"}) + + +def annotation_text_box( style: dict[str, Any], lines: list[str], x: float, @@ -4990,63 +6316,107 @@ def _svg_text_box( line_height: float, font_size: float, anchor: str, -) -> list[str]: - """SVG counterpart of the pyplot text-bbox CSS approximation.""" +) -> Optional[ChromeBox]: + """One annotation label's box, lowered onto the shared chrome-box model. + + The single geometry + declaration lowering both writers consume — the + fold of the formerly duplicated `_svg_text_box` / `_emit_text_box` pair. + Reads the merged vocabulary `annotation_style_with_slot` produces: the + pyplot text-bbox keys (`background`, `border` shorthand, `padding`, + `border_radius`) plus the slot longhands the merge passes through. + Returns None when nothing was declared, so the unstyled document stays + byte-identical. + """ background = style.get("background") - border = str(style.get("border", "")) - if background is None and not border: - return [] - pad_parts = str(style.get("padding", "0")).split() - - def px(value: str) -> float: - try: - return max(0.0, float(value.removesuffix("px"))) - except ValueError: - return 0.0 + border = str(style.get("border", "") or "") + has_slot_box = any( + prop in style for prop in ("border-width", "border-color", "border-style", "box-shadow") + ) + if background is None and not border and not has_slot_box: + return None - pad_y = px(pad_parts[0]) if pad_parts else 0.0 - pad_x = px(pad_parts[1]) if len(pad_parts) > 1 else pad_y + pad_top, pad_right, pad_bottom, pad_left = _css_box_padding( + style.get("padding", "0"), font_size + ) text_width = _estimated_text_width(lines, font_size) left = ( x - (text_width / 2 if anchor == "middle" else text_width if anchor == "end" else 0.0) - - pad_x + - pad_left ) - top = first_y - font_size * 0.8 - pad_y - height = font_size + (len(lines) - 1) * line_height + pad_y * 2 - fill = "none" if background is None else escape(str(background)) - stroke = "none" - stroke_width = 0.0 + top = first_y - font_size * 0.8 - pad_top + width = text_width + (pad_left + pad_right) + height = font_size + (len(lines) - 1) * line_height + (pad_top + pad_bottom) + + decl: dict[str, Any] = {} + if background is not None: + decl["background"] = background + explicit_stroke: Optional[tuple[str, float]] = None if border: parts = border.split() - stroke = escape(parts[-1]) + stroke_paint = parts[-1] try: stroke_width = max(0.0, float(parts[0].removesuffix("px"))) except (IndexError, ValueError): stroke_width = 1.0 - # `boxstyle="round"`/`round4` set border_radius; the browser gets it as CSS - # border-radius, so the exporters have to round the same corners or an + if stroke_width > 0: + decl["border-width"] = stroke_width + decl["border-color"] = stroke_paint + if len(parts) >= 3 and parts[1] in _BORDER_STYLE_TOKENS: + decl["border-style"] = parts[1] + elif len(parts) >= 3: + decl["border-style"] = parts[1] if parts[1] not in ("none", "hidden") else "solid" + else: + # A declared zero-width border has always serialized as an + # invisible stroke pair in SVG and as nothing in raster. + explicit_stroke = (stroke_paint, stroke_width) + else: + for prop in ("border-width", "border-color", "border-style"): + if prop in style: + decl[prop] = style[prop] + # `boxstyle="round"`/`round4` set border_radius; the browser gets it as + # CSS border-radius, so the exporters round the same corners or an # exported box is square where the live one is not. - radius = _box_corner_radius(style, text_width + pad_x * 2, height) - radius_attr = f' rx="{_num(radius)}"' if radius > 0 else "" - return [ - f'' - ] - + radius = style.get("border_radius", style.get("border-radius")) + if radius is not None: + decl["border-radius"] = _annotation_em(radius, font_size) + if "box-shadow" in style: + decl["box-shadow"] = style["box-shadow"] + if "box-opacity" in style: + decl["opacity"] = style["box-opacity"] + if "fill-opacity" in style: + decl["fill-opacity"] = style["fill-opacity"] + + box = lower_box("annotation_label", decl, x=left, y=top, w=width, h=height) + if ( + background is not None + and box.fill is None + and not any(unrep.startswith("background") for unrep in box.unrepresentable) + ): + # `lower_box` elides transparent paints; the legacy emitters wrote + # them verbatim (`fill="transparent"`), and those bytes are pinned. + box = dataclasses.replace(box, fill=str(background)) + if box.border_color is None and explicit_stroke is None: + # The legacy pair: every drawn bbox rect carries a stroke, inert + # (`stroke="none" stroke-width="0"`) when no border was declared. + explicit_stroke = ("none", 0.0) + if explicit_stroke is not None and box.border_color is None: + box = dataclasses.replace(box, explicit_stroke=explicit_stroke) + return box -def _box_corner_radius(style: dict[str, Any], width: float, height: float) -> float: - """`border_radius` in px, clamped to the box like CSS does. - Shared by the SVG and native raster text-box emitters so an exported - ``boxstyle="round"`` bbox is rounded exactly once, the same way. - """ - try: - radius = float(str(style.get("border_radius", 0) or 0).removesuffix("px")) - except (TypeError, ValueError): - return 0.0 - return max(0.0, min(radius, width / 2.0, height / 2.0)) +def _svg_text_box( + style: dict[str, Any], + lines: list[str], + x: float, + first_y: float, + line_height: float, + font_size: float, + anchor: str, +) -> list[str]: + """Adapter: the pyplot text-bbox CSS approximation over the shared box.""" + box = annotation_text_box(style, lines, x, first_y, line_height, font_size, anchor) + return [_slot_box_svg(box)] if box is not None else [] def _fontmetrics_text_width( @@ -6231,29 +7601,148 @@ def _heatmap_image( #: fits to the last subpixel. _LEGEND_FIT_EPS = 1e-9 +#: Matplotlib's legend dimensions are expressed in font-size units: +#: `borderpad` is charged on both sides and `labelspacing` between rows. +#: These are the defaults an undeclared legend still resolves to, so the +#: px vocabulary can be added without moving a single unstyled legend. +_LEGEND_BORDERPAD_EM = 0.4 +_LEGEND_ROWGAP_EM = 0.5 + +#: The legend frame's offset-rect shadow, unified across the two writers +#: (plan §8 flags A and H). Before this the SVG drew `rx="4"` unconditionally +#: with `fill-opacity="0.22"` while the raster drew the frame's own radius at +#: alpha 55/255 ≈ 0.2157 — the same shadow, two shapes and two alphas. One +#: constant now, and the shadow takes the frame's radius: a square frame +#: casting a rounded shadow was the SVG side's bug. +_LEGEND_SHADOW_OFFSET = 2.0 +_LEGEND_SHADOW_COLOR = "rgba(0, 0, 0, 0.22)" + + +def _legend_em_multiplier(value: Any) -> Optional[float]: + """`1.2em` as the bare multiplier 1.2, or None for anything else. + + The legend's historical geometry domain: `padding`/`row-gap`/`font-size` + arrived as em multipliers of the legend font size, which schema v1 + rightly refuses (a relative unit is a document dependency), so they rode + `DeclaredStyling.writer_domain` instead of the snapshot. They keep + working — an author who wrote `1.2em` still gets 1.2 em — but px is now + the resolved spelling every one of them also accepts, which is what + retires the residue (plan §6 item 2). + """ + if not isinstance(value, str): + return None + text = value.strip() + if not text.endswith("em"): + return None + try: + return max(0.0, float(text[:-2])) + except ValueError: + return None -def _legend_font_size(style: dict[str, Any]) -> float: - """Resolve the bounded pixel font size used by static legend geometry.""" - value = str(style.get("fontSize", "")).strip() - if value.endswith("px"): - try: - return max(1.0, float(value[:-2])) - except ValueError: - pass - return 11.0 +def _legend_px(value: Any) -> Optional[float]: + """A resolved px length (`12`, `12.0`, `"12px"`), or None. -def _legend_em(style: dict[str, Any], key: str, default: float) -> float: - value = str(style.get(key, "")).strip() - if value.endswith("em"): + Deliberately narrower than `_chromebox._px`: an em string must fall + through to `_legend_em_multiplier` rather than being read as pixels. + """ + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + text = value.strip() + if text.endswith("px"): + text = text[:-2].strip() try: - return max(0.0, float(value[:-2])) + return float(text) except ValueError: - pass - return default + return None + return None + + +def _legend_length( + style: dict[str, Any], keys: Sequence[str], em_default: float, font_size: float +) -> float: + """One legend length in px, from the first spelling present. + + `keys` is the accepted spellings widest-last (the browser/pyplot + camelCase and the CSS slot's kebab reach the same merged declaration, so + both must resolve here or a file disagrees with the live chart). A px + value is used as-is; an em value multiplies `font_size`; anything absent + or unparsable falls back to `em_default` em, the pre-parity constant. + """ + for key in keys: + if key not in style: + continue + value = style[key] + em = _legend_em_multiplier(value) + if em is not None: + return em * font_size + px = _legend_px(value) + if px is not None: + return max(0.0, px) + return em_default * font_size -def _legend_text_width(value: Any, char_width: float = _LEGEND_CHAR_WIDTH) -> float: +def _legend_font_size(style: dict[str, Any]) -> float: + """The bounded pixel font size static legend geometry measures at. + + `fontSize` is the browser/pyplot spelling and `font-size` the CSS slot's; + the merged declaration may carry either, and the geometry has to agree + with whatever the emitters draw at or a styled label escapes its frame. + """ + for key in ("fontSize", "font-size"): + if key not in style: + continue + px = _legend_px(style[key]) + if px is not None: + return max(1.0, px) + em = _legend_em_multiplier(style[key]) + if em is not None: + return max(1.0, em * _LEGEND_FONT_PX) + return _LEGEND_FONT_PX + + +def _legend_padding(style: dict[str, Any], font_size: float) -> tuple[float, float, float, float]: + """The frame's `(top, right, bottom, left)` padding in px. + + `padding` seeds all four sides (CSS 1-4 value expansion when it is a px + shorthand, one em multiplier when it is the legend's historical + spelling); a `padding-*` longhand then overrides its own side. The + pre-parity legend charged `borderpad` em symmetrically, so an + undeclared padding still resolves to exactly that on every side. + """ + shorthand = style.get("padding") + em = _legend_em_multiplier(shorthand) + if em is not None: + side = em * font_size + sides = [side, side, side, side] + else: + parsed = parse_padding(shorthand) if shorthand is not None else None + if parsed is None: + base = _LEGEND_BORDERPAD_EM * font_size + sides = [base, base, base, base] + else: + sides = [max(0.0, value) for value in parsed] + for index, prop in enumerate( + ("padding-top", "padding-right", "padding-bottom", "padding-left") + ): + if prop not in style: + continue + longhand_em = _legend_em_multiplier(style[prop]) + if longhand_em is not None: + sides[index] = longhand_em * font_size + continue + longhand = _legend_px(style[prop]) + if longhand is not None: + sides[index] = max(0.0, longhand) + return (sides[0], sides[1], sides[2], sides[3]) + + +def _legend_text_width( + value: Any, char_width: float = _LEGEND_CHAR_WIDTH, letter_spacing: float = 0.0 +) -> float: """Measured advance width, in pixels, of a static legend string. Legend columns used to be sized as ``len(text) * _LEGEND_CHAR_WIDTH``. A @@ -6273,31 +7762,51 @@ def _legend_text_width(value: Any, char_width: float = _LEGEND_CHAR_WIDTH) -> fl the rasterizer's zero advance: SVG resolves it against the viewer's own fonts and does paint it, and over-reserving only widens the frame, which can never spill a label. + + ``letter_spacing`` is the per-advance px the emitters will hand SVG, so a + letter-spaced label is measured at the width it is actually drawn at + rather than at its unspaced one. SVG adds the spacing after every glyph + including the last, which is what ``len(text)`` (not ``len(text) - 1``) + reserves here. """ font_size = char_width * (_LEGEND_FONT_PX / _LEGEND_CHAR_WIDTH) - return _fontmetrics_text_width(value, font_size, missing_advance=char_width) + width = _fontmetrics_text_width(value, font_size, missing_advance=char_width) + if letter_spacing: + width += letter_spacing * len(str(value)) + return width -def _legend_text(value: Any, max_width: float, char_width: float = _LEGEND_CHAR_WIDTH) -> str: +def _legend_text( + value: Any, + max_width: float, + char_width: float = _LEGEND_CHAR_WIDTH, + letter_spacing: float = 0.0, +) -> str: """Conservatively ellipsize a static legend string to a pixel budget. The budget is measured, not counted, so the returned string's own advance width is ``<= max_width`` and therefore fits the column it was sized for. """ text = str(value) - if _legend_text_width(text, char_width) <= max_width + _LEGEND_FIT_EPS: + if _legend_text_width(text, char_width, letter_spacing) <= max_width + _LEGEND_FIT_EPS: return text # Longest prefix that still leaves room for the ellipsis. keep = 0 for index in range(1, len(text)): - if _legend_text_width(f"{text[:index]}...", char_width) > max_width + _LEGEND_FIT_EPS: + if ( + _legend_text_width(f"{text[:index]}...", char_width, letter_spacing) + > max_width + _LEGEND_FIT_EPS + ): break keep = index if keep: return f"{text[:keep]}..." # Too narrow for even one glyph plus an ellipsis: emit the dots that fit. for count in (3, 2, 1): - if _legend_text_width("." * count, char_width) <= max_width + _LEGEND_FIT_EPS: + if ( + _legend_text_width("." * count, char_width, letter_spacing) + <= max_width + _LEGEND_FIT_EPS + ): return "." * count return "" @@ -6352,7 +7861,13 @@ def legend_clip_rect(plot: dict) -> tuple[float, float, float, float]: return x0, y0, x1 - x0, y1 - y0 -def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, Any]: +def _legend_layout( + named: list[dict], + plot: dict, + options: dict, + title_slot: Optional[dict[str, Any]] = None, + label_slot: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: """Shared bounded legend geometry for SVG and native raster exports. Static files cannot offer the browser legend's scrollbar, so an oversized @@ -6363,6 +7878,19 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An A polar chart hands over a `legend_box_*` gutter beside the disc (`_recut_polar_plot`); everything below then bounds and places the legend in that box instead of over the marks, and `loc` chooses where within it. + + `title_slot`/`label_slot` are the `legend_title`/`legend_label` + declarations the emitters will actually draw at. Measuring at the base + legend font while drawing at an authored one is how an oversized slot + title used to escape its own frame, so the size and letter-spacing that + reach the emitters reach the measurement too (plan §6 item 4). Callers + that have no slots — the pyplot reservation and best-loc scoring — pass + none and get exactly the pre-parity geometry. + + This is the one legend geometry in the repo: the SVG writer, the raster + writer, pyplot's anchored-legend room reservation and pyplot's best-loc + scoring all size the box here, so a padding change moves all four + together or none of them. """ if "legend_box_w" in plot: plot = { @@ -6373,19 +7901,31 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An "h": plot["legend_box_h"], } style_opts = options.get("style") or {} + title_slot = title_slot or {} + label_slot = label_slot or {} font_size = _legend_font_size(style_opts) - char_width = font_size * (_LEGEND_CHAR_WIDTH / 11.0) - text_h = font_size * 1.03 - borderpad = _legend_em(style_opts, "padding", 0.4) - labelspacing = _legend_em(style_opts, "rowGap", 0.5) + # The sizes the emitters draw at — the measurement has to use the same + # ones or the frame is sized for text that is not what appears in it. + label_font = slot_font_size(label_slot, font_size) + title_font = slot_font_size(title_slot, font_size) + label_spacing = _slot_letter_spacing(label_slot) + title_spacing = _slot_letter_spacing(title_slot) + char_width = label_font * (_LEGEND_CHAR_WIDTH / _LEGEND_FONT_PX) + title_char_width = title_font * (_LEGEND_CHAR_WIDTH / _LEGEND_FONT_PX) + text_h = label_font * 1.03 + title_text_h = title_font * 1.03 # Matplotlib's legend dimensions are expressed in font-size units: # borderpad is applied on both sides, handlelength=2, handletextpad=.8, - # columnspacing=2, and labelspacing=.5 by default. - pad = 2.0 * borderpad * font_size + # columnspacing=2, and labelspacing=.5 by default. Each is now also + # spellable in resolved px, which is what lets legend geometry leave the + # em-only `writer_domain` residue behind (plan §6 item 2). + pad_t, pad_r, pad_b, pad_l = _legend_padding(style_opts, font_size) + pad = pad_l + pad_r + pad_y = pad_t + pad_b handle = max(0.0, float(options.get("handlelength", 2.0))) * font_size gap = max(0.0, float(options.get("handletextpad", 0.8))) * font_size column_gap = 2.0 * font_size - row_gap = labelspacing * font_size + row_gap = _legend_length(style_opts, ("rowGap", "row-gap", "gap"), _LEGEND_ROWGAP_EM, font_size) line_h = text_h + row_gap requested_handleheight = options.get("handleheight") swatch_h = 8.0 @@ -6395,7 +7935,11 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An requested_cols = min(len(named), max(1, int(options.get("ncols", 1)))) title = options.get("title") - title_h = line_h if title else 0.0 + # The title band is a row of its own. `max` keeps a default-sized title + # on exactly the pre-parity `line_h` (including the handleheight bump) + # while letting an authored `legend_title` font-size grow its own band + # instead of overprinting the first entry. + title_h = max(line_h, title_text_h + row_gap) if title else 0.0 inset = 6.0 anchor = options.get("anchor") # An anchored legend is positioned from ``bbox_to_anchor`` rather than @@ -6421,7 +7965,7 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An natural_text_widths = [ max( - _legend_text_width(named[index].get("name", ""), char_width) + _legend_text_width(named[index].get("name", ""), char_width, label_spacing) for index in range(column, len(named), ncols) ) for column in range(ncols) @@ -6449,33 +7993,41 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An # ``pad`` is the sum of the two side pads. The previous one-sided # calculation expanded the box to the title's glyph width but then # ellipsized against ``box_w - 2 * pad`` (e.g. "Classes" -> "Cl..."). - title_w = _legend_text_width(title, char_width) + pad + title_w = _legend_text_width(title, title_char_width, title_spacing) + pad if title_w > box_w: extra = min(available_w - box_w, title_w - box_w) column_widths = [width + extra / ncols for width in column_widths] text_widths = [width + extra / ncols for width in text_widths] box_w += extra column_offsets = [] - cursor = pad / 2 + cursor = pad_l for width in column_widths: column_offsets.append(cursor) cursor += width + column_gap + def content_h(rows: int) -> float: + """Stacked height of the title band (if any) plus `rows` entry rows. + + Written as an explicit block list rather than `content_rows * text_h` + so the title can carry its own height once `legend_title` authors a + font size; with the default sizes every term is identical to the + pre-parity arithmetic. + """ + blocks = ([title_text_h] if title else []) + [text_h] * rows + if not blocks: + return 0.0 + return sum(blocks) + (len(blocks) - 1) * row_gap + nrows = (len(named) + ncols - 1) // ncols available_h = max(1.0, float(plot["h"]) - 2 * inset) visible_rows = nrows - content_rows = nrows + (1 if title else 0) - natural_box_h = content_rows * text_h + max(0, content_rows - 1) * row_gap + pad + natural_box_h = content_h(nrows) + pad_y if natural_box_h > available_h: - title_room = text_h + row_gap if title else 0.0 - available_entries_h = max(0.0, available_h - pad - title_room) + title_room = title_text_h + row_gap if title else 0.0 + available_entries_h = max(0.0, available_h - pad_y - title_room) visible_rows = max(0, int((available_entries_h + row_gap) // line_h)) visible_count = min(len(named), visible_rows * ncols) - visible_content_rows = visible_rows + (1 if title else 0) - box_h = min( - available_h, - visible_content_rows * text_h + max(0, visible_content_rows - 1) * row_gap + pad, - ) + box_h = min(available_h, content_h(visible_rows) + pad_y) loc = options.get("loc") or "upper right" loc_tokens = set(re.split(r"[\s_-]+", loc)) @@ -6517,20 +8069,62 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An float(plot["y"]) + float(plot["h"]) - box_h - inset, ) + # Per-entry geometry, computed once here and read by both emitters. The + # SVG and raster writers used to derive `rx`/`ry`/`hx0`/`cy` from `pad` + # independently; a per-side padding would have had to be threaded through + # two copies of the same arithmetic, which is exactly how the frame + # drifted before. One producer, two consumers. + rows: list[dict[str, float]] = [] + for index in range(visible_count): + col, row = index % ncols, index // ncols + entry_x = x + column_offsets[col] + entry_y = y + pad_t + title_h + row * line_h + rows.append( + { + "col": float(col), + "row": float(row), + "x": entry_x, + "y": entry_y, + "w": column_widths[col], + "h": text_h, + "swatch_x": entry_x, + "swatch_y": entry_y + text_h / 2 - swatch_h / 2, + "swatch_w": handle, + "swatch_h": swatch_h, + "handle_cy": entry_y + text_h / 2, + "label_x": entry_x + handle + gap, + "label_w": max(0.0, column_widths[col] - handle - gap), + "label_baseline": entry_y + label_font * 0.82, + } + ) + return { "style": style_opts, "pad": pad, + "pad_top": pad_t, + "pad_right": pad_r, + "pad_bottom": pad_b, + "pad_left": pad_l, + "pad_y": pad_y, "handle": handle, "gap": gap, "column_gap": column_gap, "row_gap": row_gap, "font_size": font_size, + "label_font": label_font, + "title_font": title_font, "text_h": text_h, + "title_text_h": title_text_h, "line_h": line_h, "swatch_h": swatch_h, "ncols": ncols, - "title": _legend_text(title, max(0.0, box_w - pad), char_width) if title else None, + "title": ( + _legend_text(title, max(0.0, box_w - pad), title_char_width, title_spacing) + if title + else None + ), "title_h": title_h, + "title_baseline": y + pad_t + title_font * 0.82, "cell_w": max(column_widths), "column_widths": column_widths, "column_offsets": column_offsets, @@ -6539,13 +8133,179 @@ def _legend_layout(named: list[dict], plot: dict, options: dict) -> dict[str, An "x": x, "y": y, "visible_count": visible_count, + "rows": rows, "names": [ - _legend_text(t.get("name", ""), text_widths[index % ncols], char_width) + _legend_text(t.get("name", ""), text_widths[index % ncols], char_width, label_spacing) for index, t in enumerate(named[:visible_count]) ], } +#: The legend frame's default paint when nothing declares a background: a +#: grey at 8% that reads as a frame over any plot. Named so the two writers +#: and the alpha logic below cannot spell it three ways. +_LEGEND_DEFAULT_FRAME_RGB = "rgb(128, 128, 128)" +_LEGEND_DEFAULT_FRAME_ALPHA = 0.08 +_LEGEND_DEFAULT_BORDER = "#cccccc" + + +def legend_frame_box(legend: dict[str, Any]) -> Optional[ChromeBox]: + """The legend frame as a `ChromeBox`, or None when there is no frame. + + One lowering for both writers, replacing the two hand-rolled frame + rects that had drifted into different shadow shapes and two different + shadow alphas (plan §8 flags A and H). What it resolves, and why each + is not simply copied from the old pair: + + - `background: transparent` still drops the frame entirely — Matplotlib's + `frameon=False`, and the one behavior both writers already agreed on. + - the authored `border-radius` VALUE is honored. Both writers used to + pin `4` for *any* truthy radius, so `border-radius: 12px` drew a 4px + corner; the number is now the number. + - `border-color` is read in both spellings. The camelCase `borderColor` + was honored and the CSS slot's `border-color` silently was not, which + also made the preflight's legend report untrue. + - the frame alpha dims the border with the fill (flag B's open question, + resolved in favor of the coupling: the live frame is one translucent + element and its border fades with it). + - `box-shadow` keeps its historical offset-rect for any value the + offset-rect model cannot express — pyplot authors a blurred + `2px 2px 4px rgba(0,0,0,0.3)` and dropping its shadow to honor the + blur literally would be a silent regression — while a value the model + CAN express is now honored as written. The blur is recorded + unrepresentable either way (§28). + """ + style_opts = legend["style"] + background_value = style_opts.get("background") + if background_value == "transparent": + return None + + declaration = { + prop: value + for prop, value in ( + ("border-color", style_opts.get("borderColor", style_opts.get("border-color"))), + ("border-width", style_opts.get("borderWidth", style_opts.get("border-width"))), + ("border-style", style_opts.get("borderStyle", style_opts.get("border-style"))), + ("border-radius", style_opts.get("borderRadius", style_opts.get("border-radius"))), + ("box-shadow", style_opts.get("boxShadow", style_opts.get("box-shadow"))), + ("opacity", style_opts.get("opacity")), + ) + if value is not None + } + # An explicit background is a paint, not a tint. The browser renders + # `background:#fef3c7` opaque, so the writers must too; the frame-alpha + # token stays the knob for the default grey frame. + frame_alpha = style_opts.get("--xy-legend-frame-alpha") + if frame_alpha is not None: + alpha = float(frame_alpha) + else: + alpha = _LEGEND_DEFAULT_FRAME_ALPHA if background_value is None else 1.0 + declaration["background"] = ( + _LEGEND_DEFAULT_FRAME_RGB if background_value is None else _css(background_value, "#808080") + ) + declaration["fill-opacity"] = alpha + declaration.setdefault("border-color", _LEGEND_DEFAULT_BORDER) + + box = lower_box( + "legend", + declaration, + x=legend["x"], + y=legend["y"], + w=legend["box_w"], + h=legend["box_h"], + ) + # A radius was authored as a bare truthiness for years (`borderRadius: + # true`, `'4px'`); anything that does not parse to a length keeps the + # historical 4. + if declaration.get("border-radius") is not None and box.radius == 0.0: + box = dataclasses.replace( + box, radius=min(4.0, legend["box_w"] / 2.0, legend["box_h"] / 2.0) + ) + shadow = box.shadow + if shadow is None and style_opts.get("boxShadow", style_opts.get("box-shadow")): + shadow = (_LEGEND_SHADOW_OFFSET, _LEGEND_SHADOW_OFFSET, _LEGEND_SHADOW_COLOR) + return dataclasses.replace(box, border_opacity=alpha, shadow=shadow) + + +def _legend_text_slot_box( + slot: str, + style: dict[str, Any], + x: float, + y: float, + w: float, + h: float, + qualifiers: tuple[str, ...] = (), +) -> Optional[ChromeBox]: + """The box behind one legend text row, or None when nothing declares one. + + The row rect, not a text-tight rect: `legend_title` and `legend_label` + are rows in the frame's flow, and a background that stopped at the glyph + advances would leave the row's own padding unpainted — which is not what + the same declaration does in the browser. Strictly declaration-gated, so + an unstyled legend emits exactly what it always did. + """ + if not _has_box_declaration(style): + return None + return box_at(box_template(slot, style), x, y, w, h, qualifiers=qualifiers) + + +def _legend_patch_box( + template: ChromeBox, + entry: dict[str, float], + handle: float, + swatch_h: float, + style: dict[str, Any], + color: str, +) -> ChromeBox: + """A patch swatch lowered through the `legend_swatch` declaration. + + The trace's own colour is the fallback fill and its stroke the fallback + border, so a slot that declares neither still paints the series' patch; + a slot that declares either wins, matching the browser, where the slot + rule is applied after the per-entry paint variables. An unauthored + radius keeps the historical `rx="2"`. + """ + box = box_at( + template, + entry["swatch_x"], + entry["swatch_y"], + handle, + swatch_h, + qualifiers=(str(int(entry["row"])), str(int(entry["col"]))), + fallback_fill=color, + ) + if box.radius == 0.0 and template.radius == 0.0: + box = dataclasses.replace(box, radius=min(2.0, handle / 2.0, swatch_h / 2.0)) + if box.border_color is None: + stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) + stroke = style.get("stroke") + if stroke is not None and stroke_width > 0.0: + box = dataclasses.replace( + box, border_color=_css(stroke, color), border_width=stroke_width + ) + return box + + +def legend_text_align( + style: dict[str, Any], x: float, w: float, default_anchor: str, default_x: float +) -> tuple[str, float]: + """`(text-anchor, x)` for a legend text row honoring `text-align`. + + Alignment is resolved against the ROW box, not the frame: a right-aligned + label ends at its own column's right edge, which is what the browser's + flex row does. Undeclared (or an unknown keyword) keeps the writer's + historical anchor and x, so unstyled output is untouched. + """ + align = str(style.get("text-align", "")).strip().lower() + if align in ("left", "start"): + return "start", x + if align in ("right", "end"): + return "end", x + w + if align == "center": + return "middle", x + w / 2.0 + return default_anchor, default_x + + def _legend( named: list[dict], plot: dict, @@ -6555,72 +8315,97 @@ def _legend( palette: Sequence[str] = DEFAULT_PALETTE, label_slot: Optional[dict[str, Any]] = None, title_slot: Optional[dict[str, Any]] = None, + item_slot: Optional[dict[str, Any]] = None, + swatch_slot: Optional[dict[str, Any]] = None, ) -> str: label_slot = label_slot or {} title_slot = title_slot or {} - legend = _legend_layout(named, plot, options) + item_slot = item_slot or {} + swatch_slot = swatch_slot or {} + legend = _legend_layout(named, plot, options, title_slot, label_slot) if not legend["visible_count"]: # A plot too short for even one entry: no floating frame/title either. return "" rows = [] - style_opts = legend["style"] - pad, handle, gap = legend["pad"], legend["handle"], legend["gap"] - line_h, ncols = legend["line_h"], legend["ncols"] + handle, gap = legend["handle"], legend["gap"] swatch_h = legend["swatch_h"] - title, title_h = legend["title"], legend["title_h"] - font_size, text_h = legend["font_size"], legend["text_h"] - column_offsets = legend["column_offsets"] - box_w, box_h = legend["box_w"], legend["box_h"] - x, y = legend["x"], legend["y"] - if style_opts.get("background") != "transparent": - if style_opts.get("boxShadow"): - rows.append( - f'' - ) - radius = "4" if style_opts.get("borderRadius") else "0" - background_value = style_opts.get("background") - # An explicit background is a paint, not a tint. The browser renders - # `background:#fef3c7` opaque, so the writers must too; the - # frame-alpha token stays the knob for the default grey frame. - frame_alpha = style_opts.get("--xy-legend-frame-alpha") - if frame_alpha is not None: - alpha = float(frame_alpha) - else: - alpha = 0.08 if background_value is None else 1.0 - if background_value is None and alpha == 0.08: - fill_attrs = 'fill="rgba(128,128,128,0.08)"' - else: - background = _css(background_value, "#808080") - fill_attrs = f'fill="{escape(background)}" fill-opacity="{_num(alpha)}"' - border = _css(style_opts.get("borderColor"), "#cccccc") - rows.append( - f'' - ) + title = legend["title"] + text_h = legend["text_h"] + box_w = legend["box_w"] + x = legend["x"] + entries = legend["rows"] + frame = legend_frame_box(legend) + if frame is not None: + rows.append(_slot_box_svg(frame)) if title: + title_box = _legend_text_slot_box( + "legend_title", title_slot, x, legend["y"] + legend["pad_top"], box_w, legend["title_h"] + ) + if title_box is not None: + rows.append(_slot_box_svg(title_box)) # The layout's measured size is the default; a slot may override it. - title_size_attr = _slot_size_attr(title_slot) or f' font-size="{_num(font_size)}"' + title_size_attr = _slot_size_attr(title_slot) or f' font-size="{_num(legend["font_size"])}"' + title_anchor, title_x = legend_text_align( + title_slot, + x + legend["pad_left"], + max(0.0, box_w - legend["pad_left"] - legend["pad_right"]), + "middle", + x + box_w / 2, + ) rows.append( - f'' f"{escape(str(title))}" ) - label_size_attr = _slot_size_attr(label_slot) or f' font-size="{_num(font_size)}"' + label_size_attr = _slot_size_attr(label_slot) or f' font-size="{_num(legend["font_size"])}"' + item_tmpl = box_template("legend_item", item_slot) if _has_box_declaration(item_slot) else None + swatch_tmpl = ( + box_template("legend_swatch", swatch_slot) if _has_box_declaration(swatch_slot) else None + ) for i, t in enumerate(named[: legend["visible_count"]]): style = t.get("style") or {} color = _css( style.get("color") or (t.get("color") or {}).get("color"), palette[i % len(palette)], ) - col, row = i % ncols, i // ncols - rx, ry = x + column_offsets[col], y + pad / 2 + title_h + row * line_h - hx0, hx1, cy = rx, rx + handle, ry + text_h / 2 + entry = entries[i] + ry = entry["y"] + hx0, hx1, cy = entry["swatch_x"], entry["swatch_x"] + handle, entry["handle_cy"] kind = t.get("kind") + # Row background first: under this row's swatch and label, over the + # frame and the title (the browser's `.xy-legend-item` order). + if item_tmpl is not None: + rows.append( + _slot_box_svg( + box_at( + item_tmpl, + entry["x"], + ry, + entry["w"], + entry["h"], + qualifiers=(str(int(entry["row"])), str(int(entry["col"]))), + ) + ) + ) + patch_kind = kind != "scatter" and kind not in _LEGEND_LINE_KINDS + if swatch_tmpl is not None and not patch_kind: + # A marker or line handle keeps its own ink; the swatch slot is + # the cell behind it. + rows.append( + _slot_box_svg( + box_at( + swatch_tmpl, + entry["swatch_x"], + entry["swatch_y"], + handle, + swatch_h, + qualifiers=(str(int(entry["row"])), str(int(entry["col"]))), + ) + ) + ) if kind == "scatter": rows.append(_legend_marker_svg(style, (hx0 + hx1) / 2, cy, color)) elif kind in _LEGEND_LINE_KINDS: @@ -6642,18 +8427,28 @@ def _legend( if isinstance(marker, dict): rows.append(_legend_marker_svg(marker, (hx0 + hx1) / 2, cy, color)) else: - stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) - stroke = style.get("stroke") - stroke_attr = ( - f' stroke="{escape(_css(stroke, color))}" stroke-width="{_num(stroke_width)}"' - if stroke is not None and stroke_width > 0.0 - else "" - ) - rows.append( - f'' - ) + if swatch_tmpl is not None: + # The patch IS the swatch: the slot's paint replaces the trace + # colour and its radius replaces the literal rx="2" (browser + # precedence — `_applySlot` runs after the paint vars). + rows.append( + _slot_box_svg( + _legend_patch_box(swatch_tmpl, entry, handle, swatch_h, style, color) + ) + ) + else: + stroke_width = max(0.0, float(style.get("stroke_width", 0.0))) + stroke = style.get("stroke") + stroke_attr = ( + f' stroke="{escape(_css(stroke, color))}" stroke-width="{_num(stroke_width)}"' + if stroke is not None and stroke_width > 0.0 + else "" + ) + rows.append( + f'' + ) if style.get("hatch"): rows.append( _legend_hatch_svg( @@ -6665,8 +8460,23 @@ def _legend( _css(style.get("hatch_color"), "#222222"), ) ) + label_box = _legend_text_slot_box( + "legend_label", + label_slot, + entry["label_x"], + ry, + entry["label_w"], + text_h, + qualifiers=(str(int(entry["row"])), str(int(entry["col"]))), + ) + if label_box is not None: + rows.append(_slot_box_svg(label_box)) + label_anchor, label_x = legend_text_align( + label_slot, entry["label_x"], entry["label_w"], "start", hx1 + gap + ) + anchor_attr = "" if label_anchor == "start" else f' text-anchor="{label_anchor}"' rows.append( - f'' diff --git a/python/xy/channel.py b/python/xy/channel.py index 12639d07..cb1b6fcc 100644 --- a/python/xy/channel.py +++ b/python/xy/channel.py @@ -104,6 +104,12 @@ class ChannelCallbacks: on_view_change: Optional[Callable[[dict[str, Any]], None]] = None on_animation_start: Optional[Callable[[dict[str, Any]], None]] = None on_animation_end: Optional[Callable[[dict[str, Any]], None]] = None + #: The `style_snapshot` reply to a kernel-initiated capture request + #: (wire-protocol §8). Receives the raw reply content — request_id plus + #: either `snapshot` or `error` — so the transport can settle whatever + #: it used to track the request; validation belongs to the consumer + #: (`resolved.snapshot_from_payload`), not the dispatcher. + on_style_snapshot: Optional[Callable[[dict[str, Any]], None]] = None _NO_CALLBACKS = ChannelCallbacks() @@ -216,6 +222,14 @@ def handle_message( event["cancelled"] = content["cancelled"] callback(event) return None + if kind == "style_snapshot": + # Reply to a kernel-initiated capture request (wire-protocol §8): + # callback-only, no wire reply. The transport that sent the request + # settles its pending future; a message with no listener is a + # request nobody is waiting on anymore and drops harmlessly. + if callbacks.on_style_snapshot is not None: + callbacks.on_style_snapshot(content) + return None if kind == "view": # Zoom/pan crossed what the shipped decimation can serve: recompute # for the visible window only (§28), stale-while-revalidate on the diff --git a/python/xy/components.py b/python/xy/components.py index a5ec5b9c..82dee236 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -40,7 +40,10 @@ from dataclasses import dataclass, field, replace from functools import lru_cache from os import PathLike -from typing import Any, Literal, Optional, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union + +if TYPE_CHECKING: + from .styling.preflight import StyleCompatibilityReport import numpy as np @@ -4127,6 +4130,17 @@ def view_state(self) -> dict[str, Any]: """Last committed durable view state (kernel-side cache).""" return self.widget().view_state() + async def capture_style_snapshot(self, *, timeout: float = 10.0) -> Any: + """The mounted chart's live cascade as a `ResolvedStyleSnapshot`. + + Asynchronous by contract (the reply rides the same comm the request + leaves on); pass the result to `to_png(style_snapshot=...)` and the + native writers reproduce what the browser resolved — host theme, + classes, dark mode — with no browser in the export path. See + `FigureWidget.capture_style_snapshot`. + """ + return await self.widget().capture_style_snapshot(timeout=timeout) + def _ipython_display_(self) -> None: from IPython.display import display # type: ignore[import-not-found] @@ -4169,15 +4183,56 @@ def html( def _repr_html_(self) -> str: return self.figure()._repr_html_() + def style_compatibility_report( + self, + target: str = "png", + *, + engine: Optional[export.Engine | str] = None, + custom_css: Optional[str] = None, + ) -> StyleCompatibilityReport: + """What of this chart's styling survives an export to ``target``. + + Report-only preflight: lists the styling sources present, how each + styled slot routes for the target and engine, and exactly which + declarations would not survive — before any bytes exist. Mirrors the + export path's behavior (including its refusals) rather than + re-deciding it; see `spec/api/export.md` §9. + """ + return self.figure().style_compatibility_report( + target, + engine=engine, + custom_css=custom_css, + ) + def to_svg( self, path: Optional[str] = None, *, width: Optional[int] = None, height: Optional[int] = None, + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> str: - """A static SVG render of the chart (written to ``path`` if given).""" - return self.figure().to_svg(path, width=width, height=height) + """A static SVG render of the chart (written to ``path`` if given). + + ``compatibility`` stages the styling contract: ``"warn"`` surfaces + any declaration this export would drop, ``"strict"`` refuses to drop + one; the default preserves current behavior. ``style_snapshot`` + feeds a captured live cascade to the vector writer. + """ + return self.figure().to_svg( + path, + width=width, + height=height, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, + ) def to_png( self, @@ -4191,12 +4246,18 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """A PNG render of the chart, returned as bytes. ``scale`` multiplies the pixel density; ``engine`` picks the raster path (native or headless Chromium). Written to ``path`` - when given. + when given. ``compatibility`` stages the styling contract + (``"legacy"``/``"warn"``/``"strict"``). """ return self.figure().to_png( path, @@ -4208,6 +4269,11 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, ) def _export_defaults( @@ -4257,14 +4323,23 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes. Omitted width/height/scale/background/quality fall back to the chart's `export_config` defaults; explicit arguments override them. - See `export.to_image` for the full format/engine/background policy.""" + See `export.to_image` for the full format/engine/background policy + and `compatibility=` for the staged styling contract.""" fmt = export._normalize_format(format) - resolved = export._resolve_image_engine(engine, fmt, custom_css) + # native_cascade consumes custom_css itself; the defaults pre-resolution + # must not route it to a browser the export will never use. + precheck_css = None if style_source == "native_cascade" else custom_css + resolved = export._resolve_image_engine(engine, fmt, precheck_css) return self.figure().to_image( format, engine=engine, @@ -4272,6 +4347,11 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, **self._export_defaults( fmt, width, @@ -4298,6 +4378,11 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Atomic file export with extension-inferred format (.png/.jpg/ .jpeg/.webp/.svg/.pdf/.html). `export_config` defaults apply as in @@ -4308,7 +4393,8 @@ def write_image( else export._infer_format(path) ) if fmt != "html": - resolved = export._resolve_image_engine(engine, fmt, custom_css) + precheck_css = None if style_source == "native_cascade" else custom_css + resolved = export._resolve_image_engine(engine, fmt, precheck_css) defaults = self._export_defaults( fmt, width, @@ -4335,6 +4421,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) return self.figure().write_image( path, @@ -4344,6 +4431,11 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, **defaults, ) diff --git a/python/xy/config.py b/python/xy/config.py index 8579700c..ce18a6e9 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -40,7 +40,11 @@ # v12 adds polar sector/grid-shape metadata on the angular axis and hole/origin # metadata on the radial axis. A v11 client would silently draw a full circular # grid with a centre-origin radius, so the new geometry must fail the handshake. -PROTOCOL_VERSION = 12 +# v13 adds the style_snapshot_request/style_snapshot capture pair (wire +# protocol §8). A cached v12 client silently ignores the request, dangling the +# kernel's awaited capture until its timeout — a slow mystery instead of the +# loud version mismatch the handshake exists to produce. +PROTOCOL_VERSION = 13 # Mark kinds the polar transform renders correctly today. Everything else is # refused by Figure._validate_coords rather than approximated: the rect, area, diff --git a/python/xy/export.py b/python/xy/export.py index f2f3dec1..8017639b 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -15,7 +15,7 @@ import sys import tempfile import warnings -from contextlib import suppress +from contextlib import contextmanager, nullcontext, suppress from enum import StrEnum from os import PathLike from pathlib import Path @@ -43,6 +43,19 @@ class Engine(StrEnum): chromium = "chromium" +def __getattr__(name: str) -> object: + # StyleCompatibilityError / StyleCompatibilityWarning are catchable from + # the module users already import for `Engine`, but resolved lazily: the + # preflight chain reaches the native library via the writers' constants, + # and importing this module must stay exactly as heavy as it was before + # the compatibility modes existed. + if name in ("StyleCompatibilityError", "StyleCompatibilityWarning"): + from .styling import preflight as _preflight + + return getattr(_preflight, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + # Warn above this payload size; base64 carries a stated ~33% tax (§29). EMBED_WARN_BYTES = 64 * 2**20 @@ -649,6 +662,7 @@ def write_images( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> list[bytes]: """Export many figures through ONE amortized pipeline (mixed formats OK). @@ -665,7 +679,17 @@ def write_images( exactly as in `Chart.to_image`. Writes are atomic per file; on error, files already exported remain. Other options match `to_image`; quality applies to JPEG and Chromium WebP and is ignored by the other formats - (native WebP stays lossless), so mixed batches stay ergonomic.""" + (native WebP stays lossless), so mixed batches stay ergonomic. + `compatibility=` applies per figure while the plan is resolved, so a + strict batch refuses whole — before any file is written — rather than + after a partial export. Its vocabulary is validated once up front, so an + invalid mode fails even an all-HTML batch; HTML entries themselves are + exempt from the mode, because a document that renders the full cascade + has nothing to check.""" + if compatibility != "legacy": + from .styling.preflight import validate_compatibility + + validate_compatibility(compatibility) if figures is not None: if figs is not None: raise ValueError("pass figs positionally or figures=, not both") @@ -709,6 +733,9 @@ def write_images( plan.append((fig, path, fmt, "html", {}, None, None)) continue resolved = _resolve_image_engine(engine, fmt, custom_css) + # Per figure, up front with the rest of the plan: a strict batch + # fails whole before any file is written, never after a partial one. + _enforce_compatibility(fig, fmt, resolved, custom_css, compatibility) if callable(getattr(obj, "_export_defaults", None)): settings = obj._export_defaults( fmt, @@ -792,6 +819,11 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Rasterize `fig` to a PNG (bytes, optionally saved). @@ -818,13 +850,48 @@ def to_png( scale = _positive_finite_float(scale, "PNG scale") optimize = _bool_option(optimize, "PNG optimize") sandbox = _bool_option(sandbox, "PNG sandbox") + if style_source not in ("declared", "native_cascade"): + raise ValueError( + f'style_source must be "declared" or "native_cascade", got {style_source!r}' + ) + if style_source == "native_cascade": + if style_snapshot is not None: + raise ValueError( + "style_snapshot and style_source='native_cascade' are two sources " + "for the same values; pass one" + ) + if engine not in (Engine.auto, "auto", None, Engine.default, "default"): + raise ValueError( + "style_source='native_cascade' is a native path; drop engine=Engine.chromium" + ) + style_snapshot = _cascade_snapshot( + fig, custom_css, compatibility, tuple(stylesheets), tailwind_profile + ) + custom_css = None # consumed by the cascade, not by a browser + elif stylesheets or tailwind_profile is not None: + raise ValueError( + "stylesheets/tailwind_profile are native-cascade inputs; pass " + 'style_source="native_cascade"' + ) resolved_engine = _png_engine(engine) + # Resolution errors precede and outrank mode logic (the migration spec's + # contract): the custom_css/native refusal must stay a ValueError in + # every mode, so it fires before enforcement can warn or raise. + if resolved_engine == "native" and custom_css is not None: + raise ValueError("custom_css requires engine=Engine.chromium") + snapshot = _coerce_style_snapshot(style_snapshot) if style_snapshot is not None else None + if snapshot is not None and resolved_engine != "native": + raise ValueError( + "style_snapshot feeds the native writers; the Chromium engine renders " + "the live cascade itself — drop one of the two" + ) + if snapshot is None: + _enforce_compatibility(fig, "png", resolved_engine, custom_css, compatibility) if resolved_engine == "native": - if custom_css is not None: - raise ValueError("custom_css requires engine=Engine.chromium") from . import _raster - data = _raster.to_png(fig, None, width=w, height=h, scale=scale, fast=not optimize) + with _snapshot_styles(fig, snapshot) if snapshot is not None else nullcontext(): + data = _raster.to_png(fig, None, width=w, height=h, scale=scale, fast=not optimize) else: doc = to_html(fig, custom_css=custom_css, animation_progress=1.0) data = html_to_png( @@ -899,6 +966,128 @@ def _infer_format(path: str | PathLike[str]) -> str: ) from None +def _coerce_style_snapshot(value: object) -> Any: + """A validated ResolvedStyleSnapshot from either accepted spelling. + + Accepts the object a capture returned, or its payload dict (a cached + snapshot round-trips through JSON); anything else is refused by the + schema's own validator, so an out-of-contract snapshot never reaches a + writer. + """ + from .styling.resolved import ResolvedStyleSnapshot, snapshot_from_payload + + if isinstance(value, ResolvedStyleSnapshot): + return value + if isinstance(value, dict): + # Keys come from JSON, so the mapping is dict[Unknown, Unknown] to a + # checker; the schema validates every key and value on the way in. + return snapshot_from_payload(cast("dict[str, Any]", value)) + raise ValueError( + "style_snapshot must be a ResolvedStyleSnapshot (from " + "capture_style_snapshot()) or its payload dict" + ) + + +@contextmanager +def _snapshot_styles(fig: "Figure", snapshot: Any) -> "Iterator[None]": + """Feed a captured snapshot to the native writers for one export. + + The writers read per-slot styling from the figure's chrome_styles (via + the declared resolver), so the snapshot overlays there for the duration + of the render: captured declarations win over declared ones — computed + values ARE the declared values after the cascade the user asked to + capture — and the token bag gains the snapshot's tokens the same way. + Per-slot granularity for now: multiple instances of one slot use the + first instance's declaration until the chrome-parity work gives writers + per-instance geometry. Restored on exit; exports are synchronous, and a + figure is not shared across threads mid-export. + """ + slot_overlay: dict[str, dict[str, Any]] = {} + for inst in snapshot.instances: + if inst.slot not in slot_overlay: + slot_overlay[inst.slot] = dict(snapshot.declarations[inst.declaration]) + saved_styles = fig.chrome_styles + saved_style = fig.style + fig.chrome_styles = {**saved_styles, **slot_overlay} + fig.style = {**saved_style, **dict(snapshot.tokens)} + try: + yield + finally: + fig.chrome_styles = saved_styles + fig.style = saved_style + + +def _cascade_snapshot( + fig: "Figure", + custom_css: Optional[str], + compatibility: str, + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, +) -> Any: + """Resolve classes + author CSS through the native cascade for export. + + The cascade is the CSS engine here, so `custom_css` is consumed by it — + not routed to Chromium — and its `unsupported` report goes through the + compatibility contract: strict refuses on any unsupported construct, + every other mode surfaces the list as one StyleCompatibilityWarning. + A brand-new surface has no legacy silence to preserve (§28). + """ + import warnings as _warnings + + from .styling import cascade as _cascade + from .styling.preflight import StyleCompatibilityError, StyleCompatibilityWarning + + snapshot, unsupported = _cascade.resolve_for_figure( + fig, + custom_css=custom_css or "", + stylesheets=tuple(stylesheets), + tailwind_profile=tailwind_profile, + ) + if unsupported: + summary = "; ".join(unsupported) + if compatibility == "strict": + from .styling.preflight import route_resolved + + raise StyleCompatibilityError( + f"native cascade could not honor: {summary}", + route_resolved(fig, fmt="png", resolved_engine="native", custom_css=None), + ) + _warnings.warn( + StyleCompatibilityWarning(f"native cascade could not honor: {summary}"), + stacklevel=2, + ) + return snapshot + + +def _enforce_compatibility( + fig: "Figure", + fmt: str, + resolved_engine: str, + custom_css: Optional[str], + compatibility: str, +) -> None: + """Apply the staged compatibility mode to one already-resolved export. + + The literal-"legacy" short-circuit is the whole performance contract: + the default export path does one string comparison and never imports the + preflight machinery. Everything else — mode validation, the constant-time + unstyled path, warning versus refusing — lives in + `styling.preflight.enforce`. Modes never re-route an engine; they decide + whether to proceed, warn, or refuse on the engine the caller resolved. + """ + if compatibility == "legacy": + return + from .styling import preflight as _preflight + + _preflight.enforce( + fig, + fmt=fmt, + resolved_engine=resolved_engine, + custom_css=custom_css, + compatibility=compatibility, + ) + + def _resolve_image_engine(engine: object, fmt: str, custom_css: Optional[str]) -> str: """Deterministic engine selection: -> "native" | "browser". @@ -1137,6 +1326,11 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Render `fig` to image bytes in the requested `format`. @@ -1153,7 +1347,40 @@ def to_image( PDF keeps text/axes/marks as vectors; density and heatmap layers embed as bounded rasters (the documented hybrid-vector policy).""" fmt = _normalize_format(format) + if style_source not in ("declared", "native_cascade"): + raise ValueError( + f'style_source must be "declared" or "native_cascade", got {style_source!r}' + ) + if style_source == "native_cascade": + if style_snapshot is not None: + raise ValueError( + "style_snapshot and style_source='native_cascade' are two sources for the same values; pass one" + ) + if engine not in (Engine.auto, "auto", None, Engine.default, "default"): + raise ValueError( + "style_source='native_cascade' is a native path; drop engine=Engine.chromium" + ) + style_snapshot = _cascade_snapshot( + fig, custom_css, compatibility, tuple(stylesheets), tailwind_profile + ) + custom_css = None # consumed by the cascade, not by a browser + elif stylesheets or tailwind_profile is not None: + raise ValueError( + "stylesheets/tailwind_profile are native-cascade inputs; pass " + 'style_source="native_cascade"' + ) resolved_engine = _resolve_image_engine(engine, fmt, custom_css) + snapshot = _coerce_style_snapshot(style_snapshot) if style_snapshot is not None else None + if snapshot is not None and resolved_engine == "browser": + raise ValueError( + "style_snapshot feeds the native writers; the Chromium engine renders " + "the live cascade itself — drop one of the two" + ) + if snapshot is None: + # A supplied snapshot IS the lossless remedy the modes recommend: + # the captured cascade carries what class_names would drop, so there + # is nothing left for warn/strict to catch on this export. + _enforce_compatibility(fig, fmt, resolved_engine, custom_css, compatibility) quality = _validated_quality(quality, fmt, resolved_engine) background = _validated_background(background, fmt) w, h = _export_dimensions(fig, width, height) @@ -1162,16 +1389,17 @@ def to_image( sandbox = _bool_option(sandbox, "export sandbox") gl = _gl_option(gl) if resolved_engine == "native": - return _native_image( - fig, - fmt, - width=w, - height=h, - scale=scale, - background=background, - quality=quality, - optimize=optimize, - ) + with _snapshot_styles(fig, snapshot) if snapshot is not None else nullcontext(): + return _native_image( + fig, + fmt, + width=w, + height=h, + scale=scale, + background=background, + quality=quality, + optimize=optimize, + ) with _browser_session(gl=gl, sandbox=sandbox) as session: return _browser_image( session, @@ -1201,6 +1429,11 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", + style_snapshot: Optional[Any] = None, + style_source: str = "declared", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, ) -> bytes: """Export `fig` to `path`, inferring the format from the extension. @@ -1221,6 +1454,10 @@ def write_image( ("background", background, None), ("quality", quality, None), ("optimize", optimize, False), + # HTML renders the full cascade in the browser — nothing can + # drop, so a compatibility mode has nothing to check and is + # rejected like the other options that cannot apply. + ("compatibility", compatibility, "legacy"), ) if value != default ] @@ -1246,6 +1483,11 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, + style_snapshot=style_snapshot, + style_source=style_source, + stylesheets=stylesheets, + tailwind_profile=tailwind_profile, ) _atomic_write_bytes(path, data) return data diff --git a/python/xy/styling/__init__.py b/python/xy/styling/__init__.py index dc644318..0e46f77a 100644 --- a/python/xy/styling/__init__.py +++ b/python/xy/styling/__init__.py @@ -1,13 +1,43 @@ """Machine-checkable records about XY's styling surface. -`capabilities` is the one that matters: what can be styled, in which renderer, -and how far it travels. It is imported by the docs generator and pinned by +`capabilities` is the inventory: what can be styled, in which renderer, and +how far it travels. It is imported by the docs generator and pinned by `tests/test_capability_registry.py`, so a claim about customization can be checked against it rather than against a reading of `styles.py`. + +`preflight` applies that inventory to one concrete chart and export target: +`chart.style_compatibility_report()` routes every declared style and names +what would not survive, before any bytes exist. + +`resolved` is the renderer-neutral styling IR those two converge on: the +versioned, interned `ResolvedStyleSnapshot` of concrete values that every +resolver produces and every renderer consumes. + +`cascade` is the mount-free resolver over the optional native extension: +classes and author CSS cascaded to concrete values with no browser. + +Submodules resolve lazily (PEP 562): `capabilities` reaches the writers' +constants and, through them, the native library — so importing this package +costs nothing until a submodule is actually used. That keeps the documented +zero-import guarantee of the `legacy` export path true even for code that +imports `xy.styling` itself. """ from __future__ import annotations -from . import capabilities +import importlib +from typing import Any + +__all__ = ["capabilities", "cascade", "preflight", "resolved"] + + +def __getattr__(name: str) -> Any: + if name in __all__: + module = importlib.import_module(f".{name}", __name__) + globals()[name] = module + return module + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + -__all__ = ["capabilities"] +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/python/xy/styling/_tailwind_core.py b/python/xy/styling/_tailwind_core.py new file mode 100644 index 00000000..bd408f95 --- /dev/null +++ b/python/xy/styling/_tailwind_core.py @@ -0,0 +1,14 @@ +"""The Tailwind-core manifest, as one generated stylesheet. + +@generated by scripts/gen_tailwind_core.py — do not edit by hand. +Palette provenance: tailwindcss@3.4.17 require("tailwindcss/colors"). +The native cascade prepends this sheet when an export names +`tailwind_profile="core-v1"`; unmatched utilities are reported by +the cascade, never guessed (§28). +""" + +from __future__ import annotations + +TAILWIND_CORE_VERSION = "core-v1" + +TAILWIND_CORE_CSS = ".bg-amber-100{background-color:#fef3c7}\n.text-amber-100{color:#fef3c7}\n.border-amber-100{border-color:#fef3c7}\n.bg-amber-200{background-color:#fde68a}\n.text-amber-200{color:#fde68a}\n.border-amber-200{border-color:#fde68a}\n.bg-amber-300{background-color:#fcd34d}\n.text-amber-300{color:#fcd34d}\n.border-amber-300{border-color:#fcd34d}\n.bg-amber-400{background-color:#fbbf24}\n.text-amber-400{color:#fbbf24}\n.border-amber-400{border-color:#fbbf24}\n.bg-amber-50{background-color:#fffbeb}\n.text-amber-50{color:#fffbeb}\n.border-amber-50{border-color:#fffbeb}\n.bg-amber-500{background-color:#f59e0b}\n.text-amber-500{color:#f59e0b}\n.border-amber-500{border-color:#f59e0b}\n.bg-amber-600{background-color:#d97706}\n.text-amber-600{color:#d97706}\n.border-amber-600{border-color:#d97706}\n.bg-amber-700{background-color:#b45309}\n.text-amber-700{color:#b45309}\n.border-amber-700{border-color:#b45309}\n.bg-amber-800{background-color:#92400e}\n.text-amber-800{color:#92400e}\n.border-amber-800{border-color:#92400e}\n.bg-amber-900{background-color:#78350f}\n.text-amber-900{color:#78350f}\n.border-amber-900{border-color:#78350f}\n.bg-amber-950{background-color:#451a03}\n.text-amber-950{color:#451a03}\n.border-amber-950{border-color:#451a03}\n.bg-black{background-color:#000}\n.text-black{color:#000}\n.border-black{border-color:#000}\n.bg-blue-100{background-color:#dbeafe}\n.text-blue-100{color:#dbeafe}\n.border-blue-100{border-color:#dbeafe}\n.bg-blue-200{background-color:#bfdbfe}\n.text-blue-200{color:#bfdbfe}\n.border-blue-200{border-color:#bfdbfe}\n.bg-blue-300{background-color:#93c5fd}\n.text-blue-300{color:#93c5fd}\n.border-blue-300{border-color:#93c5fd}\n.bg-blue-400{background-color:#60a5fa}\n.text-blue-400{color:#60a5fa}\n.border-blue-400{border-color:#60a5fa}\n.bg-blue-50{background-color:#eff6ff}\n.text-blue-50{color:#eff6ff}\n.border-blue-50{border-color:#eff6ff}\n.bg-blue-500{background-color:#3b82f6}\n.text-blue-500{color:#3b82f6}\n.border-blue-500{border-color:#3b82f6}\n.bg-blue-600{background-color:#2563eb}\n.text-blue-600{color:#2563eb}\n.border-blue-600{border-color:#2563eb}\n.bg-blue-700{background-color:#1d4ed8}\n.text-blue-700{color:#1d4ed8}\n.border-blue-700{border-color:#1d4ed8}\n.bg-blue-800{background-color:#1e40af}\n.text-blue-800{color:#1e40af}\n.border-blue-800{border-color:#1e40af}\n.bg-blue-900{background-color:#1e3a8a}\n.text-blue-900{color:#1e3a8a}\n.border-blue-900{border-color:#1e3a8a}\n.bg-blue-950{background-color:#172554}\n.text-blue-950{color:#172554}\n.border-blue-950{border-color:#172554}\n.bg-cyan-100{background-color:#cffafe}\n.text-cyan-100{color:#cffafe}\n.border-cyan-100{border-color:#cffafe}\n.bg-cyan-200{background-color:#a5f3fc}\n.text-cyan-200{color:#a5f3fc}\n.border-cyan-200{border-color:#a5f3fc}\n.bg-cyan-300{background-color:#67e8f9}\n.text-cyan-300{color:#67e8f9}\n.border-cyan-300{border-color:#67e8f9}\n.bg-cyan-400{background-color:#22d3ee}\n.text-cyan-400{color:#22d3ee}\n.border-cyan-400{border-color:#22d3ee}\n.bg-cyan-50{background-color:#ecfeff}\n.text-cyan-50{color:#ecfeff}\n.border-cyan-50{border-color:#ecfeff}\n.bg-cyan-500{background-color:#06b6d4}\n.text-cyan-500{color:#06b6d4}\n.border-cyan-500{border-color:#06b6d4}\n.bg-cyan-600{background-color:#0891b2}\n.text-cyan-600{color:#0891b2}\n.border-cyan-600{border-color:#0891b2}\n.bg-cyan-700{background-color:#0e7490}\n.text-cyan-700{color:#0e7490}\n.border-cyan-700{border-color:#0e7490}\n.bg-cyan-800{background-color:#155e75}\n.text-cyan-800{color:#155e75}\n.border-cyan-800{border-color:#155e75}\n.bg-cyan-900{background-color:#164e63}\n.text-cyan-900{color:#164e63}\n.border-cyan-900{border-color:#164e63}\n.bg-cyan-950{background-color:#083344}\n.text-cyan-950{color:#083344}\n.border-cyan-950{border-color:#083344}\n.bg-emerald-100{background-color:#d1fae5}\n.text-emerald-100{color:#d1fae5}\n.border-emerald-100{border-color:#d1fae5}\n.bg-emerald-200{background-color:#a7f3d0}\n.text-emerald-200{color:#a7f3d0}\n.border-emerald-200{border-color:#a7f3d0}\n.bg-emerald-300{background-color:#6ee7b7}\n.text-emerald-300{color:#6ee7b7}\n.border-emerald-300{border-color:#6ee7b7}\n.bg-emerald-400{background-color:#34d399}\n.text-emerald-400{color:#34d399}\n.border-emerald-400{border-color:#34d399}\n.bg-emerald-50{background-color:#ecfdf5}\n.text-emerald-50{color:#ecfdf5}\n.border-emerald-50{border-color:#ecfdf5}\n.bg-emerald-500{background-color:#10b981}\n.text-emerald-500{color:#10b981}\n.border-emerald-500{border-color:#10b981}\n.bg-emerald-600{background-color:#059669}\n.text-emerald-600{color:#059669}\n.border-emerald-600{border-color:#059669}\n.bg-emerald-700{background-color:#047857}\n.text-emerald-700{color:#047857}\n.border-emerald-700{border-color:#047857}\n.bg-emerald-800{background-color:#065f46}\n.text-emerald-800{color:#065f46}\n.border-emerald-800{border-color:#065f46}\n.bg-emerald-900{background-color:#064e3b}\n.text-emerald-900{color:#064e3b}\n.border-emerald-900{border-color:#064e3b}\n.bg-emerald-950{background-color:#022c22}\n.text-emerald-950{color:#022c22}\n.border-emerald-950{border-color:#022c22}\n.bg-fuchsia-100{background-color:#fae8ff}\n.text-fuchsia-100{color:#fae8ff}\n.border-fuchsia-100{border-color:#fae8ff}\n.bg-fuchsia-200{background-color:#f5d0fe}\n.text-fuchsia-200{color:#f5d0fe}\n.border-fuchsia-200{border-color:#f5d0fe}\n.bg-fuchsia-300{background-color:#f0abfc}\n.text-fuchsia-300{color:#f0abfc}\n.border-fuchsia-300{border-color:#f0abfc}\n.bg-fuchsia-400{background-color:#e879f9}\n.text-fuchsia-400{color:#e879f9}\n.border-fuchsia-400{border-color:#e879f9}\n.bg-fuchsia-50{background-color:#fdf4ff}\n.text-fuchsia-50{color:#fdf4ff}\n.border-fuchsia-50{border-color:#fdf4ff}\n.bg-fuchsia-500{background-color:#d946ef}\n.text-fuchsia-500{color:#d946ef}\n.border-fuchsia-500{border-color:#d946ef}\n.bg-fuchsia-600{background-color:#c026d3}\n.text-fuchsia-600{color:#c026d3}\n.border-fuchsia-600{border-color:#c026d3}\n.bg-fuchsia-700{background-color:#a21caf}\n.text-fuchsia-700{color:#a21caf}\n.border-fuchsia-700{border-color:#a21caf}\n.bg-fuchsia-800{background-color:#86198f}\n.text-fuchsia-800{color:#86198f}\n.border-fuchsia-800{border-color:#86198f}\n.bg-fuchsia-900{background-color:#701a75}\n.text-fuchsia-900{color:#701a75}\n.border-fuchsia-900{border-color:#701a75}\n.bg-fuchsia-950{background-color:#4a044e}\n.text-fuchsia-950{color:#4a044e}\n.border-fuchsia-950{border-color:#4a044e}\n.bg-gray-100{background-color:#f3f4f6}\n.text-gray-100{color:#f3f4f6}\n.border-gray-100{border-color:#f3f4f6}\n.bg-gray-200{background-color:#e5e7eb}\n.text-gray-200{color:#e5e7eb}\n.border-gray-200{border-color:#e5e7eb}\n.bg-gray-300{background-color:#d1d5db}\n.text-gray-300{color:#d1d5db}\n.border-gray-300{border-color:#d1d5db}\n.bg-gray-400{background-color:#9ca3af}\n.text-gray-400{color:#9ca3af}\n.border-gray-400{border-color:#9ca3af}\n.bg-gray-50{background-color:#f9fafb}\n.text-gray-50{color:#f9fafb}\n.border-gray-50{border-color:#f9fafb}\n.bg-gray-500{background-color:#6b7280}\n.text-gray-500{color:#6b7280}\n.border-gray-500{border-color:#6b7280}\n.bg-gray-600{background-color:#4b5563}\n.text-gray-600{color:#4b5563}\n.border-gray-600{border-color:#4b5563}\n.bg-gray-700{background-color:#374151}\n.text-gray-700{color:#374151}\n.border-gray-700{border-color:#374151}\n.bg-gray-800{background-color:#1f2937}\n.text-gray-800{color:#1f2937}\n.border-gray-800{border-color:#1f2937}\n.bg-gray-900{background-color:#111827}\n.text-gray-900{color:#111827}\n.border-gray-900{border-color:#111827}\n.bg-gray-950{background-color:#030712}\n.text-gray-950{color:#030712}\n.border-gray-950{border-color:#030712}\n.bg-green-100{background-color:#dcfce7}\n.text-green-100{color:#dcfce7}\n.border-green-100{border-color:#dcfce7}\n.bg-green-200{background-color:#bbf7d0}\n.text-green-200{color:#bbf7d0}\n.border-green-200{border-color:#bbf7d0}\n.bg-green-300{background-color:#86efac}\n.text-green-300{color:#86efac}\n.border-green-300{border-color:#86efac}\n.bg-green-400{background-color:#4ade80}\n.text-green-400{color:#4ade80}\n.border-green-400{border-color:#4ade80}\n.bg-green-50{background-color:#f0fdf4}\n.text-green-50{color:#f0fdf4}\n.border-green-50{border-color:#f0fdf4}\n.bg-green-500{background-color:#22c55e}\n.text-green-500{color:#22c55e}\n.border-green-500{border-color:#22c55e}\n.bg-green-600{background-color:#16a34a}\n.text-green-600{color:#16a34a}\n.border-green-600{border-color:#16a34a}\n.bg-green-700{background-color:#15803d}\n.text-green-700{color:#15803d}\n.border-green-700{border-color:#15803d}\n.bg-green-800{background-color:#166534}\n.text-green-800{color:#166534}\n.border-green-800{border-color:#166534}\n.bg-green-900{background-color:#14532d}\n.text-green-900{color:#14532d}\n.border-green-900{border-color:#14532d}\n.bg-green-950{background-color:#052e16}\n.text-green-950{color:#052e16}\n.border-green-950{border-color:#052e16}\n.bg-indigo-100{background-color:#e0e7ff}\n.text-indigo-100{color:#e0e7ff}\n.border-indigo-100{border-color:#e0e7ff}\n.bg-indigo-200{background-color:#c7d2fe}\n.text-indigo-200{color:#c7d2fe}\n.border-indigo-200{border-color:#c7d2fe}\n.bg-indigo-300{background-color:#a5b4fc}\n.text-indigo-300{color:#a5b4fc}\n.border-indigo-300{border-color:#a5b4fc}\n.bg-indigo-400{background-color:#818cf8}\n.text-indigo-400{color:#818cf8}\n.border-indigo-400{border-color:#818cf8}\n.bg-indigo-50{background-color:#eef2ff}\n.text-indigo-50{color:#eef2ff}\n.border-indigo-50{border-color:#eef2ff}\n.bg-indigo-500{background-color:#6366f1}\n.text-indigo-500{color:#6366f1}\n.border-indigo-500{border-color:#6366f1}\n.bg-indigo-600{background-color:#4f46e5}\n.text-indigo-600{color:#4f46e5}\n.border-indigo-600{border-color:#4f46e5}\n.bg-indigo-700{background-color:#4338ca}\n.text-indigo-700{color:#4338ca}\n.border-indigo-700{border-color:#4338ca}\n.bg-indigo-800{background-color:#3730a3}\n.text-indigo-800{color:#3730a3}\n.border-indigo-800{border-color:#3730a3}\n.bg-indigo-900{background-color:#312e81}\n.text-indigo-900{color:#312e81}\n.border-indigo-900{border-color:#312e81}\n.bg-indigo-950{background-color:#1e1b4b}\n.text-indigo-950{color:#1e1b4b}\n.border-indigo-950{border-color:#1e1b4b}\n.bg-lime-100{background-color:#ecfccb}\n.text-lime-100{color:#ecfccb}\n.border-lime-100{border-color:#ecfccb}\n.bg-lime-200{background-color:#d9f99d}\n.text-lime-200{color:#d9f99d}\n.border-lime-200{border-color:#d9f99d}\n.bg-lime-300{background-color:#bef264}\n.text-lime-300{color:#bef264}\n.border-lime-300{border-color:#bef264}\n.bg-lime-400{background-color:#a3e635}\n.text-lime-400{color:#a3e635}\n.border-lime-400{border-color:#a3e635}\n.bg-lime-50{background-color:#f7fee7}\n.text-lime-50{color:#f7fee7}\n.border-lime-50{border-color:#f7fee7}\n.bg-lime-500{background-color:#84cc16}\n.text-lime-500{color:#84cc16}\n.border-lime-500{border-color:#84cc16}\n.bg-lime-600{background-color:#65a30d}\n.text-lime-600{color:#65a30d}\n.border-lime-600{border-color:#65a30d}\n.bg-lime-700{background-color:#4d7c0f}\n.text-lime-700{color:#4d7c0f}\n.border-lime-700{border-color:#4d7c0f}\n.bg-lime-800{background-color:#3f6212}\n.text-lime-800{color:#3f6212}\n.border-lime-800{border-color:#3f6212}\n.bg-lime-900{background-color:#365314}\n.text-lime-900{color:#365314}\n.border-lime-900{border-color:#365314}\n.bg-lime-950{background-color:#1a2e05}\n.text-lime-950{color:#1a2e05}\n.border-lime-950{border-color:#1a2e05}\n.bg-neutral-100{background-color:#f5f5f5}\n.text-neutral-100{color:#f5f5f5}\n.border-neutral-100{border-color:#f5f5f5}\n.bg-neutral-200{background-color:#e5e5e5}\n.text-neutral-200{color:#e5e5e5}\n.border-neutral-200{border-color:#e5e5e5}\n.bg-neutral-300{background-color:#d4d4d4}\n.text-neutral-300{color:#d4d4d4}\n.border-neutral-300{border-color:#d4d4d4}\n.bg-neutral-400{background-color:#a3a3a3}\n.text-neutral-400{color:#a3a3a3}\n.border-neutral-400{border-color:#a3a3a3}\n.bg-neutral-50{background-color:#fafafa}\n.text-neutral-50{color:#fafafa}\n.border-neutral-50{border-color:#fafafa}\n.bg-neutral-500{background-color:#737373}\n.text-neutral-500{color:#737373}\n.border-neutral-500{border-color:#737373}\n.bg-neutral-600{background-color:#525252}\n.text-neutral-600{color:#525252}\n.border-neutral-600{border-color:#525252}\n.bg-neutral-700{background-color:#404040}\n.text-neutral-700{color:#404040}\n.border-neutral-700{border-color:#404040}\n.bg-neutral-800{background-color:#262626}\n.text-neutral-800{color:#262626}\n.border-neutral-800{border-color:#262626}\n.bg-neutral-900{background-color:#171717}\n.text-neutral-900{color:#171717}\n.border-neutral-900{border-color:#171717}\n.bg-neutral-950{background-color:#0a0a0a}\n.text-neutral-950{color:#0a0a0a}\n.border-neutral-950{border-color:#0a0a0a}\n.bg-orange-100{background-color:#ffedd5}\n.text-orange-100{color:#ffedd5}\n.border-orange-100{border-color:#ffedd5}\n.bg-orange-200{background-color:#fed7aa}\n.text-orange-200{color:#fed7aa}\n.border-orange-200{border-color:#fed7aa}\n.bg-orange-300{background-color:#fdba74}\n.text-orange-300{color:#fdba74}\n.border-orange-300{border-color:#fdba74}\n.bg-orange-400{background-color:#fb923c}\n.text-orange-400{color:#fb923c}\n.border-orange-400{border-color:#fb923c}\n.bg-orange-50{background-color:#fff7ed}\n.text-orange-50{color:#fff7ed}\n.border-orange-50{border-color:#fff7ed}\n.bg-orange-500{background-color:#f97316}\n.text-orange-500{color:#f97316}\n.border-orange-500{border-color:#f97316}\n.bg-orange-600{background-color:#ea580c}\n.text-orange-600{color:#ea580c}\n.border-orange-600{border-color:#ea580c}\n.bg-orange-700{background-color:#c2410c}\n.text-orange-700{color:#c2410c}\n.border-orange-700{border-color:#c2410c}\n.bg-orange-800{background-color:#9a3412}\n.text-orange-800{color:#9a3412}\n.border-orange-800{border-color:#9a3412}\n.bg-orange-900{background-color:#7c2d12}\n.text-orange-900{color:#7c2d12}\n.border-orange-900{border-color:#7c2d12}\n.bg-orange-950{background-color:#431407}\n.text-orange-950{color:#431407}\n.border-orange-950{border-color:#431407}\n.bg-pink-100{background-color:#fce7f3}\n.text-pink-100{color:#fce7f3}\n.border-pink-100{border-color:#fce7f3}\n.bg-pink-200{background-color:#fbcfe8}\n.text-pink-200{color:#fbcfe8}\n.border-pink-200{border-color:#fbcfe8}\n.bg-pink-300{background-color:#f9a8d4}\n.text-pink-300{color:#f9a8d4}\n.border-pink-300{border-color:#f9a8d4}\n.bg-pink-400{background-color:#f472b6}\n.text-pink-400{color:#f472b6}\n.border-pink-400{border-color:#f472b6}\n.bg-pink-50{background-color:#fdf2f8}\n.text-pink-50{color:#fdf2f8}\n.border-pink-50{border-color:#fdf2f8}\n.bg-pink-500{background-color:#ec4899}\n.text-pink-500{color:#ec4899}\n.border-pink-500{border-color:#ec4899}\n.bg-pink-600{background-color:#db2777}\n.text-pink-600{color:#db2777}\n.border-pink-600{border-color:#db2777}\n.bg-pink-700{background-color:#be185d}\n.text-pink-700{color:#be185d}\n.border-pink-700{border-color:#be185d}\n.bg-pink-800{background-color:#9d174d}\n.text-pink-800{color:#9d174d}\n.border-pink-800{border-color:#9d174d}\n.bg-pink-900{background-color:#831843}\n.text-pink-900{color:#831843}\n.border-pink-900{border-color:#831843}\n.bg-pink-950{background-color:#500724}\n.text-pink-950{color:#500724}\n.border-pink-950{border-color:#500724}\n.bg-purple-100{background-color:#f3e8ff}\n.text-purple-100{color:#f3e8ff}\n.border-purple-100{border-color:#f3e8ff}\n.bg-purple-200{background-color:#e9d5ff}\n.text-purple-200{color:#e9d5ff}\n.border-purple-200{border-color:#e9d5ff}\n.bg-purple-300{background-color:#d8b4fe}\n.text-purple-300{color:#d8b4fe}\n.border-purple-300{border-color:#d8b4fe}\n.bg-purple-400{background-color:#c084fc}\n.text-purple-400{color:#c084fc}\n.border-purple-400{border-color:#c084fc}\n.bg-purple-50{background-color:#faf5ff}\n.text-purple-50{color:#faf5ff}\n.border-purple-50{border-color:#faf5ff}\n.bg-purple-500{background-color:#a855f7}\n.text-purple-500{color:#a855f7}\n.border-purple-500{border-color:#a855f7}\n.bg-purple-600{background-color:#9333ea}\n.text-purple-600{color:#9333ea}\n.border-purple-600{border-color:#9333ea}\n.bg-purple-700{background-color:#7e22ce}\n.text-purple-700{color:#7e22ce}\n.border-purple-700{border-color:#7e22ce}\n.bg-purple-800{background-color:#6b21a8}\n.text-purple-800{color:#6b21a8}\n.border-purple-800{border-color:#6b21a8}\n.bg-purple-900{background-color:#581c87}\n.text-purple-900{color:#581c87}\n.border-purple-900{border-color:#581c87}\n.bg-purple-950{background-color:#3b0764}\n.text-purple-950{color:#3b0764}\n.border-purple-950{border-color:#3b0764}\n.bg-red-100{background-color:#fee2e2}\n.text-red-100{color:#fee2e2}\n.border-red-100{border-color:#fee2e2}\n.bg-red-200{background-color:#fecaca}\n.text-red-200{color:#fecaca}\n.border-red-200{border-color:#fecaca}\n.bg-red-300{background-color:#fca5a5}\n.text-red-300{color:#fca5a5}\n.border-red-300{border-color:#fca5a5}\n.bg-red-400{background-color:#f87171}\n.text-red-400{color:#f87171}\n.border-red-400{border-color:#f87171}\n.bg-red-50{background-color:#fef2f2}\n.text-red-50{color:#fef2f2}\n.border-red-50{border-color:#fef2f2}\n.bg-red-500{background-color:#ef4444}\n.text-red-500{color:#ef4444}\n.border-red-500{border-color:#ef4444}\n.bg-red-600{background-color:#dc2626}\n.text-red-600{color:#dc2626}\n.border-red-600{border-color:#dc2626}\n.bg-red-700{background-color:#b91c1c}\n.text-red-700{color:#b91c1c}\n.border-red-700{border-color:#b91c1c}\n.bg-red-800{background-color:#991b1b}\n.text-red-800{color:#991b1b}\n.border-red-800{border-color:#991b1b}\n.bg-red-900{background-color:#7f1d1d}\n.text-red-900{color:#7f1d1d}\n.border-red-900{border-color:#7f1d1d}\n.bg-red-950{background-color:#450a0a}\n.text-red-950{color:#450a0a}\n.border-red-950{border-color:#450a0a}\n.bg-rose-100{background-color:#ffe4e6}\n.text-rose-100{color:#ffe4e6}\n.border-rose-100{border-color:#ffe4e6}\n.bg-rose-200{background-color:#fecdd3}\n.text-rose-200{color:#fecdd3}\n.border-rose-200{border-color:#fecdd3}\n.bg-rose-300{background-color:#fda4af}\n.text-rose-300{color:#fda4af}\n.border-rose-300{border-color:#fda4af}\n.bg-rose-400{background-color:#fb7185}\n.text-rose-400{color:#fb7185}\n.border-rose-400{border-color:#fb7185}\n.bg-rose-50{background-color:#fff1f2}\n.text-rose-50{color:#fff1f2}\n.border-rose-50{border-color:#fff1f2}\n.bg-rose-500{background-color:#f43f5e}\n.text-rose-500{color:#f43f5e}\n.border-rose-500{border-color:#f43f5e}\n.bg-rose-600{background-color:#e11d48}\n.text-rose-600{color:#e11d48}\n.border-rose-600{border-color:#e11d48}\n.bg-rose-700{background-color:#be123c}\n.text-rose-700{color:#be123c}\n.border-rose-700{border-color:#be123c}\n.bg-rose-800{background-color:#9f1239}\n.text-rose-800{color:#9f1239}\n.border-rose-800{border-color:#9f1239}\n.bg-rose-900{background-color:#881337}\n.text-rose-900{color:#881337}\n.border-rose-900{border-color:#881337}\n.bg-rose-950{background-color:#4c0519}\n.text-rose-950{color:#4c0519}\n.border-rose-950{border-color:#4c0519}\n.bg-sky-100{background-color:#e0f2fe}\n.text-sky-100{color:#e0f2fe}\n.border-sky-100{border-color:#e0f2fe}\n.bg-sky-200{background-color:#bae6fd}\n.text-sky-200{color:#bae6fd}\n.border-sky-200{border-color:#bae6fd}\n.bg-sky-300{background-color:#7dd3fc}\n.text-sky-300{color:#7dd3fc}\n.border-sky-300{border-color:#7dd3fc}\n.bg-sky-400{background-color:#38bdf8}\n.text-sky-400{color:#38bdf8}\n.border-sky-400{border-color:#38bdf8}\n.bg-sky-50{background-color:#f0f9ff}\n.text-sky-50{color:#f0f9ff}\n.border-sky-50{border-color:#f0f9ff}\n.bg-sky-500{background-color:#0ea5e9}\n.text-sky-500{color:#0ea5e9}\n.border-sky-500{border-color:#0ea5e9}\n.bg-sky-600{background-color:#0284c7}\n.text-sky-600{color:#0284c7}\n.border-sky-600{border-color:#0284c7}\n.bg-sky-700{background-color:#0369a1}\n.text-sky-700{color:#0369a1}\n.border-sky-700{border-color:#0369a1}\n.bg-sky-800{background-color:#075985}\n.text-sky-800{color:#075985}\n.border-sky-800{border-color:#075985}\n.bg-sky-900{background-color:#0c4a6e}\n.text-sky-900{color:#0c4a6e}\n.border-sky-900{border-color:#0c4a6e}\n.bg-sky-950{background-color:#082f49}\n.text-sky-950{color:#082f49}\n.border-sky-950{border-color:#082f49}\n.bg-slate-100{background-color:#f1f5f9}\n.text-slate-100{color:#f1f5f9}\n.border-slate-100{border-color:#f1f5f9}\n.bg-slate-200{background-color:#e2e8f0}\n.text-slate-200{color:#e2e8f0}\n.border-slate-200{border-color:#e2e8f0}\n.bg-slate-300{background-color:#cbd5e1}\n.text-slate-300{color:#cbd5e1}\n.border-slate-300{border-color:#cbd5e1}\n.bg-slate-400{background-color:#94a3b8}\n.text-slate-400{color:#94a3b8}\n.border-slate-400{border-color:#94a3b8}\n.bg-slate-50{background-color:#f8fafc}\n.text-slate-50{color:#f8fafc}\n.border-slate-50{border-color:#f8fafc}\n.bg-slate-500{background-color:#64748b}\n.text-slate-500{color:#64748b}\n.border-slate-500{border-color:#64748b}\n.bg-slate-600{background-color:#475569}\n.text-slate-600{color:#475569}\n.border-slate-600{border-color:#475569}\n.bg-slate-700{background-color:#334155}\n.text-slate-700{color:#334155}\n.border-slate-700{border-color:#334155}\n.bg-slate-800{background-color:#1e293b}\n.text-slate-800{color:#1e293b}\n.border-slate-800{border-color:#1e293b}\n.bg-slate-900{background-color:#0f172a}\n.text-slate-900{color:#0f172a}\n.border-slate-900{border-color:#0f172a}\n.bg-slate-950{background-color:#020617}\n.text-slate-950{color:#020617}\n.border-slate-950{border-color:#020617}\n.bg-stone-100{background-color:#f5f5f4}\n.text-stone-100{color:#f5f5f4}\n.border-stone-100{border-color:#f5f5f4}\n.bg-stone-200{background-color:#e7e5e4}\n.text-stone-200{color:#e7e5e4}\n.border-stone-200{border-color:#e7e5e4}\n.bg-stone-300{background-color:#d6d3d1}\n.text-stone-300{color:#d6d3d1}\n.border-stone-300{border-color:#d6d3d1}\n.bg-stone-400{background-color:#a8a29e}\n.text-stone-400{color:#a8a29e}\n.border-stone-400{border-color:#a8a29e}\n.bg-stone-50{background-color:#fafaf9}\n.text-stone-50{color:#fafaf9}\n.border-stone-50{border-color:#fafaf9}\n.bg-stone-500{background-color:#78716c}\n.text-stone-500{color:#78716c}\n.border-stone-500{border-color:#78716c}\n.bg-stone-600{background-color:#57534e}\n.text-stone-600{color:#57534e}\n.border-stone-600{border-color:#57534e}\n.bg-stone-700{background-color:#44403c}\n.text-stone-700{color:#44403c}\n.border-stone-700{border-color:#44403c}\n.bg-stone-800{background-color:#292524}\n.text-stone-800{color:#292524}\n.border-stone-800{border-color:#292524}\n.bg-stone-900{background-color:#1c1917}\n.text-stone-900{color:#1c1917}\n.border-stone-900{border-color:#1c1917}\n.bg-stone-950{background-color:#0c0a09}\n.text-stone-950{color:#0c0a09}\n.border-stone-950{border-color:#0c0a09}\n.bg-teal-100{background-color:#ccfbf1}\n.text-teal-100{color:#ccfbf1}\n.border-teal-100{border-color:#ccfbf1}\n.bg-teal-200{background-color:#99f6e4}\n.text-teal-200{color:#99f6e4}\n.border-teal-200{border-color:#99f6e4}\n.bg-teal-300{background-color:#5eead4}\n.text-teal-300{color:#5eead4}\n.border-teal-300{border-color:#5eead4}\n.bg-teal-400{background-color:#2dd4bf}\n.text-teal-400{color:#2dd4bf}\n.border-teal-400{border-color:#2dd4bf}\n.bg-teal-50{background-color:#f0fdfa}\n.text-teal-50{color:#f0fdfa}\n.border-teal-50{border-color:#f0fdfa}\n.bg-teal-500{background-color:#14b8a6}\n.text-teal-500{color:#14b8a6}\n.border-teal-500{border-color:#14b8a6}\n.bg-teal-600{background-color:#0d9488}\n.text-teal-600{color:#0d9488}\n.border-teal-600{border-color:#0d9488}\n.bg-teal-700{background-color:#0f766e}\n.text-teal-700{color:#0f766e}\n.border-teal-700{border-color:#0f766e}\n.bg-teal-800{background-color:#115e59}\n.text-teal-800{color:#115e59}\n.border-teal-800{border-color:#115e59}\n.bg-teal-900{background-color:#134e4a}\n.text-teal-900{color:#134e4a}\n.border-teal-900{border-color:#134e4a}\n.bg-teal-950{background-color:#042f2e}\n.text-teal-950{color:#042f2e}\n.border-teal-950{border-color:#042f2e}\n.bg-violet-100{background-color:#ede9fe}\n.text-violet-100{color:#ede9fe}\n.border-violet-100{border-color:#ede9fe}\n.bg-violet-200{background-color:#ddd6fe}\n.text-violet-200{color:#ddd6fe}\n.border-violet-200{border-color:#ddd6fe}\n.bg-violet-300{background-color:#c4b5fd}\n.text-violet-300{color:#c4b5fd}\n.border-violet-300{border-color:#c4b5fd}\n.bg-violet-400{background-color:#a78bfa}\n.text-violet-400{color:#a78bfa}\n.border-violet-400{border-color:#a78bfa}\n.bg-violet-50{background-color:#f5f3ff}\n.text-violet-50{color:#f5f3ff}\n.border-violet-50{border-color:#f5f3ff}\n.bg-violet-500{background-color:#8b5cf6}\n.text-violet-500{color:#8b5cf6}\n.border-violet-500{border-color:#8b5cf6}\n.bg-violet-600{background-color:#7c3aed}\n.text-violet-600{color:#7c3aed}\n.border-violet-600{border-color:#7c3aed}\n.bg-violet-700{background-color:#6d28d9}\n.text-violet-700{color:#6d28d9}\n.border-violet-700{border-color:#6d28d9}\n.bg-violet-800{background-color:#5b21b6}\n.text-violet-800{color:#5b21b6}\n.border-violet-800{border-color:#5b21b6}\n.bg-violet-900{background-color:#4c1d95}\n.text-violet-900{color:#4c1d95}\n.border-violet-900{border-color:#4c1d95}\n.bg-violet-950{background-color:#2e1065}\n.text-violet-950{color:#2e1065}\n.border-violet-950{border-color:#2e1065}\n.bg-white{background-color:#fff}\n.text-white{color:#fff}\n.border-white{border-color:#fff}\n.bg-yellow-100{background-color:#fef9c3}\n.text-yellow-100{color:#fef9c3}\n.border-yellow-100{border-color:#fef9c3}\n.bg-yellow-200{background-color:#fef08a}\n.text-yellow-200{color:#fef08a}\n.border-yellow-200{border-color:#fef08a}\n.bg-yellow-300{background-color:#fde047}\n.text-yellow-300{color:#fde047}\n.border-yellow-300{border-color:#fde047}\n.bg-yellow-400{background-color:#facc15}\n.text-yellow-400{color:#facc15}\n.border-yellow-400{border-color:#facc15}\n.bg-yellow-50{background-color:#fefce8}\n.text-yellow-50{color:#fefce8}\n.border-yellow-50{border-color:#fefce8}\n.bg-yellow-500{background-color:#eab308}\n.text-yellow-500{color:#eab308}\n.border-yellow-500{border-color:#eab308}\n.bg-yellow-600{background-color:#ca8a04}\n.text-yellow-600{color:#ca8a04}\n.border-yellow-600{border-color:#ca8a04}\n.bg-yellow-700{background-color:#a16207}\n.text-yellow-700{color:#a16207}\n.border-yellow-700{border-color:#a16207}\n.bg-yellow-800{background-color:#854d0e}\n.text-yellow-800{color:#854d0e}\n.border-yellow-800{border-color:#854d0e}\n.bg-yellow-900{background-color:#713f12}\n.text-yellow-900{color:#713f12}\n.border-yellow-900{border-color:#713f12}\n.bg-yellow-950{background-color:#422006}\n.text-yellow-950{color:#422006}\n.border-yellow-950{border-color:#422006}\n.bg-zinc-100{background-color:#f4f4f5}\n.text-zinc-100{color:#f4f4f5}\n.border-zinc-100{border-color:#f4f4f5}\n.bg-zinc-200{background-color:#e4e4e7}\n.text-zinc-200{color:#e4e4e7}\n.border-zinc-200{border-color:#e4e4e7}\n.bg-zinc-300{background-color:#d4d4d8}\n.text-zinc-300{color:#d4d4d8}\n.border-zinc-300{border-color:#d4d4d8}\n.bg-zinc-400{background-color:#a1a1aa}\n.text-zinc-400{color:#a1a1aa}\n.border-zinc-400{border-color:#a1a1aa}\n.bg-zinc-50{background-color:#fafafa}\n.text-zinc-50{color:#fafafa}\n.border-zinc-50{border-color:#fafafa}\n.bg-zinc-500{background-color:#71717a}\n.text-zinc-500{color:#71717a}\n.border-zinc-500{border-color:#71717a}\n.bg-zinc-600{background-color:#52525b}\n.text-zinc-600{color:#52525b}\n.border-zinc-600{border-color:#52525b}\n.bg-zinc-700{background-color:#3f3f46}\n.text-zinc-700{color:#3f3f46}\n.border-zinc-700{border-color:#3f3f46}\n.bg-zinc-800{background-color:#27272a}\n.text-zinc-800{color:#27272a}\n.border-zinc-800{border-color:#27272a}\n.bg-zinc-900{background-color:#18181b}\n.text-zinc-900{color:#18181b}\n.border-zinc-900{border-color:#18181b}\n.bg-zinc-950{background-color:#09090b}\n.text-zinc-950{color:#09090b}\n.border-zinc-950{border-color:#09090b}\n.bg-transparent{background-color:transparent}\n.p-0{padding:0rem}\n.px-0{padding-left:0rem;padding-right:0rem}\n.py-0{padding-top:0rem;padding-bottom:0rem}\n.pt-0{padding-top:0rem}\n.pr-0{padding-right:0rem}\n.pb-0{padding-bottom:0rem}\n.pl-0{padding-left:0rem}\n.p-0\\.5{padding:0.125rem}\n.px-0\\.5{padding-left:0.125rem;padding-right:0.125rem}\n.py-0\\.5{padding-top:0.125rem;padding-bottom:0.125rem}\n.pt-0\\.5{padding-top:0.125rem}\n.pr-0\\.5{padding-right:0.125rem}\n.pb-0\\.5{padding-bottom:0.125rem}\n.pl-0\\.5{padding-left:0.125rem}\n.p-1{padding:0.25rem}\n.px-1{padding-left:0.25rem;padding-right:0.25rem}\n.py-1{padding-top:0.25rem;padding-bottom:0.25rem}\n.pt-1{padding-top:0.25rem}\n.pr-1{padding-right:0.25rem}\n.pb-1{padding-bottom:0.25rem}\n.pl-1{padding-left:0.25rem}\n.p-1\\.5{padding:0.375rem}\n.px-1\\.5{padding-left:0.375rem;padding-right:0.375rem}\n.py-1\\.5{padding-top:0.375rem;padding-bottom:0.375rem}\n.pt-1\\.5{padding-top:0.375rem}\n.pr-1\\.5{padding-right:0.375rem}\n.pb-1\\.5{padding-bottom:0.375rem}\n.pl-1\\.5{padding-left:0.375rem}\n.p-2{padding:0.5rem}\n.px-2{padding-left:0.5rem;padding-right:0.5rem}\n.py-2{padding-top:0.5rem;padding-bottom:0.5rem}\n.pt-2{padding-top:0.5rem}\n.pr-2{padding-right:0.5rem}\n.pb-2{padding-bottom:0.5rem}\n.pl-2{padding-left:0.5rem}\n.p-2\\.5{padding:0.625rem}\n.px-2\\.5{padding-left:0.625rem;padding-right:0.625rem}\n.py-2\\.5{padding-top:0.625rem;padding-bottom:0.625rem}\n.pt-2\\.5{padding-top:0.625rem}\n.pr-2\\.5{padding-right:0.625rem}\n.pb-2\\.5{padding-bottom:0.625rem}\n.pl-2\\.5{padding-left:0.625rem}\n.p-3{padding:0.75rem}\n.px-3{padding-left:0.75rem;padding-right:0.75rem}\n.py-3{padding-top:0.75rem;padding-bottom:0.75rem}\n.pt-3{padding-top:0.75rem}\n.pr-3{padding-right:0.75rem}\n.pb-3{padding-bottom:0.75rem}\n.pl-3{padding-left:0.75rem}\n.p-3\\.5{padding:0.875rem}\n.px-3\\.5{padding-left:0.875rem;padding-right:0.875rem}\n.py-3\\.5{padding-top:0.875rem;padding-bottom:0.875rem}\n.pt-3\\.5{padding-top:0.875rem}\n.pr-3\\.5{padding-right:0.875rem}\n.pb-3\\.5{padding-bottom:0.875rem}\n.pl-3\\.5{padding-left:0.875rem}\n.p-4{padding:1rem}\n.px-4{padding-left:1rem;padding-right:1rem}\n.py-4{padding-top:1rem;padding-bottom:1rem}\n.pt-4{padding-top:1rem}\n.pr-4{padding-right:1rem}\n.pb-4{padding-bottom:1rem}\n.pl-4{padding-left:1rem}\n.p-5{padding:1.25rem}\n.px-5{padding-left:1.25rem;padding-right:1.25rem}\n.py-5{padding-top:1.25rem;padding-bottom:1.25rem}\n.pt-5{padding-top:1.25rem}\n.pr-5{padding-right:1.25rem}\n.pb-5{padding-bottom:1.25rem}\n.pl-5{padding-left:1.25rem}\n.p-6{padding:1.5rem}\n.px-6{padding-left:1.5rem;padding-right:1.5rem}\n.py-6{padding-top:1.5rem;padding-bottom:1.5rem}\n.pt-6{padding-top:1.5rem}\n.pr-6{padding-right:1.5rem}\n.pb-6{padding-bottom:1.5rem}\n.pl-6{padding-left:1.5rem}\n.p-7{padding:1.75rem}\n.px-7{padding-left:1.75rem;padding-right:1.75rem}\n.py-7{padding-top:1.75rem;padding-bottom:1.75rem}\n.pt-7{padding-top:1.75rem}\n.pr-7{padding-right:1.75rem}\n.pb-7{padding-bottom:1.75rem}\n.pl-7{padding-left:1.75rem}\n.p-8{padding:2rem}\n.px-8{padding-left:2rem;padding-right:2rem}\n.py-8{padding-top:2rem;padding-bottom:2rem}\n.pt-8{padding-top:2rem}\n.pr-8{padding-right:2rem}\n.pb-8{padding-bottom:2rem}\n.pl-8{padding-left:2rem}\n.p-9{padding:2.25rem}\n.px-9{padding-left:2.25rem;padding-right:2.25rem}\n.py-9{padding-top:2.25rem;padding-bottom:2.25rem}\n.pt-9{padding-top:2.25rem}\n.pr-9{padding-right:2.25rem}\n.pb-9{padding-bottom:2.25rem}\n.pl-9{padding-left:2.25rem}\n.p-10{padding:2.5rem}\n.px-10{padding-left:2.5rem;padding-right:2.5rem}\n.py-10{padding-top:2.5rem;padding-bottom:2.5rem}\n.pt-10{padding-top:2.5rem}\n.pr-10{padding-right:2.5rem}\n.pb-10{padding-bottom:2.5rem}\n.pl-10{padding-left:2.5rem}\n.p-11{padding:2.75rem}\n.px-11{padding-left:2.75rem;padding-right:2.75rem}\n.py-11{padding-top:2.75rem;padding-bottom:2.75rem}\n.pt-11{padding-top:2.75rem}\n.pr-11{padding-right:2.75rem}\n.pb-11{padding-bottom:2.75rem}\n.pl-11{padding-left:2.75rem}\n.p-12{padding:3rem}\n.px-12{padding-left:3rem;padding-right:3rem}\n.py-12{padding-top:3rem;padding-bottom:3rem}\n.pt-12{padding-top:3rem}\n.pr-12{padding-right:3rem}\n.pb-12{padding-bottom:3rem}\n.pl-12{padding-left:3rem}\n.p-14{padding:3.5rem}\n.px-14{padding-left:3.5rem;padding-right:3.5rem}\n.py-14{padding-top:3.5rem;padding-bottom:3.5rem}\n.pt-14{padding-top:3.5rem}\n.pr-14{padding-right:3.5rem}\n.pb-14{padding-bottom:3.5rem}\n.pl-14{padding-left:3.5rem}\n.p-16{padding:4rem}\n.px-16{padding-left:4rem;padding-right:4rem}\n.py-16{padding-top:4rem;padding-bottom:4rem}\n.pt-16{padding-top:4rem}\n.pr-16{padding-right:4rem}\n.pb-16{padding-bottom:4rem}\n.pl-16{padding-left:4rem}\n.p-20{padding:5rem}\n.px-20{padding-left:5rem;padding-right:5rem}\n.py-20{padding-top:5rem;padding-bottom:5rem}\n.pt-20{padding-top:5rem}\n.pr-20{padding-right:5rem}\n.pb-20{padding-bottom:5rem}\n.pl-20{padding-left:5rem}\n.p-24{padding:6rem}\n.px-24{padding-left:6rem;padding-right:6rem}\n.py-24{padding-top:6rem;padding-bottom:6rem}\n.pt-24{padding-top:6rem}\n.pr-24{padding-right:6rem}\n.pb-24{padding-bottom:6rem}\n.pl-24{padding-left:6rem}\n.text-xs{font-size:0.75rem;line-height:1rem}\n.text-sm{font-size:0.875rem;line-height:1.25rem}\n.text-base{font-size:1rem;line-height:1.5rem}\n.text-lg{font-size:1.125rem;line-height:1.75rem}\n.text-xl{font-size:1.25rem;line-height:1.75rem}\n.text-2xl{font-size:1.5rem;line-height:2rem}\n.text-3xl{font-size:1.875rem;line-height:2.25rem}\n.text-4xl{font-size:2.25rem;line-height:2.5rem}\n.text-5xl{font-size:3rem;line-height:1}\n.text-6xl{font-size:3.75rem;line-height:1}\n.font-thin{font-weight:100}\n.font-extralight{font-weight:200}\n.font-light{font-weight:300}\n.font-normal{font-weight:400}\n.font-medium{font-weight:500}\n.font-semibold{font-weight:600}\n.font-bold{font-weight:700}\n.font-extrabold{font-weight:800}\n.font-black{font-weight:900}\n.italic{font-style:italic}\n.not-italic{font-style:normal}\n.rounded-none{border-radius:0px}\n.rounded-sm{border-radius:2px}\n.rounded{border-radius:4px}\n.rounded-md{border-radius:6px}\n.rounded-lg{border-radius:8px}\n.rounded-xl{border-radius:12px}\n.rounded-2xl{border-radius:16px}\n.rounded-3xl{border-radius:24px}\n.rounded-full{border-radius:9999px}\n.border{border-width:1px;border-style:solid}\n.border-0{border-width:0px;border-style:solid}\n.border-2{border-width:2px;border-style:solid}\n.border-4{border-width:4px;border-style:solid}\n.border-8{border-width:8px;border-style:solid}\n.tracking-tighter{letter-spacing:-0.05em}\n.tracking-tight{letter-spacing:-0.025em}\n.tracking-normal{letter-spacing:0em}\n.tracking-wide{letter-spacing:0.025em}\n.tracking-wider{letter-spacing:0.05em}\n.tracking-widest{letter-spacing:0.1em}" diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 205c04e2..38431fc0 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -49,6 +49,28 @@ STATUSES: frozenset[str] = frozenset({"shipped", "partial", "planned"}) VOCABULARIES: frozenset[str] = frozenset({"css", "svg", "xy"}) +#: Interaction/view states that gate live-only chrome. A slot tagged with one +#: of these exists in the document only while its state is active — a tooltip +#: under hover, the modebar under a pointer, a reduction badge under the view +#: that triggered it, an axis gesture band only while its axis is navigable — +#: so a clean static export does not *contain* it. Styling such a slot is +#: therefore not "dropped" by a clean static export: there is nothing in the +#: file to style. Counting those slots against static parity overstated the +#: gap; tagging them records the distinction instead of leaving it silent +#: (§28). +EXPORT_STATES: tuple[str, ...] = ( + "hover", + "selection", + "crosshair", + "modebar", + "view", + "navigation", +) + +#: Every slot is either present in a clean static export ("static") or gated +#: by exactly one export state. +APPLICABILITIES: frozenset[str] = frozenset({"static", *EXPORT_STATES}) + @dataclass(frozen=True) class MarkStyleProperty: @@ -79,6 +101,7 @@ class SlotCapability: support: dict[str, str] notes: str channel: str = "" + applicability: str = "static" @dataclass(frozen=True) @@ -241,6 +264,116 @@ class ExtensionPoint: visible_when="stroke-width above ~4px at a sharp angle", tracked_by="no style property selects a join; the default is the whole contract", ), + RendererDivergence( + id="chrome_slot_title_stacking", + what="A styled `chrome` slot background against the title text and the plot fill", + webgl=( + "the chrome canvas is appended AFTER the title divs " + "(js/src/50_chartview.ts) and its CSS background paints under its " + "own bitmap, so the backdrop covers titles and sits below --chart-bg" + ), + svg=( + "one rect between the backgrounds and the grid group: above the " + "root and plot fills, below every grid line and all chrome text" + ), + native="same seam as SVG (after the plot fill, before the plot clip)", + visible_when=( + "styles={'chrome': {'background': ...}} overlaps a title, or is " + "combined with a --chart-bg plot fill" + ), + tracked_by=( + "static-chrome-parity plan §3.5 pins the writers' seam; the DOM " + "order is the browser's own stacking contract" + ), + ), + RendererDivergence( + id="title_entry_box_allowlist", + what="Box styling authored on a per-entry title `style=` (not the title slot)", + webgl=( + "dropped: the client copies only color/font-family/font-size/" + "font-style/font-weight from an entry's style onto the title div " + "(js/src/50_chartview.ts entry-style allowlist)" + ), + svg="honored: `_title_metrics` merges entry style over the slot, box included", + native="honored, same merge (the two writers share the title placement)", + visible_when="xy.title(style={'background': ...}) or another per-entry box property", + tracked_by=( + "static-chrome-parity plan §3 acceptance records the divergence; " + "slot-level `styles={'title': ...}` box declarations agree everywhere" + ), + ), + RendererDivergence( + id="annotation_layer_background_geometry", + what="The annotation_layer slot's background extent", + webgl="full-bleed (the overlay canvas is inset:0 over the whole chart)", + svg="plot rect, inside the marks clip (the only seam above traces and below shapes)", + native="plot rect, under the active marks clip (same seam as SVG)", + visible_when="styles={'annotation_layer': {'background': ...}} is declared", + tracked_by="tests/test_chrome_parity_p3.py pins the plot-rect geometry", + ), + RendererDivergence( + id="labels_container_stacking", + what="Where the labels-container background sits among its siblings (flag D)", + webgl="over the chart title (the container is a later DOM sibling), " + "under the axis rules and label texts it contains", + svg="under the axis rules and label texts (the resolved flag-D order), " + "and under the title/legend/colorbar chrome, which joins later", + native="same as SVG: filled after the marks, before the chrome text phase", + visible_when="styles={'labels': {'background': ...}} on a chart with a title, " + "legend or colorbar", + tracked_by="flag D of the static-chrome parity plan; " + "tests/test_chrome_parity_p3.py pins the writers' order", + ), + RendererDivergence( + id="annotation_layer_opacity_compositing", + what="How the annotation_layer slot's opacity composites overlapping shapes", + webgl="group opacity: the overlay canvas is dimmed once as a whole", + svg="group opacity on the wrapping , PDF-legal, same as live", + native="folded into each shape's RGBA (no group compositing opcode): " + "overlapping translucent shapes double-blend", + visible_when="the slot declares opacity below 1 over overlapping annotation shapes", + tracked_by="tests/test_chrome_parity_p3.py documents the double-blend delta", + ), + RendererDivergence( + id="legend_slot_opacity_compositing", + what="How a legend slot's `opacity` composites its box", + webgl="group opacity: the element and its children fade once, together", + svg="`opacity` on the box element, PDF-legal, same as live", + native="premultiplied into the box's own RGBA (the display list has no " + "group-compositing opcode), so a translucent frame does not also fade " + "the swatches and labels drawn over it, and overlapping translucent " + "boxes double-blend", + visible_when="styles={'legend'|'legend_item'|'legend_swatch': {'opacity': <1}}", + tracked_by=( + "tests/test_chrome_parity_legend.py pins the premultiply; the raster " + "opcode that would fix it needs the dual ABI bump (plan §9.9)" + ), + ), + RendererDivergence( + id="legend_frame_border_alpha_coupling", + what="Whether the legend frame's alpha also dims its border (flag B)", + webgl="one translucent element: the border fades with the fill", + svg="`stroke-opacity` carries the frame alpha, matching live", + native="the same alpha folded into the border RGBA", + visible_when="the default grey frame, or --xy-legend-frame-alpha below 1", + tracked_by=( + "resolved in favor of the coupling when the frame folded onto the " + "shared chrome-box lowering; ChromeBox.border_opacity carries it" + ), + ), + RendererDivergence( + id="axis_line_edge_geometry", + what="Where an axis spine's box sits relative to the plot edge", + webgl="right/bottom spines inset by their own width (DIVs laid inside the box)", + svg="centered on the plot edge, where the unstyled stroke has always run", + native="centered on the plot edge (same shared box producer as SVG)", + visible_when="axis_width above ~2px, or a styled axis_line box under a magnifier", + tracked_by=( + "matching the browser would move every unstyled spine and break the " + "byte pin; the writers' centered geometry is pinned by golden in " + "tests/test_chrome_parity_p2.py" + ), + ), ) @@ -251,11 +384,23 @@ class ExtensionPoint: #: and has nothing in a file to style. _SLOT_SUBSET_NOTE = ( "Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, " - "letter-spacing, opacity and the text paint (`fill`, or `color`). The raster " - "writer's glyph primitive takes a size and one RGBA paint and nothing else, " - "so it honors font-size and the paint only — font-weight, font-style, " - "font-family, letter-spacing and opacity are vector-only rather than " - "silently approximated. Properties outside the subset stay browser-only." + "letter-spacing, opacity and the text paint (`fill`, or `color`); PDF maps " + "any declared family onto the base-14 Helvetica faces (regular/bold/" + "oblique/bold-oblique), recorded in `_pdf.py`'s contract note. The raster " + "atlas carries regular, bold and italic faces, so font-size, the paint, " + "font-weight and font-style survive there too — font-family, " + "letter-spacing and opacity remain vector-only rather than silently " + "approximated. Properties outside the subset stay browser-only." +) + +#: The box-vocabulary note shared by the P1 box slots (`_svg.SLOT_BOX_PROPS`, +#: drawn through the shared `_chromebox` lowering in both writers). +_SLOT_BOX_NOTE = ( + "Box slot: both writers honor background, border (color/width/style, " + "dashed/dotted as dash arrays), symmetric border-radius, opacity and " + "fill-opacity through the shared chrome-box lowering " + "(`xy._chromebox.lower_box`); everything it cannot draw is a named loss " + "in the preflight, never silent (§28)." ) _SLOT_EXCEPTIONS: dict[str, tuple[str, str, str]] = { @@ -265,21 +410,224 @@ class ExtensionPoint: _SLOT_EXCEPTIONS["legend"] = ( "partial", "styles={'legend': ...} / xy.legend(style=...) / --chart-legend-bg", - "The frame box. Both spellings and the theme token now converge on one " - "merged declaration block before the writers see it, so what agrees in the " - "browser agrees in a PNG. `background`, `boxShadow`, `borderRadius`, " - "`--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em` are honored; an " - "explicit background paints opaque, as it does in the browser.", + "The frame box, drawn through the shared chrome-box lowering " + "(`xy._chromebox.lower_box`) in both writers. All three sources converge " + "on one merged declaration before the writers see it, in the CSS and the " + "camelCase spelling alike, so what agrees in the browser agrees in a PNG: " + "`background`, `border-color`/`border-width`/`border-style`, " + "`border-radius` (the authored value, not a pinned 4), `box-shadow`, " + "`opacity`, `--xy-legend-frame-alpha`, and `padding`/`row-gap`/`gap` in " + "resolved px or the legend's historical `em`. Padding and row-gap resize " + "the frame in the exports, in pyplot's anchored-legend room reservation " + "and in its best-location scoring together — one geometry, four " + "consumers. An explicit background paints opaque, as it does in the " + "browser, and `background: transparent` drops the frame entirely " + "(Matplotlib `frameon=False`). A `box-shadow` carrying blur or spread " + "draws the writers' offset-rect approximation and records the blur as a " + "named loss (§28); the frame's alpha dims its border with it, matching " + "the single translucent element the browser paints.", +) +_SLOT_EXCEPTIONS["legend_item"] = ( + "partial", + "styles={'legend_item': ...}", + "The per-row cell of the legend, one instance per visible entry, drawn " + "under that row's swatch and label and over the frame and title. Box " + "vocabulary only (`_svg.SLOT_BOX_PROPS`): the row has no text of its own, " + "and its size comes from the legend layout, so `padding` is refused " + "rather than accepted and ignored.", +) +_SLOT_EXCEPTIONS["legend_swatch"] = ( + "partial", + "styles={'legend_swatch': ...}", + "The handle cell of a legend row. On a patch entry the swatch IS the " + "patch, so a declared background or border wins over the trace's own " + "paint (browser precedence: the slot rule is applied after the per-entry " + "paint variables) and a declared border-radius replaces the historical " + "`rx=2`; on a marker or line entry the box paints behind the handle, " + "which keeps its own ink. Box vocabulary only, padding excluded for the " + "same reason as `legend_item`.", +) +_SLOT_EXCEPTIONS["title"] = ( + "partial", + "styles={'title': ...}", + _SLOT_SUBSET_NOTE + " The title also takes the full box vocabulary " + "(`_svg.SLOT_BOX_PROPS`): a box under the text, sized to the measured " + "block plus padding, with the title band growing to fit. Per-entry " + "`xy.title(style=...)` box properties are native-only " + "(KNOWN_RENDERER_DIVERGENCES `title_entry_box_allowlist`).", ) _SLOT_EXCEPTIONS["root"] = ( "partial", - "chart style=", - "`styles={'root': ...}` is browser-only, but the chart-level `style=` " - "token bag targets the same element and every renderer reads it " - "(`spec['dom']['style']`). Prefer it for anything that must survive " - "export.", + "styles={'root': ...} / chart style=", + _SLOT_BOX_NOTE + " The root box is the figure patch: its fill replaces " + "the `theme(background=)` token when both are set (same element, one " + "background property, matching the browser), and an export " + "`background=` override silences it (`_svg.apply_export_background` is " + "the one precedence definition). box-shadow would fall outside the " + "canvas and is a named loss; text properties have no root text to style. " + "The chart-level `style=` token bag still reaches every renderer.", +) +_SLOT_EXCEPTIONS["chrome"] = ( + "partial", + "styles={'chrome': ...}", + "Background and opacity only (parity plan §8 flag G): one full-canvas " + "backdrop above the root and plot fills, below the grid. The rest of the " + "box vocabulary is a named preflight loss, and the browser's own " + "stacking of this slot against titles diverges by design " + "(KNOWN_RENDERER_DIVERGENCES `chrome_slot_title_stacking`).", +) +_SLOT_EXCEPTIONS["canvas"] = ( + "partial", + "styles={'canvas': ...}", + _SLOT_BOX_NOTE + " Painted at the above-grid seam, so a canvas " + "background hides the grid exactly as the browser's marks canvas does; " + "border-radius clips the marks through a dedicated clipPath in SVG/PDF " + "and opacity rides the marks group there. The raster display list clips " + "rectangles only and has no group compositing, so border-radius and " + "opacity are named raster losses (`_svg.SLOT_BOX_RASTER_UNSUPPORTED`) " + "until the rounded-clip opcode lands. An export `background=` override " + "silences a canvas background like the plot token.", +) +_SLOT_EXCEPTIONS["labels"] = ( + "partial", + "styles={'labels': ...}", + "The label container. Its color is the default under the live chain " + "`var(--chart-text, inherit)` for every contained text (tick labels, " + "axis titles, annotation labels): the theme token wins, then the " + "container color, then the writer default — the axis's own colors and " + "the specific slots stay narrower and win. Typography folds under the " + "contained slots exactly where the live stylesheet leaves the property " + "un-ruled (font-size/weight cascade into tick labels only; style/family/" + "letter-spacing into all three). `background` paints full-bleed under " + "the axis rules and every label text, the live order; the residual " + "sibling stacking difference is in KNOWN_RENDERER_DIVERGENCES. " + "`opacity` rides the SVG label group (vector-only); live it also dims " + "the contained axis rules and the container background — recorded here " + "rather than approximated.", +) +_SLOT_EXCEPTIONS["annotation_layer"] = ( + "partial", + "styles={'annotation_layer': ...}", + "The annotation-shape overlay. `opacity` dims every annotation shape as " + "a group (never the labels, which live in the labels container): SVG/PDF " + "as real group opacity on a ``, raster folded into each shape's RGBA " + "because the display list has no group compositing — overlapping " + "translucent shapes double-blend there, a recorded approximation (§28). " + "`background` paints under the shapes, plot-clipped; the live overlay is " + "full-bleed, a divergence recorded in KNOWN_RENDERER_DIVERGENCES. " + "Everything else stays browser-only.", +) +_SLOT_EXCEPTIONS["annotation_label"] = ( + "partial", + "styles={'annotation_label': ...}", + "The per-slot text subset plus the shared chrome-box model " + "(`xy._svg.SLOT_BOX_PROPS`): background, border — with solid/dashed/" + "dotted lowered to a dash pattern and other border styles drawn solid " + "and recorded (§28) — border-radius, CSS 1-4 value padding, offset " + "box-shadow (blur/spread recorded unrepresentable), and whole-label " + "opacity. The annotation's own `style=` is the narrower selector and " + "wins per property group, matching the browser's slot-then-inline " + "order. em font sizes resolve against the label's own 11px default. " + "Vertical (rotation 90/270) labels keep only size and paint in SVG — " + "a pre-existing limit of the rotated text path.", ) +#: The shared chrome-box vocabulary note, referenced by the axis-chrome slots +#: below. The vocabulary itself is `xy._svg.SLOT_BOX_PROPS` (writer-owned, the +#: preflight reads the same constant). +_BOX_VOCAB_NOTE = ( + "background, border (color/width/style, dashed/dotted as dash arrays), " + "symmetric border-radius, offset box-shadow (blur/spread recorded " + "unrepresentable), opacity and fill-opacity" +) +_SLOT_EXCEPTIONS["axis_line"] = ( + "partial", + "styles={'axis_line': ...}", + "Spines as boxes when box properties are declared: " + _BOX_VOCAB_NOTE + ". " + "The spine keeps its axis_color ink unless the slot declares a background " + "(an explicit transparent erases it, as in the browser). Writers center " + "the box on the plot edge where the unstyled stroke ran; the browser " + "insets right/bottom spines (see KNOWN_RENDERER_DIVERGENCES). Polar " + "spines stay strokes — the browser shares the limit (DIV spines cannot " + "express a circle).", +) +_SLOT_EXCEPTIONS["tick_mark"] = ( + "partial", + "styles={'tick_mark': ...}", + "Tick marks as boxes when box properties are declared: " + _BOX_VOCAB_NOTE + ". " + "Geometry is the centered stroke's own coverage — the same pixels as the " + "browser's rect. Marks exist only where an axis authors tick_length > 0; " + "a zero-length tick draws nothing (and casts no shadow) — the preflight " + "carries the note rather than a length being invented. tick_color stays " + "the narrower paint selector; polar has no cartesian tick marks " + "(recorded).", +) +_SLOT_EXCEPTIONS["tick_label"] = ( + "partial", + "styles={'tick_label': ...}", + _SLOT_SUBSET_NOTE + " Additionally a per-label box: " + _BOX_VOCAB_NOTE + ", " + "with padding growing the axis gutters so the box stays on the canvas " + "(cartesian; the polar label ring keeps its flat 30px allowance). Box " + "geometry is measured with the writers' DejaVu metrics, so an authored " + "font-family renders its own glyphs inside a DejaVu-measured box " + "(recorded misfit); letter-spacing is likewise outside the gutter " + "measurement. On the raster writer a declared opacity reaches the box, " + "not the glyphs (the atlas blit has no alpha channel).", +) +_SLOT_EXCEPTIONS["axis_title"] = ( + "partial", + "styles={'axis_title': ...}", + _SLOT_SUBSET_NOTE + " Additionally a per-title box: " + _BOX_VOCAB_NOTE + "; " + "a rotated y-title box is pre-rotated to a polygon (radius 0) or an " + "arc path (radius > 0), staying inside the PDF closed subset. The axis's " + "own label_* keys win per property over the slot (label_color, " + "label_font_family/style/weight); font-size runs the other way — the " + "slot's font-size wins over label_size (pre-existing, documented in " + "spec/api/styling.md). DejaVu-measured box vs authored-family text and " + "raster box-not-glyph opacity are recorded exactly as for tick_label.", +) + + +#: The state that gates each live-only slot. Listed explicitly, one entry per +#: slot rather than by prefix, so `tests/test_capability_registry.py` can +#: assert the partition covers `CHART_DOM_SLOTS` exactly and that every member +#: of a chrome family carries its family's state — a new `modebar_*` slot that +#: forgets its entry fails the suite instead of quietly counting as static. +_STATE_GATED_SLOTS: dict[str, str] = { + "tooltip": "hover", + "tooltip_title": "hover", + "tooltip_row": "hover", + "tooltip_label": "hover", + "tooltip_value": "hover", + "modebar": "modebar", + "modebar_drag_handle": "modebar", + "modebar_control_group": "modebar", + "modebar_separator": "modebar", + "modebar_button": "modebar", + "modebar_icon": "modebar", + "modebar_zoom_value": "modebar", + "modebar_indicator": "modebar", + "modebar_selection_icon": "modebar", + "modebar_menu": "modebar", + "modebar_menu_separator": "modebar", + "modebar_menu_icon": "modebar", + "modebar_menu_label": "modebar", + "modebar_history_controls": "modebar", + "selection": "selection", + "crosshair_x": "crosshair", + "crosshair_y": "crosshair", + "badge": "view", + "badge_item": "view", + # Flag-F resolution (static-chrome-parity plan §8): the browser creates + # the band only when its axis is navigable (`57_viewstate.ts + # _axisBandNavigable`) — pan/zoom chrome, not structure — and a static + # file has no gesture for it to serve, so it follows the badge precedent + # (interaction-gated, no writer emission) rather than the earlier + # capability-matrix "clean static" row. Deemed structural again only by + # a spec decision, never by a writer quietly drawing it. + "axis_band": "navigation", +} + CHART_SLOTS: tuple[SlotCapability, ...] = tuple( SlotCapability( @@ -291,6 +639,7 @@ class ExtensionPoint: }, channel=_SLOT_EXCEPTIONS[slot][1] if slot in _SLOT_EXCEPTIONS else "", notes=_SLOT_EXCEPTIONS[slot][2] if slot in _SLOT_EXCEPTIONS else "", + applicability=_STATE_GATED_SLOTS.get(slot, "static"), ) for slot in CHART_DOM_SLOTS ) @@ -358,12 +707,15 @@ def markdown_mark_property_table( def markdown_slot_table(slots: Iterable[SlotCapability] = CHART_SLOTS) -> list[str]: """One row per chrome slot, with how far its styling travels.""" lines = [ - "| slot | browser | native raster | native vector |", - "|---|---|---|---|", + "| slot | applicable in | browser | native raster | native vector |", + "|---|---|---|---|---|", ] for slot in slots: + applicable = ( + "clean static" if slot.applicability == "static" else f"{slot.applicability} state" + ) lines.append( - f"| `{slot.id}` | {slot.support['browser']} | " + f"| `{slot.id}` | {applicable} | {slot.support['browser']} | " f"{slot.support['native_raster']} | {slot.support['native_vector']} |" ) return lines @@ -400,22 +752,32 @@ def axis_style_keys() -> tuple[str, ...]: def summary() -> dict[str, object]: """Counts a release note can quote without anyone recounting by hand.""" shipped = [p for p in MARK_STYLE_PROPERTIES if p.status == "shipped"] + static = [s for s in CHART_SLOTS if s.applicability == "static"] return { "axis_style_keys": len(axis_style_keys()), "mark_style_properties": len(MARK_STYLE_PROPERTIES), "mark_style_properties_shipped": len(shipped), "mark_kinds": len(styles._MARK_KINDS), "chart_slots": len(CHART_SLOTS), + "chart_slots_static": len(static), + "chart_slots_state_gated": len(CHART_SLOTS) - len(static), + "static_slots_native": sum(1 for s in static if s.support["native_raster"] != "none"), "slots_styleable_natively": sum( 1 for s in CHART_SLOTS if s.support["native_raster"] != "none" ), + # The `styles={slot: ...}` channel specifically — the writers' own + # STATIC_STYLED_SLOTS, counted from the registry so generated prose + # cannot hold a stale number (the axis_style_keys lesson). + "slots_via_styles": sum(1 for s in CHART_SLOTS if s.channel.startswith("styles={")), "extension_points_shipped": sum(1 for e in EXTENSION_POINTS if e.status == "shipped"), "known_renderer_divergences": len(KNOWN_RENDERER_DIVERGENCES), } __all__ = [ + "APPLICABILITIES", "CHART_SLOTS", + "EXPORT_STATES", "EXTENSION_POINTS", "KNOWN_RENDERER_DIVERGENCES", "MARK_STYLE_PROPERTIES", diff --git a/python/xy/styling/cascade.py b/python/xy/styling/cascade.py new file mode 100644 index 00000000..f8a77f6c --- /dev/null +++ b/python/xy/styling/cascade.py @@ -0,0 +1,306 @@ +"""The mount-free native cascade: classes and author CSS resolved without a +browser, through the optional `xy-cascade` extension. + +The extension (cascade/, its own cdylib) parses stylesheets with Lightning +CSS and cascades the published profile over the synthetic chart DOM below; +this module is the lazy ctypes boundary plus the snapshot construction. It +is imported only when a native-cascade export is requested — the core +import-weight contract does not pay for it — and a missing extension +raises with the build instruction rather than degrading silently. + +Everything the resolver cannot honor arrives in an `unsupported` list with +the reason (out-of-profile selector, at-rule, percentage length, …) and is +surfaced through the compatibility machinery: warn mode says it, strict +mode refuses on it. Nothing outside the profile resolves to a guess (§28). +""" + +from __future__ import annotations + +import ctypes +import json +import os +from pathlib import Path +from typing import Any, Optional + +from ..dom import CHART_DOM_SLOTS +from .resolved import ResolvedStyleSnapshot, SnapshotBuilder, SnapshotEnvironment, assert_resolved + +#: The synthetic slot tree the cascade matches against — (slot, parent), +#: parents before children. Provenance: the parent of every slot a rendered +#: scatter+colorbar+annotation chart mounts was probed from a live headless +#: Chromium DOM (el.parentElement.closest('[data-xy-slot]')); the slots that +#: fixture does not render (legend family, tooltip/badge parts, colorbar +#: extension/line/minor, crosshairs) follow their client mount sites +#: (js/src/50_chartview.ts, 52_tooltip.ts, 53_interaction.ts). A slot whose +#: client nesting changes must change here — the differential smoke compares +#: cascade output against live-browser capture and catches a drifted parent +#: through any descendant selector that crosses it. +SYNTHETIC_TREE: tuple[tuple[str, Optional[str]], ...] = ( + ("root", None), + ("title", "root"), + ("chrome", "root"), + ("canvas", "root"), + ("annotation_layer", "root"), + ("labels", "root"), + ("axis_band", "root"), + ("axis_line", "labels"), + ("tick_mark", "labels"), + ("tick_label", "labels"), + ("axis_title", "labels"), + ("annotation_label", "labels"), + ("legend", "root"), + ("legend_title", "legend"), + ("legend_item", "legend"), + ("legend_swatch", "legend_item"), + ("legend_label", "legend_item"), + ("colorbar", "root"), + ("colorbar_bar", "colorbar"), + ("colorbar_extension", "colorbar"), + ("colorbar_line", "colorbar"), + ("colorbar_tick", "colorbar"), + ("colorbar_minor_tick", "colorbar"), + ("colorbar_title", "colorbar"), + ("tooltip", "root"), + ("tooltip_title", "tooltip"), + ("tooltip_row", "tooltip"), + ("tooltip_label", "tooltip_row"), + ("tooltip_value", "tooltip_row"), + ("modebar", "root"), + ("modebar_drag_handle", "modebar"), + ("modebar_control_group", "modebar"), + ("modebar_separator", "modebar"), + ("modebar_button", "modebar"), + ("modebar_icon", "modebar_button"), + ("modebar_zoom_value", "modebar_button"), + ("modebar_indicator", "modebar_button"), + ("modebar_selection_icon", "modebar_button"), + ("modebar_menu", "modebar"), + ("modebar_menu_separator", "modebar_menu"), + ("modebar_menu_icon", "modebar_button"), + ("modebar_menu_label", "modebar_button"), + ("modebar_history_controls", "modebar_menu"), + ("selection", "root"), + ("crosshair_x", "root"), + ("crosshair_y", "root"), + ("badge", "root"), + ("badge_item", "badge"), +) + +_LIB_ENV = "XY_CASCADE_LIB" + + +def _lib_filename() -> str: + import sys + + if sys.platform == "darwin": + return "libxy_cascade.dylib" + if sys.platform == "win32": + return "xy_cascade.dll" + return "libxy_cascade.so" + + +def _find_library() -> Path: + override = os.environ.get(_LIB_ENV) + if override: + path = Path(override) + if path.is_file(): + return path + raise FileNotFoundError(f"{_LIB_ENV}={override} does not exist") + name = _lib_filename() + candidates = [ + Path(__file__).parents[1] / "_native_lib" / name, # packaged beside the core lib + Path(__file__).parents[3] / "target" / "release" / name, # source checkout + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + raise FileNotFoundError( + "the xy-cascade extension is not built. From a source checkout run " + "`cargo build --release -p xy-cascade` (or set " + f"{_LIB_ENV}=/path/to/{name}); published wheels ship it prebuilt." + ) + + +_CASCADE_ABI = 1 +_lib: Optional[ctypes.CDLL] = None + + +def _load() -> ctypes.CDLL: + global _lib + if _lib is not None: + return _lib + lib = ctypes.CDLL(str(_find_library())) + lib.xy_cascade_abi_version.restype = ctypes.c_uint32 + got = int(lib.xy_cascade_abi_version()) + if got != _CASCADE_ABI: + raise RuntimeError( + f"xy-cascade ABI {got} does not match this xy build (wants {_CASCADE_ABI}); " + "rebuild the extension from the same checkout" + ) + lib.xy_cascade_resolve.restype = ctypes.c_int32 + lib.xy_cascade_resolve.argtypes = [ + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)), + ctypes.POINTER(ctypes.c_size_t), + ] + lib.xy_cascade_free.restype = None + lib.xy_cascade_free.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t] + _lib = lib + return lib + + +def _call(css: str, document: dict[str, Any]) -> dict[str, Any]: + lib = _load() + css_bytes = css.encode("utf-8") + doc_bytes = json.dumps(document, separators=(",", ":")).encode("utf-8") + out = ctypes.POINTER(ctypes.c_uint8)() + out_len = ctypes.c_size_t() + code = lib.xy_cascade_resolve( + css_bytes, + len(css_bytes), + doc_bytes, + len(doc_bytes), + ctypes.byref(out), + ctypes.byref(out_len), + ) + try: + payload = bytes(bytearray(out[i] for i in range(out_len.value))) + finally: + lib.xy_cascade_free(out, out_len) + reply = json.loads(payload.decode("utf-8")) + if code != 0: + detail = reply.get("error") or repr(payload[:200]) + raise ValueError(f"native cascade failed: {detail}") + return reply + + +def _expand_shorthands(declarations: dict[str, Any]) -> dict[str, Any]: + """Split the box shorthands the schema carries as longhands. + + The cascade hands back `padding` as authored; schema v1 speaks + `padding-top/right/bottom/left` so every consumer sees one spelling. + Longhands present alongside the shorthand win (they cascaded later or + more specifically — the resolver already decided that per property). + """ + out: dict[str, Any] = {} + for prop, value in declarations.items(): + if prop == "background-color": + # Schema v1 speaks `background` (the browser capture makes the + # same mapping); when both spellings cascade, the shorthand + # already reset the longhand upstream, so last-in wins here too. + out["background"] = value + continue + if prop == "padding" and isinstance(value, str): + parts = value.split() + if 1 <= len(parts) <= 4: + top = parts[0] + right = parts[1] if len(parts) > 1 else top + bottom = parts[2] if len(parts) > 2 else top + left = parts[3] if len(parts) > 3 else right + for name, side in ( + ("padding-top", top), + ("padding-right", right), + ("padding-bottom", bottom), + ("padding-left", left), + ): + out.setdefault(name, side) + continue + out[prop] = value + return out + + +def resolve_for_figure( + figure: Any, + *, + custom_css: str = "", + stylesheets: tuple[str, ...] = (), + tailwind_profile: Optional[str] = None, + color_scheme: str = "light", + root_font_size: float = 16.0, + width: Optional[float] = None, + height: Optional[float] = None, +) -> tuple[ResolvedStyleSnapshot, tuple[str, ...]]: + """Resolve the figure's classes against the supplied stylesheets. + + Returns the snapshot plus the `unsupported` report — every construct the + profile could not honor, with its reason. Stylesheet order is cascade + order: earlier sheets are wider (the Tailwind-core manifest, a project + bundle), `custom_css` is the narrowest author sheet and comes last. + """ + class_names = {str(k): str(v) for k, v in (figure.class_names or {}).items()} + root_class = "" + dom_class = getattr(figure, "class_name", None) + if isinstance(dom_class, str): + root_class = dom_class + nodes = [] + index: dict[str, int] = {} + for slot, parent in SYNTHETIC_TREE: + classes = [c for c in class_names.get(slot, "").split() if c] + if slot == "root" and root_class: + classes = [c for c in root_class.split() if c] + classes + index[slot] = len(nodes) + nodes.append( + { + "slot": slot, + "classes": classes, + "parent": index[parent] if parent is not None else None, + } + ) + sheets = list(stylesheets) + if tailwind_profile is not None: + if tailwind_profile != "core-v1": + raise ValueError( + f"unknown tailwind_profile {tailwind_profile!r}; this build ships " + '"core-v1" (a project\'s full Tailwind build rides stylesheets=)' + ) + from ._tailwind_core import TAILWIND_CORE_CSS + + # The manifest is the widest sheet: project stylesheets and + # custom_css cascade over it in that order. + sheets.insert(0, TAILWIND_CORE_CSS) + css = "\n".join((*sheets, custom_css)) if (sheets or custom_css) else "" + reply = _call( + css, + { + "env": {"color_scheme": color_scheme, "root_font_size": float(root_font_size)}, + "nodes": nodes, + }, + ) + unsupported = [str(u) for u in reply.get("unsupported", ())] + builder = SnapshotBuilder() + for node in reply.get("nodes", ()): + slot = node.get("slot") + declarations = node.get("declarations") or {} + if slot not in CHART_DOM_SLOTS or not declarations: + continue + legal: dict[str, Any] = {} + for prop, value in _expand_shorthands(declarations).items(): + try: + assert_resolved(prop, value) + except ValueError as exc: + unsupported.append(f"{slot}: {exc}") + else: + legal[prop] = value + if legal: + builder.add(slot, legal) + + def _dim(override: Optional[float], declared: Any, fallback: float) -> float: + for candidate in (override, declared): + numeric = isinstance(candidate, (int, float)) and not isinstance(candidate, bool) + if numeric and float(candidate) > 0: + return float(candidate) + return fallback # fluid ("100%") sizes fall back, as exports do + + environment = SnapshotEnvironment( + width=_dim(width, getattr(figure, "width", None), 800.0), + height=_dim(height, getattr(figure, "height", None), 500.0), + color_scheme=color_scheme, + ) + snapshot = builder.build(environment) + return snapshot, tuple(unsupported) + + +__all__ = ["SYNTHETIC_TREE", "resolve_for_figure"] diff --git a/python/xy/styling/declared.py b/python/xy/styling/declared.py new file mode 100644 index 00000000..d76a6c55 --- /dev/null +++ b/python/xy/styling/declared.py @@ -0,0 +1,237 @@ +"""The declared-styling resolver: a figure's authored chrome styling as a +`ResolvedStyleSnapshot`, beside the byte-exact view the writers consume. + +This is the Python half of the two-resolver architecture (`resolved.py` +module docstring): the browser capture resolves the *computed* cascade; this +module resolves what the chart *declared* — per-slot `styles=`, normalized +to the writers' kebab-case spelling, interned per distinct declaration. + +Two outputs, one construction, deliberately separate: + +- `slot_view()` is the mapping the static writers read — byte-for-byte the + old `_svg.slot_styles` result, authored objects preserved (an authored + `600` stays `int`, so emitted attributes keep their exact spelling). +- `snapshot` is the same declared content as IR: every schema-legal value + interned (numbers as floats, per the wire contract). New consumers — the + snapshot export path, capability tooling, the capture diff — read this. + +The divergence between the two is presentational number formatting plus the +values schema v1 refuses outright — today, exactly the relative-unit +lengths (`0.08em`), which are a document dependency the snapshot may not +carry (`resolved._RELATIVE_UNITS`). Those land in `writer_domain`, so +nothing declared is dropped in either direction and the gap is enumerable. + +The legend used to add a second, slot-specific residue on top of that: its +geometry ran in em multipliers ONLY (`padding`/`row-gap`/`font-size`), so an +author who wanted a legend measured in pixels had no spelling that worked +and every legend geometry declaration was writer-domain by construction. +The static-chrome-parity P4 family retired it — `_svg._legend_length` and +`_svg._legend_padding` resolve px and em alike, so the px spelling interns +into the snapshot like any other resolved length and only a genuinely +relative value stays writer-domain, for the same reason every other slot's +does. `row-gap` is the one remaining wrinkle and it is a vocabulary +question, not a legend one: schema v1 carries `gap` but not `row-gap` +(`resolved.LAYOUT_PROPERTIES_V1`), so a px `row-gap` is writer-domain until +the schema grows one, while the equivalent px `gap` interns. +`tests/test_declared_snapshot.py` pins the view equivalence and enumerates +whatever residue is genuinely left. +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Any, Optional + +from ..dom import CHART_DOM_SLOTS +from .resolved import ResolvedStyleSnapshot, SnapshotBuilder, SnapshotEnvironment, assert_resolved + +#: Slots whose snapshot instances carry per-instance qualifiers + geometry, +#: produced by the writers' own `axis_chrome_boxes` (one geometry source for +#: the SVG writer, the raster writer, and this snapshot). The rest of the +#: axis family joins as its per-instance identity becomes load-bearing. +_PER_INSTANCE_SLOTS: tuple[str, ...] = ("axis_line", "tick_mark") + +#: Re-entrancy guard for the per-instance producer: `axis_chrome_boxes` runs +#: `_svg.layout`, whose title measurement calls back into `resolve_declared`. +#: The nested resolution only needs the writer view, so it skips instance +#: production instead of recursing. +_INSTANCE_GUARD = threading.local() + + +def _axis_instance_records( + spec: dict[str, Any], view: dict[str, dict[str, Any]] +) -> dict[str, list[tuple[tuple[str, ...], tuple[float, float, float, float]]]]: + """Per-instance (qualifiers, geometry) records for the axis box slots.""" + if getattr(_INSTANCE_GUARD, "active", False): + return {} + if "x_axis" not in spec or "y_axis" not in spec: + return {} # a bare styling spec has no layout to resolve against + from .._svg import _has_box_declaration, axis_chrome_boxes + + if not any(_has_box_declaration(view.get(slot)) for slot in _PER_INSTANCE_SLOTS): + return {} + _INSTANCE_GUARD.active = True + try: + boxes = axis_chrome_boxes(spec, view) + finally: + _INSTANCE_GUARD.active = False + records: dict[str, list[tuple[tuple[str, ...], tuple[float, float, float, float]]]] = {} + for box in boxes: + records.setdefault(box.slot, []).append((box.qualifiers, (box.x, box.y, box.w, box.h))) + return records + + +@dataclass(frozen=True) +class DeclaredStyling: + """A figure's declared chrome styling: writer view + IR snapshot.""" + + snapshot: ResolvedStyleSnapshot + writer_domain: dict[str, dict[str, Any]] = field(default_factory=dict) + _view: dict[str, dict[str, Any]] = field(default_factory=dict) + + def slot_view(self) -> dict[str, dict[str, Any]]: + """The writers' mapping — identical to the pre-IR `slot_styles`.""" + return {slot: dict(decls) for slot, decls in self._view.items()} + + +def _kebab(name: object) -> str: + text = str(name) + return text if text.startswith("--") else text.replace("_", "-") + + +def resolve_declared( + spec: dict[str, Any], + *, + width: Optional[float] = None, + height: Optional[float] = None, +) -> DeclaredStyling: + """Resolve `spec`'s per-slot declarations into view + snapshot. + + View normalization is byte-for-byte the old `_svg.slot_styles` rule — + kebab-case property spelling, custom properties untouched, non-mapping + slots skipped, unknown slots passed through (the spec build already + rejects them for real charts). Every schema-legal value additionally + interns into the snapshot; each value the schema refuses lands in + `writer_domain` instead, so nothing declared is dropped in either + direction and the not-yet-IR remainder is exactly enumerable. + """ + from .._chromebox import expand_box_shorthands + + dom = spec.get("dom") or {} + raw = dom.get("styles") or {} + style = dom.get("style") or {} + builder = SnapshotBuilder() + view: dict[str, dict[str, Any]] = {} + writer_domain: dict[str, dict[str, Any]] = {} + environment = SnapshotEnvironment( + width=_dimension(width, spec.get("width"), 800.0), + height=_dimension(height, spec.get("height"), 500.0), + ) + for slot, decls in raw.items(): + if not isinstance(decls, dict): + continue + view[str(slot)] = {_kebab(prop): value for prop, value in decls.items()} + # Axis-chrome box slots intern one declaration and record N instances, + # each with its qualifiers (axis id, major|minor, side, tick index) and + # resolved geometry — produced by the writers' own box producer, so the + # snapshot cannot disagree with what either writer draws. + instance_records = _axis_instance_records(spec, view) + for slot_name, normalized in view.items(): + if slot_name not in CHART_DOM_SLOTS: + writer_domain[slot_name] = dict(normalized) + continue + legal: dict[str, Any] = {} + residue: dict[str, Any] = {} + # Schema v1 carries only longhands, so the box shorthands (`border`, + # `padding`) intern expanded. The expansion is per shorthand and + # all-or-nothing: if any expanded longhand is still writer-domain (an + # em legend padding), the *authored* spelling lands in the residue — + # the pinned residue enumeration stays in authored terms (§28). + for name, value in normalized.items(): + expanded = { + part: part_value + for part, part_value in expand_box_shorthands({name: value}).items() + # An explicit longhand beside the shorthand wins whatever the + # authored order was; the shorthand fills only what is unsaid. + if part == name or part not in normalized + } + try: + resolved = { + part: assert_resolved(part, part_value) for part, part_value in expanded.items() + } + except ValueError: + residue[name] = value + else: + legal.update(resolved) + if legal: + records = instance_records.get(slot_name) + if records: + # Per-instance records (axis families): real qualifiers and + # layout geometry, one interned declaration for N instances. + for qualifiers, geometry in records: + builder.add(slot_name, legal, qualifiers=qualifiers, geometry=geometry) + else: + # Otherwise the geometry the resolver can know without a + # layout pass — the canvas rect for root/chrome, else None. + builder.add( + slot_name, legal, geometry=_environment_geometry(slot_name, environment) + ) + if residue: + writer_domain[slot_name] = residue + snapshot = builder.build(environment, tokens=_concrete_tokens(style)) + return DeclaredStyling(snapshot=snapshot, writer_domain=writer_domain, _view=view) + + +def _environment_geometry( + slot: str, environment: SnapshotEnvironment +) -> Optional[tuple[float, float, float, float]]: + """The slot geometry the declared resolver can know without a layout pass. + + `root` and `chrome` are the full canvas — (0, 0, width, height) in CSS px + against the spec's own dimensions, never the host page's padding (the + normalization rule for capture diffs: a browser capture must subtract host + box offsets before comparing, plan §8 flag J). Slots whose box needs the + layout pass (`title` wants measured text, `canvas` wants the plot rect) + stay None here: the resolver cannot run `layout()` without re-entering + itself through `slot_styles`, so their geometry is populated by the + capture producers that already have the layout in hand. + """ + if slot in ("root", "chrome"): + return (0.0, 0.0, environment.width, environment.height) + return None + + +def _dimension(override: Optional[float], declared: Any, fallback: float) -> float: + for candidate in (override, declared): + if isinstance(candidate, (int, float)) and not isinstance(candidate, bool): + value = float(candidate) + if value > 0: + return value + return fallback + + +def _concrete_tokens(style: Any) -> dict[str, Any]: + """The chart token bag, minus values the schema cannot carry yet. + + The bag is already renderer-neutral (every writer reads it), so almost + everything passes; a var()-bearing token is browser-resolved chrome the + declared resolver has no cascade for, and stays out of the snapshot the + same way it stays out of a static file today. + """ + from .resolved import assert_resolved_token + + if not isinstance(style, dict): + return {} + out: dict[str, Any] = {} + for name, value in style.items(): + if isinstance(value, bool) or not isinstance(value, (str, int, float)): + continue + try: + out[str(name)] = assert_resolved_token(name, value) + except ValueError: + continue + return out + + +__all__ = ["DeclaredStyling", "resolve_declared"] diff --git a/python/xy/styling/preflight.py b/python/xy/styling/preflight.py new file mode 100644 index 00000000..8b2f1068 --- /dev/null +++ b/python/xy/styling/preflight.py @@ -0,0 +1,576 @@ +"""Report-only export preflight: what survives an export, and what does not. + +`chart.style_compatibility_report(target=...)` answers `spec/api/export.md` §9 +programmatically, per chart, before any bytes exist: for the requested target +and engine it lists which styling sources are present, how each styled slot +routes, and exactly which declarations would not survive. The reporting core +(`preflight`, `route_resolved`) changes no export behavior and can be trusted +from any code path; `enforce` is the one function that acts on a report — it +warns in `compatibility="warn"` and refuses in `"strict"`, and does nothing +at all in the default `"legacy"`. + +Three rules keep the report honest: + +1. **No silent decisions (§28).** Every declared style ends in exactly one + route: it survives, it is state-gated chrome a clean static file does not + contain, or it is named as a loss. There is no fourth, quiet bucket. +2. **Constant time when there is nothing to route.** A chart with no + `class_names`, no per-slot `styles`, and no `custom_css` short-circuits to + a lossless report without walking any slot — the preflight is free exactly + where exports are hot. +3. **One source of truth per fact.** Slot routing derives from the capability + registry, the honored property subsets from `xy._svg` (which the writers + themselves read), and engine selection from `xy.export`'s own resolver. + This module restates none of them, so it cannot disagree with them. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +from ..dom import validate_dom_slots +from . import capabilities + +if TYPE_CHECKING: # pragma: no cover - import cycle guard, typing only + from .._figure import Figure + +#: Routes a declared style can take. Stable strings: the staged +#: `compatibility=` modes and the tests key on them. +ROUTE_SURVIVES = "survives" +ROUTE_SUBSET = "native-subset" +ROUTE_BROWSER_ONLY = "browser-only" +ROUTE_STATE_GATED = "state-gated" + +#: The staged compatibility modes, in rollout order. `legacy` is today's +#: behavior and the default; `warn` surfaces every loss as one +#: `StyleCompatibilityWarning`; `strict` refuses to emit bytes that drop a +#: declaration. "lossless" is reserved for the phase that lets `Engine.auto` +#: re-route on preflight evidence — accepting it before that phase would make +#: the name a lie, so it is rejected now. The default flips on the schedule in +#: `spec/process/style-compatibility-migration.md`, never silently. +COMPATIBILITY_MODES: tuple[str, ...] = ("legacy", "warn", "strict") + + +class StyleCompatibilityWarning(UserWarning): + """An export in `compatibility="warn"` mode dropped declared styling.""" + + +class StyleCompatibilityError(ValueError): + """An export in `compatibility="strict"` mode refused to drop styling. + + Carries the full preflight `report`; the message is its `explain()` plus + the ways out, so the fix is in the traceback rather than a docs hunt. + """ + + def __init__(self, message: str, report: StyleCompatibilityReport) -> None: + super().__init__(message) + self.report = report + + +_RASTER_FORMATS = frozenset({"png", "jpeg", "webp"}) +_VECTOR_FORMATS = frozenset({"svg", "pdf"}) + +_SLOTS_BY_ID = {slot.id: slot for slot in capabilities.CHART_SLOTS} + +#: Slots whose channel carries a condition the report cannot resolve from a +#: declaration alone, so a lossless declaration still routes as a subset: +#: `tick_mark` boxes exist only where the axis authored a tick_length. +#: +#: `legend` left this set in P4. It was here because the merged legend +#: declaration honored only some spellings of its own vocabulary — kebab +#: `border-color` was folded and then silently dropped, and `padding`/ +#: `row-gap` were honored in `em` but not in the px an author would write — +#: so a declaration the report called lossless could still lose a property. +#: Both spellings and both unit domains are now honored by the writers and +#: named in `_svg.LEGEND_BOX_PROPS`, so every property the report can see +#: routes provably and the qualifier is explanatory prose, not uncertainty. +_CONDITIONAL_CHANNEL_SLOTS: frozenset[str] = frozenset({"tick_mark"}) + + +@dataclass(frozen=True) +class SlotFinding: + """How one styled slot routes for the requested target.""" + + slot: str + source: str # "styles" | "class_names" + applicability: str # "static" or the gating export state + route: str # one of the ROUTE_* strings + kept: tuple[str, ...] = () + lost: tuple[str, ...] = () + detail: str = "" + + +@dataclass(frozen=True) +class StyleCompatibilityReport: + """The preflight answer for one chart and one export target. + + `lossless` is the single bit the staged modes will act on: True means the + export preserves every declared style that the target's document can + contain (state-gated chrome is recorded, not counted — a clean static + file has no tooltip to style). `error` carries the message of an export + that would refuse outright (for example `custom_css` with a pinned native + engine), mirroring the export path's own exception rather than predicting + a different outcome. + """ + + target: str + engine: str + sources: dict[str, bool] = field(default_factory=dict) + findings: tuple[SlotFinding, ...] = () + losses: tuple[str, ...] = () + lossless: bool = True + error: Optional[str] = None + + def explain(self) -> str: + """The report as readable lines, one decision each.""" + head = f"style compatibility for {self.target} via {self.engine} engine" + lines = [head] + present = [name for name, on in self.sources.items() if on] or ["(defaults only)"] + lines.append("sources: " + ", ".join(present)) + if self.error is not None: + lines.append(f"refused: {self.error}") + return "\n".join(lines) + for finding in self.findings: + bits = [f"{finding.source}[{finding.slot!r}]: {finding.route}"] + if finding.lost: + bits.append("loses " + ", ".join(finding.lost)) + if finding.detail: + bits.append(finding.detail) + lines.append(" " + " — ".join(bits)) + lines.append("lossless" if self.lossless else f"{len(self.losses)} loss(es)") + return "\n".join(lines) + + +def _sources(figure: Figure, custom_css: Optional[str]) -> dict[str, bool]: + """Which styling sources this chart carries. Attribute checks only.""" + return { + "chart_style": bool(figure.style), + "slot_styles": bool(figure.chrome_styles), + "class_names": bool(figure.class_names), + "custom_css": custom_css is not None, + } + + +def _resolve(target: str, engine: object, custom_css: Optional[str]) -> tuple[str, str, str]: + """(format, engine, error) via the export module's own resolver. + + Deferred import: `export` pulls in the browser-discovery machinery, and + `capabilities` must stay importable from the docs generator without it. + + Browser-resolved targets validate `custom_css` through the export path's + own `_custom_css_block` — the same type check and ``/`