diff --git a/spikes/editor-toolkit/Cargo.lock b/spikes/editor-toolkit/Cargo.lock index 24fe61c..9421b1b 100644 --- a/spikes/editor-toolkit/Cargo.lock +++ b/spikes/editor-toolkit/Cargo.lock @@ -507,6 +507,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -560,7 +566,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -569,6 +575,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -820,6 +835,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -939,6 +960,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -982,6 +1012,16 @@ dependencies = [ "wgpu 27.0.1", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.10.1" @@ -997,6 +1037,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1405,6 +1461,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "float_next_after" version = "1.0.0" @@ -1621,6 +1683,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1665,6 +1737,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2254,10 +2336,26 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", + "png 0.18.1", "tiff", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "2.14.0" @@ -2389,6 +2487,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -3315,6 +3424,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -3370,6 +3485,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.1" @@ -3633,6 +3761,32 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "round1-harness" version = "0.1.0" @@ -3653,6 +3807,48 @@ dependencies = [ "serde_json", ] +[[package]] +name = "round2-diff" +version = "0.1.0" + +[[package]] +name = "round2-reference" +version = "0.1.0" +dependencies = [ + "round2-diff", + "round2-svgref", + "round2-textkit", + "serde", + "serde_json", + "ttf-parser", +] + +[[package]] +name = "round2-svgref" +version = "0.1.0" +dependencies = [ + "resvg", + "tiny-skia", + "ttf-parser", + "usvg", +] + +[[package]] +name = "round2-textkit" +version = "0.1.0" +dependencies = [ + "epiphany-core", + "epiphany-determinism", + "epiphany-layout-ir", + "rustybuzz", + "serde", + "serde_json", + "sha2", + "ttf-parser", + "unicode-bidi", + "unicode-segmentation", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3712,6 +3908,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3812,6 +4026,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3850,6 +4075,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -4045,6 +4279,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "svg_fmt" @@ -4052,6 +4289,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "swash" version = "0.2.10" @@ -4178,7 +4425,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -4192,6 +4439,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path", ] @@ -4324,6 +4572,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4341,6 +4595,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4368,6 +4634,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-script" version = "0.5.8" @@ -4380,6 +4652,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4398,6 +4676,33 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4425,7 +4730,7 @@ dependencies = [ "futures-intrusive", "log", "peniko", - "png", + "png 0.18.1", "skrifa 0.42.1", "static_assertions", "thiserror 2.0.19", @@ -5592,6 +5897,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yazi" version = "0.2.1" @@ -5804,19 +6115,34 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.1", ] [[package]] diff --git a/spikes/editor-toolkit/Cargo.toml b/spikes/editor-toolkit/Cargo.toml index 7d06bfd..a5a539f 100644 --- a/spikes/editor-toolkit/Cargo.toml +++ b/spikes/editor-toolkit/Cargo.toml @@ -8,6 +8,10 @@ members = [ "round1-candidates/harness", "round1-candidates/c1-egui-lyon", "round1-candidates/c2-vello", + "round2-diff", + "round2-textkit", + "round2-svgref", + "round2-reference", ] # a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not diff --git a/spikes/editor-toolkit/round2-diff/Cargo.toml b/spikes/editor-toolkit/round2-diff/Cargo.toml new file mode 100644 index 0000000..ab7235e --- /dev/null +++ b/spikes/editor-toolkit/round2-diff/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "round2-diff" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Packet 2A-ii (ROUND2_TEXT_RECIPE.md §10, §11): the bounded visual +# differential itself. Pure image math over RGBA buffers — it takes two +# already-rasterized `&[u8]` readbacks plus width/height plus the per-glyph +# regions D4 scores, and returns a rich verdict (D1/D2/D3/D4, each with its +# measured numbers, never just a bool). +# +# Deliberately zero dependencies: this crate must not know about fonts, +# text shaping, GPU, or the emitter that produced either raster (§9's +# `resvg`/`ttf-parser` pipeline, or whatever a future candidate uses). It +# is candidate-neutral and stand-in-neutral in the same sense the Round 1 +# harness's oracle model is candidate-neutral — it validates and compares +# pixels, and nothing about how those pixels were produced. +# +# The `selftest` binary (src/bin/selftest.rs) synthesizes its own reference +# raster and mutates it, because Packet 2A has no candidate yet (§11): +# exactly the role Round 1's oracle tranche played before any candidate +# consumed it. It exits non-zero if a required kill does not happen. It covers +# the geometric mutations only; the text mutations M4/M5/M6 need real shaped +# glyphs and live in `round2-reference`'s `text_mutations` binary. + +[dependencies] + +[[bin]] +name = "selftest" +path = "src/bin/selftest.rs" diff --git a/spikes/editor-toolkit/round2-diff/src/bin/selftest.rs b/spikes/editor-toolkit/round2-diff/src/bin/selftest.rs new file mode 100644 index 0000000..a94bc52 --- /dev/null +++ b/spikes/editor-toolkit/round2-diff/src/bin/selftest.rs @@ -0,0 +1,688 @@ +//! Packet 2A-ii mutation evidence (`ROUND2_TEXT_RECIPE.md` §11). +//! +//! There is no candidate raster yet — Packet 2A is candidate-neutral by +//! design (§10's opening line: "a tolerance chosen after seeing a +//! candidate's output is not a tolerance"). So this binary stands in for a +//! candidate by synthesizing its own reference raster and mutating it, +//! exactly the role Round 1's oracle tranche played when it mutated its own +//! geometry before any candidate consumed it. +//! +//! ## What is synthesized +//! +//! A 1920x1080 opaque-white canvas carrying opaque-black ink that resembles +//! text: five vertical stems (3, 4, 5, 4 and 12 device px wide), one straight +//! diagonal stroke, one curved stroke (a polyline of capsules approximating a +//! quadratic bezier), and one ring, whose enclosed centre is a small bounded +//! counter exactly like a real letterform's. +//! +//! **The stem widths straddle D1's structural floor on purpose.** D1 cannot +//! see an error confined to a stroke narrower than `2 * EDGE_BAND_PX + 1 = 5` +//! device px, because such a stroke lies wholly within the band around its own +//! edges. Four stems sit at or below that floor and one sits well above it, so +//! M3 and M3B measure the boundary from both sides instead of asserting it +//! from one. The 12 px stem is also the width the real fixtures actually have: +//! recipe §3's em size was itself re-derived from this floor, after revision 1 +//! pinned 64 px em and produced 5.4 px stems that D1 would have been blind to +//! throughout. +//! +//! ## How antialiasing is computed +//! +//! Each shape is a closed 2D region with an exact `inside(x, y)` test (a +//! half-plane/rect test for stems, a distance-to-segment test for capsules, +//! an annulus test for the ring — no sqrt needed, since every comparison is +//! against a squared radius). Per-pixel coverage is estimated by supersampling: +//! an `sub x sub` regular grid of sample points inside the pixel is tested +//! against every shape, and coverage is the fraction of samples that land +//! inside *any* shape (a boolean union, so overlapping shapes do not +//! double-count). The reference raster uses `sub = 4` (16 samples/pixel). +//! The M10 "legitimate AA variant" is *not* a coarser grid — see +//! [`gamma_variant`]'s doc comment for why the `sub = 3` version turned out +//! to be an artifact of this synthesis's own coordinate choices rather than a +//! fair stand-in for a differently-tuned AA kernel; it is still run, but as a +//! non-blocking observation. +//! Luma is `255 * (1 - coverage)`: full coverage paints (0,0,0) ink, zero +//! coverage leaves (255,255,255) ground, and partial coverage is a +//! continuous grey ramp — a hard-edged reference would make the edge band +//! empty and D1 trivially (and meaninglessly) strong, which is exactly what +//! the recipe warns against. + +use round2_diff::{diff, DiffReport, GlyphRegion}; + +const WIDTH: u32 = 1920; +const HEIGHT: u32 = 1080; + +// --------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------- + +#[derive(Clone, Copy)] +enum Shape { + Rect { + x0: f64, + x1: f64, + y0: f64, + y1: f64, + }, + Capsule { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + r: f64, + }, + Ring { + cx: f64, + cy: f64, + inner: f64, + outer: f64, + }, +} + +impl Shape { + /// Axis-aligned bounding box, `(minx, maxx, miny, maxy)`, used only to + /// let `coverage_grid` skip the expensive test for pixels nowhere near + /// this shape. + fn bbox(&self) -> (f64, f64, f64, f64) { + match *self { + Shape::Rect { x0, x1, y0, y1 } => (x0, x1, y0, y1), + Shape::Capsule { x0, y0, x1, y1, r } => ( + x0.min(x1) - r, + x0.max(x1) + r, + y0.min(y1) - r, + y0.max(y1) + r, + ), + Shape::Ring { cx, cy, outer, .. } => (cx - outer, cx + outer, cy - outer, cy + outer), + } + } + + fn inside(&self, px: f64, py: f64) -> bool { + match *self { + Shape::Rect { x0, x1, y0, y1 } => px >= x0 && px <= x1 && py >= y0 && py <= y1, + Shape::Capsule { x0, y0, x1, y1, r } => { + let dx = x1 - x0; + let dy = y1 - y0; + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { + (((px - x0) * dx + (py - y0) * dy) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + let cx = x0 + t * dx; + let cy = y0 + t * dy; + let d2 = (px - cx).powi(2) + (py - cy).powi(2); + d2 <= r * r + } + Shape::Ring { + cx, + cy, + inner, + outer, + } => { + let d2 = (px - cx).powi(2) + (py - cy).powi(2); + d2 >= inner * inner && d2 <= outer * outer + } + } + } +} + +/// Builds the reference geometry. `drop_stem = Some(i)` omits stem `i`, +/// expressing M3 / M3B as a regenerated geometric change rather than a raster +/// patch — a paint-over would leave an antialiasing seam of its own and the +/// differential would be scoring the patch, not the removal. +fn build_geometry(drop_stem: Option) -> Vec { + let mut shapes = Vec::new(); + + // Five vertical stems. The first four are 3/4/5/4 device px — all at or + // below D1's structural floor of `2 * EDGE_BAND_PX + 1 = 5` px, where a + // stroke lies entirely within the band around its own edges. The fifth is + // 12 px, above the floor and close to the 10.8 px stems the real fixtures + // measure at recipe §3's em size. + // + // Both sides of the floor are present **on purpose**: the selftest must + // demonstrate that D1 is blind below it and sighted above it, rather than + // assert a boundary it never crossed. M3 drops the last thin stem (D1 + // must stay silent, D4 must fire); M3B drops the thick one (D1 must fire). + let stems: [(f64, f64); 5] = [ + (300.0, 3.0), + (340.0, 4.0), + (380.0, 5.0), + (420.0, 4.0), + (480.0, 12.0), + ]; + for (i, &(xc, w)) in stems.iter().enumerate() { + if drop_stem == Some(i) { + continue; + } + shapes.push(Shape::Rect { + x0: xc - w / 2.0, + x1: xc + w / 2.0, + y0: 200.0, + y1: 900.0, + }); + } + + // One straight diagonal stroke. + shapes.push(Shape::Capsule { + x0: 600.0, + y0: 850.0, + x1: 750.0, + y1: 250.0, + r: 2.0, + }); + + // One curved stroke: a quadratic bezier (900,850) -> control (1060,550) + // -> (900,250), approximated as a chain of capsule segments. + let (p0x, p0y) = (900.0f64, 850.0f64); + let (ctlx, ctly) = (1060.0f64, 550.0f64); + let (p1x, p1y) = (900.0f64, 250.0f64); + const CURVE_SEGMENTS: usize = 48; + let mut prev = (p0x, p0y); + for i in 1..=CURVE_SEGMENTS { + let t = i as f64 / CURVE_SEGMENTS as f64; + let mt = 1.0 - t; + let x = mt * mt * p0x + 2.0 * mt * t * ctlx + t * t * p1x; + let y = mt * mt * p0y + 2.0 * mt * t * ctly + t * t * p1y; + shapes.push(Shape::Capsule { + x0: prev.0, + y0: prev.1, + x1: x, + y1: y, + r: 2.0, + }); + prev = (x, y); + } + + // One ring: the enclosed inner disc (radius 35) is the small bounded + // counter — background surrounded on all sides by the ring's ink. + shapes.push(Shape::Ring { + cx: 1250.0, + cy: 550.0, + inner: 35.0, + outer: 55.0, + }); + + shapes +} + +// --------------------------------------------------------------------- +// Coverage / rendering +// --------------------------------------------------------------------- + +/// Supersampled coverage grid: for each pixel, the fraction of an `sub x +/// sub` regular sample grid landing inside the union of `shapes`. +fn coverage_grid(shapes: &[Shape], sub: u32) -> Vec { + let bboxes: Vec<(f64, f64, f64, f64)> = shapes.iter().map(Shape::bbox).collect(); + let mut cov = vec![0f32; (WIDTH as usize) * (HEIGHT as usize)]; + let total = (sub * sub) as f32; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let mut count = 0u32; + for sy in 0..sub { + for sx in 0..sub { + let px = x as f64 + (sx as f64 + 0.5) / sub as f64; + let py = y as f64 + (sy as f64 + 0.5) / sub as f64; + let hit = shapes.iter().zip(bboxes.iter()).any(|(s, bb)| { + px >= bb.0 && px <= bb.1 && py >= bb.2 && py <= bb.3 && s.inside(px, py) + }); + if hit { + count += 1; + } + } + } + cov[(y as usize) * (WIDTH as usize) + (x as usize)] = count as f32 / total; + } + } + cov +} + +/// Renders a coverage grid to an opaque RGBA buffer: `luma = 255 * (1 - +/// coverage)`, grayscale (ink is achromatic black-on-white per recipe §3). +fn render_rgba(cov: &[f32]) -> Vec { + let mut buf = vec![0u8; cov.len() * 4]; + for (i, &c) in cov.iter().enumerate() { + let c = c.clamp(0.0, 1.0); + let l = (255.0 * (1.0 - c)).round().clamp(0.0, 255.0) as u8; + buf[i * 4] = l; + buf[i * 4 + 1] = l; + buf[i * 4 + 2] = l; + buf[i * 4 + 3] = 255; + } + buf +} + +/// Bends the antialiasing ramp: every pixel with *partial* coverage (`0 < +/// c < 1`) is remapped to `c.powf(gamma)`; fully-covered and fully-empty +/// pixels are left exactly alone. +/// +/// This is the legitimate-AA-variant technique recipe §10's own text +/// suggests ("a small gamma difference applied ONLY to partially-covered +/// pixels"), and it is deliberately used here instead of a coarser +/// supersample grid. An earlier version of this selftest tried that +/// (`coverage_grid(&geometry, 3)`, 9 samples/px, vs the reference's 16): +/// it measured a 2.26% mass delta and a **7.6 device px** centroid delta — +/// nowhere near "AA only". The cause, traced by hand: this synthesis +/// deliberately uses round shape coordinates (integer stem centres, +/// integer/half-integer widths), so several edges land at exact +/// half-integer device coordinates. A 3-sample grid's middle sample sits +/// exactly on such a boundary and the inclusive `>=` inside-test counts it +/// as ink, while a 4-sample grid has no sample there at all — a +/// **correlated** bias across every such edge, not the independent, +/// near-zero-mean noise a real renderer's coarser sampling would produce. +/// That made the sub=3 variant an artifact of this synthetic geometry's +/// coordinate choices, not a fair stand-in for "a differently-tuned real +/// AA kernel" — so it is reported as a finding below instead of kept as +/// the demonstration variant. Gamma-bending the ramp changes only the +/// pixels that are already antialiased, by construction (see the call +/// site), without that alignment artifact. +fn gamma_variant(cov: &[f32], gamma: f32) -> Vec { + cov.iter() + .map(|&c| if c > 0.0 && c < 1.0 { c.powf(gamma) } else { c }) + .collect() +} + +/// Bilinear sample of a coverage grid at a continuous coordinate. Samples +/// falling outside `[0, WIDTH) x [0, HEIGHT)` read as coverage `0` +/// (background) — a translated or scaled raster's newly-exposed edge is +/// whatever the renderer would have drawn there, and since no shape in this +/// synthesis reaches within ~150px of any border, that value is always +/// background here, not a border artefact this selftest depends on. +fn sample_bilinear(grid: &[f32], x: f64, y: f64) -> f32 { + let x0f = x.floor(); + let y0f = y.floor(); + let x0 = x0f as i64; + let y0 = y0f as i64; + let fx = (x - x0f) as f32; + let fy = (y - y0f) as f32; + let get = |xi: i64, yi: i64| -> f32 { + if xi < 0 || yi < 0 || xi >= WIDTH as i64 || yi >= HEIGHT as i64 { + 0.0 + } else { + grid[(yi as usize) * (WIDTH as usize) + (xi as usize)] + } + }; + let v00 = get(x0, y0); + let v10 = get(x0 + 1, y0); + let v01 = get(x0, y0 + 1); + let v11 = get(x0 + 1, y0 + 1); + let top = v00 * (1.0 - fx) + v10 * fx; + let bot = v01 * (1.0 - fx) + v11 * fx; + top * (1.0 - fy) + bot * fy +} + +/// Resamples `grid` by translating content by `(dx, dy)` device px: output +/// pixel `(x, y)` reads input at `(x - dx, y - dy)`. +fn translate(grid: &[f32], dx: f64, dy: f64) -> Vec { + let mut out = vec![0f32; grid.len()]; + for y in 0..HEIGHT { + for x in 0..WIDTH { + out[(y as usize) * (WIDTH as usize) + (x as usize)] = + sample_bilinear(grid, x as f64 - dx, y as f64 - dy); + } + } + out +} + +/// Resamples `grid`, scaling content by `factor` about the pixel-index +/// centre of the canvas — the inverse-mapping resample standard for a +/// "scale about centre" transform: output pixel `p` reads input at `centre +/// + (p - centre) / factor`. +fn scale_about_centre(grid: &[f32], factor: f64) -> Vec { + let cx = (WIDTH as f64 - 1.0) / 2.0; + let cy = (HEIGHT as f64 - 1.0) / 2.0; + let mut out = vec![0f32; grid.len()]; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let sx = cx + (x as f64 - cx) / factor; + let sy = cy + (y as f64 - cy) / factor; + out[(y as usize) * (WIDTH as usize) + (x as usize)] = sample_bilinear(grid, sx, sy); + } + } + out +} + +// --------------------------------------------------------------------- +// Report printing +// --------------------------------------------------------------------- + +fn print_case(label: &str, expectation: &str, r: &DiffReport) -> bool { + println!("--- {label} ---"); + println!(" expected to fail: {expectation}"); + println!( + " D1 outside-band differing pixels = {} (pass = {})", + r.d1_pixels_outside_band_differing, r.d1_pass + ); + println!( + " D2 ref_mass = {:.3} cand_mass = {:.3} relative_delta = {:.4}% (pass = {}, tolerance 2%)", + r.reference_ink_mass, + r.candidate_ink_mass, + r.d2_relative_delta * 100.0, + r.d2_pass + ); + match ( + r.reference_centroid, + r.candidate_centroid, + r.d3_delta, + r.d3_pass, + ) { + (Some(rc), Some(cc), Some(d), Some(pass)) => { + println!( + " D3 ref_centroid = ({:.3}, {:.3}) cand_centroid = ({:.3}, {:.3}) \ + delta = ({:.3}, {:.3}) px (pass = {pass}, tolerance 0.5 px/axis)", + rc.0, rc.1, cc.0, cc.1, d.0, d.1 + ); + } + _ => { + println!(" D3 undefined (one side has zero ink mass) — not blocking, see d3_pass doc") + } + } + println!( + " in-band: pixel_count = {}, max|delta luma| = {}, count(|delta| > 16) = {} (reported only)", + r.band_pixel_count, r.in_band_max_abs_delta_luma, r.in_band_count_delta_gt_report_threshold + ); + match &r.d4_worst { + Some(w) => println!( + " D4 {} region(s); worst = {:?} ref_mass = {:.3} cand_mass = {:.3} \ + relative_delta = {:.4}% (pass = {}, tolerance {}%)", + r.d4_regions.len(), + w.label, + w.reference_mass, + w.candidate_mass, + w.relative_delta * 100.0, + r.d4_pass, + round2_diff::D4_RELATIVE_TOLERANCE * 100.0 + ), + None => println!(" D4 no regions supplied"), + } + for reg in r.d4_regions.iter().filter(|x| !x.pass) { + println!( + " FAIL region {:?}: ref {:.3} -> cand {:.3} ({:.4}%)", + reg.label, + reg.reference_mass, + reg.candidate_mass, + reg.relative_delta * 100.0 + ); + } + println!(" overall pass = {}", r.pass()); + r.pass() +} + +/// The D4 regions, standing in for the per-glyph bounding boxes the real +/// fixtures supply. One per drawn feature, sized to the feature's own extent — +/// the crate dilates each by `D4_REGION_DILATION_PX` internally. +/// +/// These are stated as literals rather than derived from `build_geometry`, on +/// purpose: a region list computed from the same function that draws the +/// shapes would shrink automatically when a shape is dropped, and the dropped +/// glyph would be scored against a region that no longer covers it — which is +/// precisely the failure D4 exists to catch, silently repaired. +fn regions() -> Vec { + let mut v = vec![]; + for (i, (xc, w)) in [ + (300.0, 3.0), + (340.0, 4.0), + (380.0, 5.0), + (420.0, 4.0), + (480.0, 12.0), + ] + .into_iter() + .enumerate() + { + let half = (w as f64) / 2.0; + v.push(GlyphRegion { + label: format!("stem{i}(w={w})"), + x0: (xc - half).floor() as u32, + x1: (xc + half).ceil() as u32, + y0: 200, + y1: 900, + }); + } + v.push(GlyphRegion { + label: "diagonal".into(), + x0: 595, + y0: 245, + x1: 755, + y1: 855, + }); + v.push(GlyphRegion { + label: "curve".into(), + x0: 895, + y0: 245, + x1: 1065, + y1: 855, + }); + v.push(GlyphRegion { + label: "ring".into(), + x0: 1193, + y0: 493, + x1: 1307, + y1: 607, + }); + v +} + +fn main() { + let t_start = std::time::Instant::now(); + let regions = regions(); + + let full_geometry = build_geometry(None); + let cov_ref = coverage_grid(&full_geometry, 4); + let rgba_ref = render_rgba(&cov_ref); + round2_diff::validate_rgba(&rgba_ref, WIDTH, HEIGHT) + .unwrap_or_else(|e| panic!("synthesized reference is malformed: {e}")); + + println!( + "synthesized {WIDTH}x{HEIGHT} reference in {:.2}s", + t_start.elapsed().as_secs_f64() + ); + + let selfdiff = diff(&rgba_ref, &rgba_ref, WIDTH, HEIGHT, ®ions).unwrap(); + let selfcheck_ok = print_case( + "selfcheck (reference vs itself)", + "nothing — must pass everything", + &selfdiff, + ); + + // Two lists, and the difference between them is the exit code. + // + // `failures` is the blocking set: a mutation the recipe requires to kill + // that did not kill, or the M10 AA-only variant failing. Any entry here + // means this binary exits non-zero, because a self-test that prints its + // own bad news and then reports success is indistinguishable from one + // that found nothing — which is exactly how a mutation set rots. + // + // `observations` is the non-blocking set: measured facts worth printing + // that the recipe does not require anything of (today, only the + // coarse-grid supersampling artifact, which traces to this synthesis's + // own coordinate choices rather than to D1/D2/D3 — see + // `gamma_variant`'s doc comment). + let mut failures: Vec = Vec::new(); + let mut observations: Vec = Vec::new(); + if !selfcheck_ok { + failures.push("selfcheck: the reference does not pass the differential against itself — the \ + differential or the synthesis has a bug, and nothing below is trustworthy until \ + that is fixed" + .to_string()); + } + + // M1: translate by 1 device px in x. Must fail D1 and/or D3. + let rgba_m1 = render_rgba(&translate(&cov_ref, 1.0, 0.0)); + round2_diff::validate_rgba(&rgba_m1, WIDTH, HEIGHT).unwrap(); + let r1 = diff(&rgba_ref, &rgba_m1, WIDTH, HEIGHT, ®ions).unwrap(); + let m1_killed = !r1.d1_pass || !r1.d3_pass.unwrap_or(true); + print_case("M1: translate 1 device px in x", "D1 and/or D3", &r1); + if !m1_killed { + failures.push( + "M1 (translate 1px) did not fail D1 or D3 — the differential cannot see a whole-run \ + 1px shift" + .to_string(), + ); + } + + // M2: translate by 0.5 device px (resample). BOUNDARY PROBE — nothing is + // required of it; see the explanation below and recipe §11. + let rgba_m2 = render_rgba(&translate(&cov_ref, 0.5, 0.0)); + round2_diff::validate_rgba(&rgba_m2, WIDTH, HEIGHT).unwrap(); + let r2 = diff(&rgba_ref, &rgba_m2, WIDTH, HEIGHT, ®ions).unwrap(); + // Recipe revision 2 demotes M2 to a BOUNDARY PROBE. Revision 1 required + // it to kill D3 while setting the mutation magnitude (0.5 px) exactly + // equal to D3's own tolerance (0.5 px) — a test of arithmetic, not of the + // rule. Its outcome is recorded either way; nothing is required of it. + print_case( + "M2: translate 0.5 device px (resample) [BOUNDARY PROBE — no kill required]", + "nothing required; recipe §10 declares D3's floor at ~1 px", + &r2, + ); + + // M3: drop the last THIN stem (4 px, below D1's 5 px structural floor). + // Recipe revision 2: D4 must fire. D1 is EXPECTED to stay silent — that + // is the measured blind spot, and this case is what measures it. + let rgba_m3 = render_rgba(&coverage_grid(&build_geometry(Some(3)), 4)); + round2_diff::validate_rgba(&rgba_m3, WIDTH, HEIGHT).unwrap(); + let r3 = diff(&rgba_ref, &rgba_m3, WIDTH, HEIGHT, ®ions).unwrap(); + print_case( + "M3: drop a 4px stem (below D1's 5px floor)", + "D4 (and D2); D1 expected SILENT — this measures the blind spot", + &r3, + ); + if r3.d4_pass { + failures.push( + "M3 (drop a thin stem) did not fail D4 — the one rule that is supposed to catch a \ + dropped glyph did not" + .to_string(), + ); + } + if !r3.d1_pass { + failures.push( + "M3: D1 FIRED on a 4px stem, contradicting recipe §10's stated blind spot — the \ + stated floor is wrong and the recipe must be corrected, not the expectation" + .to_string(), + ); + } + + // M3B: drop the 12px stem, ABOVE D1's floor. D1 must fire. Without this + // case the blind-spot claim would be asserted from one side only. + let rgba_m3b = render_rgba(&coverage_grid(&build_geometry(Some(4)), 4)); + round2_diff::validate_rgba(&rgba_m3b, WIDTH, HEIGHT).unwrap(); + let r3b = diff(&rgba_ref, &rgba_m3b, WIDTH, HEIGHT, ®ions).unwrap(); + print_case( + "M3B: drop the 12px stem (above D1's 5px floor)", + "D1 AND D4 — proves the floor is a floor, not blanket blindness", + &r3b, + ); + if r3b.d1_pass || r3b.d4_pass { + failures.push(format!( + "M3B (drop a 12px stem) did not fail both D1 and D4 — d1_pass={}, d4_pass={}. If D1 \ + is blind even above its stated floor, the rule is not doing the job the recipe \ + assigns it", + r3b.d1_pass, r3b.d4_pass + )); + } + + // M7: scale by 1% about the image centre. Must fail D1 and D3. + let rgba_m7 = render_rgba(&scale_about_centre(&cov_ref, 1.01)); + round2_diff::validate_rgba(&rgba_m7, WIDTH, HEIGHT).unwrap(); + let r7 = diff(&rgba_ref, &rgba_m7, WIDTH, HEIGHT, ®ions).unwrap(); + print_case("M7: scale by 1% about image centre", "D1 and D3", &r7); + if r7.d1_pass || r7.d3_pass.unwrap_or(true) { + failures.push(format!( + "M7 (scale 1%) did not fail both D1 and D3 — d1_pass={}, d3_pass={:?}", + r7.d1_pass, r7.d3_pass + )); + } + + // M8: blank the target entirely. Must fail D2. + let rgba_m8 = vec![255u8; (WIDTH as usize) * (HEIGHT as usize) * 4]; + round2_diff::validate_rgba(&rgba_m8, WIDTH, HEIGHT).unwrap(); + let r8 = diff(&rgba_ref, &rgba_m8, WIDTH, HEIGHT, ®ions).unwrap(); + print_case("M8: blank target to all white", "D2", &r8); + if r8.d2_pass { + failures.push( + "M8 (blank target) did not fail D2 — the differential cannot see a completely \ + missing render" + .to_string(), + ); + } + + // Supplementary evidence, not the demonstration variant: a coarser + // supersample grid (9 vs the reference's 16 samples/px) over the SAME + // geometry, printed for the record because it is exactly what surfaced + // the coordinate-alignment artifact `gamma_variant`'s doc comment + // explains. It is reported, not treated as a differential defect: the + // bias traces to this synthesis's own round shape coordinates landing + // on the coarse grid's sample offsets, not to D1/D2/D3 themselves. + let cov_coarse = coverage_grid(&full_geometry, 3); + let rgba_coarse = render_rgba(&cov_coarse); + round2_diff::validate_rgba(&rgba_coarse, WIDTH, HEIGHT).unwrap(); + let r_coarse = diff(&rgba_ref, &rgba_coarse, WIDTH, HEIGHT, ®ions).unwrap(); + let coarse_passed = print_case( + "(supplementary) coarser-grid AA variant: 9-sample vs 16-sample supersampling", + "nothing, if this were a fair AA-only variant", + &r_coarse, + ); + if !coarse_passed { + observations.push(format!( + "supplementary: a 9-sample-vs-16-sample supersample grid on this synthesis's \ + round shape coordinates does NOT pass the differential (d1_pass={}, d2_pass={}, \ + d3_pass={:?}) — traced to sample points landing exactly on shape boundaries at \ + several edges, a correlated bias specific to these coordinates rather than \ + independent AA noise; not used as the pass-demonstrating variant for that reason, \ + see gamma_variant's doc comment", + r_coarse.d1_pass, r_coarse.d2_pass, r_coarse.d3_pass + )); + } + + // Legitimate AA-only variant: identical geometry, identical sampling + // grid, but a small gamma bend applied ONLY to partially-covered pixels + // (see gamma_variant's doc comment for why this — not a coarser + // supersample grid — is the honest way to model "a different AA + // kernel" here). Must PASS all three. + let cov_variant = gamma_variant(&cov_ref, 0.97); + let rgba_variant = render_rgba(&cov_variant); + round2_diff::validate_rgba(&rgba_variant, WIDTH, HEIGHT).unwrap(); + let rv = diff(&rgba_ref, &rgba_variant, WIDTH, HEIGHT, ®ions).unwrap(); + let variant_passed = print_case( + "AA-only variant: same geometry/grid, gamma 0.97 on partial-coverage pixels only", + "nothing — must PASS D1, D2, and D3", + &rv, + ); + if !variant_passed { + failures.push(format!( + "the legitimate AA-only variant did NOT pass the differential — d1_pass={}, d2_pass={}, \ + d3_pass={:?}; the tolerance is too tight, rejecting a change that is antialiasing only", + rv.d1_pass, rv.d2_pass, rv.d3_pass + )); + } + + println!("\n=== observations (non-blocking) ==="); + if observations.is_empty() { + println!(" none"); + } else { + for o in &observations { + println!(" OBSERVATION: {o}"); + } + } + + println!("\n=== failures (blocking) ==="); + if failures.is_empty() { + println!(" none: every mutation killed as required, and the legitimate AA variant passed"); + } else { + for f in &failures { + println!(" FAILURE: {f}"); + } + } + + println!("\ntotal wall time: {:.2}s", t_start.elapsed().as_secs_f64()); + + // The exit code is the point. An earlier version of this binary printed + // the list above and then returned normally, so a run that reported a + // surviving mutation still exited 0 — a self-test whose bad news is + // invisible to every caller that checks a status is a self-test that will + // rot without anyone noticing. + if !failures.is_empty() { + eprintln!( + "\nselftest FAILED: {} blocking failure(s) — see the list above", + failures.len() + ); + std::process::exit(1); + } +} diff --git a/spikes/editor-toolkit/round2-diff/src/lib.rs b/spikes/editor-toolkit/round2-diff/src/lib.rs new file mode 100644 index 0000000..7891a0a --- /dev/null +++ b/spikes/editor-toolkit/round2-diff/src/lib.rs @@ -0,0 +1,701 @@ +//! The bounded visual differential (`ROUND2_TEXT_RECIPE.md` §10): pure image +//! math over two RGBA raster buffers, with no dependency on how either one +//! was produced. Ruling A permitted "geometry/scene equivalence plus a +//! bounded visual differential under a controlled backend, NOT pixel +//! equality"; this crate is where that phrase finally gets a number, fixed +//! **before** either candidate's raster exists (see the `selftest` binary, +//! which stands in for a candidate the way Round 1's oracle tranche mutated +//! its own geometry before any candidate did). +//! +//! Four rules decide, all hard (§10): +//! +//! - **D1** — outside a band around reference edges, zero pixels may differ +//! in ink class. +//! - **D2** — whole-image ink mass (Σ(255 − luma)/255) agrees within 2%. +//! - **D3** — the whole-image ink centroid agrees within 0.5 device px per +//! axis, for *gross* misplacement only; its detection floor is declared on +//! [`D3_TOLERANCE_DEVICE_PX`]. +//! - **D4** — **per-glyph** ink mass agrees within 2%, for every glyph. +//! +//! **D4 is the rule that catches a wrong, dropped, or re-shaped glyph, and it +//! exists because recipe revision 1 shipped without it and was wrong.** That +//! revision assigned the job to D1, and the mutation set proved D1 cannot do +//! it: D1 is structurally blind to any error confined to a stroke narrower +//! than `2 * EDGE_BAND_PX + 1 = 5` device px, because such a stroke lies +//! entirely inside the band around its own edges. Measured here — deleting a +//! 4 px stem gives `d1 = 0` differing pixels while D4 reports 100%; deleting a +//! 12 px stem, above the floor, gives `d1 = 4164`. The floor is measured from +//! both sides rather than asserted from one. +//! +//! Two more numbers are *reported inside the band but never decide anything* +//! — the max |Δluma| and the count of pixels differing by more than 16 — +//! because inside the band a difference is expected to be antialiasing, and +//! that is precisely what this differential is bounded *against* measuring. + +/// Rec. 601 integer luma weights, identical to +/// `round1-candidates/harness`'s `classify_pixel` — the same convention +/// Round 1 used, so "ink" means the same thing in both rounds. +fn luma(rgba: [u8; 4]) -> u8 { + let [r, g, b, _a] = rgba; + ((299 * r as u32 + 587 * g as u32 + 114 * b as u32) / 1000) as u8 +} + +/// A pixel is ink if its luma is strictly below this. Recipe §10: "Ink = +/// luma < 128." Not a tunable — the recipe fixes it, so it is a `const`, +/// not a parameter a caller could quietly relax. +pub const INK_LUMA_THRESHOLD: u8 = 128; + +/// The band radius, in Chebyshev distance, around a reference edge pixel. +/// Recipe §10 / §11: `EDGE_BAND_PX = 2`, "the same device Round 1 used for +/// its 8 px clearance floor" — confine D1 to where the answer is geometric, +/// not a coin flip about antialiasing. +pub const EDGE_BAND_PX: i32 = 2; + +/// D2's relative tolerance on ink mass: 2% (recipe §10). +pub const D2_RELATIVE_TOLERANCE: f64 = 0.02; + +/// D3's per-axis tolerance on the ink centroid, in device px (recipe §10). +/// +/// **D3's declared detection floor.** A whole-image centroid is one number +/// over two million pixels. Measured on this crate's own synthetic reference, +/// a legitimate antialiasing-only variant moved it 0.346 px while a true +/// 0.5 px translation moved it 0.486 px — not separable. So D3 does **not** +/// detect uniform drift below roughly 1 device px, and the recipe does not +/// claim it does; it is retained for gross misplacement, where it is decisive +/// (deleting one stem moved it 40.7 px). Sub-pixel registration is out of +/// scope for this round, declared in advance rather than inferred later from +/// a candidate's numbers. +pub const D3_TOLERANCE_DEVICE_PX: f64 = 0.5; + +/// D4's relative tolerance on **per-glyph** ink mass: 2% (recipe §10). +pub const D4_RELATIVE_TOLERANCE: f64 = 0.02; + +/// Each glyph region is dilated by this many device px before its mass is +/// measured, so a glyph whose ink legitimately spills a fraction of a pixel +/// past its reported bounding box (antialiasing does exactly this) is not +/// scored on a clipped footprint. +pub const D4_REGION_DILATION_PX: u32 = 3; + +/// One glyph's device-space footprint, for D4. Half-open in the same sense as +/// the raster: `x0..x1`, `y0..y1`. +#[derive(Clone, Debug)] +pub struct GlyphRegion { + /// Human-readable identity for the report — a `FAIL` names the glyph, not + /// an index into a list the reader does not have. + pub label: String, + pub x0: u32, + pub y0: u32, + pub x1: u32, + pub y1: u32, +} + +/// One region's D4 outcome, kept per region so a `FAIL` says *which glyph*. +#[derive(Clone, Debug)] +pub struct RegionMass { + pub label: String, + pub reference_mass: f64, + pub candidate_mass: f64, + pub relative_delta: f64, + pub pass: bool, +} + +/// The in-band "reported, not deciding" delta-luma threshold (recipe §10): +/// pixels inside the band whose |Δluma| exceeds this are counted, but the +/// count never feeds a pass/fail verdict. +pub const IN_BAND_REPORT_DELTA_LUMA: u8 = 16; + +fn is_ink(rgba: [u8; 4]) -> bool { + (luma(rgba) as u32) < INK_LUMA_THRESHOLD as u32 +} + +fn pixel_at(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let idx = ((y as usize) * (width as usize) + (x as usize)) * 4; + [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]] +} + +/// Checks `rgba` is exactly `width * height * 4` bytes and every pixel is +/// fully opaque, **before** anything reads a single sample out of it. +/// +/// Both checks exist for the same reason Round 1's `evaluate_glyph` checks +/// them: a short buffer indexed with a substituted default reads back as +/// `(0,0,0,0)`, whose luma is 0 — that classifies as *ink*, so a truncated +/// or otherwise malformed readback would silently turn a broken run green. +/// This function is the single gate every other function in this crate +/// calls before it touches a pixel, so "malformed buffer" is always an +/// `Err`, never a value. +pub fn validate_rgba(rgba: &[u8], width: u32, height: u32) -> Result<(), String> { + let expected_len = (width as usize) * (height as usize) * 4; + if rgba.len() != expected_len { + return Err(format!( + "buffer is {} bytes, expected exactly {expected_len} ({width}x{height} RGBA) — a \ + short or padded buffer cannot be sampled safely", + rgba.len() + )); + } + for y in 0..height { + for x in 0..width { + let a = pixel_at(rgba, width, x, y)[3]; + if a != 255 { + return Err(format!( + "pixel at ({x}, {y}) has alpha {a}, not 255 — the target must be fully \ + opaque; a transparent pixel means the clear or the blend is wrong, not \ + that the differential should guess a colour for it" + )); + } + } + } + Ok(()) +} + +/// Total ink mass: Σ over every pixel of `(255 - luma) / 255`. Recipe §10, +/// D2's quantity. A fully black image has mass `width * height`; a fully +/// white image has mass `0`. +pub fn ink_mass(rgba: &[u8], width: u32, height: u32) -> Result { + validate_rgba(rgba, width, height)?; + let mut mass = 0.0f64; + for y in 0..height { + for x in 0..width { + let l = luma(pixel_at(rgba, width, x, y)); + mass += (255 - l) as f64 / 255.0; + } + } + Ok(mass) +} + +/// The mass-weighted ink centroid, using the same per-pixel weight as +/// [`ink_mass`]. Returns `Ok(None)` rather than dividing by zero when the +/// image carries no ink at all (mass `0`) — a blank image has no centroid +/// to report, and a `NaN` snuck into a report would be worse than an +/// explicit "undefined". +pub fn centroid(rgba: &[u8], width: u32, height: u32) -> Result, String> { + validate_rgba(rgba, width, height)?; + let mut mass = 0.0f64; + let mut sx = 0.0f64; + let mut sy = 0.0f64; + for y in 0..height { + for x in 0..width { + let l = luma(pixel_at(rgba, width, x, y)); + let w = (255 - l) as f64 / 255.0; + mass += w; + sx += w * x as f64; + sy += w * y as f64; + } + } + if mass <= 0.0 { + return Ok(None); + } + Ok(Some((sx / mass, sy / mass))) +} + +/// The edge band, as a `width * height` row-major mask of `true` = "inside +/// the band", computed from **`rgba` alone** — recipe §10 defines the band +/// from the *reference* pixel's own neighbourhood, never the candidate's, so +/// `diff` always calls this on the reference buffer only. +/// +/// **Border handling, decided and documented (recipe §10 requires this be +/// explicit):** a pixel's 3x3 neighbourhood, and later its 5x5 dilation +/// window, is clipped to the buffer's actual extent — out-of-bounds +/// neighbours are simply absent from the neighbourhood, not synthesized as +/// either class. The alternative (treating off-image neighbours as +/// background, since the canvas is nominally an infinite white page) would +/// make every border pixel automatically non-edge whenever the interior +/// pixel at the border is uniform, which is *true* for this recipe's +/// rasters (ink never reaches the image border) but is not a property this +/// function should assume for a caller's buffer in general. Treating +/// off-image neighbours as ink would be worse — it would manufacture edges +/// along the whole border of any image whose border pixels are ink. Only +/// "absent from the vote" makes no assumption about what lies outside the +/// buffer, at the cost that a border pixel needs strictly fewer differing +/// neighbours to qualify as an edge than an interior pixel does. For this +/// recipe's rasters (ink confined well within the frame) the choice is +/// inert in practice; it is recorded here because a general-purpose +/// function must still pick something. +pub fn edge_band_mask(rgba: &[u8], width: u32, height: u32) -> Result, String> { + validate_rgba(rgba, width, height)?; + let w = width as i32; + let h = height as i32; + let len = (width as usize) * (height as usize); + + let mut ink = vec![false; len]; + for y in 0..height { + for x in 0..width { + ink[(y as usize) * (width as usize) + (x as usize)] = + is_ink(pixel_at(rgba, width, x, y)); + } + } + + let mut edge = vec![false; len]; + for y in 0..h { + for x in 0..w { + let mut seen_ink = false; + let mut seen_bg = false; + for dy in -1..=1 { + for dx in -1..=1 { + let (nx, ny) = (x + dx, y + dy); + if nx < 0 || ny < 0 || nx >= w || ny >= h { + // Out-of-bounds neighbour: excluded from the vote. + // See the doc comment above for why. + continue; + } + if ink[(ny as usize) * (width as usize) + (nx as usize)] { + seen_ink = true; + } else { + seen_bg = true; + } + } + } + if seen_ink && seen_bg { + edge[(y as usize) * (width as usize) + (x as usize)] = true; + } + } + } + + // Dilate every edge pixel out to Chebyshev distance EDGE_BAND_PX, + // clipped to the buffer (same border rule: no wraparound, no synthesized + // pixels beyond the edge). + let mut band = vec![false; len]; + for y in 0..h { + for x in 0..w { + if !edge[(y as usize) * (width as usize) + (x as usize)] { + continue; + } + let y0 = (y - EDGE_BAND_PX).max(0); + let y1 = (y + EDGE_BAND_PX).min(h - 1); + let x0 = (x - EDGE_BAND_PX).max(0); + let x1 = (x + EDGE_BAND_PX).min(w - 1); + for by in y0..=y1 { + for bx in x0..=x1 { + band[(by as usize) * (width as usize) + (bx as usize)] = true; + } + } + } + } + + Ok(band) +} + +/// Every number D1/D2/D3 rest on, plus the in-band figures that are +/// reported but never decide — a `FAIL` must be diagnosable from this +/// struct alone, without re-running anything (recipe §10). +#[derive(Clone, Debug)] +pub struct DiffReport { + pub width: u32, + pub height: u32, + + /// Pixel count in the edge band (computed from the reference only). + pub band_pixel_count: u64, + + /// D1: pixels *outside* the band whose ink class differs between the + /// two images. Must be exactly 0 to pass. + pub d1_pixels_outside_band_differing: u64, + pub d1_pass: bool, + + pub reference_ink_mass: f64, + pub candidate_ink_mass: f64, + /// D2: relative delta of ink mass, `|candidate - reference| / + /// reference`. If the reference has no ink at all (mass 0) the + /// denominator is undefined; see the field doc on how that case is + /// handled. + pub d2_relative_delta: f64, + pub d2_pass: bool, + + pub reference_centroid: Option<(f64, f64)>, + pub candidate_centroid: Option<(f64, f64)>, + /// D3: `(|Δx|, |Δy|)` between the two centroids. `None` when either + /// image has zero ink mass, so no centroid exists to compare — see + /// `d3_pass`. + pub d3_delta: Option<(f64, f64)>, + /// `None` when [`DiffReport::d3_delta`] is `None` (no verdict is + /// possible, not a failed one). `Some(true)`/`Some(false)` otherwise. + /// [`DiffReport::pass`] treats `None` as non-blocking: a comparison + /// that cannot be made cannot itself fail D3, and in every case this + /// crate's mutation set produces, an undefined D3 is accompanied by a + /// D2 failure that already dooms the overall verdict (blanking the + /// candidate to zero ink is the case in point — see the `selftest` + /// binary's M8). + pub d3_pass: Option, + + /// Inside the band, the largest |Δluma| observed. Reported only — this + /// number is antialiasing, which is what the band exists to stop from + /// deciding anything. + pub in_band_max_abs_delta_luma: u8, + /// Inside the band, the count of pixels whose |Δluma| exceeds + /// [`IN_BAND_REPORT_DELTA_LUMA`]. Reported only, same reason. + pub in_band_count_delta_gt_report_threshold: u64, + + /// D4: per-glyph ink mass, one entry per supplied region, in the order + /// they were given. Every entry must pass. + pub d4_regions: Vec, + pub d4_pass: bool, + /// The single worst region, for the one-line summary a report leads with. + pub d4_worst: Option, +} + +impl DiffReport { + /// The overall verdict: D1, D2 and D4 must all hold, and D3 must either + /// hold or be inapplicable (see `d3_pass`'s doc comment). + pub fn pass(&self) -> bool { + self.d1_pass && self.d2_pass && self.d4_pass && self.d3_pass.unwrap_or(true) + } +} + +/// Per-glyph ink mass over one region, dilated by [`D4_REGION_DILATION_PX`] +/// and clipped to the image. +/// +/// This is the rule that actually catches a wrong, dropped, or re-shaped +/// glyph, and it exists because the other three demonstrably do not: +/// +/// - **D1 cannot**, because it is structurally blind to any error confined to +/// a stroke narrower than `2 * EDGE_BAND_PX + 1 = 5 device px` — such a +/// stroke lies entirely within 2 px of its own edges, so deleting it changes +/// no unbanded pixel. Measured: deleting a 4 px stem from the synthetic +/// reference gave `d1 = 0`. +/// - **Whole-image D2 cannot**, because one glyph in a 28-glyph run is a few +/// percent of the total mass, and the tolerance is 2%. +/// +/// D4 has neither weakness: the denominator is one glyph's own mass, so a +/// dropped glyph is a 100% error and a missing diacritic is a large one. +fn region_mass(rgba: &[u8], width: u32, height: u32, region: &GlyphRegion) -> Result { + let d = D4_REGION_DILATION_PX; + let x0 = region.x0.saturating_sub(d); + let y0 = region.y0.saturating_sub(d); + let x1 = (region.x1 + d).min(width); + let y1 = (region.y1 + d).min(height); + if x0 >= x1 || y0 >= y1 { + return Err(format!( + "region {:?} is empty after clipping to {width}x{height}: x {x0}..{x1}, y {y0}..{y1} \ + — an empty region would score 0 mass against 0 mass and pass vacuously", + region.label + )); + } + let mut mass = 0.0f64; + for y in y0..y1 { + for x in x0..x1 { + let l = luma(pixel_at(rgba, width, x, y)); + mass += (255 - l) as f64 / 255.0; + } + } + Ok(mass) +} + +/// Runs the full bounded visual differential (recipe §10) between a +/// reference raster and a candidate raster of identical dimensions. +/// +/// The edge band is computed from `reference_rgba` alone (see +/// [`edge_band_mask`]'s doc comment) — the recipe defines "edge pixel" in +/// terms of the reference image, never the candidate, because the band is +/// meant to bound where the reference's own antialiasing lives, not where +/// the candidate happened to draw one. +/// `regions` must be **non-empty**. An empty list is refused rather than +/// accepted, because D4 over zero regions is a rule that always passes, and a +/// caller that forgot to supply the glyph boxes would otherwise get a green +/// verdict from a differential missing its strongest rule. Refusing here means +/// "D4 was not evaluated" can never be mistaken for "D4 held". +pub fn diff( + reference_rgba: &[u8], + candidate_rgba: &[u8], + width: u32, + height: u32, + regions: &[GlyphRegion], +) -> Result { + validate_rgba(reference_rgba, width, height)?; + validate_rgba(candidate_rgba, width, height)?; + if regions.is_empty() { + return Err( + "diff called with no glyph regions — D4 (per-glyph ink mass) would be vacuous, and a \ + vacuous rule that reports `pass` is worse than an absent one. Supply one region per \ + shaped glyph." + .to_string(), + ); + } + + let band = edge_band_mask(reference_rgba, width, height)?; + let band_pixel_count = band.iter().filter(|&&b| b).count() as u64; + + let mut d1_diff = 0u64; + let mut in_band_max_delta = 0u8; + let mut in_band_gt_threshold = 0u64; + for y in 0..height { + for x in 0..width { + let idx = (y as usize) * (width as usize) + (x as usize); + let rp = pixel_at(reference_rgba, width, x, y); + let cp = pixel_at(candidate_rgba, width, x, y); + let rl = luma(rp); + let cl = luma(cp); + let delta = (rl as i32 - cl as i32).unsigned_abs() as u8; + if band[idx] { + if delta > in_band_max_delta { + in_band_max_delta = delta; + } + if delta > IN_BAND_REPORT_DELTA_LUMA { + in_band_gt_threshold += 1; + } + } else { + let r_ink = (rl as u32) < INK_LUMA_THRESHOLD as u32; + let c_ink = (cl as u32) < INK_LUMA_THRESHOLD as u32; + if r_ink != c_ink { + d1_diff += 1; + } + } + } + } + let d1_pass = d1_diff == 0; + + let reference_ink_mass = ink_mass(reference_rgba, width, height)?; + let candidate_ink_mass = ink_mass(candidate_rgba, width, height)?; + // Relative to the reference mass, which is the recipe's own framing + // ("agrees within 2% relative" — relative *to the reference*, the fixed + // point of the comparison). If the reference itself carries no ink, + // "relative" has no denominator: any candidate ink at all is reported + // as an unbounded (infinite) relative delta rather than a fabricated + // number, and two blank images agree exactly (delta 0). + let d2_relative_delta = if reference_ink_mass > 0.0 { + (candidate_ink_mass - reference_ink_mass).abs() / reference_ink_mass + } else if candidate_ink_mass > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + let d2_pass = d2_relative_delta <= D2_RELATIVE_TOLERANCE; + + let reference_centroid = centroid(reference_rgba, width, height)?; + let candidate_centroid = centroid(candidate_rgba, width, height)?; + let (d3_delta, d3_pass) = match (reference_centroid, candidate_centroid) { + (Some((rx, ry)), Some((cx, cy))) => { + let delta = ((cx - rx).abs(), (cy - ry).abs()); + let pass = delta.0 <= D3_TOLERANCE_DEVICE_PX && delta.1 <= D3_TOLERANCE_DEVICE_PX; + (Some(delta), Some(pass)) + } + _ => (None, None), + }; + + let mut d4_regions = Vec::with_capacity(regions.len()); + for r in regions { + let reference_mass = region_mass(reference_rgba, width, height, r)?; + let candidate_mass = region_mass(candidate_rgba, width, height, r)?; + // Same convention as D2, for the same reason: a region the reference + // left blank has no denominator, so any candidate ink there is an + // unbounded error rather than a fabricated percentage. + let relative_delta = if reference_mass > 0.0 { + (candidate_mass - reference_mass).abs() / reference_mass + } else if candidate_mass > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + d4_regions.push(RegionMass { + label: r.label.clone(), + reference_mass, + candidate_mass, + relative_delta, + pass: relative_delta <= D4_RELATIVE_TOLERANCE, + }); + } + let d4_pass = d4_regions.iter().all(|r| r.pass); + let d4_worst = d4_regions + .iter() + .max_by(|a, b| { + a.relative_delta + .partial_cmp(&b.relative_delta) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .cloned(); + + Ok(DiffReport { + width, + height, + band_pixel_count, + d1_pixels_outside_band_differing: d1_diff, + d1_pass, + reference_ink_mass, + candidate_ink_mass, + d2_relative_delta, + d2_pass, + reference_centroid, + candidate_centroid, + d3_delta, + d3_pass, + in_band_max_abs_delta_luma: in_band_max_delta, + in_band_count_delta_gt_report_threshold: in_band_gt_threshold, + d4_regions, + d4_pass, + d4_worst, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(width: u32, height: u32, rgb: [u8; 3]) -> Vec { + let mut buf = vec![0u8; (width as usize) * (height as usize) * 4]; + for px in buf.chunks_mut(4) { + px[0] = rgb[0]; + px[1] = rgb[1]; + px[2] = rgb[2]; + px[3] = 255; + } + buf + } + + #[test] + fn validate_rejects_wrong_length() { + let buf = vec![0u8; 3]; + let err = validate_rgba(&buf, 2, 2).unwrap_err(); + assert!(err.contains("expected exactly 16")); + } + + #[test] + fn validate_rejects_non_opaque_pixel_and_names_coordinate() { + let mut buf = solid(2, 2, [255, 255, 255]); + // Pixel (1, 0): index 1*4 = 4. + buf[4 * 1 + 3] = 254; + let err = validate_rgba(&buf, 2, 2).unwrap_err(); + assert!( + err.contains("(1, 0)"), + "error should name the coordinate: {err}" + ); + } + + #[test] + fn short_buffer_never_silently_classifies() { + // A short buffer must error, not decode as (0,0,0,0) "ink". + let buf = vec![0u8; 4]; // one pixel's worth, for a claimed 2x2 image + assert!(ink_mass(&buf, 2, 2).is_err()); + assert!(centroid(&buf, 2, 2).is_err()); + assert!(edge_band_mask(&buf, 2, 2).is_err()); + assert!(diff(&buf, &buf, 2, 2, &one_region("x", 0, 0, 2, 2)).is_err()); + } + + #[test] + fn all_white_has_zero_mass_and_no_centroid() { + let buf = solid(4, 4, [255, 255, 255]); + assert_eq!(ink_mass(&buf, 4, 4).unwrap(), 0.0); + assert_eq!(centroid(&buf, 4, 4).unwrap(), None); + } + + #[test] + fn all_black_has_full_mass_and_centre_centroid() { + let buf = solid(4, 4, [0, 0, 0]); + assert_eq!(ink_mass(&buf, 4, 4).unwrap(), 16.0); + // Uniform mass over a 4x4 grid centres at (1.5, 1.5). + let (cx, cy) = centroid(&buf, 4, 4).unwrap().unwrap(); + assert!((cx - 1.5).abs() < 1e-9); + assert!((cy - 1.5).abs() < 1e-9); + } + + fn one_region(label: &str, x0: u32, y0: u32, x1: u32, y1: u32) -> Vec { + vec![GlyphRegion { + label: label.to_string(), + x0, + y0, + x1, + y1, + }] + } + + #[test] + fn empty_region_list_is_refused_not_passed_vacuously() { + let buf = solid(16, 16, [255, 255, 255]); + let err = diff(&buf, &buf, 16, 16, &[]).unwrap_err(); + assert!( + err.contains("no glyph regions"), + "an empty region list must be an error, not a green D4: {err}" + ); + } + + #[test] + fn d4_catches_a_dropped_shape_that_d1_is_blind_to() { + // A 4px-wide stem: narrower than D1's floor of 2*EDGE_BAND_PX+1 = 5, + // so every one of its pixels lies inside the band around its own + // edges and D1 cannot see it vanish. D4 must. + let mut reference = solid(64, 64, [255, 255, 255]); + for y in 10..54u32 { + for x in 30..34u32 { + let idx = ((y as usize) * 64 + x as usize) * 4; + reference[idx] = 0; + reference[idx + 1] = 0; + reference[idx + 2] = 0; + } + } + let candidate = solid(64, 64, [255, 255, 255]); + let regions = one_region("stem", 30, 10, 34, 54); + let r = diff(&reference, &candidate, 64, 64, ®ions).unwrap(); + assert!( + r.d1_pass, + "D1 is expected to be blind here — if it fired, the declared 5px floor is wrong and \ + the recipe must change, not this assertion" + ); + assert!(!r.d4_pass, "D4 must catch the dropped stem"); + assert_eq!(r.d4_worst.as_ref().unwrap().label, "stem"); + assert!(!r.pass()); + } + + #[test] + fn identical_images_pass_trivially() { + let mut buf = solid(16, 16, [255, 255, 255]); + // Paint a small black square so the band is non-degenerate. + for y in 6..10u32 { + for x in 6..10u32 { + let idx = ((y as usize) * 16 + x as usize) * 4; + buf[idx] = 0; + buf[idx + 1] = 0; + buf[idx + 2] = 0; + } + } + let report = diff(&buf, &buf, 16, 16, &one_region("square", 6, 6, 10, 10)).unwrap(); + assert!(report.pass()); + assert_eq!(report.d1_pixels_outside_band_differing, 0); + assert_eq!(report.d2_relative_delta, 0.0); + assert_eq!(report.d3_delta, Some((0.0, 0.0))); + assert!( + report.band_pixel_count > 0, + "the square's edge should produce a band" + ); + } + + #[test] + fn blanking_the_candidate_fails_d2() { + let mut reference = solid(16, 16, [255, 255, 255]); + for y in 6..10u32 { + for x in 6..10u32 { + let idx = ((y as usize) * 16 + x as usize) * 4; + reference[idx] = 0; + reference[idx + 1] = 0; + reference[idx + 2] = 0; + } + } + let candidate = solid(16, 16, [255, 255, 255]); + let report = diff( + &reference, + &candidate, + 16, + 16, + &one_region("square", 6, 6, 10, 10), + ) + .unwrap(); + assert!(!report.d2_pass); + assert!(!report.pass()); + } + + #[test] + fn edge_band_excludes_out_of_bounds_neighbours_rather_than_assuming_a_class() { + // A 3x3 image, all white except the centre pixel, which is black. + // The border pixels' 3x3 neighbourhoods are clipped to the 3x3 + // image itself; every one of them sees the black centre, so every + // pixel in this tiny image is an edge pixel, and thus the whole + // image is banded. This confirms the border rule does not silently + // extend the image with a synthesized background that would make + // border pixels edge-blind. + let mut buf = solid(3, 3, [255, 255, 255]); + let idx = (1 * 3 + 1) * 4; + buf[idx] = 0; + buf[idx + 1] = 0; + buf[idx + 2] = 0; + let band = edge_band_mask(&buf, 3, 3).unwrap(); + assert!( + band.iter().all(|&b| b), + "every pixel should fall in the band: {band:?}" + ); + } +} diff --git a/spikes/editor-toolkit/round2-reference/Cargo.toml b/spikes/editor-toolkit/round2-reference/Cargo.toml new file mode 100644 index 0000000..ef5ed90 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "round2-reference" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Packet 2A-iii, Deliverable 2 (ROUND2_TEXT_RECIPE.md §9/§10): the reference +# rasters + D4 regions binary. This crate composes, but does not modify, +# round2-textkit (fixtures), round2-svgref (the explicit-glyph SVG emitter), +# and round2-diff (the bounded visual differential) — it exists so that +# composition has a home that is neither "the fixture crate", "the emitter +# crate", nor "the differential crate", each of which stays single-purpose. + +[dependencies] +round2-textkit = { path = "../round2-textkit" } +round2-svgref = { path = "../round2-svgref" } +round2-diff = { path = "../round2-diff" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Pinned to the same version round2-textkit and round2-svgref use, for the +# same reason they pin it: the emitter's face-coverage refusal (FacePolicy, +# recipe §11 M6) must ask the *same* cmap implementation that resolved the +# faces in the first place, or "this face covers this codepoint" could mean +# two different things in one pipeline. +ttf-parser = "=0.25.1" + +[[bin]] +name = "generate_reference" +path = "src/bin/generate_reference.rs" + +# Recipe §11 M4/M5/M6: the three text-specific mutations, executed against the +# real frozen fixtures rather than the synthetic geometry `round2-diff`'s +# selftest uses. They need shaped glyphs from real faces, which is why they +# live here and not there. Exits non-zero if any required kill does not +# happen. +[[bin]] +name = "text_mutations" +path = "src/bin/text_mutations.rs" diff --git a/spikes/editor-toolkit/round2-reference/output/F-A.regions.json b/spikes/editor-toolkit/round2-reference/output/F-A.regions.json new file mode 100644 index 0000000..2ca756f --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-A.regions.json @@ -0,0 +1,156 @@ +[ + { + "label": "F-A seg0.glyph0 (face 0, gid 34)", + "x0": 161, + "y0": 450, + "x1": 257, + "y1": 541 + }, + { + "label": "F-A seg0.glyph1 (face 0, gid 77)", + "x0": 262, + "y0": 447, + "x1": 295, + "y1": 541 + }, + { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "x0": 299, + "y0": 447, + "x1": 332, + "y1": 541 + }, + { + "label": "F-A seg0.glyph3 (face 0, gid 70)", + "x0": 337, + "y0": 479, + "x1": 392, + "y1": 543 + }, + { + "label": "F-A seg0.glyph4 (face 0, gid 72)", + "x0": 399, + "y0": 479, + "x1": 465, + "y1": 577 + }, + { + "label": "F-A seg0.glyph5 (face 0, gid 83)", + "x0": 471, + "y0": 479, + "x1": 517, + "y1": 541 + }, + { + "label": "F-A seg0.glyph6 (face 0, gid 80)", + "x0": 523, + "y0": 479, + "x1": 585, + "y1": 543 + }, + { + "label": "F-A seg0.glyph8 (face 0, gid 66)", + "x0": 624, + "y0": 479, + "x1": 682, + "y1": 542 + }, + { + "label": "F-A seg0.glyph9 (face 0, gid 234)", + "x0": 687, + "y0": 446, + "x1": 766, + "y1": 541 + }, + { + "label": "F-A seg0.glyph10 (face 0, gid 70)", + "x0": 767, + "y0": 479, + "x1": 822, + "y1": 543 + }, + { + "label": "F-A seg0.glyph11 (face 0, gid 85)", + "x0": 828, + "y0": 460, + "x1": 867, + "y1": 542 + }, + { + "label": "F-A seg0.glyph12 (face 0, gid 85)", + "x0": 870, + "y0": 460, + "x1": 909, + "y1": 542 + }, + { + "label": "F-A seg0.glyph13 (face 0, gid 86)", + "x0": 911, + "y0": 479, + "x1": 984, + "y1": 542 + }, + { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "x0": 990, + "y0": 479, + "x1": 1053, + "y1": 543 + }, + { + "label": "F-A seg0.glyph15 (face 0, gid 84)", + "x0": 1060, + "y0": 479, + "x1": 1107, + "y1": 543 + }, + { + "label": "F-A seg0.glyph16 (face 0, gid 80)", + "x0": 1114, + "y0": 479, + "x1": 1177, + "y1": 543 + }, + { + "label": "F-A seg0.glyph18 (face 0, gid 119)", + "x0": 1212, + "y0": 504, + "x1": 1341, + "y1": 512 + }, + { + "label": "F-A seg0.glyph20 (face 0, gid 66)", + "x0": 1376, + "y0": 479, + "x1": 1433, + "y1": 542 + }, + { + "label": "F-A seg0.glyph21 (face 0, gid 77)", + "x0": 1439, + "y0": 447, + "x1": 1472, + "y1": 541 + }, + { + "label": "F-A seg0.glyph23 (face 0, gid 97)", + "x0": 1508, + "y0": 446, + "x1": 1581, + "y1": 541 + }, + { + "label": "F-A seg0.glyph24 (face 0, gid 79)", + "x0": 1584, + "y0": 479, + "x1": 1657, + "y1": 541 + }, + { + "label": "F-A seg0.glyph25 (face 0, gid 70)", + "x0": 1661, + "y0": 479, + "x1": 1716, + "y1": 543 + } +] \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-A.rgba b/spikes/editor-toolkit/round2-reference/output/F-A.rgba new file mode 100644 index 0000000..5b8db99 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-A.rgba @@ -0,0 +1 @@ +ppp@@@000@@@000@@@000ppppppPPP@@@000ppp ???000???@@@```___ppp ???PPPPPP???``` ???ppp???```???@@@???@@@???000000???@@@???000___PPP000???```@@@??? 000???PPP000??? OOO??????000????????????000000??????000??????000```??????000 ?????????```OOOOOO```///OOO??????```OOOOOO@@@?????????```//////??????```///OOO??????///OOO///___??????///OOO___??????______???___000```??????___??????___000?????? ???@@@ ??????000@@@ppp@@@000???000000??????ooo///ooo```???ooo///ooo000@@@????????????```@@@?????????OOO??????@@@___???000ppp ???@@@???___??????ooo000???OOOooo??????OOOoooOOO??????OOOOOO?????? ///??????___ ?????????___???ooo ??????ooo?????????ooo___???@@@??????000OOO?????????000@@@?????? OOO??????___?????????___??????????????????___ ??????????????????___///@@@????????? ????????????___?????????@@@ooo??????```@@@000@@@```@@@@@@ @@@PPPPPPPPP@@@ PPP```@@@@@@@@@```@@@@@@@@@@@@@@@@@@000@@@``````@@@000@@@```PPP@@@ @@@```PPP@@@@@@PPP```@@@000@@@PPP???@@@000000 ppp@@@ @@@@@@```PPP@@@ 000@@@ppp??????```````````````pppppp000 ``````000000000 ??? ???PPP000ppp PPP```000pppPPPPPP??? ```PPPPPP ppp___OOO ?????? 000```@@@```  ``````ppp000???ppp000???```000 ppp``` ```000???PPPPPP000@@@```PPP??????@@@@@@pppPPP```ppp```@@@@@@```???```???@@@ OOO@@@```ppp???PPP@@@PPP``````___OOO??????______@@@//////ppp000```ooo???000@@@```@@@``````@@@@@@@@@@@@@@@@@@@@@000@@@???///000ppp```@@@@@@@@@ooo@@@PPP@@@@@@@@@@@@???@@@???PPP///___000@@@///oooooo///___```///ooo/// ppp```???@@@``````@@@@@@@@@@@@@@@@@@@@@ppp@@@```000000```///ooo????????? ___```PPP@@@@@@@@@@@@@@@@@@@@@@@@___ooo000ooo000000000PPP___ooo```?????????___???oooppp```???PPP???@@@ ___```///ooo??????pppOOOOOO@@@oooooo///OOO```///????????????ooo@@@??? ```@@@??????PPPOOO000OOO???pppooo000@@@??????????????????@@@ooo??????@@@@@@///000___?????? ___```ppp???______ ooo///@@@ ??????000OOO000___000///pppOOO??? ??????000???///@@@///?????????```___?????????????????????```___ooo000///@@@```/// ?????????????????????????????????????????????000???000???000/////////??????ooo???????????????///______///OOO??? ??????```???/// ???```___000///000___OOO???OOO??? ///000//////OOO???ooo``` ?????? ooo000OOO_________??????///???????????????????????????????????????????????????????????????????????????///?????????????????????????????????????????????000??????///???///???000000///OOO@@@///?????????///?????????????????????????????????????????????///```ooo_________OOO????????????OOOOOO```ooo___//////PPP?????????PPP```ooo@@@OOO???@@@OOO///000```??????000OOO///000___ooo???___?????????/// ///___?????????OOO///ooo??????000___///___000///PPP___?????????___?????? ___??????000 ???___ ///?????? ????????????@@@___ooo@@@___???oooOOO@@@OOOooo??????/// ooo oooOOO?????????ooo???ooo_________?????? ooo @@@ooo??? ??????___///___///ooo??????___???///___?????????000___???OOO@@@___??????___ ??????___OOO ????????????///??????OOO?????????______???///___OOO___?????? ???  ooo?????????OOO___???___???ooo???@@@??????___??????000///OOO///ooo???___?????????000???@@@/// ??????oooooo????????????OOOoooooo___????????????___???000???___?????????000???000 ?????????000 ___???___??????_________???___ooo????????????___000?????? ooo///000??????000ooo///___ @@@???????????????000PPP___ppp@@@?????????___```@@@????????????OOO PPPPPP000???@@@ ppp___??????@@@ OOO@@@ ooo``````???????????????000```ooo///ppp@@@???```??????@@@@@@@@@@@@@@@@@@@@@@@@000?????????PPP???000@@@@@@@@@@@@@@@@@@@@@@@@?????????000???ooo@@@??????@@@??? @@@@@@@@@@@@@@@@@@@@@@@@ ___??????ooo???``` ///OOO///???????????????OOO///```000???PPP???___///????????????OOO??????```???ooo???ooo????????????////// ??????PPP????????????///????????????oooooo??????@@@OOO??????OOO???????????????OOO ???OOO ___???????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????000??????___??????@@@___??????????????????```??? ooo???????????????___@@@??????ppp??????????????????000000@@@___?????????000???___??????```??????000???????????????OOO```///???????????????000???___///????????????OOO___?????????OOO??????___///???///????????????______?????????OOO ??????OOO___000ppp???ooo ????????????OOO????????????___?????????ooo000@@@PPPPPP@@@ OOO___???ooo??????????????????OOO000000??????OOO___@@@????????????___???????????????ooooooOOO???___OOO??????___??????OOO___???___???oooOOO??????????????????OOO000 ??????000 ///___??????___???___///??? ?????????ooo???///???ooo??????OOO??????///___000????????????OOO___?????????OOO??????ooo??????@@@??????___ ???OOO///ooo?????????___ooo000??????______ ??????000??????/// ???000???000___//////??????OOO___??????@@@PPP///___ ???ooo??????ooo___????????????@@@??????/// ___ @@@??????000???ooo???OOO___?????? oooOOO??????@@@___@@@?????????___??? ?????????___??????```___ ```ooo ??????000???______000???///??????OOO000______```ooo???___???@@@ooo______000???@@@ oooooo??????000000@@@???000______???___000??????PPP ???```??????OOOPPP___ooooooOOO___???___???______///???000///???@@@@@@000@@@///ppp///??????OOO000___///???000oooOOOPPP___?????? ___???000```???___ ?????????///ppp000@@@PPP@@@@@@///000OOO```000___000ooo@@@PPP___000??????OOOOOO```@@@ ___ ___@@@???000@@@///????????????000@@@???@@@@@@ PPPooo ```//////oooooo___???```///```/// OOO???___oooOOO???///```000oooooo/// ooo```______000000 ppp ooo ___ OOOPPP@@@///@@@```@@@ PPPPPP000000PPP______???___```///___@@@```___ooo000000 ??? @@@```PPP PPP___///`````````???000/// @@@ 000000???______```ooo```___@@@___000@@@ ///   ppp@@@???```@@@@@@PPPOOO@@@pppPPP000ppp___OOOOOOPPP000000 @@@ppp@@@///___000ppp@@@___ oooPPP000 000@@@@@@@@@@@@000000@@@@@@pppPPP@@@@@@@@@000@@@PPPppp@@@@@@@@@ @@@@@@@@@PPP@@@@@@@@@000000@@@@@@@@@ooo```ppp@@@@@@@@@000@@@@@@@@@pppooo ```@@@@@@@@@PPP@@@@@@@@@ @@@@@@@@@ppp```@@@@@@@@@@@@@@@@@@ppp///oooooo___///000@@@@@@```///``` /// pppPPP///@@@000OOO///@@@@@@PPPppp@@@@@@@@@ @@@@@@@@@PPPPPP@@@@@@@@@ @@@@@@@@@ppp```@@@@@@@@@@@@@@@@@@``````@@@@@@@@@@@@@@@@@@ppp@@@@@@@@@ppp????????????___```??? ppp```???___??????ooo??????___???OOO000 @@@ppp@@@///ooo000@@@???ooo??????????????????OOO @@@@@@@@@@@@ ????????????OOO///___???@@@@@@@@@@@@@@@___@@@@@@@@@@@@@@@OOOooo????????????///??????___????????????___///???///ooo///???ooo___???ooo___???OOO????????????___///ooo@@@___///???___OOO///oooooo///___///___///OOOOOOooo???OOOooo///ooo///OOOOOO ???????????????????????????___///___??????oooOOO???___///OOOOOO???ooo???///___000???OOO___@@@___ooo ```oooppp//////000OOO///???000______///OOO ???@@@000///@@@OOOoooooo000000000```@@@OOOPPP```ooo ```PPP???000@@@@@@@@@000___???___OOOooo???///???ooo \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-A.svg b/spikes/editor-toolkit/round2-reference/output/F-A.svg new file mode 100644 index 0000000..6506d25 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-A.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-B.regions.json b/spikes/editor-toolkit/round2-reference/output/F-B.regions.json new file mode 100644 index 0000000..a131733 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-B.regions.json @@ -0,0 +1,51 @@ +[ + { + "label": "F-B seg0.glyph0 (face 0, gid 36)", + "x0": 162, + "y0": 449, + "x1": 246, + "y1": 543 + }, + { + "label": "F-B seg0.glyph1 (face 0, gid 80)", + "x0": 254, + "y0": 479, + "x1": 317, + "y1": 543 + }, + { + "label": "F-B seg0.glyph2 (face 0, gid 83)", + "x0": 323, + "y0": 479, + "x1": 369, + "y1": 541 + }, + { + "label": "F-B seg0.glyph3 (face 0, gid 80)", + "x0": 375, + "y0": 479, + "x1": 437, + "y1": 543 + }, + { + "label": "F-B seg1.glyph0 (face 1, gid 1282)", + "x0": 474, + "y0": 464, + "x1": 512, + "y1": 542 + }, + { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "x0": 517, + "y0": 464, + "x1": 572, + "y1": 540 + }, + { + "label": "F-B seg1.glyph2 (face 1, gid 1280)", + "x0": 582, + "y0": 464, + "x1": 650, + "y1": 542 + } +] \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-B.rgba b/spikes/editor-toolkit/round2-reference/output/F-B.rgba new file mode 100644 index 0000000..5c34364 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-B.rgba @@ -0,0 +1 @@ +PPP@@@@@@@@@@@@@@@@@@ppp@@@000 @@@```ppp000000```PPPPPP```000ppp000????????????OOO????????????ooo ????????? ooo///@@@OOO///______PPP?????????///___```___```???000`````` 000 @@@```___PPP000PPP000???/// ooo???___ ```@@@oooPPP??? @@@@@@@@@@@@@@@@@@PPP000@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ppp___ @@@@@@@@@@@@@@@@@@@@@@@@PPPoooOOO`````` 000??????000OOO??????``` ___???___???___??????___ooo______000___OOO@@@///???```ooo___ooo???OOO///___OOOOOO@@@@@@@@@`````````@@@ @@@```@@@ 000@@@OOO///OOO???___PPP000ppp ??? ppp000PPP//////```???```000ppp ???000000```???___ooo000___@@@ PPP???PPP???___ ```///ooo///000@@@??? ___OOOppp???///oooppp???@@@___/// //////000///```OOO000?????????000000///???OOO```OOO??????@@@___???000//////????????????????????? ```??????```???OOO000//////OOO???000``````___000 ???```???ooo///```PPPooo???___???OOO___??????___OOO000 000/// ___ooo///ooo000```///___PPP???OOO???ooo___OOO@@@@@@///ooo@@@___??????OOO___```//////000000___OOOOOO___```OOO???___OOO???oooooo///??? oooooo???ooo ///OOO???OOO___OOOooo000ooo///OOO///???OOO???OOO OOO______///???___000OOO????????????ooo000///000?????????ooo??????///000?????? PPP???___?????????ooo000```???????????????ooo??????OOO????????????ooo?????????___ooo___?????????OOO???___///???___ooo___???___???OOO ooo??????ooo ///000///???///000@@@///ooo___???@@@ ```??????___???___///```??????_________???///@@@ ///???///000???ooo000oooooo???_________ooo/// ???OOO___///___///ooo___???@@@??????000_________???OOO???ooo ///000///??????ooo______???///@@@ooo???000OOOOOO OOOooo```___PPP___ooo???```___???___ ooo???___```@@@ooo ___```000___???@@@??????______ @@@ ??????______000OOO @@@oooPPP@@@___???ooo???///``` ///```___  000///000???______oooooo ppp000```OOO000___???___@@@``` ???```___ ___000000???OOO@@@@@@PPP@@@  PPP```___ppp000///OOO///OOOppp???///___///```000///ppp@@@@@@@@@ 000@@@@@@@@@``` ___???oooooo///___000000???``````???OOOooo///___@@@@@@@@@@@@@@@___???ooo @@@@@@@@@@@@000oooooo//////______???OOO///000ooo___ ???___OOO???///OOO___ooo///???///???oooOOO???ooo///OOO \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-B.svg b/spikes/editor-toolkit/round2-reference/output/F-B.svg new file mode 100644 index 0000000..eb97cd6 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-B.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-C.regions.json b/spikes/editor-toolkit/round2-reference/output/F-C.regions.json new file mode 100644 index 0000000..e4d8b75 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-C.regions.json @@ -0,0 +1,30 @@ +[ + { + "label": "F-C seg0.glyph0 (face 0, gid 36)", + "x0": 162, + "y0": 449, + "x1": 246, + "y1": 543 + }, + { + "label": "F-C seg0.glyph1 (face 0, gid 80)", + "x0": 254, + "y0": 479, + "x1": 317, + "y1": 543 + }, + { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "x0": 323, + "y0": 479, + "x1": 369, + "y1": 541 + }, + { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "x0": 375, + "y0": 479, + "x1": 437, + "y1": 543 + } +] \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-C.rgba b/spikes/editor-toolkit/round2-reference/output/F-C.rgba new file mode 100644 index 0000000..eb9fc71 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-C.rgba @@ -0,0 +1 @@ +PPP@@@@@@@@@@@@@@@@@@ppp@@@000 @@@```ppp000000```PPPPPP```000ppp000????????????OOO????????????ooo ????????? ooo///@@@OOO///______PPP?????????///___```___```???000``````___???///??????PPPoooOOO____________oooOOO@@@@@@@@@`````````@@@ @@@```@@@ 000@@@PPP000ppp ??? ppp000PPP???```000ppp ???000000```___@@@ PPP???PPP ```///ooo///000@@@??? ___OOOpppoooppp???@@@___/// ```OOO000??????000///???OOO```___???000//////????????????????????? ```???OOO000//////OOO???000```000 ???```???ooo```PPPooo???___???OOO??????___OOO000 /// ___ooo///ooo___PPP???OOOooo___OOO@@@@@@@@@___??????OOO______OOOOOO___???___OOO?????? ooooooooo ///OOOOOO___OOO///OOO///???OOO???OOO___///???000OOO?????????????????????ooo????????? ???___?????????????????????ooo??????OOO?????????ooo?????????___?????????///???___ooo___ooo??????///???@@@///ooo___?????????___?????????___///@@@ ///???///000oooooo???___ooo/// ???OOO___///ooo___???@@@______???OOO???///??????ooo???000OOO___PPP___ooo???___ ooo???______```000___???@@@???@@@ ??????___ @@@oooPPP@@@___???``` ///```___  ooo ppp000```OOO000@@@``` ???```___ ___000@@@@@@PPP@@@  PPP```___ppp000//////```000///ppp@@@@@@@@@ 000@@@@@@@@@``` ooo///___000000???``````???///___@@@@@@@@@@@@@@@___???ooo @@@@@@@@@@@@000///______???OOO///OOO???///OOO___ooo???///???oooOOO???ooo///OOO \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-C.svg b/spikes/editor-toolkit/round2-reference/output/F-C.svg new file mode 100644 index 0000000..a7f6fdf --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-C.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-D.regions.json b/spikes/editor-toolkit/round2-reference/output/F-D.regions.json new file mode 100644 index 0000000..4168767 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-D.regions.json @@ -0,0 +1,121 @@ +[ + { + "label": "F-D seg0.glyph0 (face 0, gid 34)", + "x0": 161, + "y0": 450, + "x1": 257, + "y1": 541 + }, + { + "label": "F-D seg0.glyph1 (face 0, gid 77)", + "x0": 262, + "y0": 447, + "x1": 295, + "y1": 541 + }, + { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "x0": 299, + "y0": 447, + "x1": 332, + "y1": 541 + }, + { + "label": "F-D seg0.glyph3 (face 0, gid 70)", + "x0": 337, + "y0": 479, + "x1": 392, + "y1": 543 + }, + { + "label": "F-D seg0.glyph4 (face 0, gid 72)", + "x0": 399, + "y0": 479, + "x1": 465, + "y1": 577 + }, + { + "label": "F-D seg0.glyph5 (face 0, gid 83)", + "x0": 471, + "y0": 479, + "x1": 517, + "y1": 541 + }, + { + "label": "F-D seg0.glyph6 (face 0, gid 80)", + "x0": 523, + "y0": 479, + "x1": 585, + "y1": 543 + }, + { + "label": "F-D seg2.glyph1 (face 0, gid 68)", + "x0": 836, + "y0": 479, + "x1": 887, + "y1": 543 + }, + { + "label": "F-D seg2.glyph2 (face 0, gid 80)", + "x0": 894, + "y0": 479, + "x1": 957, + "y1": 543 + }, + { + "label": "F-D seg2.glyph3 (face 0, gid 79)", + "x0": 961, + "y0": 479, + "x1": 1034, + "y1": 541 + }, + { + "label": "F-D seg2.glyph5 (face 0, gid 67)", + "x0": 1064, + "y0": 447, + "x1": 1132, + "y1": 542 + }, + { + "label": "F-D seg2.glyph6 (face 0, gid 83)", + "x0": 1140, + "y0": 479, + "x1": 1186, + "y1": 541 + }, + { + "label": "F-D seg2.glyph7 (face 0, gid 74)", + "x0": 1190, + "y0": 457, + "x1": 1223, + "y1": 541 + }, + { + "label": "F-D seg2.glyph8 (face 0, gid 80)", + "x0": 1228, + "y0": 479, + "x1": 1291, + "y1": 543 + }, + { + "label": "F-D seg1.glyph0 (face 1, gid 1282)", + "x0": 621, + "y0": 464, + "x1": 660, + "y1": 542 + }, + { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "x0": 664, + "y0": 464, + "x1": 720, + "y1": 540 + }, + { + "label": "F-D seg1.glyph2 (face 1, gid 1280)", + "x0": 730, + "y0": 464, + "x1": 797, + "y1": 542 + } +] \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-D.rgba b/spikes/editor-toolkit/round2-reference/output/F-D.rgba new file mode 100644 index 0000000..5c33959 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-D.rgba @@ -0,0 +1 @@ +ppp@@@000000ppp ???000???PPPPPP???``` ???ppp000@@@???@@@???PPP ___PPP000???```@@@???ppp@@@OOO?????????????????? ?????????___@@@?????????OOO??????___?????????000PPP?????????```????????? ?????????OOO000?????????@@@?????????OOO??????```???___?????? ???___ ???ooo??????000???000?????????___@@@@@@??????___ ??????OOOppp???/// ?????? @@@@@@@@@@@@@@@@@@PPP@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@000@@@@@@@@@@@@@@@@@@@@@@@@??????ooo___???@@@?????????ppp___000000???@@@??????000000 000```??????___??????ooo000@@@???___?????????????????? ///___???___ ??????___OOO000???????????????@@@???___///@@@??????___OOO```??? ??????///OOO___?????????___?????????OOO???///???OOOooo???@@@ooo??????```@@@000@@@```@@@@@@ @@@PPPPPPPPP@@@ PPP```@@@@@@@@@OOO?????? @@@@@@@@@@@@ppp@@@000 @@@ppp000 @@@ @@@@@@```???PPP@@@ @@@ppp``````@@@ @@@000 PPP@@@@@@PPP??????```````````````pppppp000 ```///OOO___PPP000000@@@@@@``` PPP???```@@@ppp ??? PPP``` ppp___OOO ?????? 000```@@@```  ___000///@@@```PPPPPP```000@@@??? ppp ???000PPPppp ??????@@@@@@pppPPP```ppp```/// ///``` 000@@@@@@??? PPP???000```___OOO??????______@@@//////ppp000```ooo???ooo///000///@@@OOO___PPP```000 ???ppp@@@???``` oooooo///?????? ___```PPP@@@@@@@@@@@@@@@@@@@@@@@@___ooo000ooo000000///ppp??????___```???PPPPPP???___ oooPPP///ooo??????pppOOOOOO@@@oooooo///OOO??????```/// ///??????OOO??????000///```???PPP???000000??????ooo000___?????? ___```ppp??????OOO```PPPOOO___///___000???000???///@@@???```PPP??????```//////?????????```___?????????????????????```___ooo?????? ooo______000ooo???????????????/// ______???```???___///ooo????????????????????? ooo???????????????///```???OOO??? ??????```???/// ???```___??????OOO___000???```oooooo??????000///```OOO?????? ooo000OOO___??????___OOO```___OOO???PPP///???``` ???```???```///______OOO????????????OOOOOO```ooo___//////??????```______000///PPPOOO/// ???```OOO???ooo???___ooo???OOO ??????000OOO///000???ppp```ooo///ooo___OOOOOO___///???```///??????______???PPP///ooo??????000___///___000///PPP___??? @@@OOO???000???/// ___???000ooo ___??????000 ???___ ///???000 ______OOO??? ??? ??????/// ooo???```///000OOO???///???OOO///@@@ooo??? ??????___///___///ooo??????___??????```???@@@///___???ooo???OOO______?????? ??????___OOO ???000___???___ ///???___OOO___?????? ??? ???PPP___???@@@??? oooooo???______OOO??????000///OOO///ooo???///???___OOO???ooo000???___?????????OOOooo??????OOO___///???///___?????????000???___///???OOO___??????_________???___ooo???______???___?????????///?????? ooo///000??????000ooo///___ ???ooo???OOO???OOO???????????????___???///000OOO?????????/// ???@@@ ppp___??????@@@ OOO???///000???ooo?????????@@@@@@@@@@@@@@@@@@@@@@@@000????????????___```???///???___??????ooo?????????ooo???```___?????????????????????OOO??????????????????????????????OOO????????????oooooo???????????????___??????????????????????????????????????????????????????????????????????????????????????????????????????????????????000??????___?????????///???????????????___@@@?????????______?????????000???___??????```????????? ___OOO???????????????000???___???000///000????????????OOO??????___///??? ///000OOO000??????___???OOO ??????OOO___000ppp???ooo000OOO@@@PPP ????????????___?????????ooo000@@@PPPPPP@@@ OOO___???```???ooo???OOO??????______@@@????????????ooo___ooo///@@@????????? OOO???___???///??????OOO___???000??????OOOooo ??????ooo ///___??????___???___???oooooo ????????????OOO??????///___000????????????OOO___PPPoooooo???ooo???___@@@??????___ ???OOO///000///______??????000???OOO___ ??????000 ///OOO ///___??????______??????@@@PPP///______PPP///000///OOO???OOO???@@@??????/// ___???@@@???///000?????? oooOOO??????@@@___@@@??? ```OOO___@@@???PPP___???___???___??????```___ ooo___ooo___ooo???///???@@@///??????OOO000___@@@ /// ???@@@??? ????????? oooooo??????000000@@@```000___```@@@000___??????```///???000 ??????OOOPPP___ooooooooo///OOO___000 ???000??????___000///???@@@@@@000@@@///???OOO______000PPP???000000ppp??? OOO___???000```???___OOO___000___///___???000///???```///???___000/// ___ ___@@@???000ooo???___@@@___ooo???000ooo ???___000///000oooooo/// ooo```______000000???___???ooo___???___ ```000000PPP@@@ooo ___///000///PPP PPP___///`````````???000///oooooo OOO///???___ pppPPP```000pppppp ??? ///000///```@@@@@@PPPOOO@@@pppPPP000ppp___OOO000000PPP___ ``````000ooo@@@PPP @@@```000///___@@@@@@000000@@@@@@pppPPP@@@@@@@@@000@@@PPPppp@@@@@@@@@ @@@@@@@@@PPP@@@@@@@@@000000@@@@@@@@@ooo```ppp@@@@@@@@@000@@@@@@@@@pppooo ```___???___pppOOO```@@@@@@@@@@@@@@@@@@ppp@@@@@@@@@pppppp@@@@@@@@@ 000@@@@@@@@@``````@@@@@@@@@@@@@@@@@@pppOOO@@@@@@??????___```??? ppp```???ooo___OOO//////PPPppp ???///___?????? pppPPP___?????????OOO @@@@@@@@@@@@ OOOOOOOOO@@@@@@@@@@@@@@@???______?????????000@@@@@@@@@@@@?????????___????????????///_________???___oooooo??????___///ooo@@@___///___///OOO______ooo//////OOO///OOOOOO ???????????????????????????___///______///OOO???OOO___///ooo000???OOO___@@@___ooo ```oooppp//////000OOO///???000______///OOO ???@@@000///@@@OOOoooooo000000000```@@@OOOPPP```ooo ```PPP???000@@@@@@@@@000___???___OOOooo???///???ooo \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-D.svg b/spikes/editor-toolkit/round2-reference/output/F-D.svg new file mode 100644 index 0000000..85c5400 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-D.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-E.regions.json b/spikes/editor-toolkit/round2-reference/output/F-E.regions.json new file mode 100644 index 0000000..fe7a3e3 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-E.regions.json @@ -0,0 +1,79 @@ +[ + { + "label": "F-E seg0.glyph0 (face 0, gid 36)", + "x0": 162, + "y0": 449, + "x1": 246, + "y1": 543 + }, + { + "label": "F-E seg0.glyph1 (face 0, gid 66)", + "x0": 254, + "y0": 479, + "x1": 311, + "y1": 542 + }, + { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "x0": 317, + "y0": 446, + "x1": 359, + "y1": 541 + }, + { + "label": "F-E seg0.glyph3 (face 0, gid 198)", + "x0": 359, + "y0": 453, + "x1": 414, + "y1": 543 + }, + { + "label": "F-E seg0.glyph5 (face 0, gid 119)", + "x0": 449, + "y0": 504, + "x1": 578, + "y1": 512 + }, + { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "x0": 612, + "y0": 479, + "x1": 658, + "y1": 541 + }, + { + "label": "F-E seg0.glyph8 (face 0, gid 70)", + "x0": 663, + "y0": 479, + "x1": 718, + "y1": 543 + }, + { + "label": "F-E seg0.glyph9 (face 0, gid 84)", + "x0": 725, + "y0": 479, + "x1": 772, + "y1": 543 + }, + { + "label": "F-E seg0.glyph10 (face 0, gid 86)", + "x0": 777, + "y0": 479, + "x1": 850, + "y1": 542 + }, + { + "label": "F-E seg0.glyph11 (face 0, gid 78)", + "x0": 854, + "y0": 479, + "x1": 964, + "y1": 541 + }, + { + "label": "F-E seg0.glyph12 (face 0, gid 198)", + "x0": 969, + "y0": 453, + "x1": 1024, + "y1": 543 + } +] \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-E.rgba b/spikes/editor-toolkit/round2-reference/output/F-E.rgba new file mode 100644 index 0000000..155670d --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-E.rgba @@ -0,0 +1 @@ +@@@@@@@@@PPP@@@@@@@@@@@@@@@@@@ppp```@@@000 @@@```ppp000000```PPPPPP```000ppppppppp000????????????OOO????????????ooo000``` ?????????000OOO???@@@ ooo///```OOO000___@@@OOO///```OOO///???______ooo???PPP??????ooo???///ooo///___///ooo```___@@@???ooo```???OOO000 ```???```___000??????///___???OOO???OOOPPPoooOOO??????______OOO____________??????ooo???OOO```@@@000@@@PPP???```@@@000 @@@```@@@PPP@@@000PPPPPP@@@ @@@```PPP@@@ @@@```pppPPP```ooo 000PPP@@@@@@ppp@@@ @@@@@@```PPP@@@000@@@pppPPPPPP???``````````````` ppp``` ```000 ???PPP PPP```@@@PPP000???``` ??? @@@```ppp @@@ 000000000???PPP000@@@@@@``````___``````???@@@@@@``````@@@``` ``` ???ppp000000```@@@ppp pppPPP PPP@@@@@@@@@@@@@@@@@@000???______ppp000```///___OOO000???ooo___///PPP@@@???``` ```000000```???ooo/// ```@@@___ ___```ooo```ooo```/// ??? ``````000ooo@@@??????????????? ```OOOOOOPPP???///PPPOOO???```ooo???```@@@000pppOOO___```??????OOO```___???```///??????///___??? ///___///ooo?????????????????????///OOO?????????????????????????????????????????????????????????///@@@ooo??? OOOooo```///???OOO___///OOOppp___oooooo___???000???```___@@@@@@///000?????????????????????????????????????????????????????? 000@@@___OOO??????000///```ooo@@@OOO????????????///PPPooo_________???PPP000///___```___??? ooo```ooo/// ???///OOO///______???000___000/// ?????????ooo???ooo___???000ooooooOOOoooOOO??????///000//////OOO???///___???///???OOOOOO___??????OOO///000???___???___??????OOO ??????___OOO______???______???ooo???????????????___??????000???___?????????OOO000OOO????????? ///???```?????????OOO?????????___///```?????????????????????///???000???????????????///???OOO___000@@@?????????OOO???ppp@@@??? PPP?????????___PPP000??? ```?????????000ppp@@@???@@@@@@000```?????????```@@@???@@@@@@@@@@@@@@@@@@@@@@@@000 @@@@@@@@@@@@@@@@@@@@@@@@ ???PPP?????????OOO000@@@@@@@@@@@@@@@@@@@@@@@@ PPP???___///??? ppp???????????????___PPP?????????///000????????????ooo ___????????????oooooo??????/// ???????????????OOO ooo??????___???___ppp?????????OOO@@@___?????????___```?????????ooo///?????????///?????????______?????????///?????????///___?????????OOO ?????????ooo?????????___???????????????000?????????OOO???OOO????????????@@@?????? ???OOO?????????OOO ??????????????????ooo??????///?????????///@@@ooo??????ooo?????????ooo000??????___ ???000?????????oooooo????????????OOO?????????///?????????OOO___??????????????????PPP?????? ???ooo@@@______??????___??????ooo000???ooo??????___PPP000???OOO/// 000 ???PPP___ ???000```ooo???@@@000???ooo___```000??????```???```___000000000ooo???@@@___PPPOOO???PPP@@@```000????????????000 @@@ ???000```???000```___???PPP@@@???PPP///?????????```@@@```  ???___OOO???ppp??? ```///000 PPP???ooo ppp000___@@@pppOOO/// ??? /// ooo000pppPPP///ooo___@@@```@@@``` ??? @@@ ///  ///ppp```/// ppp```ppp___oooOOO___ 000@@@___@@@@@@PPP@@@ ```PPP???@@@pppPPP000///@@@ppp@@@ 000___000PPP000 @@@ppp@@@///OOO@@@@@@PPPPPP@@@@@@@@@ @@@@@@@@@pppooo///```@@@@@@@@@@@@@@@@@@@@@pppOOO/// 000ooo000@@@@@@```PPP@@@@@@@@@ @@@@@@@@@ppp@@@@@@@@@ @@@@@@@@@PPP@@@@@@@@@ppp///OOOooo///???ooo??? PPPppp000????????????///___???ooo___OOO??????????????????///___???OOO???___OOO___OOO???___?????????___OOO???ooo/////////oooooo///OOOoooOOO???///???oooOOOOOOOOO___OOO///OOO???///ooo \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/output/F-E.svg b/spikes/editor-toolkit/round2-reference/output/F-E.svg new file mode 100644 index 0000000..4899b6a --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/output/F-E.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-reference/src/bin/generate_reference.rs b/spikes/editor-toolkit/round2-reference/src/bin/generate_reference.rs new file mode 100644 index 0000000..5837b51 --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/src/bin/generate_reference.rs @@ -0,0 +1,198 @@ +//! `generate_reference` — Packet 2A-iii, Deliverable 2. +//! +//! For each of the five committed fixtures: builds `DrawGlyph`s from its +//! `SpikeResolvedText`, emits an explicit-glyph SVG (composing per-face +//! output through `round2_svgref::emit_glyph_paths` + `wrap_document`, which +//! is what F-B and F-D need since each mixes two faces in one document), +//! asserts no ``, rasterizes +//! at 1920x1080, derives `GlyphRegion`s from the emitter's own returned +//! bounds, runs the differential against itself as a self-check, and prints +//! the required per-fixture numbers. + +use std::path::PathBuf; + +use round2_textkit::faces::{self, FaceResolution, LoadedFace}; +use round2_textkit::output; + +fn main() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let textkit_dir = manifest_dir + .parent() + .expect("round2-reference has a parent directory") + .join("round2-textkit"); + let fixtures_path = textkit_dir.join("fixtures.json"); + + let fixtures = output::load_fixtures(&fixtures_path).unwrap_or_else(|e| { + panic!( + "{}: {e} — run `cargo run -p round2-textkit --bin generate` first", + fixtures_path.display() + ) + }); + + let resolved = faces::resolve_declared_chain(); + let missing: Vec<&std::path::PathBuf> = resolved + .iter() + .filter_map(|r| match r { + FaceResolution::Missing { path } => Some(path), + FaceResolution::Loaded(_) => None, + }) + .collect(); + if !missing.is_empty() { + for path in &missing { + println!("NOT RUN: {}", path.display()); + } + println!( + "{} of {} declared faces are absent (pin 14: environment absence, not a failure). \ + No reference rasters were generated.", + missing.len(), + resolved.len() + ); + return; + } + let loaded: Vec = resolved + .into_iter() + .map(|r| match r { + FaceResolution::Loaded(lf) => lf, + FaceResolution::Missing { .. } => unreachable!("handled above"), + }) + .collect(); + + let out_dir = manifest_dir.join("output"); + std::fs::create_dir_all(&out_dir).expect("create output dir"); + + const WIDTH: u32 = round2_reference::WIDTH; + const HEIGHT: u32 = round2_reference::HEIGHT; + const MIN_INK_PIXELS: usize = 1000; + + let mut any_selfcheck_failed = false; + let mut any_ink_too_low = false; + + println!( + "=== Packet 2A-iii Deliverable 2: reference rasters + D4 regions ({WIDTH}x{HEIGHT}) ===\n" + ); + + for f in &fixtures.fixtures { + let result = round2_reference::build_fixture_raster( + &f.id, + &f.resolved, + &loaded, + WIDTH, + HEIGHT, + round2_reference::FacePolicy::Enforce, + ) + .unwrap_or_else(|e| panic!("{}: {e}", f.id)); + + round2_diff::validate_rgba(&result.rgba, WIDTH, HEIGHT) + .unwrap_or_else(|e| panic!("{}: rasterized buffer is malformed: {e}", f.id)); + + let ink_pixels = round2_reference::count_ink_pixels(&result.rgba); + + // Self-check (task step 5): the reference must pass the differential + // against itself. If it does not, the reference or the differential + // is broken and nothing downstream is trustworthy. + let selfdiff = + round2_diff::diff(&result.rgba, &result.rgba, WIDTH, HEIGHT, &result.regions) + .unwrap_or_else(|e| panic!("{}: self-diff call failed: {e}", f.id)); + let selfcheck_ok = selfdiff.pass(); + if !selfcheck_ok { + any_selfcheck_failed = true; + } + + println!("--- {} ({}) ---", f.id, f.purpose); + println!( + " stored glyphs (SpikeResolvedText): {}", + result.stored_glyph_count + ); + println!( + " glyphs drawn (path emitted): {}", + result.drawn_glyph_count + ); + println!( + " glyphs empty (outline-less, e.g. space): {}", + result.empty_glyph_count + ); + println!( + " unresolved segments (no face, e.g. F-C's Arabic letter): {}", + result.unresolved_segment_count + ); + println!( + " regions (D4): {}", + result.regions.len() + ); + println!(" ink pixels: {ink_pixels}"); + println!( + " self-diff (reference vs itself): d1={} d2={:.6}% d3={:?} d4_pass={} overall_pass={}", + selfdiff.d1_pixels_outside_band_differing, + selfdiff.d2_relative_delta * 100.0, + selfdiff.d3_delta, + selfdiff.d4_pass, + selfcheck_ok + ); + if !selfcheck_ok { + println!(" FINDING (LOUD): reference does NOT pass the differential against itself."); + } + if ink_pixels < MIN_INK_PIXELS { + any_ink_too_low = true; + println!( + " FINDING (LOUD): only {ink_pixels} ink pixels, below the {MIN_INK_PIXELS} \ + sanity floor — a reference this blank would make every later comparison \ + meaningless while looking green (Round 1's unregistered-texture failure)." + ); + } + + let stem = out_dir.join(&f.id); + let svg_path = stem.with_extension("svg"); + std::fs::write(&svg_path, &result.svg).expect("write svg"); + let rgba_path = stem.with_extension("rgba"); + std::fs::write(&rgba_path, &result.rgba).expect("write raw rgba"); + let regions_path = stem.with_extension("regions.json"); + let region_records: Vec = result + .regions + .iter() + .map(round2_reference::RegionRecord::from) + .collect(); + std::fs::write( + ®ions_path, + serde_json::to_string_pretty(®ion_records).unwrap(), + ) + .expect("write regions.json"); + println!( + " wrote {} ({} bytes), {} ({}x{}x4 = {} bytes), {}", + svg_path.display(), + result.svg.len(), + rgba_path.display(), + WIDTH, + HEIGHT, + result.rgba.len(), + regions_path.display() + ); + println!(); + } + + println!("=== summary ==="); + if any_selfcheck_failed { + println!( + "FINDING: at least one fixture's reference did NOT pass the differential against \ + itself — the reference or the differential is broken; nothing above is trustworthy \ + until this is fixed." + ); + } else { + println!("every fixture's reference passes the differential against itself."); + } + if any_ink_too_low { + println!( + "FINDING: at least one fixture's raster carries suspiciously little ink (below \ + {MIN_INK_PIXELS} px)." + ); + } else { + println!("every fixture's raster carries non-trivial ink (>= {MIN_INK_PIXELS} px)."); + } + + // Same defect `round2-diff`'s selftest had: this binary used to print its + // FINDINGs and exit 0, so a blank or self-inconsistent reference would + // have been generated, reported, and recorded as a successful run. + if any_selfcheck_failed || any_ink_too_low { + eprintln!("\ngenerate_reference FAILED: see the FINDINGs above"); + std::process::exit(1); + } +} diff --git a/spikes/editor-toolkit/round2-reference/src/bin/text_mutations.rs b/spikes/editor-toolkit/round2-reference/src/bin/text_mutations.rs new file mode 100644 index 0000000..674d65c --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/src/bin/text_mutations.rs @@ -0,0 +1,457 @@ +//! `text_mutations` — recipe §11's M4, M5 and M6, **executed** against the +//! real frozen fixtures. +//! +//! `round2-diff`'s `selftest` binary covers M1/M2/M3/M3B/M7/M8/M10 on +//! synthetic geometry, because those mutations are geometric and need no +//! fonts. M4, M5 and M6 are not: they are *text* mutations — a ligature +//! unligated, a combining acute omitted, a segment rendered in the wrong face +//! — and each one is only meaningful against shaped glyphs from the declared +//! faces. Recipe revision 2 asserted all three killed D4 and had executed none +//! of them; this binary is the fix, and its exit code is the point. +//! +//! Running it corrected the recipe immediately: **M4 does not kill D4.** An +//! `ff` ligature and two `f` glyphs carry very nearly the same ink (measured: +//! 0.07% of whole-image mass, 1.20% inside the ligature's own region, against +//! a 2% tolerance), so a *mass* rule is simply the wrong instrument for a +//! *shape* substitution. D1 — which asks where the ink is, not how much — sees +//! it at once (221 differing pixels outside the edge band). Recipe revision 3 +//! assigns M4 to D1 and records the margin; see `run_m4`. +//! +//! ## Rules of engagement +//! +//! * Every mutation is applied to a **clone of the loaded fixture**, and the +//! fixture file on disk is never touched. +//! * Every diff is taken against the **unmutated** reference's regions. A +//! region list recomputed from the mutated glyphs would shrink or move with +//! the mutation and score it against a box that had already accommodated it +//! — the same trap `round2-diff`'s `selftest` documents for its literal +//! region table. +//! * Every substituted glyph id and advance is **measured from the real +//! faces** through `round2_textkit::shape`, never guessed and never +//! hard-coded. The measurements are anchor-asserted first (e.g. the probe +//! for a standalone `f` must not come back as the `ff` ligature id), so a +//! mutation that silently became a no-op fails here rather than passing as +//! "did not kill". +//! * A required kill that does not happen is a **blocking failure** and this +//! binary exits non-zero. + +use std::path::PathBuf; + +use round2_reference::FacePolicy; +use round2_textkit::faces::{self, FaceResolution, LoadedFace}; +use round2_textkit::output; +use round2_textkit::shape::shape_text; +use round2_textkit::types::{SpikePositionedGlyph, SpikeResolvedText}; + +const WIDTH: u32 = round2_reference::WIDTH; +const HEIGHT: u32 = round2_reference::HEIGHT; + +/// F-A's `ff` ligature, recipe §4: "cluster at byte 9 spans `ff` -> gid 234". +const FA_LIGATURE_GID: u32 = 234; +/// F-E's composed `é`, recipe §4: "`e`+U+0301 composes to gid 198". +const FE_COMPOSED_GID: u32 = 198; + +fn main() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixtures_path = manifest_dir + .parent() + .expect("round2-reference has a parent directory") + .join("round2-textkit") + .join("fixtures.json"); + + let fixtures = output::load_fixtures(&fixtures_path).unwrap_or_else(|e| { + panic!( + "{}: {e} — run `cargo run -p round2-textkit --bin generate` first", + fixtures_path.display() + ) + }); + + let resolved = faces::resolve_declared_chain(); + let missing: Vec<&std::path::PathBuf> = resolved + .iter() + .filter_map(|r| match r { + FaceResolution::Missing { path } => Some(path), + FaceResolution::Loaded(_) => None, + }) + .collect(); + if !missing.is_empty() { + for path in &missing { + println!("NOT RUN: {}", path.display()); + } + println!( + "{} of {} declared faces are absent (pin 14: environment absence, not a failure). \ + M4/M5/M6 were not run.", + missing.len(), + resolved.len() + ); + return; + } + let loaded: Vec = resolved + .into_iter() + .map(|r| match r { + FaceResolution::Loaded(lf) => lf, + FaceResolution::Missing { .. } => unreachable!("handled above"), + }) + .collect(); + + let fixture = |id: &str| -> SpikeResolvedText { + fixtures + .fixtures + .iter() + .find(|f| f.id == id) + .unwrap_or_else(|| panic!("fixture {id} is missing from fixtures.json")) + .resolved + .clone() + }; + + println!("=== recipe §11 M4/M5/M6 against the real frozen fixtures ===\n"); + + let mut failures: Vec = Vec::new(); + + run_m4(&fixture("F-A"), &loaded, &mut failures); + run_m5(&fixture("F-E"), &loaded, &mut failures); + run_m6(&fixture("F-B"), &loaded, &mut failures); + + println!("=== failures (blocking) ==="); + if failures.is_empty() { + println!( + " none: M4 killed D1, M5 and M6 killed D4 (and everything else), and M6's emitter \ + refusal fired before any raster existed — each as recipe §11 revision 3 requires." + ); + } else { + for f in &failures { + println!(" FAILURE: {f}"); + } + eprintln!( + "\ntext_mutations FAILED: {} blocking failure(s)", + failures.len() + ); + std::process::exit(1); + } +} + +/// Builds a fixture's raster and D4 regions, refusing anything the emitter +/// refuses. +fn raster( + label: &str, + rt: &SpikeResolvedText, + faces: &[LoadedFace], + policy: FacePolicy, +) -> round2_reference::FixtureRasterResult { + round2_reference::build_fixture_raster(label, rt, faces, WIDTH, HEIGHT, policy) + .unwrap_or_else(|e| panic!("{label}: {e}")) +} + +/// Diffs a mutated raster against the reference, using the **reference's** +/// regions, and reports whether D4 fired. +fn score( + label: &str, + reference: &round2_reference::FixtureRasterResult, + mutated: &[u8], +) -> round2_diff::DiffReport { + round2_diff::validate_rgba(mutated, WIDTH, HEIGHT) + .unwrap_or_else(|e| panic!("{label}: mutated buffer is malformed: {e}")); + let r = round2_diff::diff(&reference.rgba, mutated, WIDTH, HEIGHT, &reference.regions) + .unwrap_or_else(|e| panic!("{label}: diff call failed: {e}")); + println!( + " D1 outside-band differing px = {} (pass = {})", + r.d1_pixels_outside_band_differing, r.d1_pass + ); + println!( + " D2 ref_mass = {:.1} cand_mass = {:.1} delta = {:.4}% (pass = {})", + r.reference_ink_mass, + r.candidate_ink_mass, + r.d2_relative_delta * 100.0, + r.d2_pass + ); + match (r.d3_delta, r.d3_pass) { + (Some(d), Some(p)) => println!( + " D3 centroid delta = ({:.4}, {:.4}) px (pass = {p})", + d.0, d.1 + ), + _ => println!(" D3 undefined (one side has zero ink)"), + } + match &r.d4_worst { + Some(w) => println!( + " D4 {} regions; worst = {:?} ref {:.1} -> cand {:.1} ({:.4}%) (pass = {})", + r.d4_regions.len(), + w.label, + w.reference_mass, + w.candidate_mass, + w.relative_delta * 100.0, + r.d4_pass + ), + None => println!(" D4 no regions"), + } + let failing: Vec<&str> = r + .d4_regions + .iter() + .filter(|x| !x.pass) + .map(|x| x.label.as_str()) + .collect(); + println!(" D4 failing regions ({}): {:?}", failing.len(), failing); + // The top regions by relative delta, whether or not any of them failed. + // When D4 stays silent, "how close did it come" is the measurement that + // decides whether the rule is blind or merely under-tuned, and a rule + // that reports only pass/fail cannot answer it. + let mut ranked: Vec<&round2_diff::RegionMass> = r.d4_regions.iter().collect(); + ranked.sort_by(|a, b| b.relative_delta.partial_cmp(&a.relative_delta).unwrap()); + for reg in ranked.iter().take(3) { + println!( + " region {:?}: ref {:.1} -> cand {:.1} ({:.4}%)", + reg.label, + reg.reference_mass, + reg.candidate_mass, + reg.relative_delta * 100.0 + ); + } + println!( + " {label}: d1_pass={} d2_pass={} d3_pass={:?} d4_pass={}", + r.d1_pass, r.d2_pass, r.d3_pass, r.d4_pass + ); + r +} + +/// **M4 — the `ff` ligature replaced by two unligated `f` glyphs**, which is +/// what a consumer that re-shapes the source string with a differently +/// configured shaper produces. +/// +/// The two replacement glyphs are placed at the ligature's own origin and one +/// standalone-`f` advance to its right; **every following glyph stays where it +/// was.** A real unligated shaping would also shift the rest of the run, and +/// that shift would fire D4 on every downstream region — which would prove +/// only that D4 notices a global translation, something M1 and M7 already +/// establish. Confining the change to the ligature's own box is the sharper +/// question: does D4 catch an error inside one glyph's bounds while everything +/// around it is pixel-identical? +fn run_m4(reference_rt: &SpikeResolvedText, faces: &[LoadedFace], failures: &mut Vec) { + println!("--- M4: F-A's `ff` ligature replaced by two unligated `f` glyphs ---"); + + // Measure a standalone `f`'s glyph id and advance from the real face. + // "fa" is used rather than "f" because a single-glyph run carries no + // advance to read; `fa` is not a ligature in either declared face, which + // the anchor assertions below establish rather than assume. + let probe = shape_text("fa", faces); + assert_eq!( + probe.segments.len(), + 1, + "M4 probe: `fa` must itemize to one segment" + ); + let pg = &probe.segments[0].glyphs; + assert_eq!( + pg.len(), + 2, + "M4 probe: `fa` shaped to {} glyphs — if it ligated, this probe cannot measure a \ + standalone `f` and the mutation would be meaningless", + pg.len() + ); + let f_gid = pg[0].glyph_id; + let f_advance = pg[1].offset.x - pg[0].offset.x; + assert_ne!( + f_gid, FA_LIGATURE_GID, + "M4 probe: the standalone `f` came back as the ligature id — the mutation would be a no-op" + ); + assert!( + f_advance > 0.0, + "M4 probe: measured a non-positive advance for `f` ({f_advance})" + ); + println!(" measured: standalone `f` = gid {f_gid}, advance {f_advance} staff space"); + + let mut mutated = reference_rt.clone(); + let seg = &mut mutated.segments[0]; + let lig_positions: Vec = seg + .glyphs + .iter() + .enumerate() + .filter(|(_, g)| g.glyph_id == FA_LIGATURE_GID) + .map(|(i, _)| i) + .collect(); + assert_eq!( + lig_positions.len(), + 1, + "M4: expected exactly one gid-{FA_LIGATURE_GID} ligature in F-A, found {}", + lig_positions.len() + ); + let at = lig_positions[0]; + let lig = seg.glyphs[at].clone(); + let second = SpikePositionedGlyph { + glyph_id: f_gid, + offset: round2_textkit::types::SpikePoint::new( + round2_textkit::quantize::quantize_component(lig.offset.x + f_advance), + lig.offset.y, + ), + transform: lig.transform, + }; + seg.glyphs[at] = SpikePositionedGlyph { + glyph_id: f_gid, + ..lig + }; + seg.glyphs.insert(at + 1, second); + println!( + " mutated: 1 ligature glyph -> 2 `f` glyphs at x = {} and {}", + seg.glyphs[at].offset.x, + seg.glyphs[at + 1].offset.x + ); + + let reference = raster("F-A", reference_rt, faces, FacePolicy::Enforce); + let m = raster("F-A/M4", &mutated, faces, FacePolicy::Enforce); + let r = score("M4", &reference, &m.rgba); + + // **Recipe revision 3 requires D1 here, not D4, and the number below is + // why.** Revision 2 assigned M4 to D4 by analogy with M3 (a dropped + // glyph), and executing it showed the analogy is false: an `ff` ligature + // and two `f` glyphs carry very nearly the *same ink*, so a mass rule is + // blind to the substitution by construction, not by mis-tuning. What + // differs is *where* the ink sits — the ligature's joined crossbar versus + // two separate ones — and that is D1's question, which it answers loudly. + if r.d1_pass { + failures.push(format!( + "M4 (unligated `ff`) did not fail D1 — {} differing pixels outside the edge band. A \ + ligature swapped for its components is the single most likely way a re-shaping \ + consumer diverges, and nothing would catch it", + r.d1_pixels_outside_band_differing + )); + } + if !r.d4_pass { + failures.push( + "M4: D4 FIRED, contradicting recipe §11's declared mass-preserving blind spot. That \ + is not a free win — the recipe states D4 cannot see this, and a recipe that is wrong \ + about its own rule must be corrected rather than quietly benefit" + .to_string(), + ); + } + println!(); +} + +/// **M5 — the composed `é` drawn as a bare `e`, acute omitted.** +/// +/// The base glyph keeps its position: a combining mark carries zero advance, +/// so a consumer that dropped it would not reflow anything. The whole error is +/// therefore confined to two glyph boxes. +/// +/// **D2 fires anyway, at 2.55%, and an earlier version of this comment said it +/// would not.** The guess was that two acutes are a negligible share of the +/// image's ink; measured, F-E's whole-image ink mass is 16241.0 and dropping +/// the two acutes takes it to 15826.7 — a difference of 414.3, or 2.55%, over +/// D2's 2% tolerance. The lesson is not about +/// this fixture: D2's sensitivity scales inversely with how much ink is on the +/// page, so the same omission in a full score would be invisible to it. D4 is +/// what catches this independently of page content — 13.28% and 13.19% in the +/// two `é` regions — which is exactly why the rule exists. +fn run_m5(reference_rt: &SpikeResolvedText, faces: &[LoadedFace], failures: &mut Vec) { + println!("--- M5: F-E's composed `é` drawn as `e`, acute omitted ---"); + + let probe = shape_text("e", faces); + assert_eq!(probe.segments.len(), 1, "M5 probe: `e` must be one segment"); + assert_eq!( + probe.segments[0].glyphs.len(), + 1, + "M5 probe: `e` must shape to exactly one glyph" + ); + let e_gid = probe.segments[0].glyphs[0].glyph_id; + assert_ne!( + e_gid, FE_COMPOSED_GID, + "M5 probe: bare `e` came back as the composed `é` id — the mutation would be a no-op" + ); + println!(" measured: bare `e` = gid {e_gid}, composed `é` = gid {FE_COMPOSED_GID}"); + + let mut mutated = reference_rt.clone(); + let mut replaced = 0usize; + for seg in &mut mutated.segments { + for g in &mut seg.glyphs { + if g.glyph_id == FE_COMPOSED_GID { + g.glyph_id = e_gid; + replaced += 1; + } + } + } + assert_eq!( + replaced, 2, + "M5: F-E carries the composed glyph twice (recipe §4: at byte 3 and byte 16); replaced {replaced}" + ); + println!(" mutated: {replaced} composed glyphs -> bare `e`"); + + let reference = raster("F-E", reference_rt, faces, FacePolicy::Enforce); + let m = raster("F-E/M5", &mutated, faces, FacePolicy::Enforce); + if score("M5", &reference, &m.rgba).d4_pass { + failures.push( + "M5 (omitted acute) did not fail D4 — a dropped diacritic is invisible to the \ + differential, which would make check 1 unable to distinguish `resumé` from `resume`" + .to_string(), + ); + } + println!(); +} + +/// **M6 — F-B's Hebrew segment forced onto face 0**, the host substitution +/// W3 §5 check 2 exists to forbid. +/// +/// Two halves, and both are required: +/// +/// 1. The **emitter refuses** it. `FacePolicy::Enforce` checks that every +/// segment's declared face has a `cmap` entry for every codepoint in that +/// segment's own source range, so this never reaches a raster. This is the +/// half recipe revision 2 claimed and did not implement. +/// 2. **Forced past the refusal, D4 fires.** Pagella's glyph ids in the range +/// Liberation Serif assigns to Hebrew are either absent or unrelated +/// letterforms, so what gets drawn in the Hebrew segment's boxes is not +/// what belongs there — and the differential must say so, because a future +/// candidate might reach the same state by a route this emitter does not +/// control. +fn run_m6(reference_rt: &SpikeResolvedText, faces: &[LoadedFace], failures: &mut Vec) { + println!("--- M6: F-B's Hebrew segment forced onto face 0 (host substitution) ---"); + + let mut mutated = reference_rt.clone(); + let hebrew = mutated + .segments + .iter() + .position(|s| s.face == Some(1)) + .expect("M6: F-B must have a segment on face 1"); + println!( + " segment {hebrew} (source bytes {}..{}) moved from face 1 to face 0", + mutated.segments[hebrew].source.start, mutated.segments[hebrew].source.end + ); + mutated.segments[hebrew].face = Some(0); + + // Half 1: the refusal. + match round2_reference::build_fixture_raster( + "F-B/M6", + &mutated, + faces, + WIDTH, + HEIGHT, + FacePolicy::Enforce, + ) { + Err(e) => println!(" emitter REFUSED, as required: {e}"), + Ok(_) => { + failures.push( + "M6: the emitter did NOT refuse a segment whose declared face has no cmap \ + coverage for its own codepoints — recipe §11's structural safeguard is absent" + .to_string(), + ); + println!(" FAILURE: the emitter accepted the substitution."); + } + } + + // Half 2: forced past it. + let reference = raster("F-B", reference_rt, faces, FacePolicy::Enforce); + let m = raster( + "F-B/M6", + &mutated, + faces, + FacePolicy::AllowUncoveredForM6Only, + ); + println!( + " forced: {} glyphs drawn, {} empty (a glyph id with no outline in the substituted face \ + draws nothing at all)", + m.drawn_glyph_count, m.empty_glyph_count + ); + if score("M6", &reference, &m.rgba).d4_pass { + failures.push( + "M6 (host-substituted face), forced past the refusal, did not fail D4 — the \ + differential cannot see a segment drawn from the wrong face" + .to_string(), + ); + } + println!(); +} diff --git a/spikes/editor-toolkit/round2-reference/src/lib.rs b/spikes/editor-toolkit/round2-reference/src/lib.rs new file mode 100644 index 0000000..e1906ed --- /dev/null +++ b/spikes/editor-toolkit/round2-reference/src/lib.rs @@ -0,0 +1,373 @@ +//! Packet 2A-iii, Deliverable 2: turns a `round2_textkit::SpikeResolvedText` +//! into a reference raster via `round2_svgref`'s explicit-glyph emitter, and +//! derives `round2_diff::GlyphRegion` values from the emitter's own returned +//! bounds — never from a second, independently computed geometry. +//! +//! **This crate does not modify `round2-svgref` or `round2-diff`.** Both are +//! reviewed and settled (packet rule). It only calls their public APIs. + +use std::collections::{BTreeMap, HashSet}; + +use round2_diff::GlyphRegion; +use round2_svgref::{DrawGlyph, DrawnBounds}; +use round2_textkit::faces::LoadedFace; +use round2_textkit::hittest; +use round2_textkit::types::SpikeResolvedText; + +pub const WIDTH: u32 = round2_textkit::TARGET_WIDTH as u32; +pub const HEIGHT: u32 = round2_textkit::TARGET_HEIGHT as u32; + +/// **Resolved, not worked around.** An earlier version of this file sliced +/// the `` fragment out of `round2_svgref::emit_svg`'s complete document +/// by searching for that crate's background-rect and `` markers, because +/// a multi-face run (F-B and F-D each mix face 0 and face 1) needs paths from +/// two faces inside one document and `emit_svg` takes a single face. That +/// worked, and it was a trap: any formatting change in `round2-svgref` would +/// have broken it silently — no compiler error, no failing test, just a +/// reference raster that came out wrong. `round2-svgref` now exposes +/// [`round2_svgref::emit_glyph_paths`] and [`round2_svgref::wrap_document`], +/// so the composition is an API call and a rename would be a build failure. +fn correlate_bounds<'a>( + glyphs: &[DrawGlyph], + bounds: &'a [DrawnBounds], + empty: &[u16], +) -> Vec> { + let empty_set: HashSet = empty.iter().copied().collect(); + let mut bi = 0usize; + let mut out = Vec::with_capacity(glyphs.len()); + for g in glyphs { + if empty_set.contains(&g.glyph_id) { + out.push(None); + } else { + let b = &bounds[bi]; + assert_eq!( + b.glyph_id, + g.glyph_id, + "bounds/glyph correlation mismatch at input index {} — emit_svg's returned \ + bounds order must match its input glyphs order", + out.len() + ); + out.push(Some(b)); + bi += 1; + } + } + assert_eq!( + bi, + bounds.len(), + "not every returned DrawnBounds was consumed — correlation logic under-counted" + ); + out +} + +/// Whether the emitter enforces that a segment's declared face actually +/// covers that segment's own codepoints. +/// +/// **This is recipe §11's M6 refusal, implemented.** Revision 2 of the recipe +/// claimed "emitter refuses; if forced, D4" for a host-substituted face, and +/// nothing implemented the first half — [`build_fixture_raster`] simply used +/// whatever face index the segment carried. A claim that a structural +/// safeguard exists, when it does not, is worse than no claim: it is the +/// safeguard everyone downstream believes is standing. +/// +/// The check is cheap and exact: for each segment with `face: Some(i)`, every +/// `char` in the segment's source range must have a `cmap` entry in face `i`. +/// Face resolution (`round2_textkit::shape`) walks the declared chain in order +/// and only ever assigns a face that covers the codepoint, so `Enforce` never +/// fires on an honestly-generated fixture — it fires on a *tampered* one, +/// which is the whole point. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum FacePolicy { + /// Every real caller, including `bin/generate_reference`. + Enforce, + /// **Only** the M6 mutation harness (`bin/text_mutations`), which must get + /// past the refusal in order to measure what D4 says about a substitution + /// that a real pipeline could never produce. Named this verbosely so that + /// any other use of it is visible in a grep. + AllowUncoveredForM6Only, +} + +/// Refuses a segment whose declared face cannot represent its own text. +fn enforce_face_coverage( + fixture_id: &str, + seg_idx: usize, + face_idx: u32, + face: &LoadedFace, + text: &str, + range: &std::ops::Range, +) -> Result<(), String> { + let sub = text + .get(range.start as usize..range.end as usize) + .ok_or_else(|| { + format!("{fixture_id}: segment {seg_idx} source range is not on a UTF-8 boundary") + })?; + let parsed = ttf_parser::Face::parse(&face.bytes, face.identity.face_index) + .map_err(|e| format!("{fixture_id}: face {face_idx} failed to parse: {e}"))?; + for ch in sub.chars() { + if parsed.glyph_index(ch).is_none() { + return Err(format!( + "{fixture_id}: segment {seg_idx} declares face {face_idx} ({}), which has no cmap \ + entry for U+{:04X} — this is a host substitution, exactly what W3 §5 check 2 \ + forbids, and the emitter refuses it rather than drawing whatever glyph id \ + happens to land in that face's outline table", + face.identity.family, ch as u32 + )); + } + } + Ok(()) +} + +/// Everything measured while turning one fixture into a reference raster. +pub struct FixtureRasterResult { + pub svg: String, + pub rgba: Vec, + pub regions: Vec, + pub drawn_glyph_count: usize, + pub empty_glyph_count: usize, + /// Segments with `face: None` (F-C's uncovered Arabic letter) — each + /// contributes zero glyphs to `drawn_glyph_count + empty_glyph_count` by + /// construction (`SpikeShapedSegment::glyphs` is always empty for these, + /// W3-F3 / `invariants::assert_unresolved_clusters_are_diagnostic`), so + /// this count is how that fact stays *visible* rather than silently + /// absent from the report. + pub unresolved_segment_count: usize, + pub stored_glyph_count: usize, +} + +/// Builds one fixture's reference SVG + raster, deriving every +/// `round2_diff::GlyphRegion` from the bounds `emit_svg` itself returned. +/// +/// Each segment's own `size` (staff-space em, recipe §3 — `1.28` for every +/// segment in this recipe, but read from the data, never hard-coded) and +/// each glyph's own `offset` (relative to `rt.origin`, recipe §5) are +/// converted to device space via `round2_textkit::hittest::to_device` — +/// reused rather than re-implemented, so there is exactly one transform +/// implementation in this whole packet, not two that could quietly diverge. +pub fn build_fixture_raster( + fixture_id: &str, + rt: &SpikeResolvedText, + faces: &[LoadedFace], + width: u32, + height: u32, + policy: FacePolicy, +) -> Result { + struct Entry { + seg_idx: usize, + glyph_idx: usize, + draw: DrawGlyph, + } + + let mut by_face: BTreeMap> = BTreeMap::new(); + let mut unresolved_segment_count = 0usize; + let mut stored_glyph_count = 0usize; + + for (seg_idx, seg) in rt.segments.iter().enumerate() { + stored_glyph_count += seg.glyphs.len(); + let Some(face_idx) = seg.face else { + // F-C's uncovered codepoint: no face resolved, and per W3-F3 / + // invariant 4, `seg.glyphs` is guaranteed empty here — nothing to + // draw, nothing added to `by_face`. Shaping was never attempted + // against a face that cannot represent the codepoint (see + // `SpikeShapedSegment::face`'s own doc comment), so there is no + // "draw the .notdef glyph" fallback to suppress here either. + unresolved_segment_count += 1; + continue; + }; + if policy == FacePolicy::Enforce { + let face = faces.get(face_idx as usize).ok_or_else(|| { + format!( + "{fixture_id}: segment {seg_idx} resolved to face {face_idx}, but only {} \ + faces were loaded", + faces.len() + ) + })?; + enforce_face_coverage(fixture_id, seg_idx, face_idx, face, &rt.text, &seg.source)?; + } + let em_px = seg.size.0 * hittest::DEVICE_SCALE; + for (glyph_idx, g) in seg.glyphs.iter().enumerate() { + let device = hittest::to_device(rt, &g.offset); + by_face.entry(face_idx).or_default().push(Entry { + seg_idx, + glyph_idx, + draw: DrawGlyph { + glyph_id: g.glyph_id as u16, + origin_x: device.x, + origin_y: device.y, + em_px, + }, + }); + } + } + + let mut path_fragments = Vec::new(); + let mut regions = Vec::new(); + let mut drawn_glyph_count = 0usize; + let mut empty_glyph_count = 0usize; + + for (face_idx, entries) in &by_face { + let face = faces.get(*face_idx as usize).ok_or_else(|| { + format!( + "{fixture_id}: a segment resolved to face {face_idx}, but only {} faces were \ + loaded", + faces.len() + ) + })?; + let draw_glyphs: Vec = entries.iter().map(|e| e.draw).collect(); + let (fragments, bounds, empty) = + round2_svgref::emit_glyph_paths(&face.bytes, face.identity.face_index, &draw_glyphs)?; + + let correlated = correlate_bounds(&draw_glyphs, &bounds, &empty); + for (entry, maybe_bounds) in entries.iter().zip(correlated.iter()) { + match maybe_bounds { + Some(b) => { + let label = format!( + "{fixture_id} seg{}.glyph{} (face {face_idx}, gid {})", + entry.seg_idx, entry.glyph_idx, entry.draw.glyph_id + ); + regions.push(GlyphRegion { + label, + x0: b.x0.floor().max(0.0) as u32, + y0: b.y0.floor().max(0.0) as u32, + x1: b.x1.ceil().max(0.0) as u32, + y1: b.y1.ceil().max(0.0) as u32, + }); + drawn_glyph_count += 1; + } + None => empty_glyph_count += 1, + } + } + + path_fragments.extend(fragments); + } + + let final_svg = round2_svgref::wrap_document(width, height, &path_fragments); + round2_svgref::assert_no_text_elements(&final_svg)?; + let rgba = round2_svgref::rasterize(&final_svg, width, height)?; + + Ok(FixtureRasterResult { + svg: final_svg, + rgba, + regions, + drawn_glyph_count, + empty_glyph_count, + unresolved_segment_count, + stored_glyph_count, + }) +} + +/// A discrete ink-pixel count — distinct from `round2_diff::ink_mass`'s +/// continuous sum. Reimplements the same Rec. 601 luma weights +/// `round2_diff` documents (its own `luma` helper is private), so "ink" +/// means the same thing here as it does inside the differential: `luma < +/// round2_diff::INK_LUMA_THRESHOLD`. +pub fn count_ink_pixels(rgba: &[u8]) -> usize { + rgba.chunks(4) + .filter(|p| { + let luma = (299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000; + luma < round2_diff::INK_LUMA_THRESHOLD as u32 + }) + .count() +} + +/// Serializable mirror of `round2_diff::GlyphRegion` (which carries no +/// `serde` derive — it is a working type in a dependency-free crate, not a +/// wire type). Same boundary-mirror pattern `round2_textkit::types` uses for +/// `epiphany_layout_ir` types, for the same reason. +#[derive(serde::Serialize)] +pub struct RegionRecord { + pub label: String, + pub x0: u32, + pub y0: u32, + pub x1: u32, + pub y1: u32, +} + +impl From<&GlyphRegion> for RegionRecord { + fn from(r: &GlyphRegion) -> Self { + RegionRecord { + label: r.label.clone(), + x0: r.x0, + y0: r.y0, + x1: r.x1, + y1: r.y1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multi_face_composition_goes_through_the_api_not_a_substring_search() { + // Regression guard for the finding at the top of this file: composing + // a two-face document must use round2-svgref's own API. If that crate + // ever renames or reshapes these functions, this fails to COMPILE, + // which is the entire point — the substring version failed silently. + let paths = vec![ + "".to_string(), + "".to_string(), + ]; + let doc = round2_svgref::wrap_document(64, 32, &paths); + assert!(doc.starts_with("")); + assert_eq!(doc.matches("` element carries *characters*, and the +//! viewer's own shaper picks the glyphs from them — so for anything +//! contextual (this round's measured `ff`/`fi` ligatures, the composed `é`) +//! `` silently draws a different glyph than the layout resolved. Without +//! an emitter that addresses glyphs by id, Round 2's check 1 would be +//! `NOT RUN` for every candidate and the round would decide nothing. +//! +//! So the emitter writes **explicit glyph outlines as ``**, looked up by +//! font-internal glyph id from the same content-hashed face that shaped the +//! run. This is a prototype of the explicit-glyph output W3 says the real +//! exporter needs (`ANALYSIS_TEXT_RUN_PRIMITIVES.md:496-509`), and its +//! findings are reported as such. +//! +//! **It never emits ``, and [`assert_no_text_elements`] enforces that +//! structurally** rather than leaving it to a threshold. A `` element in +//! the output would reintroduce exactly the re-shaping this round exists to +//! forbid, and it would do so *invisibly*: the raster might even look right on +//! a machine whose shaper agrees, which is the worst possible failure — a +//! reference that is wrong only sometimes. + +use std::fmt::Write as _; + +/// One glyph to draw: a font-internal id in `face`'s namespace, and where its +/// origin sits in device pixels. +/// +/// The id is meaningless without the face's content hash — that is the whole +/// point of W3's identity discipline — so the caller passes the face bytes it +/// hashed, never a family name. +#[derive(Clone, Copy, Debug)] +pub struct DrawGlyph { + pub glyph_id: u16, + /// Device-space pen position for this glyph's origin, y-down. + pub origin_x: f64, + pub origin_y: f64, + /// Device pixels per em for this glyph's face. + pub em_px: f64, +} + +/// A glyph's device-space bounding box, as measured from the outline the +/// emitter actually drew — returned so the caller can hand D4 its regions +/// without re-deriving them from a second source that might disagree. +#[derive(Clone, Debug)] +pub struct DrawnBounds { + pub glyph_id: u16, + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +/// Collects a glyph outline into an SVG path `d` string, converting font units +/// to device pixels and flipping y (font space is y-up, SVG user space is +/// y-down). +struct PathSink { + d: String, + scale: f64, + ox: f64, + oy: f64, + minx: f64, + miny: f64, + maxx: f64, + maxy: f64, + any: bool, +} + +impl PathSink { + fn map(&mut self, x: f32, y: f32) -> (f64, f64) { + let dx = self.ox + x as f64 * self.scale; + let dy = self.oy - y as f64 * self.scale; + self.minx = self.minx.min(dx); + self.miny = self.miny.min(dy); + self.maxx = self.maxx.max(dx); + self.maxy = self.maxy.max(dy); + self.any = true; + (dx, dy) + } +} + +impl ttf_parser::OutlineBuilder for PathSink { + fn move_to(&mut self, x: f32, y: f32) { + let (a, b) = self.map(x, y); + let _ = write!(self.d, "M{a:.4} {b:.4} "); + } + fn line_to(&mut self, x: f32, y: f32) { + let (a, b) = self.map(x, y); + let _ = write!(self.d, "L{a:.4} {b:.4} "); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let (c1, d1) = self.map(x1, y1); + let (a, b) = self.map(x, y); + let _ = write!(self.d, "Q{c1:.4} {d1:.4} {a:.4} {b:.4} "); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let (c1, d1) = self.map(x1, y1); + let (c2, d2) = self.map(x2, y2); + let (a, b) = self.map(x, y); + let _ = write!(self.d, "C{c1:.4} {d1:.4} {c2:.4} {d2:.4} {a:.4} {b:.4} "); + } + fn close(&mut self) { + self.d.push_str("Z "); + } +} + +/// Emits one SVG document drawing every glyph in `glyphs` as an explicit +/// ``, on an opaque white ground, in opaque black ink. +/// +/// Returns the SVG source and the device-space bounds of each glyph actually +/// drawn. +/// +/// **A glyph whose outline the face cannot produce is an error, not an +/// omission.** Skipping it would produce a reference raster that is silently +/// missing ink, and every candidate would then be compared against a +/// reference that is itself wrong — the failure mode is a *false PASS for a +/// candidate that also drew nothing there*, which no threshold can catch. +/// (`outline_glyph` legitimately returns `None` for a whitespace glyph with an +/// empty outline; those are reported separately as `empty`, not as errors, and +/// contribute no bounds.) +pub fn emit_svg( + face_bytes: &[u8], + face_index: u32, + glyphs: &[DrawGlyph], + width: u32, + height: u32, +) -> Result<(String, Vec, Vec), String> { + let (paths, bounds, empty) = emit_glyph_paths(face_bytes, face_index, glyphs)?; + Ok((wrap_document(width, height, &paths), bounds, empty)) +} + +/// Wraps already-emitted `` fragments in one complete document on an +/// opaque white ground. +/// +/// Public because a **multi-face run cannot be emitted any other way**: a run +/// whose segments resolve to different faces (this recipe's F-B and F-D do) +/// needs paths from more than one face inside a single document, and each +/// `emit_glyph_paths` call sees exactly one face. +pub fn wrap_document(width: u32, height: u32, paths: &[String]) -> String { + let mut svg = String::new(); + let _ = write!( + svg, + "\ + " + ); + for p in paths { + svg.push_str(p); + } + svg.push_str(""); + svg +} + +/// Emits one `` fragment per drawable glyph, **without** a surrounding +/// document, plus the drawn bounds and the ids of glyphs that had no outline. +/// +/// This exists because the first version of this crate exposed only +/// [`emit_svg`], which always returns a complete self-contained document. A +/// caller composing a multi-face run had no way to get at the paths, and +/// worked around it by string-slicing the fragment out from between this +/// crate's `` and `` markers. That worked, and it was a trap: any +/// change to this function's formatting would have broken the caller silently, +/// with no compiler signal and no failing test until a raster came out wrong. +/// A structural need deserves an API, not a substring search. +pub fn emit_glyph_paths( + face_bytes: &[u8], + face_index: u32, + glyphs: &[DrawGlyph], +) -> Result<(Vec, Vec, Vec), String> { + let face = ttf_parser::Face::parse(face_bytes, face_index) + .map_err(|e| format!("face parse failed: {e}"))?; + let upem = face.units_per_em() as f64; + if upem <= 0.0 { + return Err("face reports units_per_em = 0".to_string()); + } + + let mut paths = Vec::new(); + let mut bounds = Vec::new(); + let mut empty = Vec::new(); + for g in glyphs { + let mut sink = PathSink { + d: String::new(), + scale: g.em_px / upem, + ox: g.origin_x, + oy: g.origin_y, + minx: f64::INFINITY, + miny: f64::INFINITY, + maxx: f64::NEG_INFINITY, + maxy: f64::NEG_INFINITY, + any: false, + }; + let gid = ttf_parser::GlyphId(g.glyph_id); + match face.outline_glyph(gid, &mut sink) { + Some(_) if sink.any => { + // fill-rule nonzero: the same rule Round 1 measured Bravura's + // contours to agree with, and the rule TrueType/CFF outlines + // are authored for. Counters are subtracted by winding, not by + // a second painted shape. + paths.push(format!( + "", + sink.d.trim_end() + )); + bounds.push(DrawnBounds { + glyph_id: g.glyph_id, + x0: sink.minx, + y0: sink.miny, + x1: sink.maxx, + y1: sink.maxy, + }); + } + _ => { + // No outline: whitespace and other blank glyphs are legitimate + // here. Recorded so the count can be checked against the + // fixture's own expectation rather than assumed. + empty.push(g.glyph_id); + } + } + } + Ok((paths, bounds, empty)) +} + +/// Hard structural check (recipe §9): the emitted document must contain no +/// ``, ``, ``, or `` construct. +/// +/// This is deliberately a check on the *source*, run before anything is +/// rasterized. A ``-bearing reference could rasterize to something that +/// looks correct on this machine and wrong on another, so catching it in the +/// pixels is not good enough — by then the reference has already been trusted. +pub fn assert_no_text_elements(svg: &str) -> Result<(), String> { + for forbidden in [", never by character. A element lets the \ + viewer's own shaper choose the glyphs, which is precisely the re-shaping this \ + round exists to forbid, and it would do so invisibly on any machine whose \ + shaper happens to agree" + )); + } + } + Ok(()) +} + +/// Rasterizes the emitted SVG under pin 4's configuration: fixed +/// `width x height`, opaque ground, straight (un-premultiplied) RGBA8 out. +/// +/// The returned buffer is exactly `width * height * 4` bytes with every alpha +/// at 255, which is what `round2-diff` requires — it refuses anything else +/// rather than guessing, so any transparency escaping from here would be +/// caught immediately rather than classified as ink. +pub fn rasterize(svg: &str, width: u32, height: u32) -> Result, String> { + assert_no_text_elements(svg)?; + + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| format!("usvg parse failed: {e}"))?; + + let mut pixmap = tiny_skia::Pixmap::new(width, height) + .ok_or_else(|| format!("could not allocate a {width}x{height} pixmap"))?; + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + + // tiny-skia stores premultiplied RGBA. The white `` covers the whole + // frame, so every pixel should already be opaque; demultiplication is + // therefore a no-op here, but it is done explicitly rather than assumed, + // and a non-opaque pixel is an error rather than a silent divide. + let mut out = Vec::with_capacity((width as usize) * (height as usize) * 4); + for p in pixmap.pixels() { + if p.alpha() != 255 { + return Err(format!( + "rasterizer produced a pixel with alpha {} — the background rect should make the \ + whole frame opaque; a transparent pixel would be classified as ink by luma and \ + quietly corrupt the differential", + p.alpha() + )); + } + out.extend_from_slice(&[p.red(), p.green(), p.blue(), 255]); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAGELLA: &str = "/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf"; + + fn face_bytes() -> Option> { + std::fs::read(PAGELLA).ok() + } + + #[test] + fn text_elements_are_refused_at_the_source_not_the_raster() { + let svg = "fi"; + let err = assert_no_text_elements(svg).unwrap_err(); + assert!(err.contains(""; + assert!(assert_no_text_elements(svg).is_err()); + } + + #[test] + fn emits_explicit_paths_and_rasterizes_opaque_ink() { + let Some(bytes) = face_bytes() else { + eprintln!("NOT RUN: {PAGELLA} absent — environment absence, not a failure"); + return; + }; + let face = ttf_parser::Face::parse(&bytes, 0).unwrap(); + let gid = face.glyph_index('A').unwrap(); + let glyphs = [DrawGlyph { + glyph_id: gid.0, + origin_x: 100.0, + origin_y: 300.0, + em_px: 128.0, + }]; + let (svg, bounds, empty) = emit_svg(&bytes, 0, &glyphs, 400, 400).unwrap(); + assert!(svg.contains(" 200, "expected real ink coverage, got {ink} px"); + } + + #[test] + fn a_glyph_with_no_outline_is_reported_empty_not_silently_skipped() { + let Some(bytes) = face_bytes() else { + eprintln!("NOT RUN: {PAGELLA} absent"); + return; + }; + let face = ttf_parser::Face::parse(&bytes, 0).unwrap(); + let space = face.glyph_index(' ').unwrap(); + let glyphs = [DrawGlyph { + glyph_id: space.0, + origin_x: 100.0, + origin_y: 300.0, + em_px: 128.0, + }]; + let (_svg, bounds, empty) = emit_svg(&bytes, 0, &glyphs, 400, 400).unwrap(); + assert!(bounds.is_empty()); + assert_eq!(empty, vec![space.0]); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/Cargo.toml b/spikes/editor-toolkit/round2-textkit/Cargo.toml new file mode 100644 index 0000000..e956ebe --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "round2-textkit" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Packet 2A-i (ROUND2_TEXT_RECIPE.md, governed by +# spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14, and +# spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md §3E / §5): the candidate-neutral text +# fixture set. This crate mirrors W3's proposed `ResolvedText` shape +# (non-canonically — see `src/lib.rs`), resolves the two declared faces, +# shapes/itemizes/segments the five committed fixture strings with +# `rustybuzz` + `unicode-bidi` + `unicode-segmentation`, asserts every W3 §5 +# invariant on the result, and writes `fixtures.json` + `FIXTURES_SUMMARY.md` +# for candidates to consume in a later packet. It renders nothing itself — +# the SVG reference emitter (recipe §9) and the bounded visual differential +# (recipe §10/§11, `round2-diff`) are separate, parallel packets. +# +# Dependency versions are pinned exactly as the recipe measured them (§4): +# a version bump would change what the generator's precommitted expectations +# describe, which is the same discipline the Round 1 oracle applies to its +# own frozen inputs. + +[dependencies] +rustybuzz = "=0.20.1" +unicode-bidi = "=0.3.18" +unicode-segmentation = "=1.13.3" +ttf-parser = "=0.25.1" +sha2 = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +epiphany-layout-ir = { path = "../../../crates/epiphany-layout-ir" } +epiphany-determinism = { path = "../../../crates/epiphany-determinism" } +epiphany-core = { path = "../../../crates/epiphany-core" } + +[[bin]] +name = "generate" +path = "src/bin/generate.rs" + +[[bin]] +name = "generate_hittest" +path = "src/bin/generate_hittest.rs" diff --git a/spikes/editor-toolkit/round2-textkit/FIXTURES_SUMMARY.md b/spikes/editor-toolkit/round2-textkit/FIXTURES_SUMMARY.md new file mode 100644 index 0000000..42d5aca --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/FIXTURES_SUMMARY.md @@ -0,0 +1,95 @@ +# Round 2 text fixtures — measured summary + +Generated by `round2-textkit`. Em size: `1.28` staff space. Target: 1920x1080. + +## Faces + +- chain[0] `/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf` — family `TeX Gyre Pagella`, version `Some("Version 2.501;PS 2.501;ffdkm 0.1")`, sha256 `44e64260716d8f2bbe412baa1ee99b7c995190ac4573177c24def0b9200438c7` +- chain[1] `/usr/share/fonts/liberation-fonts/LiberationSerif-Regular.ttf` — family `Liberation Serif`, version `Some("Version 2.1.5")`, sha256 `058ea80864aef09a23f45cbec2bb5400bc3dfbdea01c3f10538a21fcb497fb74` + +## Shaping identity (from the first fixture; identical for all) + +- shaper: `rustybuzz` `0.20.1` +- unicode-bidi: crate `0.3.18`, Unicode `16.0.0` +- unicode-segmentation: crate `1.13.3`, Unicode `17.0.0` +- **the two Unicode-data versions disagree** — reported, not reconciled (`crate::findings::W3_F2`). + +## Fixtures + +### F-A — check 1 (faithful consumption), check 5 (accessibility) + +text: `"Allegro affettuoso — al fine"` + +28 codepoints, 30 bytes, 1 segments, 26 glyphs, 26 clusters + +| segment | source | face | direction | script | glyphs | +|---|---|---|---|---|---| +| 0 | 0..30 | Some(0) | Ltr | Latn | 26 | + +check 5 (accessibility, disqualifying) — name must be exactly `Allegro affettuoso — al fine` (30 bytes, hex `416c6c6567726f20616666657474756f736f20e2809420616c2066696e65`); Ligatures: the name must be the source `ff`/`fi`, never the ligature glyphs that drew them. + +### F-B — check 2 (fallback, forced) + +text: `"Coro אבג"` + +8 codepoints, 11 bytes, 2 segments, 8 glyphs, 8 clusters + +| segment | source | face | direction | script | glyphs | +|---|---|---|---|---|---| +| 0 | 0..5 | Some(0) | Ltr | Latn | 5 | +| 1 | 5..11 | Some(1) | Rtl | Hebr | 3 | + +check 5 (accessibility, disqualifying) — name must be exactly `Coro אבג` (11 bytes, hex `436f726f20d790d791d792`); Two segments, two faces: the name must be the whole logical string, assembled in logical order, not the visual order the Hebrew tail is drawn in. + +### F-C — check 2 (uncovered codepoint) + +text: `"Coro ا"` + +6 codepoints, 7 bytes, 2 segments, 5 glyphs, 6 clusters + +| segment | source | face | direction | script | glyphs | +|---|---|---|---|---|---| +| 0 | 0..5 | Some(0) | Ltr | Latn | 5 | +| 1 | 5..7 | None | Rtl | Arab | 0 | + +check 5 (accessibility, disqualifying) — name must be exactly `Coro ا` (7 bytes, hex `436f726f20d8a7`); Uncovered codepoint: U+0627 draws no ink in either declared face, and must appear in the name regardless — the tree carries the text, not the ink. + +### F-D — SUPPLEMENTARY bidi evidence (Hebrew/Latin) — check 3 remains NOT RUN (no Arabic-capable face; recipe §1.2) + +text: `"Allegro אבג con brio"` + +20 codepoints, 23 bytes, 3 segments, 20 glyphs, 20 clusters + +| segment | source | face | direction | script | glyphs | +|---|---|---|---|---|---| +| 0 | 0..8 | Some(0) | Ltr | Latn | 8 | +| 1 | 8..14 | Some(1) | Rtl | Hebr | 3 | +| 2 | 14..23 | Some(0) | Ltr | Latn | 9 | + +check 5 (accessibility, disqualifying) — name must be exactly `Allegro אבג con brio` (23 bytes, hex `416c6c6567726f20d790d791d79220636f6e206272696f`); Three visual runs at levels 0/1/0: the concatenation is logical-order, so a tree built by walking the visual runs left to right fails here and only here. + +### F-E — check 4 (hit testing / caret) + +text: `"Cafe\u{301} — resume\u{301}"` + +15 codepoints, 19 bytes, 1 segments, 13 glyphs, 13 clusters + +| segment | source | face | direction | script | glyphs | +|---|---|---|---|---|---| +| 0 | 0..19 | Some(0) | Ltr | Latn | 13 | + +check 5 (accessibility, disqualifying) — name must be exactly `Café — resumé` (19 bytes, hex `43616665cc8120e2809420726573756d65cc81`); NFD: `Cafe\u{301}` must surface as NFD. A tree exposing `Café` (NFC) has silently normalized, and that is a FAIL, not a formatting difference. + +## Check-5 role vocabulary (identical for every fixture) + +| platform | accepted | prohibited | +|---|---|---| +| accesskit-0.24 | Label, TextRun, Paragraph | Image, GraphicsObject, GraphicsSymbol, GenericContainer, Unknown, Pane | +| at-spi2 | label, static, text, paragraph | image, canvas, filler, panel, unknown | +| aria | (none), text, paragraph | img, presentation, none, graphics-object, graphics-symbol | +| macos-nsaccessibility | AXStaticText | AXImage, AXUnknown, AXGroup | +| windows-uia | Text | Image, Pane, Custom | + +Name composition: single-text-node-name, or logical-order concatenation of the run subtree's text-descendant names — either must equal `name` byte for byte + +Prohibited outcomes, whatever the role: absent-from-tree, name-empty, name-normalized, name-is-shaped-glyphs, name-drops-unresolved-codepoints diff --git a/spikes/editor-toolkit/round2-textkit/fixtures.json b/spikes/editor-toolkit/round2-textkit/fixtures.json new file mode 100644 index 0000000..db21e52 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/fixtures.json @@ -0,0 +1,3688 @@ +{ + "contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14", + "recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md", + "em_size_staff_space": 1.28, + "quantize_grid": 1024.0, + "target": { + "width": 1920.0, + "height": 1080.0 + }, + "faces": [ + { + "chain_index": 0, + "path": "/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf", + "identity": { + "family": "TeX Gyre Pagella", + "version": "Version 2.501;PS 2.501;ffdkm 0.1", + "file_hash": [ + 68, + 230, + 66, + 96, + 113, + 109, + 143, + 43, + 190, + 65, + 43, + 170, + 30, + 233, + 155, + 124, + 153, + 81, + 144, + 172, + 69, + 115, + 23, + 124, + 36, + 222, + 240, + 185, + 32, + 4, + 56, + 199 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + }, + { + "chain_index": 1, + "path": "/usr/share/fonts/liberation-fonts/LiberationSerif-Regular.ttf", + "identity": { + "family": "Liberation Serif", + "version": "Version 2.1.5", + "file_hash": [ + 5, + 142, + 168, + 8, + 100, + 174, + 240, + 154, + 35, + 244, + 92, + 190, + 194, + 187, + 84, + 0, + 188, + 61, + 251, + 222, + 160, + 28, + 63, + 16, + 83, + 138, + 33, + 252, + 180, + 151, + 251, + 116 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + } + ], + "fixtures": [ + { + "id": "F-A", + "purpose": "check 1 (faithful consumption), check 5 (accessibility)", + "resolved": { + "provenance": { + "source": { + "discriminant": 0, + "canonical_bytes_hex": "0000000000000000000000000000f00d0000" + }, + "synthesis": null, + "dependencies": [], + "stable_id": "82875741697311382809239399464544864365" + }, + "text": "Allegro affettuoso — al fine", + "shaping": { + "faces": [ + { + "family": "TeX Gyre Pagella", + "version": "Version 2.501;PS 2.501;ffdkm 0.1", + "file_hash": [ + 68, + 230, + 66, + 96, + 113, + 109, + 143, + 43, + 190, + 65, + 43, + 170, + 30, + 233, + 155, + 124, + 153, + 81, + 144, + 172, + 69, + 115, + 23, + 124, + 36, + 222, + 240, + 185, + 32, + 4, + 56, + 199 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + }, + { + "family": "Liberation Serif", + "version": "Version 2.1.5", + "file_hash": [ + 5, + 142, + 168, + 8, + 100, + 174, + 240, + 154, + 35, + 244, + 92, + 190, + 194, + 187, + 84, + 0, + 188, + 61, + 251, + 222, + 160, + 28, + 63, + 16, + 83, + 138, + 33, + 252, + 180, + 151, + 251, + 116 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + ], + "shaper": "rustybuzz", + "shaper_version": { + "major": 0, + "minor": 20, + "patch": 1 + }, + "features": [], + "unicode_bidi": { + "impl_name": "unicode-bidi", + "crate_version": "0.3.18", + "unicode_version": "16.0.0" + }, + "unicode_segmentation": { + "impl_name": "unicode-segmentation", + "crate_version": "1.13.3", + "unicode_version": "17.0.0" + } + }, + "segments": [ + { + "face": 0, + "glyphs": [ + { + "glyph_id": 34, + "offset": { + "x": 0.0, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 77, + "offset": { + "x": 0.99609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 77, + "offset": { + "x": 1.3681640625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 70, + "offset": { + "x": 1.7412109375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 72, + "offset": { + "x": 2.353515625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 83, + "offset": { + "x": 3.0849609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 3.5908203125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 4.2890625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 66, + "offset": { + "x": 4.609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 234, + "offset": { + "x": 5.2490234375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 70, + "offset": { + "x": 6.046875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 85, + "offset": { + "x": 6.66015625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 85, + "offset": { + "x": 7.0771484375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 86, + "offset": { + "x": 7.494140625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 8.2666015625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 84, + "offset": { + "x": 8.96484375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 9.5078125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 10.20703125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 119, + "offset": { + "x": 10.5263671875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 11.806640625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 66, + "offset": { + "x": 12.126953125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 77, + "offset": { + "x": 12.7666015625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 13.1396484375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 97, + "offset": { + "x": 13.458984375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 79, + "offset": { + "x": 14.2333984375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 70, + "offset": { + "x": 14.978515625, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 0, + "end": 30 + }, + "direction": "Ltr", + "script": "Latn", + "language": null, + "size": 1.28 + } + ], + "clusters": { + "clusters": [ + { + "source": { + "start": 0, + "end": 1 + }, + "segment": 0, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 0, + "position": { + "x": 0.0, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 1, + "end": 2 + }, + "segment": 0, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 1, + "position": { + "x": 0.99609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 2, + "end": 3 + }, + "segment": 0, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 2, + "position": { + "x": 1.3681640625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 3, + "end": 4 + }, + "segment": 0, + "glyph_indices": [ + 3 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 3, + "position": { + "x": 1.7412109375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 4, + "end": 5 + }, + "segment": 0, + "glyph_indices": [ + 4 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 4, + "position": { + "x": 2.353515625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 5, + "end": 6 + }, + "segment": 0, + "glyph_indices": [ + 5 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 5, + "position": { + "x": 3.0849609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 6, + "end": 7 + }, + "segment": 0, + "glyph_indices": [ + 6 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 6, + "position": { + "x": 3.5908203125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 7, + "end": 8 + }, + "segment": 0, + "glyph_indices": [ + 7 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 7, + "position": { + "x": 4.2890625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 8, + "end": 9 + }, + "segment": 0, + "glyph_indices": [ + 8 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 8, + "position": { + "x": 4.609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 9, + "end": 11 + }, + "segment": 0, + "glyph_indices": [ + 9 + ], + "resolved": true, + "grapheme_count": 2, + "caret_stops": [ + { + "source_offset": 9, + "position": { + "x": 5.2490234375, + "y": 0.0 + }, + "affinity": "Downstream" + }, + { + "source_offset": 10, + "position": { + "x": 5.6484375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 11, + "end": 12 + }, + "segment": 0, + "glyph_indices": [ + 10 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 11, + "position": { + "x": 6.046875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 12, + "end": 13 + }, + "segment": 0, + "glyph_indices": [ + 11 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 12, + "position": { + "x": 6.66015625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 13, + "end": 14 + }, + "segment": 0, + "glyph_indices": [ + 12 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 13, + "position": { + "x": 7.0771484375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 14, + "end": 15 + }, + "segment": 0, + "glyph_indices": [ + 13 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 14, + "position": { + "x": 7.494140625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 15, + "end": 16 + }, + "segment": 0, + "glyph_indices": [ + 14 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 15, + "position": { + "x": 8.2666015625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 16, + "end": 17 + }, + "segment": 0, + "glyph_indices": [ + 15 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 16, + "position": { + "x": 8.96484375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 17, + "end": 18 + }, + "segment": 0, + "glyph_indices": [ + 16 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 17, + "position": { + "x": 9.5078125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 18, + "end": 19 + }, + "segment": 0, + "glyph_indices": [ + 17 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 18, + "position": { + "x": 10.20703125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 19, + "end": 22 + }, + "segment": 0, + "glyph_indices": [ + 18 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 19, + "position": { + "x": 10.5263671875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 22, + "end": 23 + }, + "segment": 0, + "glyph_indices": [ + 19 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 22, + "position": { + "x": 11.806640625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 23, + "end": 24 + }, + "segment": 0, + "glyph_indices": [ + 20 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 23, + "position": { + "x": 12.126953125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 24, + "end": 25 + }, + "segment": 0, + "glyph_indices": [ + 21 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 24, + "position": { + "x": 12.7666015625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 25, + "end": 26 + }, + "segment": 0, + "glyph_indices": [ + 22 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 25, + "position": { + "x": 13.1396484375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 26, + "end": 28 + }, + "segment": 0, + "glyph_indices": [ + 23 + ], + "resolved": true, + "grapheme_count": 2, + "caret_stops": [ + { + "source_offset": 26, + "position": { + "x": 13.458984375, + "y": 0.0 + }, + "affinity": "Downstream" + }, + { + "source_offset": 27, + "position": { + "x": 13.8466796875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 28, + "end": 29 + }, + "segment": 0, + "glyph_indices": [ + 24 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 28, + "position": { + "x": 14.2333984375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 29, + "end": 30 + }, + "segment": 0, + "glyph_indices": [ + 25 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 29, + "position": { + "x": 14.978515625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + } + ] + }, + "bounds": { + "left": 1.619140625, + "bottom": -0.3623046875, + "right": 17.1513671875, + "top": 0.931640625 + }, + "reserved_box": { + "left": 1.51953125, + "bottom": -0.4619140625, + "right": 17.2509765625, + "top": 1.03125 + }, + "origin": { + "x": 1.599609375, + "y": 0.0 + }, + "align": "Start", + "style": { + "rgba": 255 + }, + "layer": 0 + }, + "accessibility": { + "name": "Allegro affettuoso — al fine", + "name_bytes_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "name_byte_len": 30, + "name_composition": "single-text-node-name, or logical-order concatenation of the run subtree's text-descendant names — either must equal `name` byte for byte", + "accepted_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Label", + "TextRun", + "Paragraph" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "label", + "static", + "text", + "paragraph" + ] + }, + { + "platform": "aria", + "tokens": [ + "(none)", + "text", + "paragraph" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXStaticText" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Text" + ] + } + ], + "prohibited_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Image", + "GraphicsObject", + "GraphicsSymbol", + "GenericContainer", + "Unknown", + "Pane" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ] + }, + { + "platform": "aria", + "tokens": [ + "img", + "presentation", + "none", + "graphics-object", + "graphics-symbol" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXImage", + "AXUnknown", + "AXGroup" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Image", + "Pane", + "Custom" + ] + } + ], + "prohibited_outcomes": [ + "absent-from-tree", + "name-empty", + "name-normalized", + "name-is-shaped-glyphs", + "name-drops-unresolved-codepoints" + ], + "note": "Ligatures: the name must be the source `ff`/`fi`, never the ligature glyphs that drew them." + } + }, + { + "id": "F-B", + "purpose": "check 2 (fallback, forced)", + "resolved": { + "provenance": { + "source": { + "discriminant": 0, + "canonical_bytes_hex": "0000000000000000000000000000f00d0001" + }, + "synthesis": null, + "dependencies": [], + "stable_id": "13546022191488132682411636294095908055" + }, + "text": "Coro אבג", + "shaping": { + "faces": [ + { + "family": "TeX Gyre Pagella", + "version": "Version 2.501;PS 2.501;ffdkm 0.1", + "file_hash": [ + 68, + 230, + 66, + 96, + 113, + 109, + 143, + 43, + 190, + 65, + 43, + 170, + 30, + 233, + 155, + 124, + 153, + 81, + 144, + 172, + 69, + 115, + 23, + 124, + 36, + 222, + 240, + 185, + 32, + 4, + 56, + 199 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + }, + { + "family": "Liberation Serif", + "version": "Version 2.1.5", + "file_hash": [ + 5, + 142, + 168, + 8, + 100, + 174, + 240, + 154, + 35, + 244, + 92, + 190, + 194, + 187, + 84, + 0, + 188, + 61, + 251, + 222, + 160, + 28, + 63, + 16, + 83, + 138, + 33, + 252, + 180, + 151, + 251, + 116 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + ], + "shaper": "rustybuzz", + "shaper_version": { + "major": 0, + "minor": 20, + "patch": 1 + }, + "features": [], + "unicode_bidi": { + "impl_name": "unicode-bidi", + "crate_version": "0.3.18", + "unicode_version": "16.0.0" + }, + "unicode_segmentation": { + "impl_name": "unicode-segmentation", + "crate_version": "1.13.3", + "unicode_version": "17.0.0" + } + }, + "segments": [ + { + "face": 0, + "glyphs": [ + { + "glyph_id": 36, + "offset": { + "x": 0.0, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 0.9072265625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 83, + "offset": { + "x": 1.6064453125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 2.1123046875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 2.810546875, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 0, + "end": 5 + }, + "direction": "Ltr", + "script": "Latn", + "language": null, + "size": 1.28 + }, + { + "face": 1, + "glyphs": [ + { + "glyph_id": 1282, + "offset": { + "x": 3.130859375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1281, + "offset": { + "x": 3.5537109375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1280, + "offset": { + "x": 4.169921875, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 5, + "end": 11 + }, + "direction": "Rtl", + "script": "Hebr", + "language": null, + "size": 1.28 + } + ], + "clusters": { + "clusters": [ + { + "source": { + "start": 0, + "end": 1 + }, + "segment": 0, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 0, + "position": { + "x": 0.0, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 1, + "end": 2 + }, + "segment": 0, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 1, + "position": { + "x": 0.9072265625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 2, + "end": 3 + }, + "segment": 0, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 2, + "position": { + "x": 1.6064453125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 3, + "end": 4 + }, + "segment": 0, + "glyph_indices": [ + 3 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 3, + "position": { + "x": 2.1123046875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 4, + "end": 5 + }, + "segment": 0, + "glyph_indices": [ + 4 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 4, + "position": { + "x": 2.810546875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 5, + "end": 7 + }, + "segment": 1, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 5, + "position": { + "x": 4.169921875, + "y": 0.0 + }, + "affinity": "Downstream" + }, + { + "source_offset": 5, + "position": { + "x": 3.130859375, + "y": 0.0 + }, + "affinity": "Upstream" + } + ] + }, + { + "source": { + "start": 7, + "end": 9 + }, + "segment": 1, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 7, + "position": { + "x": 3.5537109375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 9, + "end": 11 + }, + "segment": 1, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 9, + "position": { + "x": 3.130859375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + } + ] + }, + "bounds": { + "left": 1.6279296875, + "bottom": -0.025390625, + "right": 6.4912109375, + "top": 0.9072265625 + }, + "reserved_box": { + "left": 1.5283203125, + "bottom": -0.125, + "right": 6.5908203125, + "top": 1.0068359375 + }, + "origin": { + "x": 1.599609375, + "y": 0.0 + }, + "align": "Start", + "style": { + "rgba": 255 + }, + "layer": 0 + }, + "accessibility": { + "name": "Coro אבג", + "name_bytes_hex": "436f726f20d790d791d792", + "name_byte_len": 11, + "name_composition": "single-text-node-name, or logical-order concatenation of the run subtree's text-descendant names — either must equal `name` byte for byte", + "accepted_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Label", + "TextRun", + "Paragraph" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "label", + "static", + "text", + "paragraph" + ] + }, + { + "platform": "aria", + "tokens": [ + "(none)", + "text", + "paragraph" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXStaticText" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Text" + ] + } + ], + "prohibited_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Image", + "GraphicsObject", + "GraphicsSymbol", + "GenericContainer", + "Unknown", + "Pane" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ] + }, + { + "platform": "aria", + "tokens": [ + "img", + "presentation", + "none", + "graphics-object", + "graphics-symbol" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXImage", + "AXUnknown", + "AXGroup" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Image", + "Pane", + "Custom" + ] + } + ], + "prohibited_outcomes": [ + "absent-from-tree", + "name-empty", + "name-normalized", + "name-is-shaped-glyphs", + "name-drops-unresolved-codepoints" + ], + "note": "Two segments, two faces: the name must be the whole logical string, assembled in logical order, not the visual order the Hebrew tail is drawn in." + } + }, + { + "id": "F-C", + "purpose": "check 2 (uncovered codepoint)", + "resolved": { + "provenance": { + "source": { + "discriminant": 0, + "canonical_bytes_hex": "0000000000000000000000000000f00d0002" + }, + "synthesis": null, + "dependencies": [], + "stable_id": "153906977500971752007575741292334795301" + }, + "text": "Coro ا", + "shaping": { + "faces": [ + { + "family": "TeX Gyre Pagella", + "version": "Version 2.501;PS 2.501;ffdkm 0.1", + "file_hash": [ + 68, + 230, + 66, + 96, + 113, + 109, + 143, + 43, + 190, + 65, + 43, + 170, + 30, + 233, + 155, + 124, + 153, + 81, + 144, + 172, + 69, + 115, + 23, + 124, + 36, + 222, + 240, + 185, + 32, + 4, + 56, + 199 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + }, + { + "family": "Liberation Serif", + "version": "Version 2.1.5", + "file_hash": [ + 5, + 142, + 168, + 8, + 100, + 174, + 240, + 154, + 35, + 244, + 92, + 190, + 194, + 187, + 84, + 0, + 188, + 61, + 251, + 222, + 160, + 28, + 63, + 16, + 83, + 138, + 33, + 252, + 180, + 151, + 251, + 116 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + ], + "shaper": "rustybuzz", + "shaper_version": { + "major": 0, + "minor": 20, + "patch": 1 + }, + "features": [], + "unicode_bidi": { + "impl_name": "unicode-bidi", + "crate_version": "0.3.18", + "unicode_version": "16.0.0" + }, + "unicode_segmentation": { + "impl_name": "unicode-segmentation", + "crate_version": "1.13.3", + "unicode_version": "17.0.0" + } + }, + "segments": [ + { + "face": 0, + "glyphs": [ + { + "glyph_id": 36, + "offset": { + "x": 0.0, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 0.9072265625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 83, + "offset": { + "x": 1.6064453125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 2.1123046875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 2.810546875, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 0, + "end": 5 + }, + "direction": "Ltr", + "script": "Latn", + "language": null, + "size": 1.28 + }, + { + "face": null, + "glyphs": [], + "source": { + "start": 5, + "end": 7 + }, + "direction": "Rtl", + "script": "Arab", + "language": null, + "size": 1.28 + } + ], + "clusters": { + "clusters": [ + { + "source": { + "start": 0, + "end": 1 + }, + "segment": 0, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 0, + "position": { + "x": 0.0, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 1, + "end": 2 + }, + "segment": 0, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 1, + "position": { + "x": 0.9072265625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 2, + "end": 3 + }, + "segment": 0, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 2, + "position": { + "x": 1.6064453125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 3, + "end": 4 + }, + "segment": 0, + "glyph_indices": [ + 3 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 3, + "position": { + "x": 2.1123046875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 4, + "end": 5 + }, + "segment": 0, + "glyph_indices": [ + 4 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 4, + "position": { + "x": 2.810546875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 5, + "end": 7 + }, + "segment": 1, + "glyph_indices": [], + "resolved": false, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 5, + "position": { + "x": 3.130859375, + "y": 0.0 + }, + "affinity": "Downstream" + }, + { + "source_offset": 5, + "position": { + "x": 3.130859375, + "y": 0.0 + }, + "affinity": "Upstream" + } + ] + } + ] + }, + "bounds": { + "left": 1.6279296875, + "bottom": -0.025390625, + "right": 4.3701171875, + "top": 0.9072265625 + }, + "reserved_box": { + "left": 1.5283203125, + "bottom": -0.125, + "right": 4.4697265625, + "top": 1.0068359375 + }, + "origin": { + "x": 1.599609375, + "y": 0.0 + }, + "align": "Start", + "style": { + "rgba": 255 + }, + "layer": 0 + }, + "accessibility": { + "name": "Coro ا", + "name_bytes_hex": "436f726f20d8a7", + "name_byte_len": 7, + "name_composition": "single-text-node-name, or logical-order concatenation of the run subtree's text-descendant names — either must equal `name` byte for byte", + "accepted_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Label", + "TextRun", + "Paragraph" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "label", + "static", + "text", + "paragraph" + ] + }, + { + "platform": "aria", + "tokens": [ + "(none)", + "text", + "paragraph" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXStaticText" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Text" + ] + } + ], + "prohibited_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Image", + "GraphicsObject", + "GraphicsSymbol", + "GenericContainer", + "Unknown", + "Pane" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ] + }, + { + "platform": "aria", + "tokens": [ + "img", + "presentation", + "none", + "graphics-object", + "graphics-symbol" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXImage", + "AXUnknown", + "AXGroup" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Image", + "Pane", + "Custom" + ] + } + ], + "prohibited_outcomes": [ + "absent-from-tree", + "name-empty", + "name-normalized", + "name-is-shaped-glyphs", + "name-drops-unresolved-codepoints" + ], + "note": "Uncovered codepoint: U+0627 draws no ink in either declared face, and must appear in the name regardless — the tree carries the text, not the ink." + } + }, + { + "id": "F-D", + "purpose": "SUPPLEMENTARY bidi evidence (Hebrew/Latin) — check 3 remains NOT RUN (no Arabic-capable face; recipe §1.2)", + "resolved": { + "provenance": { + "source": { + "discriminant": 0, + "canonical_bytes_hex": "0000000000000000000000000000f00d0003" + }, + "synthesis": null, + "dependencies": [], + "stable_id": "58574227627094720634005627628385763371" + }, + "text": "Allegro אבג con brio", + "shaping": { + "faces": [ + { + "family": "TeX Gyre Pagella", + "version": "Version 2.501;PS 2.501;ffdkm 0.1", + "file_hash": [ + 68, + 230, + 66, + 96, + 113, + 109, + 143, + 43, + 190, + 65, + 43, + 170, + 30, + 233, + 155, + 124, + 153, + 81, + 144, + 172, + 69, + 115, + 23, + 124, + 36, + 222, + 240, + 185, + 32, + 4, + 56, + 199 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + }, + { + "family": "Liberation Serif", + "version": "Version 2.1.5", + "file_hash": [ + 5, + 142, + 168, + 8, + 100, + 174, + 240, + 154, + 35, + 244, + 92, + 190, + 194, + 187, + 84, + 0, + 188, + 61, + 251, + 222, + 160, + 28, + 63, + 16, + 83, + 138, + 33, + 252, + 180, + 151, + 251, + 116 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + ], + "shaper": "rustybuzz", + "shaper_version": { + "major": 0, + "minor": 20, + "patch": 1 + }, + "features": [], + "unicode_bidi": { + "impl_name": "unicode-bidi", + "crate_version": "0.3.18", + "unicode_version": "16.0.0" + }, + "unicode_segmentation": { + "impl_name": "unicode-segmentation", + "crate_version": "1.13.3", + "unicode_version": "17.0.0" + } + }, + "segments": [ + { + "face": 0, + "glyphs": [ + { + "glyph_id": 34, + "offset": { + "x": 0.0, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 77, + "offset": { + "x": 0.99609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 77, + "offset": { + "x": 1.3681640625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 70, + "offset": { + "x": 1.7412109375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 72, + "offset": { + "x": 2.353515625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 83, + "offset": { + "x": 3.0849609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 3.5908203125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 4.2890625, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 0, + "end": 8 + }, + "direction": "Ltr", + "script": "Latn", + "language": null, + "size": 1.28 + }, + { + "face": 1, + "glyphs": [ + { + "glyph_id": 1282, + "offset": { + "x": 4.609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1281, + "offset": { + "x": 5.0322265625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1280, + "offset": { + "x": 5.6484375, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 8, + "end": 14 + }, + "direction": "Rtl", + "script": "Hebr", + "language": null, + "size": 1.28 + }, + { + "face": 0, + "glyphs": [ + { + "glyph_id": 1, + "offset": { + "x": 6.4169921875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 68, + "offset": { + "x": 6.736328125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 7.3046875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 79, + "offset": { + "x": 8.00390625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 8.7490234375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 67, + "offset": { + "x": 9.0693359375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 83, + "offset": { + "x": 9.7763671875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 74, + "offset": { + "x": 10.2822265625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 80, + "offset": { + "x": 10.6494140625, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 14, + "end": 23 + }, + "direction": "Ltr", + "script": "Latn", + "language": null, + "size": 1.28 + } + ], + "clusters": { + "clusters": [ + { + "source": { + "start": 0, + "end": 1 + }, + "segment": 0, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 0, + "position": { + "x": 0.0, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 1, + "end": 2 + }, + "segment": 0, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 1, + "position": { + "x": 0.99609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 2, + "end": 3 + }, + "segment": 0, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 2, + "position": { + "x": 1.3681640625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 3, + "end": 4 + }, + "segment": 0, + "glyph_indices": [ + 3 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 3, + "position": { + "x": 1.7412109375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 4, + "end": 5 + }, + "segment": 0, + "glyph_indices": [ + 4 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 4, + "position": { + "x": 2.353515625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 5, + "end": 6 + }, + "segment": 0, + "glyph_indices": [ + 5 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 5, + "position": { + "x": 3.0849609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 6, + "end": 7 + }, + "segment": 0, + "glyph_indices": [ + 6 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 6, + "position": { + "x": 3.5908203125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 7, + "end": 8 + }, + "segment": 0, + "glyph_indices": [ + 7 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 7, + "position": { + "x": 4.2890625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 8, + "end": 10 + }, + "segment": 1, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 8, + "position": { + "x": 5.6484375, + "y": 0.0 + }, + "affinity": "Downstream" + }, + { + "source_offset": 8, + "position": { + "x": 4.609375, + "y": 0.0 + }, + "affinity": "Upstream" + } + ] + }, + { + "source": { + "start": 10, + "end": 12 + }, + "segment": 1, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 10, + "position": { + "x": 5.0322265625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 12, + "end": 14 + }, + "segment": 1, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 12, + "position": { + "x": 4.609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 14, + "end": 15 + }, + "segment": 2, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 14, + "position": { + "x": 6.4169921875, + "y": 0.0 + }, + "affinity": "Downstream" + }, + { + "source_offset": 14, + "position": { + "x": 4.609375, + "y": 0.0 + }, + "affinity": "Upstream" + } + ] + }, + { + "source": { + "start": 15, + "end": 16 + }, + "segment": 2, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 15, + "position": { + "x": 6.736328125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 16, + "end": 17 + }, + "segment": 2, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 16, + "position": { + "x": 7.3046875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 17, + "end": 18 + }, + "segment": 2, + "glyph_indices": [ + 3 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 17, + "position": { + "x": 8.00390625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 18, + "end": 19 + }, + "segment": 2, + "glyph_indices": [ + 4 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 18, + "position": { + "x": 8.7490234375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 19, + "end": 20 + }, + "segment": 2, + "glyph_indices": [ + 5 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 19, + "position": { + "x": 9.0693359375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 20, + "end": 21 + }, + "segment": 2, + "glyph_indices": [ + 6 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 20, + "position": { + "x": 9.7763671875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 21, + "end": 22 + }, + "segment": 2, + "glyph_indices": [ + 7 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 21, + "position": { + "x": 10.2822265625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 22, + "end": 23 + }, + "segment": 2, + "glyph_indices": [ + 8 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 22, + "position": { + "x": 10.6494140625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + } + ] + }, + "bounds": { + "left": 1.619140625, + "bottom": -0.3623046875, + "right": 12.9072265625, + "top": 0.9296875 + }, + "reserved_box": { + "left": 1.51953125, + "bottom": -0.4619140625, + "right": 13.0068359375, + "top": 1.029296875 + }, + "origin": { + "x": 1.599609375, + "y": 0.0 + }, + "align": "Start", + "style": { + "rgba": 255 + }, + "layer": 0 + }, + "accessibility": { + "name": "Allegro אבג con brio", + "name_bytes_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "name_byte_len": 23, + "name_composition": "single-text-node-name, or logical-order concatenation of the run subtree's text-descendant names — either must equal `name` byte for byte", + "accepted_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Label", + "TextRun", + "Paragraph" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "label", + "static", + "text", + "paragraph" + ] + }, + { + "platform": "aria", + "tokens": [ + "(none)", + "text", + "paragraph" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXStaticText" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Text" + ] + } + ], + "prohibited_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Image", + "GraphicsObject", + "GraphicsSymbol", + "GenericContainer", + "Unknown", + "Pane" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ] + }, + { + "platform": "aria", + "tokens": [ + "img", + "presentation", + "none", + "graphics-object", + "graphics-symbol" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXImage", + "AXUnknown", + "AXGroup" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Image", + "Pane", + "Custom" + ] + } + ], + "prohibited_outcomes": [ + "absent-from-tree", + "name-empty", + "name-normalized", + "name-is-shaped-glyphs", + "name-drops-unresolved-codepoints" + ], + "note": "Three visual runs at levels 0/1/0: the concatenation is logical-order, so a tree built by walking the visual runs left to right fails here and only here." + } + }, + { + "id": "F-E", + "purpose": "check 4 (hit testing / caret)", + "resolved": { + "provenance": { + "source": { + "discriminant": 0, + "canonical_bytes_hex": "0000000000000000000000000000f00d0004" + }, + "synthesis": null, + "dependencies": [], + "stable_id": "44621424182266112310717110217199269314" + }, + "text": "Café — resumé", + "shaping": { + "faces": [ + { + "family": "TeX Gyre Pagella", + "version": "Version 2.501;PS 2.501;ffdkm 0.1", + "file_hash": [ + 68, + 230, + 66, + 96, + 113, + 109, + 143, + 43, + 190, + 65, + 43, + 170, + 30, + 233, + 155, + 124, + 153, + 81, + 144, + 172, + 69, + 115, + 23, + 124, + 36, + 222, + 240, + 185, + 32, + 4, + 56, + 199 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + }, + { + "family": "Liberation Serif", + "version": "Version 2.1.5", + "file_hash": [ + 5, + 142, + 168, + 8, + 100, + 174, + 240, + 154, + 35, + 244, + 92, + 190, + 194, + 187, + 84, + 0, + 188, + 61, + 251, + 222, + 160, + 28, + 63, + 16, + 83, + 138, + 33, + 252, + 180, + 151, + 251, + 116 + ], + "face_index": 0, + "variations": [], + "synthesis": "None" + } + ], + "shaper": "rustybuzz", + "shaper_version": { + "major": 0, + "minor": 20, + "patch": 1 + }, + "features": [], + "unicode_bidi": { + "impl_name": "unicode-bidi", + "crate_version": "0.3.18", + "unicode_version": "16.0.0" + }, + "unicode_segmentation": { + "impl_name": "unicode-segmentation", + "crate_version": "1.13.3", + "unicode_version": "17.0.0" + } + }, + "segments": [ + { + "face": 0, + "glyphs": [ + { + "glyph_id": 36, + "offset": { + "x": 0.0, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 66, + "offset": { + "x": 0.9072265625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 71, + "offset": { + "x": 1.5478515625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 198, + "offset": { + "x": 1.9609375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 2.57421875, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 119, + "offset": { + "x": 2.89453125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 1, + "offset": { + "x": 4.173828125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 83, + "offset": { + "x": 4.494140625, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 70, + "offset": { + "x": 5.0, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 84, + "offset": { + "x": 5.61328125, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 86, + "offset": { + "x": 6.1552734375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 78, + "offset": { + "x": 6.927734375, + "y": 0.0 + }, + "transform": null + }, + { + "glyph_id": 198, + "offset": { + "x": 8.0576171875, + "y": 0.0 + }, + "transform": null + } + ], + "source": { + "start": 0, + "end": 19 + }, + "direction": "Ltr", + "script": "Latn", + "language": null, + "size": 1.28 + } + ], + "clusters": { + "clusters": [ + { + "source": { + "start": 0, + "end": 1 + }, + "segment": 0, + "glyph_indices": [ + 0 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 0, + "position": { + "x": 0.0, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 1, + "end": 2 + }, + "segment": 0, + "glyph_indices": [ + 1 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 1, + "position": { + "x": 0.9072265625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 2, + "end": 3 + }, + "segment": 0, + "glyph_indices": [ + 2 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 2, + "position": { + "x": 1.5478515625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 3, + "end": 6 + }, + "segment": 0, + "glyph_indices": [ + 3 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 3, + "position": { + "x": 1.9609375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 6, + "end": 7 + }, + "segment": 0, + "glyph_indices": [ + 4 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 6, + "position": { + "x": 2.57421875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 7, + "end": 10 + }, + "segment": 0, + "glyph_indices": [ + 5 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 7, + "position": { + "x": 2.89453125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 10, + "end": 11 + }, + "segment": 0, + "glyph_indices": [ + 6 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 10, + "position": { + "x": 4.173828125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 11, + "end": 12 + }, + "segment": 0, + "glyph_indices": [ + 7 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 11, + "position": { + "x": 4.494140625, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 12, + "end": 13 + }, + "segment": 0, + "glyph_indices": [ + 8 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 12, + "position": { + "x": 5.0, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 13, + "end": 14 + }, + "segment": 0, + "glyph_indices": [ + 9 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 13, + "position": { + "x": 5.61328125, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 14, + "end": 15 + }, + "segment": 0, + "glyph_indices": [ + 10 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 14, + "position": { + "x": 6.1552734375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 15, + "end": 16 + }, + "segment": 0, + "glyph_indices": [ + 11 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 15, + "position": { + "x": 6.927734375, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + }, + { + "source": { + "start": 16, + "end": 19 + }, + "segment": 0, + "glyph_indices": [ + 12 + ], + "resolved": true, + "grapheme_count": 1, + "caret_stops": [ + { + "source_offset": 16, + "position": { + "x": 8.0576171875, + "y": 0.0 + }, + "affinity": "Downstream" + } + ] + } + ] + }, + "bounds": { + "left": 1.6279296875, + "bottom": -0.025390625, + "right": 10.23046875, + "top": 0.931640625 + }, + "reserved_box": { + "left": 1.5283203125, + "bottom": -0.125, + "right": 10.330078125, + "top": 1.03125 + }, + "origin": { + "x": 1.599609375, + "y": 0.0 + }, + "align": "Start", + "style": { + "rgba": 255 + }, + "layer": 0 + }, + "accessibility": { + "name": "Café — resumé", + "name_bytes_hex": "43616665cc8120e2809420726573756d65cc81", + "name_byte_len": 19, + "name_composition": "single-text-node-name, or logical-order concatenation of the run subtree's text-descendant names — either must equal `name` byte for byte", + "accepted_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Label", + "TextRun", + "Paragraph" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "label", + "static", + "text", + "paragraph" + ] + }, + { + "platform": "aria", + "tokens": [ + "(none)", + "text", + "paragraph" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXStaticText" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Text" + ] + } + ], + "prohibited_roles": [ + { + "platform": "accesskit-0.24", + "tokens": [ + "Image", + "GraphicsObject", + "GraphicsSymbol", + "GenericContainer", + "Unknown", + "Pane" + ] + }, + { + "platform": "at-spi2", + "tokens": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ] + }, + { + "platform": "aria", + "tokens": [ + "img", + "presentation", + "none", + "graphics-object", + "graphics-symbol" + ] + }, + { + "platform": "macos-nsaccessibility", + "tokens": [ + "AXImage", + "AXUnknown", + "AXGroup" + ] + }, + { + "platform": "windows-uia", + "tokens": [ + "Image", + "Pane", + "Custom" + ] + } + ], + "prohibited_outcomes": [ + "absent-from-tree", + "name-empty", + "name-normalized", + "name-is-shaped-glyphs", + "name-drops-unresolved-codepoints" + ], + "note": "NFD: `Cafe\\u{301}` must surface as NFD. A tree exposing `Café` (NFC) has silently normalized, and that is a FAIL, not a formatting difference." + } + } + ] +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-textkit/hittest_probes.json b/spikes/editor-toolkit/round2-textkit/hittest_probes.json new file mode 100644 index 0000000..e2bd35e --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/hittest_probes.json @@ -0,0 +1,834 @@ +{ + "contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14", + "recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §7", + "min_separation_device_px": 4.0, + "edge_margin_device_px": 20.0, + "fixtures": [ + { + "fixture_id": "F-A", + "probes": [ + { + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 0 (interval byte 0..byte 1)", + "kind": "Interior" + }, + { + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 1 (interval byte 1..byte 2)", + "kind": "Interior" + }, + { + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 2 (interval byte 2..byte 3)", + "kind": "Interior" + }, + { + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 3 (interval byte 3..byte 4)", + "kind": "Interior" + }, + { + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 4 (interval byte 4..byte 5)", + "kind": "Interior" + }, + { + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 5 (interval byte 5..byte 6)", + "kind": "Interior" + }, + { + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 6 (interval byte 6..byte 7)", + "kind": "Interior" + }, + { + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 7 (interval byte 7..byte 8)", + "kind": "Interior" + }, + { + "point": { + "x": 652.880859375, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 8 (interval byte 8..byte 9)", + "kind": "Interior" + }, + { + "point": { + "x": 704.833984375, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 9 (interval byte 9..byte 10)", + "kind": "Interior" + }, + { + "point": { + "x": 744.7265625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 10 (interval byte 10..byte 11)", + "kind": "Interior" + }, + { + "point": { + "x": 795.3125, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 11 (interval byte 11..byte 12)", + "kind": "Interior" + }, + { + "point": { + "x": 846.826171875, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 12 (interval byte 12..byte 13)", + "kind": "Interior" + }, + { + "point": { + "x": 888.525390625, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 13 (interval byte 13..byte 14)", + "kind": "Interior" + }, + { + "point": { + "x": 947.998046875, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 14 (interval byte 14..byte 15)", + "kind": "Interior" + }, + { + "point": { + "x": 1021.533203125, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 15 (interval byte 15..byte 16)", + "kind": "Interior" + }, + { + "point": { + "x": 1083.59375, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 16 (interval byte 16..byte 17)", + "kind": "Interior" + }, + { + "point": { + "x": 1145.703125, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 17 (interval byte 17..byte 18)", + "kind": "Interior" + }, + { + "point": { + "x": 1196.630859375, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 18 (interval byte 18..byte 19)", + "kind": "Interior" + }, + { + "point": { + "x": 1276.611328125, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 19 (interval byte 19..byte 22)", + "kind": "Interior" + }, + { + "point": { + "x": 1356.640625, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 22 (interval byte 22..byte 23)", + "kind": "Interior" + }, + { + "point": { + "x": 1404.638671875, + "y": 540.0 + }, + "expected_source_offset": 23, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 23 (interval byte 23..byte 24)", + "kind": "Interior" + }, + { + "point": { + "x": 1455.2734375, + "y": 540.0 + }, + "expected_source_offset": 24, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 24 (interval byte 24..byte 25)", + "kind": "Interior" + }, + { + "point": { + "x": 1489.892578125, + "y": 540.0 + }, + "expected_source_offset": 25, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 25 (interval byte 25..byte 26)", + "kind": "Interior" + }, + { + "point": { + "x": 1525.244140625, + "y": 540.0 + }, + "expected_source_offset": 26, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 26 (interval byte 26..byte 27)", + "kind": "Interior" + }, + { + "point": { + "x": 1563.96484375, + "y": 540.0 + }, + "expected_source_offset": 27, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 27 (interval byte 27..byte 28)", + "kind": "Interior" + }, + { + "point": { + "x": 1620.556640625, + "y": 540.0 + }, + "expected_source_offset": 28, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: interior, grapheme starting at byte 28 (interval byte 28..byte 29)", + "kind": "Interior" + }, + { + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: before the first caret stop (byte 0)", + "kind": "BeforeFirst" + }, + { + "point": { + "x": 1677.8125, + "y": 540.0 + }, + "expected_source_offset": 29, + "expected_affinity": "Downstream", + "source_grapheme": "F-A: after the last caret stop (byte 29)", + "kind": "AfterLast" + } + ] + }, + { + "fixture_id": "F-B", + "probes": [ + { + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 0 (interval byte 0..byte 1)", + "kind": "Interior" + }, + { + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 1 (interval byte 1..byte 2)", + "kind": "Interior" + }, + { + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 2 (interval byte 2..byte 3)", + "kind": "Interior" + }, + { + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 3 (interval byte 3..byte 4)", + "kind": "Interior" + }, + { + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 4 (interval byte 4..byte 9)", + "kind": "Interior" + }, + { + "point": { + "x": 494.189453125, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 9 (interval byte 9..byte 7)", + "kind": "Interior" + }, + { + "point": { + "x": 546.142578125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: interior, grapheme starting at byte 7 (interval byte 7..byte 5)", + "kind": "Interior" + }, + { + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: before the first caret stop (byte 0)", + "kind": "BeforeFirst" + }, + { + "point": { + "x": 596.953125, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "source_grapheme": "F-B: after the last caret stop (byte 5)", + "kind": "AfterLast" + } + ] + }, + { + "fixture_id": "F-C", + "probes": [ + { + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: interior, grapheme starting at byte 0 (interval byte 0..byte 1)", + "kind": "Interior" + }, + { + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: interior, grapheme starting at byte 1 (interval byte 1..byte 2)", + "kind": "Interior" + }, + { + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: interior, grapheme starting at byte 2 (interval byte 2..byte 3)", + "kind": "Interior" + }, + { + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: interior, grapheme starting at byte 3 (interval byte 3..byte 4)", + "kind": "Interior" + }, + { + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: interior, grapheme starting at byte 4 (interval byte 4..byte 5)", + "kind": "Interior" + }, + { + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: before the first caret stop (byte 0)", + "kind": "BeforeFirst" + }, + { + "point": { + "x": 493.046875, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "source_grapheme": "F-C: after the last caret stop (byte 5)", + "kind": "AfterLast" + } + ] + }, + { + "fixture_id": "F-D", + "probes": [ + { + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 0 (interval byte 0..byte 1)", + "kind": "Interior" + }, + { + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 1 (interval byte 1..byte 2)", + "kind": "Interior" + }, + { + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 2 (interval byte 2..byte 3)", + "kind": "Interior" + }, + { + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 3 (interval byte 3..byte 4)", + "kind": "Interior" + }, + { + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 4 (interval byte 4..byte 5)", + "kind": "Interior" + }, + { + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 5 (interval byte 5..byte 6)", + "kind": "Interior" + }, + { + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 6 (interval byte 6..byte 7)", + "kind": "Interior" + }, + { + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 7 (interval byte 7..byte 12)", + "kind": "Interior" + }, + { + "point": { + "x": 642.041015625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 12 (interval byte 12..byte 10)", + "kind": "Interior" + }, + { + "point": { + "x": 693.994140625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 10 (interval byte 10..byte 8)", + "kind": "Interior" + }, + { + "point": { + "x": 763.232421875, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 8 (interval byte 8..byte 14)", + "kind": "Interior" + }, + { + "point": { + "x": 817.626953125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 14 (interval byte 14..byte 15)", + "kind": "Interior" + }, + { + "point": { + "x": 862.01171875, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 15 (interval byte 15..byte 16)", + "kind": "Interior" + }, + { + "point": { + "x": 925.390625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 16 (interval byte 16..byte 17)", + "kind": "Interior" + }, + { + "point": { + "x": 997.607421875, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 17 (interval byte 17..byte 18)", + "kind": "Interior" + }, + { + "point": { + "x": 1050.87890625, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 18 (interval byte 18..byte 19)", + "kind": "Interior" + }, + { + "point": { + "x": 1102.24609375, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 19 (interval byte 19..byte 20)", + "kind": "Interior" + }, + { + "point": { + "x": 1162.890625, + "y": 540.0 + }, + "expected_source_offset": 20, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 20 (interval byte 20..byte 21)", + "kind": "Interior" + }, + { + "point": { + "x": 1206.54296875, + "y": 540.0 + }, + "expected_source_offset": 21, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: interior, grapheme starting at byte 21 (interval byte 21..byte 22)", + "kind": "Interior" + }, + { + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: before the first caret stop (byte 0)", + "kind": "BeforeFirst" + }, + { + "point": { + "x": 1244.90234375, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "source_grapheme": "F-D: after the last caret stop (byte 22)", + "kind": "AfterLast" + } + ] + }, + { + "fixture_id": "F-E", + "probes": [ + { + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 0 (interval byte 0..byte 1)", + "kind": "Interior" + }, + { + "point": { + "x": 282.71484375, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 1 (interval byte 1..byte 2)", + "kind": "Interior" + }, + { + "point": { + "x": 335.400390625, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 2 (interval byte 2..byte 3)", + "kind": "Interior" + }, + { + "point": { + "x": 386.71875, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 3 (interval byte 3..byte 6)", + "kind": "Interior" + }, + { + "point": { + "x": 433.3984375, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 6 (interval byte 6..byte 7)", + "kind": "Interior" + }, + { + "point": { + "x": 513.37890625, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 7 (interval byte 7..byte 10)", + "kind": "Interior" + }, + { + "point": { + "x": 593.359375, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 10 (interval byte 10..byte 11)", + "kind": "Interior" + }, + { + "point": { + "x": 634.66796875, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 11 (interval byte 11..byte 12)", + "kind": "Interior" + }, + { + "point": { + "x": 690.625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 12 (interval byte 12..byte 13)", + "kind": "Interior" + }, + { + "point": { + "x": 748.388671875, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 13 (interval byte 13..byte 14)", + "kind": "Interior" + }, + { + "point": { + "x": 814.111328125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 14 (interval byte 14..byte 15)", + "kind": "Interior" + }, + { + "point": { + "x": 909.228515625, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: interior, grapheme starting at byte 15 (interval byte 15..byte 16)", + "kind": "Interior" + }, + { + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: before the first caret stop (byte 0)", + "kind": "BeforeFirst" + }, + { + "point": { + "x": 985.72265625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "source_grapheme": "F-E: after the last caret stop (byte 16)", + "kind": "AfterLast" + } + ] + } + ], + "dropped": [] +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round2-textkit/src/a11y.rs b/spikes/editor-toolkit/round2-textkit/src/a11y.rs new file mode 100644 index 0000000..0671133 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/a11y.rs @@ -0,0 +1,297 @@ +//! The accessibility oracle for W3 §5 check 5 — **precommitted, before any +//! candidate exists** (recipe §8). +//! +//! Check 5 is in the disqualifying set. Revision 2 of the recipe said "the +//! expected node role is committed per fixture" and then named no role, encoded +//! nothing, and left the whole oracle as prose. An unencoded expectation for a +//! disqualifying check is not an oracle; it is a place where a judgement would +//! have been made *after* seeing a candidate's tree, which is the one thing +//! pin 13 exists to prevent. +//! +//! ## What is pinned, and why it is pinned this way +//! +//! The property W3 §5 check 5 states is that the run reaches assistive +//! technology **as its source string** — not as its shaped glyphs, not as a +//! picture, not as a normalization of it. Three things follow, and all three +//! are encoded here: +//! +//! 1. **The name is the exact source bytes.** Stored twice, as the string and +//! as its lowercase hex, so a silent normalization is visible even to +//! someone reading the JSON by eye. F-E is the load-bearing case: its NFD +//! source must surface as NFD, and `Café` (NFC) is a FAIL, not a nicety. +//! F-C is the second: its U+0627 is covered by **no** declared face and +//! draws no ink at all, and the accessible name must contain it anyway — +//! the accessibility tree carries the text, not the ink. +//! 2. **The role is the platform's static-text role**, from a closed accepted +//! set per platform, with an explicitly named prohibited set so the failure +//! mode has a name rather than being "not in the list". +//! 3. **A set of prohibited outcomes** that fail check 5 whatever the role is +//! — chiefly absence from the tree, which is the outcome a +//! canvas-rendering toolkit produces by default and the one this check is +//! most likely to actually catch. +//! +//! ## Candidate neutrality +//! +//! The pin is stated per platform, not in one toolkit's vocabulary. Naming +//! only AccessKit's `Role` enum would have quietly favoured C1 (egui ships +//! AccessKit) over C2 (vello is a rendering crate with no accessibility layer +//! of its own), and a criterion that encodes one candidate's stack is not a +//! criterion. A candidate that must build its own accessibility layer to pass +//! is free to do so on any of the platforms below; what it may not do is +//! expose the run as a picture, or not expose it at all. +//! +//! Recorded so it is not mistaken for an oversight later: **the accepted-role +//! rows below were read from the actual `accesskit` 0.24.1 `Role` enum in this +//! workspace's lockfile**, not from memory — `Label`, `TextRun`, and +//! `Paragraph` all exist there, as do every prohibited name. + +use serde::{Deserialize, Serialize}; + +/// One platform's vocabulary for "this node is static text". +/// +/// A candidate satisfies the role half of check 5 by matching **one** row: the +/// platform it actually exposes a tree on, and one of that row's tokens. It +/// does not have to satisfy all of them, and it is not required to expose +/// trees on platforms it does not target. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeRoleMapping { + pub platform: String, + pub tokens: Vec, +} + +fn mapping(platform: &str, tokens: &[&str]) -> SpikeRoleMapping { + SpikeRoleMapping { + platform: platform.to_string(), + tokens: tokens.iter().map(|s| s.to_string()).collect(), + } +} + +/// The accepted static-text roles, per platform. Restated as literals in +/// [`accepted_roles`] and again in [`crate::output::FixtureFile::validate`], +/// never read back out of the file being checked. +pub const ACCEPTED_ROLE_TABLE: [(&str, &[&str]); 5] = [ + ("accesskit-0.24", &["Label", "TextRun", "Paragraph"]), + ("at-spi2", &["label", "static", "text", "paragraph"]), + ("aria", &["(none)", "text", "paragraph"]), + ("macos-nsaccessibility", &["AXStaticText"]), + ("windows-uia", &["Text"]), +]; + +/// Roles that are a FAIL of check 5, named rather than left as "anything not +/// accepted", so a candidate's result reads as *this specific* divergence. +/// These are the outcomes a canvas toolkit actually produces: the whole run +/// lands in the tree as one picture, or as a presentational container that +/// assistive technology is told to skip. +pub const PROHIBITED_ROLE_TABLE: [(&str, &[&str]); 5] = [ + ( + "accesskit-0.24", + &[ + "Image", + "GraphicsObject", + "GraphicsSymbol", + "GenericContainer", + "Unknown", + "Pane", + ], + ), + ( + "at-spi2", + &["image", "canvas", "filler", "panel", "unknown"], + ), + ( + "aria", + &[ + "img", + "presentation", + "none", + "graphics-object", + "graphics-symbol", + ], + ), + ( + "macos-nsaccessibility", + &["AXImage", "AXUnknown", "AXGroup"], + ), + ("windows-uia", &["Image", "Pane", "Custom"]), +]; + +/// Outcomes that fail check 5 **whatever role is reported**. +pub const PROHIBITED_OUTCOMES: [&str; 5] = [ + // The default outcome for a toolkit that draws to a canvas and stops. + "absent-from-tree", + // The name is present but empty, which is absence wearing a role. + "name-empty", + // F-E's case: the tree exposes NFC for an NFD source. + "name-normalized", + // The tree exposes what was drawn rather than what was said — glyph names, + // glyph ids, or the ligated/substituted text. + "name-is-shaped-glyphs", + // F-C's case: the uncovered codepoint is dropped from the name because it + // drew no ink. + "name-drops-unresolved-codepoints", +]; + +/// How the accessible name may be assembled — the one place this oracle +/// deliberately admits two shapes. +/// +/// F-B and F-D are multi-segment runs, and an implementation that exposes one +/// text node per direction run is not wrong; requiring exactly one node would +/// have manufactured a failure for a legitimate tree. So the requirement is on +/// the *concatenation*: the run's own accessible name, or the names of its text +/// descendants concatenated in **logical** (not visual) order, must equal the +/// source string byte-for-byte. +pub const NAME_COMPOSITION: &str = "single-text-node-name, or logical-order concatenation of the \ + run subtree's text-descendant names — either must equal `name` byte for byte"; + +/// The precommitted check-5 expectation for one fixture. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeAccessibilityExpectation { + /// The exact accessible name: the fixture's source string, verbatim. + pub name: String, + /// The same bytes in lowercase hex. Redundant on purpose — a + /// normalization, a trimmed space, or a re-encoded dash changes this field + /// visibly in a diff of the JSON, where the string form can look + /// identical. + pub name_bytes_hex: String, + pub name_byte_len: usize, + pub name_composition: String, + pub accepted_roles: Vec, + pub prohibited_roles: Vec, + pub prohibited_outcomes: Vec, + /// What this fixture is load-bearing for in check 5, in one line. + pub note: String, +} + +pub fn accepted_roles() -> Vec { + ACCEPTED_ROLE_TABLE + .iter() + .map(|(p, t)| mapping(p, t)) + .collect() +} + +pub fn prohibited_roles() -> Vec { + PROHIBITED_ROLE_TABLE + .iter() + .map(|(p, t)| mapping(p, t)) + .collect() +} + +/// The per-fixture note, keyed by fixture id. Every fixture in the recipe has +/// one; an id with no note is a hard error rather than a blank field, so +/// adding a sixth fixture cannot silently arrive with an unstated +/// accessibility expectation. +pub fn note_for(fixture_id: &str) -> Result<&'static str, String> { + Ok(match fixture_id { + "F-A" => { + "Ligatures: the name must be the source `ff`/`fi`, never the ligature glyphs that \ + drew them." + } + "F-B" => { + "Two segments, two faces: the name must be the whole logical string, assembled in \ + logical order, not the visual order the Hebrew tail is drawn in." + } + "F-C" => { + "Uncovered codepoint: U+0627 draws no ink in either declared face, and must appear \ + in the name regardless — the tree carries the text, not the ink." + } + "F-D" => { + "Three visual runs at levels 0/1/0: the concatenation is logical-order, so a tree \ + built by walking the visual runs left to right fails here and only here." + } + "F-E" => { + "NFD: `Cafe\\u{301}` must surface as NFD. A tree exposing `Café` (NFC) has silently \ + normalized, and that is a FAIL, not a formatting difference." + } + other => { + return Err(format!( + "no accessibility note precommitted for fixture {other}" + )) + } + }) +} + +pub fn build_expectation( + fixture_id: &str, + text: &str, +) -> Result { + Ok(SpikeAccessibilityExpectation { + name: text.to_string(), + name_bytes_hex: hex_lower(text.as_bytes()), + name_byte_len: text.len(), + name_composition: NAME_COMPOSITION.to_string(), + accepted_roles: accepted_roles(), + prohibited_roles: prohibited_roles(), + prohibited_outcomes: PROHIBITED_OUTCOMES.iter().map(|s| s.to_string()).collect(), + note: note_for(fixture_id)?.to_string(), + }) +} + +pub fn hex_lower(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_recipe_fixture_has_a_precommitted_note() { + for def in crate::fixtures::FIXTURES { + note_for(def.id).unwrap_or_else(|e| panic!("{e}")); + } + } + + #[test] + fn an_unknown_fixture_id_is_an_error_not_a_blank_note() { + assert!(note_for("F-Z").is_err()); + } + + #[test] + fn the_hex_form_tracks_the_exact_bytes() { + let e = build_expectation("F-E", "Cafe\u{301}").unwrap(); + assert_eq!(e.name_bytes_hex, "43616665cc81"); + assert_eq!(e.name_byte_len, 6); + // The NFC form is a different string AND a different hex — which is + // the entire reason the hex is stored. + let nfc = build_expectation("F-E", "Caf\u{e9}").unwrap(); + assert_ne!(nfc.name_bytes_hex, e.name_bytes_hex); + assert_eq!(nfc.name_byte_len, 5); + } + + /// The accepted and prohibited sets must not overlap. A token in both + /// would make the oracle unfalsifiable for that platform, and the tables + /// are hand-maintained. + #[test] + fn no_token_is_both_accepted_and_prohibited() { + for (plat, accepted) in ACCEPTED_ROLE_TABLE { + let prohibited = PROHIBITED_ROLE_TABLE + .iter() + .find(|(p, _)| *p == plat) + .unwrap_or_else(|| panic!("platform {plat} has accepted roles but no prohibited")) + .1; + for a in accepted { + assert!( + !prohibited.contains(a), + "{plat}: {a:?} is both accepted and prohibited" + ); + } + } + } + + /// Every platform named in one table is named in the other, in the same + /// order — so a platform cannot arrive with an accepted set and no + /// prohibitions (or the reverse), which would silently accept anything. + #[test] + fn the_two_role_tables_cover_the_same_platforms() { + let a: Vec<&str> = ACCEPTED_ROLE_TABLE.iter().map(|(p, _)| *p).collect(); + let p: Vec<&str> = PROHIBITED_ROLE_TABLE.iter().map(|(p, _)| *p).collect(); + assert_eq!(a, p); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/bin/generate.rs b/spikes/editor-toolkit/round2-textkit/src/bin/generate.rs new file mode 100644 index 0000000..af551a5 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/bin/generate.rs @@ -0,0 +1,106 @@ +//! `generate` — Packet 2A-i's entry point: resolves the declared faces, +//! shapes the five committed fixtures, asserts every W3 §5 invariant and +//! every recipe §4 precommitted expectation, then writes +//! `round2-textkit/fixtures.json` and `round2-textkit/FIXTURES_SUMMARY.md`. +//! +//! Exit behavior: +//! * A declared face **missing from disk** is an environment absence (pin +//! 14): prints `NOT RUN: ` for each missing face and exits `0` +//! without writing anything. +//! * A declared face **present but hash-mismatched**, or any invariant/ +//! recipe-expectation disagreement, is a hard failure: `faces::resolve_one` +//! or `fixtures::build_fixture` panics, and this binary exits non-zero. + +use round2_textkit::faces::{self, FaceResolution, LoadedFace}; +use round2_textkit::fixtures::{self, FIXTURES}; +use round2_textkit::output; + +fn main() { + let resolved = faces::resolve_declared_chain(); + let missing: Vec<&std::path::PathBuf> = resolved + .iter() + .filter_map(|r| match r { + FaceResolution::Missing { path } => Some(path), + FaceResolution::Loaded(_) => None, + }) + .collect(); + if !missing.is_empty() { + for path in &missing { + println!("NOT RUN: {}", path.display()); + } + println!( + "{} of {} declared faces are absent from this machine (pin 14: environment absence, \ + not a failure). No fixtures.json was written.", + missing.len(), + resolved.len() + ); + return; + } + + let loaded: Vec = resolved + .into_iter() + .map(|r| match r { + FaceResolution::Loaded(lf) => lf, + FaceResolution::Missing { .. } => unreachable!("handled above"), + }) + .collect(); + + println!("Resolved {} declared faces:", loaded.len()); + for (i, f) in loaded.iter().enumerate() { + println!( + " [{i}] {} — family {:?}, version {:?}, {} bytes", + f.path.display(), + f.identity.family, + f.identity.version, + f.bytes.len() + ); + } + + let mut built = Vec::with_capacity(FIXTURES.len()); + for (ordinal, def) in FIXTURES.iter().enumerate() { + println!("Shaping {} ({}) ...", def.id, def.purpose); + let rt = fixtures::build_fixture(def, &loaded, ordinal as u64); + let total_glyphs: usize = rt.segments.iter().map(|s| s.glyphs.len()).sum(); + println!( + " {} codepoints, {} bytes, {} segments, {} glyphs, {} clusters — OK", + rt.text.chars().count(), + rt.text.len(), + rt.segments.len(), + total_glyphs, + rt.clusters.clusters.len() + ); + built.push((def.id.to_string(), def.purpose.to_string(), rt)); + } + + let file = output::build_fixture_file(&loaded, built) + .expect("every fixture must have a precommitted accessibility note"); + + // Printed BEFORE validation, and unconditionally: when the digest is what + // disagrees, the number needed to fix it must be on screen even though + // `validate()` is about to fail. Re-record it only after establishing why + // it changed — see `EXPECTED_ARTIFACT_DIGEST_HEX`'s doc comment. + let digest = output::artifact_digest(&file); + println!("artifact digest (sha256 of canonical JSON): {digest}"); + if digest != output::expected_artifact_digest() { + println!( + " compiled-in expectation: {}", + output::expected_artifact_digest() + ); + println!(" ^ these differ — validation below will fail until the constant is re-recorded"); + } + + file.validate() + .expect("freshly generated fixtures.json must pass its own validator"); + + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let json_path = manifest_dir.join("fixtures.json"); + let summary_path = manifest_dir.join("FIXTURES_SUMMARY.md"); + + let json = serde_json::to_string_pretty(&file).expect("fixtures.json must serialize"); + std::fs::write(&json_path, json).expect("write fixtures.json"); + println!("wrote {}", json_path.display()); + + let summary = output::render_summary_markdown(&file); + std::fs::write(&summary_path, summary).expect("write FIXTURES_SUMMARY.md"); + println!("wrote {}", summary_path.display()); +} diff --git a/spikes/editor-toolkit/round2-textkit/src/bin/generate_hittest.rs b/spikes/editor-toolkit/round2-textkit/src/bin/generate_hittest.rs new file mode 100644 index 0000000..616902a --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/bin/generate_hittest.rs @@ -0,0 +1,72 @@ +//! `generate_hittest` — Packet 2A-iii, Deliverable 1: builds the recipe §7 +//! closing-paragraph hit-test *probe table* from the already-committed +//! `fixtures.json` and writes it to a sibling `hittest_probes.json`. +//! +//! **Why a sibling file, not extra fields on `fixtures.json`.** +//! `fixtures.json` is the candidate-neutral §3E mirror — the shaped-text +//! shape itself, precommitted before any candidate. The probe table is +//! *derived, candidate-testing apparatus* built on top of that data (recipe +//! §7's own wording: the probes are how a later packet checks a candidate's +//! hit-test function, not part of what a candidate is asked to reproduce). +//! Keeping them separate means `fixtures.json` stays exactly what +//! `ROUND2_TEXT_RECIPE.md` §5's invariants describe and nothing else, and a +//! probe-table regeneration never touches the file whose hash/structure +//! other tooling already depends on. +//! +//! **Why this reads `fixtures.json` rather than re-shaping from the font +//! files.** The probe table's whole job is to be checkable against the +//! *committed* fixture data — reading it back with `output::load_fixtures` +//! (which validates structurally and semantically before this ever sees it) +//! ties the probes directly to the file every consumer actually reads, and +//! means this binary needs no font files at all, only `fixtures.json`. + +use round2_textkit::hittest; +use round2_textkit::output; + +fn main() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixtures_path = manifest_dir.join("fixtures.json"); + + let fixtures = output::load_fixtures(&fixtures_path).unwrap_or_else(|e| { + panic!( + "{}: {e} — run `cargo run -p round2-textkit --bin generate` first", + fixtures_path.display() + ) + }); + + let file = hittest::build_hittest_probe_file(&fixtures); + file.validate(&fixtures) + .expect("freshly generated hittest_probes.json must pass its own validator"); + + println!("Hit-test probe table (recipe §7):"); + let mut total_probes = 0usize; + for ft in &file.fixtures { + println!(" {}: {} probes", ft.fixture_id, ft.probes.len()); + total_probes += ft.probes.len(); + } + println!( + " total: {total_probes} probes across {} fixtures", + file.fixtures.len() + ); + + if file.dropped.is_empty() { + println!( + " 0 probes dropped for the {} px separation floor", + hittest::MIN_STOP_SEPARATION_DEVICE_PX + ); + } else { + println!( + " {} probe(s) DROPPED for the {} px separation floor:", + file.dropped.len(), + hittest::MIN_STOP_SEPARATION_DEVICE_PX + ); + for d in &file.dropped { + println!(" [{}] {}", d.fixture_id, d.reason); + } + } + + let out_path = manifest_dir.join("hittest_probes.json"); + let json = serde_json::to_string_pretty(&file).expect("serialize hittest_probes.json"); + std::fs::write(&out_path, json).expect("write hittest_probes.json"); + println!("wrote {}", out_path.display()); +} diff --git a/spikes/editor-toolkit/round2-textkit/src/faces.rs b/spikes/editor-toolkit/round2-textkit/src/faces.rs new file mode 100644 index 0000000..ee432e6 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/faces.rs @@ -0,0 +1,220 @@ +//! Face resolution (recipe §1; pins 9, 14): the declared chain resolves +//! once, from an explicit path list, with bytes hashed. A missing file is an +//! environment absence (`NOT RUN`, pin 14), never a failure; a **present** +//! file whose hash disagrees with the recipe's recorded value is a failure +//! that stops the generator rather than silently re-recording — under pin 9 +//! the content hash *is* the identity, so a changed file is a changed +//! fixture set. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use ttf_parser::{name_id, Face as TtfFace}; + +use crate::identity::{SpikeFaceSynthesis, SpikeTextFaceIdentity}; + +/// The declared chain, in order (recipe §1 table). Resolution is closed over +/// this list: nothing outside it is ever consulted (no ambient host lookup). +pub const DECLARED_CHAIN: &[DeclaredFace] = &[ + DeclaredFace { + path: "/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf", + expected_sha256_hex: "44e64260716d8f2bbe412baa1ee99b7c995190ac4573177c24def0b9200438c7", + expected_bytes: 218_100, + }, + DeclaredFace { + path: "/usr/share/fonts/liberation-fonts/LiberationSerif-Regular.ttf", + expected_sha256_hex: "058ea80864aef09a23f45cbec2bb5400bc3dfbdea01c3f10538a21fcb497fb74", + expected_bytes: 393_576, + }, +]; + +pub struct DeclaredFace { + pub path: &'static str, + /// As recorded in recipe §1, "as observed on this machine on + /// 2026-07-29." Compared against the freshly computed hash, never + /// replaced by it. + pub expected_sha256_hex: &'static str, + pub expected_bytes: u64, +} + +/// A face successfully resolved: its bytes (owned, so a `rustybuzz::Face` +/// can be built from them on demand without a self-referential struct) and +/// its identity record. +pub struct LoadedFace { + pub path: PathBuf, + pub bytes: Vec, + pub identity: SpikeTextFaceIdentity, +} + +impl LoadedFace { + /// Builds a fresh `rustybuzz::Face` borrowing this face's bytes, for the + /// duration of one shaping call. `rustybuzz::Face` derefs to + /// `ttf_parser::Face`, so both crates' queries (`glyph_index`, + /// `units_per_em`, ...) are available through the same handle. + pub fn face(&self) -> rustybuzz::Face<'_> { + rustybuzz::Face::from_slice(&self.bytes, self.identity.face_index) + .unwrap_or_else(|| panic!("{}: bytes no longer parse as a font", self.path.display())) + } +} + +/// The outcome of resolving one declared face: loaded, or an environment +/// absence (pin 14: `NOT RUN`, never a failure). +pub enum FaceResolution { + Loaded(LoadedFace), + Missing { path: PathBuf }, +} + +impl fmt::Debug for FaceResolution { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FaceResolution::Loaded(lf) => write!(f, "Loaded({})", lf.path.display()), + FaceResolution::Missing { path } => write!(f, "Missing({})", path.display()), + } + } +} + +/// Resolves the whole declared chain (recipe §1). Never partial: every +/// declared face is attempted, so a caller sees every `NOT RUN` at once +/// rather than stopping at the first. +/// +/// # Panics +/// +/// Panics — loudly, not a `NOT RUN` and not a silent re-record — if a +/// **present** file's SHA-256 disagrees with `DeclaredFace::expected_sha256_hex` +/// or its byte count disagrees with `expected_bytes`. Under pin 9 the content +/// hash *is* the identity: a mismatch means this machine's font differs from +/// the one the recipe's §4 measurements describe, so every downstream +/// expectation in this crate would be describing a font that is not actually +/// being shaped. +pub fn resolve_declared_chain() -> Vec { + DECLARED_CHAIN.iter().map(resolve_one).collect() +} + +fn resolve_one(declared: &DeclaredFace) -> FaceResolution { + let path = Path::new(declared.path).to_path_buf(); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return FaceResolution::Missing { path }; + } + Err(e) => panic!( + "{}: unreadable ({e}) — not a NOT_FOUND, not a NOT RUN", + path.display() + ), + }; + + if bytes.len() as u64 != declared.expected_bytes { + panic!( + "{}: {} bytes on disk, recipe §1 records {} — this machine's font differs from the \ + one §4's measurements describe; STOP, do not re-record", + path.display(), + bytes.len(), + declared.expected_bytes + ); + } + + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let digest = hasher.finalize(); + let hex = hex_encode(&digest); + if hex != declared.expected_sha256_hex { + panic!( + "{}: sha256 {hex} disagrees with recipe §1's recorded {} — STOP, do not re-record", + path.display(), + declared.expected_sha256_hex + ); + } + + let mut file_hash = [0u8; 32]; + file_hash.copy_from_slice(&digest); + + let ttf = TtfFace::parse(&bytes, 0) + .unwrap_or_else(|e| panic!("{}: does not parse as a font: {e:?}", path.display())); + + let family = best_name(&ttf, name_id::FAMILY) + .unwrap_or_else(|| panic!("{}: no name-table family (id 1) entry", path.display())); + let version = best_name(&ttf, name_id::VERSION); + + let identity = SpikeTextFaceIdentity { + family, + version, + file_hash, + face_index: 0, + variations: Vec::new(), + synthesis: SpikeFaceSynthesis::None, + }; + + FaceResolution::Loaded(LoadedFace { + path, + bytes, + identity, + }) +} + +/// Reads one name-table id, preferring a Unicode-encoded entry (readable +/// without further platform-specific decoding); falls back to the first +/// entry present under any encoding `ttf_parser::Name::to_string` can +/// decode. Diagnostic-only field (recipe §6), so "first decodable entry" is +/// an adequate rule — this never feeds shaping. +fn best_name(face: &TtfFace, id: u16) -> Option { + let names = face.names(); + let mut fallback = None; + for name in names { + if name.name_id != id { + continue; + } + if name.is_unicode() { + if let Some(s) = name.to_string() { + return Some(s); + } + } + if fallback.is_none() { + fallback = name.to_string(); + } + } + fallback +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn declared_chain_has_two_faces_with_distinct_upem() { + // Not a face-loading test (that needs the real files, exercised by + // the generate binary and the integration tests below); this checks + // the *declared* literals themselves stay in the shape recipe §1 + // commits to — two entries, non-empty hash hex, non-zero byte counts. + assert_eq!(DECLARED_CHAIN.len(), 2); + for d in DECLARED_CHAIN { + assert_eq!( + d.expected_sha256_hex.len(), + 64, + "sha256 hex must be 64 chars (32 bytes, no 0x prefix)" + ); + assert!(d.expected_bytes > 0); + } + } + + #[test] + fn resolving_the_real_chain_succeeds_or_reports_missing_never_panics_on_a_present_file() { + // This is an environment-dependent smoke test: on a machine with the + // recipe's declared fonts installed (this one, per recipe §1), every + // entry resolves to `Loaded`. It intentionally does not assert + // `Loaded` unconditionally, so it degrades to recording `Missing` + // rather than failing on a machine without the fonts — but it must + // never itself panic, which is what `resolve_one`'s hash check would + // do on a *present-but-wrong* file. + let resolved = resolve_declared_chain(); + assert_eq!(resolved.len(), DECLARED_CHAIN.len()); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/fixtures.rs b/spikes/editor-toolkit/round2-textkit/src/fixtures.rs new file mode 100644 index 0000000..a42e29a --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/fixtures.rs @@ -0,0 +1,546 @@ +//! The five committed fixture strings (recipe §2) and the precommitted +//! measured expectations (recipe §4) the generator checks its own shaped +//! output against. +//! +//! Every string below is a Rust string literal with every non-ASCII +//! codepoint escaped (recipe §2: "so the file is unambiguous under any +//! editor or normalization"). **Never re-record an expectation to make a +//! check pass**: [`check_against_recipe`] returning an error is this crate's +//! signal to stop and report the disagreement, not to edit the literal it +//! failed against. + +use epiphany_core::{EventId, TypedObjectId}; +use epiphany_layout_ir::{BoundingBox as RealBoundingBox, Point as RealPoint, Provenance}; +use ttf_parser::GlyphId; + +use crate::faces::LoadedFace; +use crate::identity::build_shaping_identity; +use crate::invariants; +use crate::shape::shape_text; +use crate::types::{ + SpikeBoundingBox, SpikeGlyphStyle, SpikePoint, SpikeResolvedText, SpikeTextAlign, +}; +use crate::{EM_SIZE_STAFF_SPACE, RUN_ORIGIN_STAFF}; + +/// One committed fixture: its id, W3 §5 purpose, and verbatim literal. +pub struct FixtureDef { + pub id: &'static str, + pub purpose: &'static str, + pub text: &'static str, +} + +/// The five fixtures, in the recipe §2 table's order. This exact set, in +/// this exact order, is what `output::FixtureFile::validate` restates as a +/// literal roster (mirroring `round1-candidates/harness`'s `ROUND1_ROSTER` +/// discipline). +pub const FIXTURES: &[FixtureDef] = &[ + FixtureDef { + id: "F-A", + purpose: "check 1 (faithful consumption), check 5 (accessibility)", + text: "Allegro affettuoso \u{2014} al fine", + }, + FixtureDef { + id: "F-B", + purpose: "check 2 (fallback, forced)", + text: "Coro \u{05D0}\u{05D1}\u{05D2}", + }, + FixtureDef { + id: "F-C", + purpose: "check 2 (uncovered codepoint)", + text: "Coro \u{0627}", + }, + // **Not "check 3 (bidi)".** Ruled 2026-07-29 (recipe §1.2): check 3 + // requires an Arabic/Latin run, no Arabic-capable face exists on this + // machine, and pin 9 makes an absent required face an environmental + // `NOT RUN`. F-D is Hebrew/Latin and cannot exercise contextual Arabic + // joining, so it is scored on its own **Supplementary** row and must never + // upgrade check 3 to PASS. The purpose string says so because this string + // is what `fixtures.json`, `FIXTURES_SUMMARY.md` and every generator's + // console output print — labelling it "check 3" there recreates exactly + // the scoring ambiguity the ruling forbids, whatever the recipe says + // elsewhere. + FixtureDef { + id: "F-D", + purpose: "SUPPLEMENTARY bidi evidence (Hebrew/Latin) — check 3 remains NOT RUN \ + (no Arabic-capable face; recipe §1.2)", + text: "Allegro \u{05D0}\u{05D1}\u{05D2} con brio", + }, + FixtureDef { + id: "F-E", + purpose: "check 4 (hit testing / caret)", + text: "Cafe\u{301} \u{2014} resume\u{301}", + }, +]; + +/// Builds one fixture's `SpikeResolvedText`: shapes it against the resolved +/// faces, computes `bounds`/`reserved_box`/`origin`, and asserts every W3 §5 +/// invariant before returning. +pub fn build_fixture( + def: &FixtureDef, + faces: &[LoadedFace], + fixture_ordinal: u64, +) -> SpikeResolvedText { + let shaped = shape_text(def.text, faces); + // Recipe §3's nominal `(1.6, 0.0)` is not itself on the `1/1024` grid + // (see `crate::quantize`'s doc comment) — invariant 5 requires every + // position this crate records to be, `origin` included, so it is + // quantized like everything else rather than kept as the raw literal. + let origin = SpikePoint::new( + crate::quantize::quantize_component(RUN_ORIGIN_STAFF.0 as f64), + crate::quantize::quantize_component(RUN_ORIGIN_STAFF.1 as f64), + ); + let bounds = compute_bounds(&shaped.segments, faces, origin); + // Reserved-box policy (§3E: "a solver policy over bounds — padding, a + // minimum allocation"): this spike has no real solver, so the policy is + // the simplest defensible one — bounds padded by a fixed 0.1 staff space + // on every side — recorded as a *named* policy, not an unshaped guess. + const PAD: f64 = 0.1; + let reserved_box = SpikeBoundingBox { + left: crate::quantize::quantize_component(bounds.left - PAD), + bottom: crate::quantize::quantize_component(bounds.bottom - PAD), + right: crate::quantize::quantize_component(bounds.right + PAD), + top: crate::quantize::quantize_component(bounds.top + PAD), + }; + + let identity = build_shaping_identity(faces.iter().map(|f| f.identity.clone()).collect()); + + let source = TypedObjectId::Event(EventId::from_raw(0xF00D_0000 + fixture_ordinal as u128)); + let real_provenance = Provenance::projected(source, Vec::new()); + + let rt = SpikeResolvedText { + provenance: (&real_provenance).into(), + text: def.text.to_string(), + shaping: identity, + segments: shaped.segments, + clusters: shaped.clusters, + bounds, + reserved_box, + origin, + align: SpikeTextAlign::Start, + style: SpikeGlyphStyle { rgba: 0x0000_00ff }, + layer: 0, + }; + + let expect_unresolved = def.id == "F-C"; + invariants::assert_all(def.id, &rt, expect_unresolved); + if def.id == "F-D" { + invariants::assert_direction_boundary_stops_differ("F-D", &rt, 8) + .expect("F-D byte 8 direction-boundary stops"); + invariants::assert_direction_boundary_stops_differ("F-D", &rt, 14) + .expect("F-D byte 14 direction-boundary stops"); + } + + check_against_recipe(def.id, &rt).unwrap_or_else(|e| { + panic!( + "{}: measured shaping disagrees with recipe §4's precommitted expectation — STOPPING \ + per the packet's rule against silent re-recording:\n{e}", + def.id + ) + }); + + rt +} + +/// Real-type ink bounding box over every positioned glyph across every +/// segment, computed from each glyph's *own resolving face*'s outline +/// bounds (`ttf_parser::Face::glyph_bounding_box`) — never estimated from +/// advances. A glyph with no outline (a space) contributes no extent but +/// still occupies pen advance, exactly as `PositionedGlyph::offset` already +/// records. +fn compute_bounds( + segments: &[crate::types::SpikeShapedSegment], + faces: &[LoadedFace], + origin: SpikePoint, +) -> SpikeBoundingBox { + let mut left = f64::INFINITY; + let mut bottom = f64::INFINITY; + let mut right = f64::NEG_INFINITY; + let mut top = f64::NEG_INFINITY; + + for seg in segments { + let Some(face_idx) = seg.face else { continue }; + let loaded = &faces[face_idx as usize]; + let rb_face = loaded.face(); + let upem = rb_face.units_per_em() as f64; + let scale = EM_SIZE_STAFF_SPACE / upem; + for g in &seg.glyphs { + let Some(bbox) = rb_face.glyph_bounding_box(GlyphId(g.glyph_id as u16)) else { + continue; + }; + let gx = origin.x + g.offset.x; + let gy = origin.y + g.offset.y; + left = left.min(gx + bbox.x_min as f64 * scale); + right = right.max(gx + bbox.x_max as f64 * scale); + bottom = bottom.min(gy + bbox.y_min as f64 * scale); + top = top.max(gy + bbox.y_max as f64 * scale); + } + } + + if !left.is_finite() { + // No glyph produced ink (a degenerate all-unresolved fixture) — an + // empty box at the origin rather than an infinite one, matching + // `epiphany_layout_ir::BoundingBox::default()`'s zero convention. + let real_default: RealBoundingBox = RealBoundingBox::default(); + return SpikeBoundingBox::from(real_default); + } + // Round-trip through the real `BoundingBox`/`Point` types (they carry no + // extra invariant beyond `f32` storage) so this function is honestly + // computing the *real* type's value, not a shape only this crate defines. + let _real_point_smoke_test = RealPoint::new(left as f32, bottom as f32); + SpikeBoundingBox { + left: crate::quantize::quantize_component(left), + bottom: crate::quantize::quantize_component(bottom), + right: crate::quantize::quantize_component(right), + top: crate::quantize::quantize_component(top), + } +} + +/// Checks the shaped result against recipe §4's precommitted, measured +/// expectations. **A mismatch is stopped and reported, never silently +/// re-recorded** — this function's only job is to say which fact disagreed. +fn check_against_recipe(id: &str, rt: &SpikeResolvedText) -> Result<(), String> { + match id { + "F-A" => check_f_a(rt), + "F-B" => check_f_b(rt), + "F-C" => check_f_c(rt), + "F-D" => check_f_d(rt), + "F-E" => check_f_e(rt), + other => Err(format!( + "no recipe §4 check registered for fixture {other:?}" + )), + } +} + +fn total_glyphs(rt: &SpikeResolvedText) -> usize { + rt.segments.iter().map(|s| s.glyphs.len()).sum() +} + +/// F-A: 28 codepoints, 30 bytes, 26 glyphs, all face 0. `ff` ligature at +/// byte 9 -> gid 234; `fi` ligature at byte 26 -> gid 97; em dash -> gid 119. +fn check_f_a(rt: &SpikeResolvedText) -> Result<(), String> { + expect_eq("codepoints", rt.text.chars().count(), 28)?; + expect_eq("bytes", rt.text.len(), 30)?; + expect_eq("glyphs", total_glyphs(rt), 26)?; + // Recipe §4 does not literally write "1 segment" for F-A — it says "all + // face 0", from which a single segment follows (unidirectional Latin + // text, one face throughout, nothing to split on). This is a derived + // check, not a quoted number; `output::FixtureFile::validate` does not + // repeat it as a "recipe §4 literal" for exactly that reason. + expect_eq("segments", rt.segments.len(), 1)?; + if rt.segments[0].face != Some(0) { + return Err(format!( + "F-A segment 0 resolved to face {:?}, recipe says face 0", + rt.segments[0].face + )); + } + expect_cluster_glyph("F-A ff ligature", rt, 9, 11, 234)?; + expect_cluster_glyph("F-A fi ligature", rt, 26, 28, 97)?; + // The em dash: U+2014 sits at byte offset 19 (after "Allegro affettuoso " + // — "Allegro " is 8 bytes, "affettuoso " is 11 bytes, 8+11=19). + expect_cluster_glyph("F-A em dash", rt, 19, 22, 119)?; + Ok(()) +} + +/// F-B: Latin head "Coro " (5 bytes) -> 5 glyphs on face 0; Hebrew tail +/// (6 bytes) -> 3 glyphs on face 1, RTL, clusters descending 4/2/0 (segment- +/// relative). Two segments, two faces. +fn check_f_b(rt: &SpikeResolvedText) -> Result<(), String> { + expect_eq("segments", rt.segments.len(), 2)?; + let head = &rt.segments[0]; + let tail = &rt.segments[1]; + expect_eq( + "F-B head bytes", + (head.source.end - head.source.start) as usize, + 5, + )?; + expect_eq("F-B head glyphs", head.glyphs.len(), 5)?; + if head.face != Some(0) { + return Err(format!( + "F-B head resolved to face {:?}, recipe says face 0", + head.face + )); + } + expect_eq( + "F-B tail bytes", + (tail.source.end - tail.source.start) as usize, + 6, + )?; + expect_eq("F-B tail glyphs", tail.glyphs.len(), 3)?; + if tail.face != Some(1) { + return Err(format!( + "F-B tail resolved to face {:?}, recipe says face 1", + tail.face + )); + } + if tail.direction != crate::types::SpikeTextDirection::Rtl { + return Err("F-B tail must be Rtl".to_string()); + } + // Clusters covering the tail must exist at absolute bytes 5,7,9 (segment- + // relative 0,2,4), each a single grapheme/glyph, descending in the + // shaped glyph *array* order (checked via clusters' recorded glyph + // index order matching descending source offsets is implicit in the + // shaping; here we assert the three source spans exist). + for abs in [5u32, 7, 9] { + let found = rt + .clusters + .clusters + .iter() + .any(|c| c.source.start == abs && c.segment == 1); + if !found { + return Err(format!( + "F-B: no cluster starts at absolute byte {abs} in the Hebrew segment" + )); + } + } + Ok(()) +} + +/// F-C: U+0627 resolves in neither face — an explicit unresolved cluster. +fn check_f_c(rt: &SpikeResolvedText) -> Result<(), String> { + let unresolved_count = rt.clusters.clusters.iter().filter(|c| !c.resolved).count(); + if unresolved_count != 1 { + return Err(format!( + "F-C: expected exactly 1 unresolved cluster, found {unresolved_count}" + )); + } + Ok(()) +} + +/// F-D: base level 0; visual runs `0..8` (Latn, face 0), `8..14` (Hebr, +/// face 1), `14..23` (Latn, face 0). Three segments. +fn check_f_d(rt: &SpikeResolvedText) -> Result<(), String> { + expect_eq("segments", rt.segments.len(), 3)?; + let expected = [ + (0u32, 8u32, Some(0u32)), + (8, 14, Some(1)), + (14, 23, Some(0)), + ]; + for (i, (s, e, face)) in expected.into_iter().enumerate() { + let seg = &rt.segments[i]; + if seg.source.start != s || seg.source.end != e { + return Err(format!( + "F-D segment[{i}] source {:?}, recipe says {s}..{e}", + seg.source + )); + } + if seg.face != face { + return Err(format!( + "F-D segment[{i}] face {:?}, recipe says {face:?}", + seg.face + )); + } + } + if rt.segments[1].direction != crate::types::SpikeTextDirection::Rtl { + return Err("F-D middle segment must be Rtl".to_string()); + } + Ok(()) +} + +/// F-E: 15 codepoints, 19 bytes, 13 glyphs; `e`+U+0301 composes to gid 198 +/// at byte 3 and again at byte 16; 13 graphemes. +fn check_f_e(rt: &SpikeResolvedText) -> Result<(), String> { + expect_eq("codepoints", rt.text.chars().count(), 15)?; + expect_eq("bytes", rt.text.len(), 19)?; + expect_eq("glyphs", total_glyphs(rt), 13)?; + let grapheme_count: u32 = rt.clusters.clusters.iter().map(|c| c.grapheme_count).sum(); + expect_eq("graphemes", grapheme_count as usize, 13)?; + // e+U+0301 is 3 bytes (1 + 2); the composed cluster covering it should + // span exactly 3 bytes and carry 1 glyph (gid 198), at byte 3 and byte 16. + expect_composed_e_acute(rt, 3)?; + expect_composed_e_acute(rt, 16)?; + Ok(()) +} + +fn expect_composed_e_acute(rt: &SpikeResolvedText, start: u32) -> Result<(), String> { + let c = rt + .clusters + .clusters + .iter() + .find(|c| c.source.start == start) + .ok_or_else(|| format!("F-E: no cluster starts at byte {start}"))?; + if c.source.end - c.source.start != 3 { + return Err(format!( + "F-E: cluster at byte {start} spans {} bytes, recipe says 3 (e + U+0301)", + c.source.end - c.source.start + )); + } + if c.glyph_indices.len() != 1 { + return Err(format!( + "F-E: cluster at byte {start} carries {} glyphs, recipe says 1 (composed)", + c.glyph_indices.len() + )); + } + let seg = &rt.segments[c.segment]; + let gid = seg.glyphs[c.glyph_indices[0] as usize].glyph_id; + if gid != 198 { + return Err(format!( + "F-E: cluster at byte {start} is gid {gid}, recipe says gid 198" + )); + } + Ok(()) +} + +/// Asserts the (single) glyph named by the cluster starting at `start` +/// (spanning to `end`) is `expected_gid`. +fn expect_cluster_glyph( + label: &str, + rt: &SpikeResolvedText, + start: u32, + end: u32, + expected_gid: u32, +) -> Result<(), String> { + let c = rt + .clusters + .clusters + .iter() + .find(|c| c.source.start == start && c.source.end == end) + .ok_or_else(|| format!("{label}: no cluster spans {start}..{end}"))?; + if c.glyph_indices.len() != 1 { + return Err(format!( + "{label}: cluster {start}..{end} carries {} glyphs, expected exactly 1", + c.glyph_indices.len() + )); + } + let seg = &rt.segments[c.segment]; + let gid = seg.glyphs[c.glyph_indices[0] as usize].glyph_id; + if gid != expected_gid { + return Err(format!( + "{label}: cluster {start}..{end} is gid {gid}, recipe says gid {expected_gid}" + )); + } + Ok(()) +} + +fn expect_eq(label: &str, actual: usize, expected: usize) -> Result<(), String> { + if actual != expected { + return Err(format!( + "{label}: measured {actual}, recipe §4 records {expected}" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::faces::{resolve_declared_chain, FaceResolution, LoadedFace}; + + /// Loads the real declared faces, or `None` (pin 14: environment + /// absence) — mirrors `output::tests::real_valid_file`'s pattern so + /// these mutation tests exercise the *real* recipe-§4 checks against + /// genuinely shaped output, not a hand-built stand-in. + fn real_faces() -> Option> { + let mut out = Vec::new(); + for r in resolve_declared_chain() { + match r { + FaceResolution::Loaded(lf) => out.push(lf), + FaceResolution::Missing { .. } => return None, + } + } + Some(out) + } + + fn require_faces() -> Vec { + real_faces().expect("this test requires the two declared faces to be present") + } + + #[test] + fn check_f_a_kills_a_wrong_ligature_gid() { + let faces = require_faces(); + let mut rt = build_fixture(&FIXTURES[0], &faces, 0); + let c = rt + .clusters + .clusters + .iter() + .find(|c| c.source.start == 9) + .unwrap() + .clone(); + let seg = &mut rt.segments[c.segment]; + seg.glyphs[c.glyph_indices[0] as usize].glyph_id = 1; // not gid 234 + let err = check_f_a(&rt).unwrap_err(); + assert!(err.contains("gid 234"), "{err}"); + } + + #[test] + fn check_f_a_kills_a_wrong_em_dash_gid() { + let faces = require_faces(); + let mut rt = build_fixture(&FIXTURES[0], &faces, 0); + let c = rt + .clusters + .clusters + .iter() + .find(|c| c.source.start == 19) + .unwrap() + .clone(); + let seg = &mut rt.segments[c.segment]; + seg.glyphs[c.glyph_indices[0] as usize].glyph_id = 1; + let err = check_f_a(&rt).unwrap_err(); + assert!(err.contains("gid 119"), "{err}"); + } + + #[test] + fn check_f_b_kills_a_swapped_face_assignment() { + let faces = require_faces(); + let mut rt = build_fixture(&FIXTURES[1], &faces, 1); + rt.segments[1].face = Some(0); // Hebrew tail must be face 1, not 0 + let err = check_f_b(&rt).unwrap_err(); + assert!(err.contains("face 1"), "{err}"); + } + + #[test] + fn check_f_c_kills_a_dropped_unresolved_cluster() { + let faces = require_faces(); + let mut rt = build_fixture(&FIXTURES[2], &faces, 2); + rt.clusters.clusters.retain(|c| c.resolved); // simulate silently dropping it + let err = check_f_c(&rt).unwrap_err(); + assert!(err.contains("unresolved cluster"), "{err}"); + } + + #[test] + fn check_f_d_kills_a_wrong_segment_source_range() { + let faces = require_faces(); + let mut rt = build_fixture(&FIXTURES[3], &faces, 3); + rt.segments[1].source = 9..14; // recipe says 8..14 + let err = check_f_d(&rt).unwrap_err(); + assert!(err.contains("recipe says 8..14"), "{err}"); + } + + #[test] + fn check_f_e_kills_a_wrong_composed_gid() { + let faces = require_faces(); + let mut rt = build_fixture(&FIXTURES[4], &faces, 4); + let c = rt + .clusters + .clusters + .iter() + .find(|c| c.source.start == 3) + .unwrap() + .clone(); + let seg = &mut rt.segments[c.segment]; + seg.glyphs[c.glyph_indices[0] as usize].glyph_id = 1; // not gid 198 + let err = check_f_e(&rt).unwrap_err(); + assert!(err.contains("gid 198"), "{err}"); + } + + #[test] + fn fixture_roster_matches_the_recipe_table() { + let ids: Vec<&str> = FIXTURES.iter().map(|f| f.id).collect(); + assert_eq!(ids, ["F-A", "F-B", "F-C", "F-D", "F-E"]); + assert_eq!(FIXTURES[0].text, "Allegro affettuoso \u{2014} al fine"); + assert_eq!(FIXTURES[1].text, "Coro \u{05D0}\u{05D1}\u{05D2}"); + assert_eq!(FIXTURES[2].text, "Coro \u{0627}"); + assert_eq!( + FIXTURES[3].text, + "Allegro \u{05D0}\u{05D1}\u{05D2} con brio" + ); + assert_eq!(FIXTURES[4].text, "Cafe\u{301} \u{2014} resume\u{301}"); + } + + /// Mutation-first: `expect_eq` must actually fail when the numbers + /// disagree, not just when they happen to agree. + #[test] + fn expect_eq_kills_a_disagreement() { + assert!(expect_eq("x", 5, 6).is_err()); + assert!(expect_eq("x", 5, 5).is_ok()); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/hittest.rs b/spikes/editor-toolkit/round2-textkit/src/hittest.rs new file mode 100644 index 0000000..ea67db0 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/hittest.rs @@ -0,0 +1,867 @@ +//! The hit-test probe table (`ROUND2_TEXT_RECIPE.md` §7 closing paragraph): +//! a committed, per-fixture table of `(device point) -> (byte offset, +//! affinity)` probes, derived from `SpikeResolvedText`'s own caret-stop data +//! via recipe §3's transform. +//! +//! **This is candidate-testing apparatus, not part of the candidate-neutral +//! §3E mirror** — `crate::fixtures`/`crate::output` build and validate +//! `SpikeResolvedText` itself; this module only *reads* an already-built, +//! already-validated [`crate::output::FixtureFile`] and derives a table from +//! it. That is why it lives in a sibling file, `hittest_probes.json`, rather +//! than as extra fields on `fixtures.json`'s own records — see +//! `bin/generate_hittest.rs`'s doc comment for the full reasoning. +//! +//! ## The hit-test semantics this table commits to +//! +//! The recipe names the *rule for generating probes* ("midpoint of each +//! adjacent grapheme... at least 4 device px from any stop") but not the +//! *hit-test semantics a probe's expected answer assumes*. This module picks +//! the simplest one that is well-defined everywhere a probe can legally sit +//! (i.e. everywhere the 4 px floor allows a probe at all): **a device point +//! maps to the caret stop that begins the grapheme whose box contains it** — +//! graphemes' own `Downstream` caret stops, sorted by device x, partition the +//! whole line into non-overlapping boxes with no gaps, so this is a pure +//! interval lookup ("floor" to the nearest stop at or before the point), +//! never a nearest-neighbour vote. A point exactly at the *literal* midpoint +//! between two stops (rather than a stop-to-stop interval interior) would be +//! a genuine 50/50 tie under a nearest-caret rule; the interval-floor +//! semantics used here have no such tie anywhere strictly inside a box, which +//! is exactly what lets every probe carry one unambiguous expected answer. +//! +//! **Consequence, stated plainly: every probe's expected affinity is +//! `Downstream`.** `Upstream` caret stops (the direction-boundary extras +//! `crate::shape::inject_direction_boundary_stops` adds — F-D bytes 8 and 14) +//! are deliberately excluded from probe generation. Measured on F-D's own +//! data: every `Upstream` stop's device position coincides *exactly* with +//! some other grapheme's own `Downstream` box boundary elsewhere in the +//! fixture (byte 8's and byte 14's `Upstream` positions both equal byte 12's +//! `Downstream` position, 4.609375 staff-space x — segment 1's own +//! `start_pen`, referenced from both sides of the RTL run). A probe placed +//! near an `Upstream` stop's position is therefore a probe placed near an +//! *already-covered* `Downstream` box boundary — exactly the kind of position +//! the 4 px separation rule exists to keep every probe away from. This table +//! cannot exercise affinity disambiguation at a direction boundary without +//! violating its own separation invariant; that property is committed and +//! checked elsewhere (`invariants::assert_direction_boundary_stops_differ`, +//! already asserted on every loaded fixture by `output::FixtureFile::validate`). +//! Recorded here as a finding, not silently worked around. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::output::FixtureFile; +use crate::types::{SpikeCaretAffinity, SpikePoint, SpikeResolvedText}; + +/// Device pixels per staff space (recipe §3): `scale = 100`. +pub const DEVICE_SCALE: f64 = crate::DEVICE_SCALE; + +/// Recipe §7: "Points are placed at least 4 device px from any stop +/// position." Not a tunable — a probe violating this is dropped, never +/// accepted with a smaller margin (see [`build_probe_table`]'s doc comment). +pub const MIN_STOP_SEPARATION_DEVICE_PX: f64 = 4.0; + +/// How far past the first/last caret stop the two edge probes sit. Not +/// pinned by the recipe (which only requires clearing the 4 px floor); fixed +/// here at 20 px — five times the floor — so the edge probes are nowhere +/// near a rounding tie on any fixture in this set (every measured interior +/// gap is at least 31.9 device px; see this module's generation output). +pub const EDGE_MARGIN_DEVICE_PX: f64 = 20.0; + +/// The five fixture ids, in order — restated (not read back from +/// `crate::fixtures::FIXTURES`), the same discipline +/// `output::EXPECTED_FIXTURES` uses and for the same reason: a validator +/// built from a different copy of this crate must still catch drift. +pub const EXPECTED_FIXTURE_IDS: [&str; 5] = ["F-A", "F-B", "F-C", "F-D", "F-E"]; + +#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePoint { + pub x: f64, + pub y: f64, +} + +fn distance(a: &DevicePoint, b: &DevicePoint) -> f64 { + ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() +} + +/// Converts a position **relative to `rt.origin`** (the convention every +/// `SpikePositionedGlyph::offset` and `SpikeCaretStop::position` already +/// uses) to device pixels, via recipe §3's transform: `device = (staff.x * +/// scale, target_height/2 - staff.y * scale)`. +/// +/// This adds `rt.origin` first rather than using the recipe's nominal +/// `(160, 540)` as an additive device-space constant — the two are *not* +/// quite the same thing. `rt.origin` is the honestly-**quantized** origin +/// (`crate::quantize`'s doc comment: `1638/1024 = 1.599609375`, not the raw +/// literal `1.6`), so the run's baseline actually sits at device +/// `(159.9609375, 540)`, not `(160, 540)`. The discrepancy is under +/// 0.04 device px — far below anything that matters for an 8+ px probe +/// margin — but this module computes it exactly rather than silently +/// re-introducing the same rounded-literal shortcut `crate::quantize`'s +/// module doc comment names as a bug shape to avoid. +pub fn to_device(rt: &SpikeResolvedText, relative: &SpikePoint) -> DevicePoint { + let staff_x = rt.origin.x + relative.x; + let staff_y = rt.origin.y + relative.y; + DevicePoint { + x: staff_x * DEVICE_SCALE, + y: crate::TARGET_HEIGHT / 2.0 - staff_y * DEVICE_SCALE, + } +} + +/// Every caret stop in `rt`, in device space, **both affinities** — this is +/// what a probe must clear the [`MIN_STOP_SEPARATION_DEVICE_PX`] floor +/// against (recipe §7: "at least 4 device px from any stop position" — any, +/// not just the two stops bounding the interior interval a probe was +/// generated from). +fn all_stop_device_points(rt: &SpikeResolvedText) -> Vec { + rt.clusters + .clusters + .iter() + .flat_map(|c| c.caret_stops.iter()) + .map(|s| to_device(rt, &s.position)) + .collect() +} + +/// One grapheme's own leading-edge (`Downstream`) caret stop, resolved to +/// device space, kept in **device-x-sorted (visual) order** — see this +/// module's doc comment for why this order, not source-byte order, is the +/// one probe generation needs (an RTL segment's clusters are byte-ascending +/// but device-x-descending). +struct GraphemeEntry { + source_offset: u32, + device: DevicePoint, +} + +fn downstream_sequence(rt: &SpikeResolvedText) -> Vec { + let mut v: Vec = rt + .clusters + .clusters + .iter() + .flat_map(|c| c.caret_stops.iter()) + .filter(|s| s.affinity == SpikeCaretAffinity::Downstream) + .map(|s| GraphemeEntry { + source_offset: s.source_offset, + device: to_device(rt, &s.position), + }) + .collect(); + v.sort_by(|a, b| { + a.device + .x + .partial_cmp(&b.device.x) + .expect("device x is always finite") + }); + v +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +pub enum ProbeKind { + /// Generated from the midpoint of two device-x-adjacent `Downstream` + /// stops (recipe §7: "for every caret stop, one probe at the midpoint of + /// each adjacent grapheme" — see the module doc comment for why this is + /// realized as one probe per adjacent *pair*, not two identical probes + /// per interior grapheme). + Interior, + /// Before the first caret stop (recipe §7). + BeforeFirst, + /// After the last caret stop (recipe §7). + AfterLast, +} + +/// One committed probe: a device point, its expected hit-test answer, and +/// which grapheme/interval it was generated from (traceability — task +/// requirement). +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HitTestProbe { + pub point: DevicePoint, + pub expected_source_offset: u32, + pub expected_affinity: SpikeCaretAffinity, + /// Human-readable provenance: which fixture, which grapheme/interval, + /// and which probe kind. Not machine-checked itself (the geometry and + /// expected offset/affinity are); a report or a `FAIL` names the probe by + /// this string rather than an opaque index. + pub source_grapheme: String, + pub kind: ProbeKind, +} + +/// A probe the generator refused to emit because it could not clear the +/// [`MIN_STOP_SEPARATION_DEVICE_PX`] floor — recipe §7: "do not shrink the +/// margin — drop that probe and RECORD that it was dropped, with which +/// fixture and why." +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DroppedProbe { + pub fixture_id: String, + pub point: DevicePoint, + pub nearest_stop_distance_device_px: f64, + pub reason: String, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureProbeTable { + pub fixture_id: String, + pub probes: Vec, +} + +/// `hittest_probes.json`'s root document. +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HitTestProbeFile { + pub contract: String, + pub recipe: String, + pub min_separation_device_px: f64, + pub edge_margin_device_px: f64, + pub fixtures: Vec, + pub dropped: Vec, +} + +/// Considers one candidate probe: accepts it into `probes` if it clears the +/// separation floor against **every** caret stop in `all_stops` (both +/// affinities), otherwise records it in `dropped` with the measured distance +/// and never lowers the floor to make it fit (recipe §7, restated in this +/// module's own doc comment). +fn consider( + fixture_id: &str, + all_stops: &[DevicePoint], + point: DevicePoint, + expected_source_offset: u32, + expected_affinity: SpikeCaretAffinity, + kind: ProbeKind, + label: String, + probes: &mut Vec, + dropped: &mut Vec, +) { + let min_dist = all_stops + .iter() + .map(|s| distance(&point, s)) + .fold(f64::INFINITY, f64::min); + if min_dist < MIN_STOP_SEPARATION_DEVICE_PX { + dropped.push(DroppedProbe { + fixture_id: fixture_id.to_string(), + point, + nearest_stop_distance_device_px: min_dist, + reason: format!( + "{label}: nearest caret stop is only {min_dist:.4} device px away, below the \ + {MIN_STOP_SEPARATION_DEVICE_PX} px floor recipe §7 requires — dropped rather \ + than shrinking the margin" + ), + }); + } else { + probes.push(HitTestProbe { + point, + expected_source_offset, + expected_affinity, + source_grapheme: label, + kind, + }); + } +} + +/// Builds one fixture's probe table (recipe §7): one probe per pair of +/// device-x-adjacent graphemes (their shared interior interval — see the +/// module doc comment for why this, not two probes per stop, is the correct +/// non-redundant realization of "midpoint of each adjacent grapheme"), plus +/// one probe before the first stop and one after the last, each checked +/// against the [`MIN_STOP_SEPARATION_DEVICE_PX`] floor and dropped (not +/// shrunk) if it fails. +pub fn build_probe_table( + fixture_id: &str, + rt: &SpikeResolvedText, +) -> (FixtureProbeTable, Vec) { + let seq = downstream_sequence(rt); + let all_stops = all_stop_device_points(rt); + let mut probes = Vec::new(); + let mut dropped = Vec::new(); + + for w in seq.windows(2) { + let (a, b) = (&w[0], &w[1]); + let mid = DevicePoint { + x: (a.device.x + b.device.x) / 2.0, + y: a.device.y, + }; + let label = format!( + "{fixture_id}: interior, grapheme starting at byte {} (interval byte {}..byte {})", + a.source_offset, a.source_offset, b.source_offset + ); + consider( + fixture_id, + &all_stops, + mid, + a.source_offset, + SpikeCaretAffinity::Downstream, + ProbeKind::Interior, + label, + &mut probes, + &mut dropped, + ); + } + + if let Some(first) = seq.first() { + let point = DevicePoint { + x: first.device.x - EDGE_MARGIN_DEVICE_PX, + y: first.device.y, + }; + let label = format!( + "{fixture_id}: before the first caret stop (byte {})", + first.source_offset + ); + consider( + fixture_id, + &all_stops, + point, + first.source_offset, + SpikeCaretAffinity::Downstream, + ProbeKind::BeforeFirst, + label, + &mut probes, + &mut dropped, + ); + } + if let Some(last) = seq.last() { + let point = DevicePoint { + x: last.device.x + EDGE_MARGIN_DEVICE_PX, + y: last.device.y, + }; + let label = format!( + "{fixture_id}: after the last caret stop (byte {})", + last.source_offset + ); + consider( + fixture_id, + &all_stops, + point, + last.source_offset, + SpikeCaretAffinity::Downstream, + ProbeKind::AfterLast, + label, + &mut probes, + &mut dropped, + ); + } + + ( + FixtureProbeTable { + fixture_id: fixture_id.to_string(), + probes, + }, + dropped, + ) +} + +/// Builds every fixture's probe table from an already-loaded, already-valid +/// [`FixtureFile`] (`crate::output::load_fixtures` validates before this ever +/// sees it). +pub fn build_all(fixtures: &FixtureFile) -> (Vec, Vec) { + let mut tables = Vec::with_capacity(fixtures.fixtures.len()); + let mut all_dropped = Vec::new(); + for f in &fixtures.fixtures { + let (table, mut dropped) = build_probe_table(&f.id, &f.resolved); + tables.push(table); + all_dropped.append(&mut dropped); + } + (tables, all_dropped) +} + +pub fn build_hittest_probe_file(fixtures: &FixtureFile) -> HitTestProbeFile { + let (tables, dropped) = build_all(fixtures); + HitTestProbeFile { + contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14".to_string(), + recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §7".to_string(), + min_separation_device_px: MIN_STOP_SEPARATION_DEVICE_PX, + edge_margin_device_px: EDGE_MARGIN_DEVICE_PX, + fixtures: tables, + dropped, + } +} + +impl HitTestProbeFile { + /// Checks the loaded file against literals restated here, then against a + /// **fresh recomputation** from `fixtures` — the same two-tier discipline + /// `output::FixtureFile::validate` uses (structural/literal checks, plus + /// re-deriving the checkable facts rather than trusting the file's own + /// other fields). + /// + /// The per-probe 4 px separation check runs first and independently + /// (against `fixtures`'s own caret-stop positions, recomputed fresh, never + /// against a self-declared distance field this file could carry + /// un-audited) so a probe moved too close to a stop fails with a specific, + /// on-topic message rather than the generic "recomputation disagrees" + /// one below it. + pub fn validate(&self, fixtures: &FixtureFile) -> Result<(), String> { + if self.min_separation_device_px != MIN_STOP_SEPARATION_DEVICE_PX { + return Err(format!( + "min_separation_device_px is {}, recipe §7 fixes it at {MIN_STOP_SEPARATION_DEVICE_PX}", + self.min_separation_device_px + )); + } + if self.edge_margin_device_px != EDGE_MARGIN_DEVICE_PX { + return Err(format!( + "edge_margin_device_px is {}, this crate fixes it at {EDGE_MARGIN_DEVICE_PX}", + self.edge_margin_device_px + )); + } + if self.fixtures.len() != EXPECTED_FIXTURE_IDS.len() { + return Err(format!( + "{} fixture probe tables recorded, recipe §2 names {} fixtures", + self.fixtures.len(), + EXPECTED_FIXTURE_IDS.len() + )); + } + for (i, expected_id) in EXPECTED_FIXTURE_IDS.iter().enumerate() { + if self.fixtures[i].fixture_id != *expected_id { + return Err(format!( + "fixtures[{i}] id is {:?}, recipe §2 names {expected_id:?}", + self.fixtures[i].fixture_id + )); + } + if self.fixtures[i].probes.is_empty() { + return Err(format!( + "{expected_id}: no probes recorded — every fixture must have probes (recipe §7)" + )); + } + } + + for ft in &self.fixtures { + let resolved = &fixtures + .fixtures + .iter() + .find(|f| f.id == ft.fixture_id) + .ok_or_else(|| { + format!( + "{}: not present in the supplied fixtures file", + ft.fixture_id + ) + })? + .resolved; + let stops = all_stop_device_points(resolved); + for p in &ft.probes { + let d = stops + .iter() + .map(|s| distance(&p.point, s)) + .fold(f64::INFINITY, f64::min); + if d < MIN_STOP_SEPARATION_DEVICE_PX { + return Err(format!( + "{}: probe {:?} (expects byte {} / {:?}) sits {d:.4} device px from its \ + nearest caret stop, below the {MIN_STOP_SEPARATION_DEVICE_PX} px floor \ + recipe §7 requires — a rounding tie could pass or fail it either way", + ft.fixture_id, p.point, p.expected_source_offset, p.expected_affinity + )); + } + } + } + + let (expected_fixtures, expected_dropped) = build_all(fixtures); + if self.fixtures != expected_fixtures { + return Err( + "hit-test probe table disagrees with a fresh recomputation from the supplied \ + fixtures — the committed file has drifted from the data it was built from" + .to_string(), + ); + } + if self.dropped != expected_dropped { + return Err(format!( + "dropped-probe list disagrees with a fresh recomputation: {} recorded, {} expected", + self.dropped.len(), + expected_dropped.len() + )); + } + + Ok(()) + } +} + +/// Loads `hittest_probes.json` and validates it against `fixtures` in one +/// call — the same "no public load-without-validating" discipline +/// `output::load_fixtures` establishes, for the same reason. +pub fn load_hittest_probes( + path: &Path, + fixtures: &FixtureFile, +) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read hit-test probes at {}: {e}", path.display()))?; + let file: HitTestProbeFile = serde_json::from_str(&text) + .map_err(|e| format!("failed to parse hit-test probes at {}: {e}", path.display()))?; + file.validate(fixtures).map_err(|e| { + format!( + "hit-test probes at {} failed validation: {e}", + path.display() + ) + })?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::{ + SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent, + }; + use crate::output; + use crate::types::{ + SpikeBoundingBox, SpikeCaretStop, SpikeCluster, SpikeClusterMap, SpikeGlyphStyle, + SpikeLanguageTag, SpikePositionedGlyph, SpikeProvenance, SpikeScriptTag, + SpikeShapedSegment, SpikeStaffSpace, SpikeTextAlign, SpikeTextDirection, + }; + + fn dummy_identity() -> SpikeTextShapingIdentity { + SpikeTextShapingIdentity { + faces: Vec::new(), + shaper: SpikeShaperId("rustybuzz".to_string()), + shaper_version: SemVerRecord { + major: 0, + minor: 20, + patch: 1, + }, + features: Vec::new(), + unicode_bidi: SpikeUnicodeComponent { + impl_name: "unicode-bidi".to_string(), + crate_version: "0.3.18".to_string(), + unicode_version: Some("16.0.0".to_string()), + }, + unicode_segmentation: SpikeUnicodeComponent { + impl_name: "unicode-segmentation".to_string(), + crate_version: "1.13.3".to_string(), + unicode_version: Some("17.0.0".to_string()), + }, + } + } + + fn dummy_provenance() -> SpikeProvenance { + SpikeProvenance { + source: crate::types::SpikeTypedObjectId { + discriminant: 0, + canonical_bytes_hex: "00".repeat(18), + }, + synthesis: None, + dependencies: Vec::new(), + stable_id: 0, + } + } + + /// Three graphemes at staff-space x = 0.0, 1.0, 1.02 — the last gap is + /// 0.02 staff space = 2 device px, so its interior midpoint sits 1 device + /// px from each of its bounding stops, below the 4 px floor. Used to + /// prove [`build_probe_table`] actually drops a too-close probe rather + /// than silently shrinking the margin (task requirement). + fn fixture_with_a_narrow_gap() -> SpikeResolvedText { + let seg = SpikeShapedSegment { + face: Some(0), + glyphs: vec![ + SpikePositionedGlyph { + glyph_id: 1, + offset: SpikePoint::new(0.0, 0.0), + transform: None, + }, + SpikePositionedGlyph { + glyph_id: 2, + offset: SpikePoint::new(1.0, 0.0), + transform: None, + }, + SpikePositionedGlyph { + glyph_id: 3, + offset: SpikePoint::new(1.02, 0.0), + transform: None, + }, + ], + source: 0..3, + direction: SpikeTextDirection::Ltr, + script: SpikeScriptTag("Latn".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + }; + let mk_cluster = |byte: u32, x: f64| SpikeCluster { + source: byte..byte + 1, + segment: 0, + glyph_indices: vec![byte], + resolved: true, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: byte, + position: SpikePoint::new(x, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }; + SpikeResolvedText { + provenance: dummy_provenance(), + text: "abc".to_string(), + shaping: dummy_identity(), + segments: vec![seg], + clusters: SpikeClusterMap { + clusters: vec![mk_cluster(0, 0.0), mk_cluster(1, 1.0), mk_cluster(2, 1.02)], + }, + bounds: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + reserved_box: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + origin: SpikePoint::new(0.0, 0.0), + align: SpikeTextAlign::Start, + style: SpikeGlyphStyle { rgba: 0x0000_00ff }, + layer: 0, + } + } + + #[test] + fn a_probe_within_the_floor_is_dropped_and_recorded_not_shrunk() { + let rt = fixture_with_a_narrow_gap(); + let (table, dropped) = build_probe_table("T-NARROW", &rt); + // Two interior intervals: byte0..byte1 (gap 100 device px, fine) and + // byte1..byte2 (gap 2 device px, must be dropped). + assert_eq!( + dropped.len(), + 1, + "exactly the narrow byte1..byte2 interval must be dropped, got {dropped:?}" + ); + assert_eq!(dropped[0].fixture_id, "T-NARROW"); + assert!( + dropped[0].nearest_stop_distance_device_px < MIN_STOP_SEPARATION_DEVICE_PX, + "recorded distance must actually be below the floor: {:?}", + dropped[0] + ); + assert!( + dropped[0].reason.contains("T-NARROW"), + "{}", + dropped[0].reason + ); + assert!( + dropped[0].reason.contains("below the 4"), + "{}", + dropped[0].reason + ); + // The surviving probes must never include the dropped point's + // interval — i.e. no probe expects byte 1 from an Interior kind at + // this narrow gap. (byte 1 still legitimately appears from the wide + // byte0..byte1 interval and is not itself excluded.) + let narrow_interval_survived = table.probes.iter().any(|p| { + p.kind == ProbeKind::Interior + && p.expected_source_offset == 1 + && p.source_grapheme.contains("byte 1..byte 2") + }); + assert!( + !narrow_interval_survived, + "the narrow interval's probe must not appear among the accepted probes: {:#?}", + table.probes + ); + } + + #[test] + fn a_wide_gap_produces_an_accepted_probe_at_its_midpoint() { + let rt = fixture_with_a_narrow_gap(); + let (table, _dropped) = build_probe_table("T-NARROW", &rt); + let wide = table + .probes + .iter() + .find(|p| p.kind == ProbeKind::Interior && p.expected_source_offset == 0) + .expect("the wide byte0..byte1 interval must survive"); + // origin (0,0) + relative (0.5, 0) staff -> device x = 50.0 + assert!((wide.point.x - 50.0).abs() < 1e-9, "{:?}", wide.point); + assert_eq!(wide.expected_affinity, SpikeCaretAffinity::Downstream); + } + + fn committed_fixtures() -> Option { + let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures.json"); + if !p.exists() { + return None; + } + Some(output::load_fixtures(&p).expect("committed fixtures.json must load and validate")) + } + + #[test] + fn a_freshly_built_probe_file_validates() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let file = build_hittest_probe_file(&fixtures); + file.validate(&fixtures).unwrap(); + } + + #[test] + fn every_committed_fixture_has_probes_and_zero_are_dropped() { + // Measured fact, restated as a check: every interior gap in the five + // committed fixtures is at least 31.9 device px (see this crate's + // generation output), comfortably clearing the 4 px floor, so a + // freshly built table drops nothing. If this ever starts dropping + // probes, that is itself a finding worth surfacing, not a silently + // absorbed change. + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let file = build_hittest_probe_file(&fixtures); + for ft in &file.fixtures { + assert!(!ft.probes.is_empty(), "{} has no probes", ft.fixture_id); + } + assert!( + file.dropped.is_empty(), + "expected zero drops on the committed fixture set, got {:#?}", + file.dropped + ); + } + + /// Mutation-first (task requirement): move one probe to within 4 px of a + /// stop and confirm `validate` rejects it with the specific + /// separation-floor message, not the generic recomputation-drift one. + #[test] + fn validate_kills_a_probe_moved_within_the_floor() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + let fa = &mut file.fixtures[0]; + assert_eq!(fa.fixture_id, "F-A"); + // F-A byte0's own Downstream stop sits at device x = 160.0 (relative + // x 0.0 + origin.x 1.599609375, times scale 100 = 159.9609375). + // Move the first accepted probe to 2 device px away from it — + // inside the 4 px floor. + fa.probes[0].point.x = 159.9609375 + 2.0; + let err = file.validate(&fixtures).unwrap_err(); + assert!(err.contains("below the 4"), "{err}"); + assert!(err.contains("device px"), "{err}"); + } + + /// Mutation-first (task requirement): change one expected byte offset + /// and confirm `validate` catches it via the recomputation-drift check. + #[test] + fn validate_kills_a_wrong_expected_offset() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + let fa = &mut file.fixtures[0]; + assert_eq!(fa.fixture_id, "F-A"); + fa.probes[0].expected_source_offset += 1; + let err = file.validate(&fixtures).unwrap_err(); + assert!( + err.contains("disagrees with a fresh recomputation"), + "{err}" + ); + } + + #[test] + fn validate_kills_a_wrong_min_separation_literal() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + file.min_separation_device_px = 1.0; + let err = file.validate(&fixtures).unwrap_err(); + assert!(err.contains("min_separation_device_px"), "{err}"); + } + + #[test] + fn validate_kills_a_wrong_edge_margin_literal() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + file.edge_margin_device_px = 5.0; + let err = file.validate(&fixtures).unwrap_err(); + assert!(err.contains("edge_margin_device_px"), "{err}"); + } + + #[test] + fn validate_kills_a_missing_fixture_table() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + file.fixtures.pop(); + let err = file.validate(&fixtures).unwrap_err(); + assert!(err.contains("fixture probe tables"), "{err}"); + } + + #[test] + fn validate_kills_an_empty_probe_list() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + file.fixtures[0].probes.clear(); + let err = file.validate(&fixtures).unwrap_err(); + assert!(err.contains("no probes recorded"), "{err}"); + } + + #[test] + fn validate_kills_an_extra_spurious_probe() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let mut file = build_hittest_probe_file(&fixtures); + let extra = file.fixtures[0].probes[0].clone(); + file.fixtures[0].probes.push(extra); + let err = file.validate(&fixtures).unwrap_err(); + assert!( + err.contains("disagrees with a fresh recomputation"), + "{err}" + ); + } + + #[test] + fn json_round_trip_preserves_validity() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let file = build_hittest_probe_file(&fixtures); + let json = serde_json::to_string_pretty(&file).unwrap(); + let reloaded: HitTestProbeFile = serde_json::from_str(&json).unwrap(); + reloaded.validate(&fixtures).unwrap(); + } + + #[test] + fn an_unknown_field_is_refused() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let file = build_hittest_probe_file(&fixtures); + let mut v: serde_json::Value = serde_json::to_value(&file).unwrap(); + v.as_object_mut() + .unwrap() + .insert("smuggled_field".into(), serde_json::json!(1)); + let err = serde_json::from_value::(v) + .unwrap_err() + .to_string(); + assert!(err.contains("smuggled_field"), "{err}"); + } + + /// Every probe's expected affinity is `Downstream` (this module's own + /// documented consequence of interval-floor semantics) — checked here so + /// the claim in the module doc comment cannot silently stop being true. + #[test] + fn every_probe_expects_downstream_affinity() { + let Some(fixtures) = committed_fixtures() else { + eprintln!("NOT RUN: fixtures.json absent"); + return; + }; + let file = build_hittest_probe_file(&fixtures); + for ft in &file.fixtures { + for p in &ft.probes { + assert_eq!( + p.expected_affinity, + SpikeCaretAffinity::Downstream, + "{}: probe {:?} expects a non-Downstream affinity", + ft.fixture_id, + p + ); + } + } + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/identity.rs b/spikes/editor-toolkit/round2-textkit/src/identity.rs new file mode 100644 index 0000000..2f994b8 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/identity.rs @@ -0,0 +1,233 @@ +//! `SpikeTextShapingIdentity` — every pin-9 field (recipe §6), and the local +//! stand-ins §3E's `TextShapingIdentity`/`TextFaceIdentity` name that this +//! crate does not yet have real types for. +//! +//! Two fields deliberately deviate from §3E's stated shape, both named in +//! `crate::findings` rather than taken silently: +//! +//! * [`SpikeTextFaceIdentity::version`] is `Option`, not +//! `Option` (`crate::findings::W3_F1`) — see the field's doc +//! comment for the two measured version strings that motivate it. +//! * [`SpikeTextShapingIdentity`] carries `unicode_bidi` and +//! `unicode_segmentation` as two separate fields rather than one +//! `unicode_version` (`crate::findings::W3_F2`) — see the type's doc +//! comment. +//! +//! `shaper_version` reuses the *real* `epiphany_layout_ir::glyph::SemVer` +//! unchanged: `rustybuzz`'s version string, `"0.20.1"`, **is** valid semver, +//! so nothing is lost by typing it that way — the lossiness W3-F1 names is +//! specific to font `name`-table version strings, not every version-shaped +//! field in the identity. + +use epiphany_layout_ir::{FontId, SemVer}; +use serde::{Deserialize, Serialize}; + +/// Stand-in for §3E's `ShaperId`: which shaping engine produced the run. +/// One value in this crate — `rustybuzz` — but kept as a named type (rather +/// than a bare `String` field) because §3E treats "which shaper" and "which +/// version" as a typed pair, not two interchangeable strings. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeShaperId(pub String); + +/// Stand-in for §3E's `AxisTag`: a 4-byte OpenType variation-axis tag +/// (`"wght"`, `"ital"`, ...). Unused by both faces in this recipe (§6: "both +/// faces measured non-variable"), so no fixture ever populates a non-empty +/// `variations` list — but the type is defined, not stubbed away, since a +/// static-font recipe proving the field can stay empty is a different claim +/// from the field not existing. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeAxisTag(pub String); + +/// Stand-in for §3E's `FaceSynthesis`: whether a renderer applied a +/// synthetic weight/slant because the face itself lacks the requested style. +/// Both faces in this recipe are used at their native style, so every +/// fixture records `None` (§6: "no synthetic weight or slant is applied"). +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SpikeFaceSynthesis { + None, + Bold, + Italic, + BoldItalic, +} + +/// Stand-in for §3E's `FeatureSetting`: one explicit OpenType feature +/// override (tag + value). Every fixture in this recipe applies the empty +/// set (§6: "the fixtures apply no explicit feature settings; rustybuzz's +/// default horizontal feature set governs"), so no fixture ever populates +/// this — but, as with `SpikeAxisTag`, the type exists so "empty" is a +/// measured fact about the fixtures rather than an artifact of the type +/// being unable to hold anything else. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeFeatureSetting { + pub tag: String, + pub value: u32, +} + +/// One face in the declared resolution chain (recipe §1 table), a stand-in +/// for §3E's `TextFaceIdentity` with W3-F1 already corrected. +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeTextFaceIdentity { + /// Human-facing name — diagnostic only, never an input (name-table id 1). + pub family: String, + /// **`crate::findings::W3_F1`**: the raw name-table id-5 string, not a + /// parsed `SemVer`. This recipe's two faces measure + /// `"Version 2.501;PS 2.501;ffdkm 0.1"` and `"Version 2.1.5"`; only the + /// second parses as semver, and only after stripping the `"Version "` + /// prefix a real-world font is not obligated to use. Typing this field + /// `SemVer` would force either a lossy parse or an empty field on a face + /// that plainly declares a version. + pub version: Option, + /// SHA-256 over the exact font file's bytes — the identity that matters + /// (recipe §1: "the content hash *is* the identity"). + pub file_hash: [u8; 32], + /// Which face within a collection (`.ttc`/`.otc`); `0` for both faces + /// here (neither file is a collection). + pub face_index: u32, + pub variations: Vec<(SpikeAxisTag, f64)>, + pub synthesis: SpikeFaceSynthesis, +} + +/// One Unicode-backed component's version identity — recorded once for the +/// bidi implementation and once for the segmentation implementation +/// (`crate::findings::W3_F2`). `unicode_version` is read from the crate's +/// own exported constant, never hand-typed, so a dependency bump that moves +/// the Unicode Character Database version is reflected automatically rather +/// than silently going stale in a literal. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeUnicodeComponent { + /// Which crate implements this component (`"unicode-bidi"` / + /// `"unicode-segmentation"`). + pub impl_name: String, + /// That crate's own version, from `Cargo.toml` / `CARGO_PKG_VERSION`. + pub crate_version: String, + /// The Unicode Character Database version the crate documents itself as + /// implementing (`major.minor.patch`), read from the crate's exported + /// `UNICODE_VERSION` constant — never guessed, and recorded as absent + /// rather than invented if a future dependency swap does not expose one. + pub unicode_version: Option, +} + +/// Every input that determines the ink (§3E `TextShapingIdentity`, +/// recipe §6) — with `crate::findings::W3_F2` already taken: the single +/// `unicode_version: UnicodeVersion` field §3E specifies is replaced with +/// `unicode_bidi` and `unicode_segmentation`, because pin 9 requires the +/// segmentation implementation and its Unicode-data version to be named +/// *separately* from the bidi implementation's, and one field cannot +/// honestly do both without asserting the two agree. **This crate's measured +/// values do not agree** (`unicode-bidi` 0.3.18 documents UAX44 database +/// 16.0.0; `unicode-segmentation` 1.13.3 documents 17.0.0) — see +/// `FIXTURES_SUMMARY.md`'s identity section for the as-measured record. That +/// disagreement is reported, never reconciled (recipe §6: "the report prints +/// both"). +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeTextShapingIdentity { + /// The ordered fallback chain, tried in order (recipe §1). + pub faces: Vec, + pub shaper: SpikeShaperId, + pub shaper_version: SemVerRecord, + /// The OpenType feature set applied, in canonical order. Empty on every + /// fixture (see `SpikeFeatureSetting`'s doc comment). + pub features: Vec, + pub unicode_bidi: SpikeUnicodeComponent, + pub unicode_segmentation: SpikeUnicodeComponent, +} + +/// Serializable mirror of the real `epiphany_layout_ir::glyph::SemVer` +/// (which does not derive `serde::Serialize`/`Deserialize` — see +/// `crate::types`'s note on why the boundary mirrors exist at all). +#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemVerRecord { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl From for SemVerRecord { + fn from(v: SemVer) -> Self { + SemVerRecord { + major: v.major, + minor: v.minor, + patch: v.patch, + } + } +} + +/// The exact `rustybuzz` version this identity is pinned to (matches +/// `Cargo.toml`'s `=0.20.1` — an exact pin, not a caret range, because a +/// shaper upgrade moves the ink (W3 §3E cost 3) and this recipe's precommitted +/// glyph ids/counts (§4) describe this exact version). +pub fn rustybuzz_identity() -> (SpikeShaperId, SemVerRecord) { + ( + SpikeShaperId("rustybuzz".to_string()), + SemVerRecord::from(SemVer::new(0, 20, 1)), + ) +} + +/// Reads `unicode-bidi`'s own exported version constants — never hand-typed. +/// `unicode_bidi::UNICODE_VERSION` is `(u64, u64, u64)`; `CARGO_PKG_VERSION` +/// values for the two crates are captured at *this crate's* compile time via +/// its own `Cargo.lock`-pinned dependency, which is exactly the version the +/// generator actually links against. +pub fn unicode_bidi_component() -> SpikeUnicodeComponent { + let (maj, min, patch) = unicode_bidi::UNICODE_VERSION; + SpikeUnicodeComponent { + impl_name: "unicode-bidi".to_string(), + crate_version: unicode_bidi_crate_version(), + unicode_version: Some(format!("{maj}.{min}.{patch}")), + } +} + +pub fn unicode_segmentation_component() -> SpikeUnicodeComponent { + let (maj, min, patch) = unicode_segmentation::UNICODE_VERSION; + SpikeUnicodeComponent { + impl_name: "unicode-segmentation".to_string(), + crate_version: unicode_segmentation_crate_version(), + unicode_version: Some(format!("{maj}.{min}.{patch}")), + } +} + +/// `unicode-bidi` does not export its own `CARGO_PKG_VERSION` as a crate +/// constant, so this reads it the only reproducible way available at build +/// time: from the same lockfile-pinned version this crate's `Cargo.toml` +/// declares (`=0.3.18`). Hand-typing this the same way `SemVer::new(0, 20, +/// 1)` above hand-types the shaper version, rather than a build-script probe, +/// keeps this crate's own dependency footprint unchanged — and a version +/// drift is caught structurally: `Cargo.toml` pins `=0.3.18` exactly, so a +/// mismatched lockfile fails the build before this string could go stale. +fn unicode_bidi_crate_version() -> String { + "0.3.18".to_string() +} + +fn unicode_segmentation_crate_version() -> String { + "1.13.3".to_string() +} + +/// Constructs the identity for the two faces this recipe declares (recipe +/// §1), given their already-resolved [`SpikeTextFaceIdentity`] records. +pub fn build_shaping_identity(faces: Vec) -> SpikeTextShapingIdentity { + let (shaper, shaper_version) = rustybuzz_identity(); + SpikeTextShapingIdentity { + faces, + shaper, + shaper_version, + features: Vec::new(), + unicode_bidi: unicode_bidi_component(), + unicode_segmentation: unicode_segmentation_component(), + } +} + +/// Placeholder for `FontId` re-export convenience at call sites that want +/// the real family-name type without importing `epiphany_layout_ir` +/// directly. Not used for serialization (see [`SpikeTextFaceIdentity::family`], +/// which is a plain `String` since `FontId` does not derive serde traits). +pub fn font_family_name(id: &FontId) -> String { + id.0.to_string() +} diff --git a/spikes/editor-toolkit/round2-textkit/src/invariants.rs b/spikes/editor-toolkit/round2-textkit/src/invariants.rs new file mode 100644 index 0000000..f1e26b1 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/invariants.rs @@ -0,0 +1,634 @@ +//! The five W3 §3E invariants (`spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` +//! lines 472-487), **asserted**, not merely honoured (task requirement, +//! recipe §5). Every checker here returns `Result<(), String>` naming the +//! fixture and the invariant, so a caller can either `.expect()` it (the +//! generator does, and a violation is a hard failure) or assert it rejects a +//! deliberately broken case (the mutation tests in this module's `tests` +//! submodule do that, per the task's mutation-first requirement). + +use crate::types::{SpikeCluster, SpikeResolvedText, SpikeShapedSegment}; + +/// Invariant 1: every `ClusterMap` offset and every `ShapedSegment::source` +/// bound is a valid UTF-8 boundary in `text`. +pub fn assert_utf8_boundaries(fixture_id: &str, rt: &SpikeResolvedText) -> Result<(), String> { + let check = |label: &str, offset: u32| -> Result<(), String> { + let o = offset as usize; + if o > rt.text.len() || !rt.text.is_char_boundary(o) { + return Err(format!( + "{fixture_id}: invariant 1 (UTF-8 boundaries) violated: {label} offset {o} is not \ + a char boundary in a {}-byte string", + rt.text.len() + )); + } + Ok(()) + }; + for (i, seg) in rt.segments.iter().enumerate() { + check(&format!("segment[{i}].source.start"), seg.source.start)?; + check(&format!("segment[{i}].source.end"), seg.source.end)?; + } + for (i, c) in rt.clusters.clusters.iter().enumerate() { + check(&format!("cluster[{i}].source.start"), c.source.start)?; + check(&format!("cluster[{i}].source.end"), c.source.end)?; + for (j, stop) in c.caret_stops.iter().enumerate() { + check( + &format!("cluster[{i}].caret_stops[{j}].source_offset"), + stop.source_offset, + )?; + } + } + Ok(()) +} + +/// Invariant 2: segment source ranges cover the whole string, totally and +/// without invalid overlap, in **logical** order (visual order may differ +/// under bidi — this crate stores `segments` in logical order throughout, +/// see `crate::shape`). +pub fn assert_segments_partition_totally( + fixture_id: &str, + rt: &SpikeResolvedText, +) -> Result<(), String> { + let mut expected_next: u32 = 0; + for (i, seg) in rt.segments.iter().enumerate() { + if seg.source.start != expected_next { + return Err(format!( + "{fixture_id}: invariant 2 (total partition) violated: segment[{i}] starts at byte \ + {}, but the partition left off at {expected_next} — a gap or overlap", + seg.source.start + )); + } + if seg.source.end < seg.source.start { + return Err(format!( + "{fixture_id}: invariant 2 violated: segment[{i}] has end {} < start {}", + seg.source.end, seg.source.start + )); + } + expected_next = seg.source.end; + } + let total = rt.text.len() as u32; + if expected_next != total { + return Err(format!( + "{fixture_id}: invariant 2 (total partition) violated: segments cover up to byte \ + {expected_next}, but the string is {total} bytes" + )); + } + Ok(()) +} + +/// Invariant 3: every cluster carries its source range (structural — always +/// present), its glyph indices, and its caret stops, **each stop with a +/// geometric position and a bidi affinity** (structural — the type has no +/// way to omit either). What is *not* structurally guaranteed, and is +/// checked here: every cluster has **at least one** caret stop (a cluster +/// with none would silently have no caret contract at all), and every glyph +/// index a resolved cluster names actually exists in its segment's glyph +/// list. +pub fn assert_clusters_carry_required_fields( + fixture_id: &str, + rt: &SpikeResolvedText, +) -> Result<(), String> { + for (i, c) in rt.clusters.clusters.iter().enumerate() { + if c.caret_stops.is_empty() { + return Err(format!( + "{fixture_id}: invariant 3 violated: cluster[{i}] ({:?}) carries no caret stops", + c.source + )); + } + if c.segment >= rt.segments.len() { + return Err(format!( + "{fixture_id}: invariant 3 violated: cluster[{i}] names segment {} but there are \ + only {} segments", + c.segment, + rt.segments.len() + )); + } + let seg = &rt.segments[c.segment]; + for &gi in &c.glyph_indices { + if gi as usize >= seg.glyphs.len() { + return Err(format!( + "{fixture_id}: invariant 3 violated: cluster[{i}] names glyph index {gi} in \ + segment {}, which has only {} glyphs", + c.segment, + seg.glyphs.len() + )); + } + } + } + Ok(()) +} + +/// Invariant 4: a cluster that shaping could not resolve is represented +/// **diagnostically** (an explicit unresolved marker), never dropped. +/// `expect_unresolved` names whether *this* fixture is expected to contain +/// one (only F-C does); when it is, this also checks the marker's segment +/// carries no glyphs and no face (`crate::findings::W3_F3`) — a "resolved: +/// false" flag with glyphs attached would be a marker that lies. +pub fn assert_unresolved_clusters_are_diagnostic( + fixture_id: &str, + rt: &SpikeResolvedText, + expect_unresolved: bool, +) -> Result<(), String> { + let unresolved: Vec<&SpikeCluster> = rt + .clusters + .clusters + .iter() + .filter(|c| !c.resolved) + .collect(); + if expect_unresolved && unresolved.is_empty() { + return Err(format!( + "{fixture_id}: invariant 4 violated: this fixture is expected to carry an unresolved \ + cluster, but none is present — an uncovered codepoint must never be silently dropped" + )); + } + if !expect_unresolved && !unresolved.is_empty() { + return Err(format!( + "{fixture_id}: invariant 4 check mismatch: this fixture is not expected to carry an \ + unresolved cluster, but {} are present", + unresolved.len() + )); + } + for c in &unresolved { + if !c.glyph_indices.is_empty() { + return Err(format!( + "{fixture_id}: invariant 4 violated: unresolved cluster {:?} carries glyph \ + indices — an unresolved marker that also claims glyphs is not diagnostic, it is \ + a silent substitution", + c.source + )); + } + let seg = &rt.segments[c.segment]; + if seg.face.is_some() { + return Err(format!( + "{fixture_id}: invariant 4 violated: unresolved cluster {:?} belongs to a segment \ + that names a face ({:?}) — an unresolved cluster's segment must be the \ + `face: None` marker (crate::findings::W3_F3)", + c.source, seg.face + )); + } + } + Ok(()) +} + +/// Invariant 5: positions are staff-space, y-up, quantized on the +/// `crate::QUANTIZE_GRID` (`1/1024`) grid — the same convention as glyph +/// positions, so text quantization is not a second convention. +pub fn assert_positions_quantized(fixture_id: &str, rt: &SpikeResolvedText) -> Result<(), String> { + let check = |label: String, x: f64, y: f64| -> Result<(), String> { + if !crate::quantize::is_on_grid(x) || !crate::quantize::is_on_grid(y) { + return Err(format!( + "{fixture_id}: invariant 5 (1/1024 grid) violated: {label} at ({x}, {y}) is not \ + on the grid" + )); + } + Ok(()) + }; + for (i, seg) in rt.segments.iter().enumerate() { + for (j, g) in seg.glyphs.iter().enumerate() { + check( + format!("segment[{i}].glyphs[{j}].offset"), + g.offset.x, + g.offset.y, + )?; + } + } + for (i, c) in rt.clusters.clusters.iter().enumerate() { + for (j, s) in c.caret_stops.iter().enumerate() { + check( + format!("cluster[{i}].caret_stops[{j}].position"), + s.position.x, + s.position.y, + )?; + } + } + check("origin".to_string(), rt.origin.x, rt.origin.y)?; + check( + "bounds.left/bottom".to_string(), + rt.bounds.left, + rt.bounds.bottom, + )?; + check( + "bounds.right/top".to_string(), + rt.bounds.right, + rt.bounds.top, + )?; + check( + "reserved_box.left/bottom".to_string(), + rt.reserved_box.left, + rt.reserved_box.bottom, + )?; + check( + "reserved_box.right/top".to_string(), + rt.reserved_box.right, + rt.reserved_box.top, + )?; + Ok(()) +} + +/// Extra, recipe-specific check (§7, F-D): a direction-boundary offset +/// carries two caret stops with **different affinities at different +/// geometric positions** — not merely two stops that happen to coincide, +/// which would satisfy invariant 3's cardinality but defeat the reason +/// affinity exists. +/// +/// **Applied to F-D and deliberately NOT to F-C** (recipe §12, W3-F4). F-C's +/// byte 5 is also a direction boundary — Latin LTR into Arabic RTL — but its +/// downstream side is an *unresolved, zero-advance* cluster, so both +/// affinities necessarily land on the identical position (measured: staff-space +/// x = 3.130859375 for both). Enforcing distinctness there would demand a +/// difference that cannot exist, so the exemption is correct; it is written +/// down because an unstated exemption is indistinguishable from having +/// forgotten the case. The caller decides which boundaries to check, and the +/// recipe records why. +pub fn assert_direction_boundary_stops_differ( + fixture_id: &str, + rt: &SpikeResolvedText, + boundary_offset: u32, +) -> Result<(), String> { + let stops: Vec<_> = rt + .clusters + .clusters + .iter() + .flat_map(|c| c.caret_stops.iter()) + .filter(|s| s.source_offset == boundary_offset) + .collect(); + if stops.len() != 2 { + return Err(format!( + "{fixture_id}: byte {boundary_offset} must carry exactly 2 caret stops (one per \ + affinity), found {}", + stops.len() + )); + } + let (a, b) = (stops[0], stops[1]); + if a.affinity == b.affinity { + return Err(format!( + "{fixture_id}: byte {boundary_offset}'s two stops share affinity {:?} instead of one \ + each", + a.affinity + )); + } + let dx = (a.position.x - b.position.x).abs(); + let dy = (a.position.y - b.position.y).abs(); + if dx < 1e-9 && dy < 1e-9 { + return Err(format!( + "{fixture_id}: byte {boundary_offset}'s two stops sit at the identical position \ + {:?} — affinity exists precisely so these differ", + a.position + )); + } + Ok(()) +} + +/// Runs invariants 1, 2, 3, 5 (universal) plus invariant 4 (parameterized by +/// whether this fixture is expected to carry an unresolved cluster). Every +/// fixture in this recipe calls this; F-D additionally calls +/// [`assert_direction_boundary_stops_differ`] at its two direction +/// boundaries (see `crate::fixtures`). +pub fn assert_all(fixture_id: &str, rt: &SpikeResolvedText, expect_unresolved: bool) { + assert_utf8_boundaries(fixture_id, rt).expect("invariant 1"); + assert_segments_partition_totally(fixture_id, rt).expect("invariant 2"); + assert_clusters_carry_required_fields(fixture_id, rt).expect("invariant 3"); + assert_unresolved_clusters_are_diagnostic(fixture_id, rt, expect_unresolved) + .expect("invariant 4"); + assert_positions_quantized(fixture_id, rt).expect("invariant 5"); +} + +/// Exposed for `crate::output`'s validator and for cross-crate tests that +/// want to confirm a `SpikeShapedSegment`'s face/glyph coherence without +/// depending on `crate::shape` directly. +pub fn segment_is_unresolved_marker(seg: &SpikeShapedSegment) -> bool { + seg.face.is_none() && seg.glyphs.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::{ + SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent, + }; + use crate::types::{ + SpikeBoundingBox, SpikeCaretAffinity, SpikeCaretStop, SpikeCluster, SpikeClusterMap, + SpikeGlyphStyle, SpikeLanguageTag, SpikePoint, SpikePositionedGlyph, SpikeProvenance, + SpikeResolvedText, SpikeScriptTag, SpikeShapedSegment, SpikeStaffSpace, SpikeTextAlign, + SpikeTextDirection, + }; + + fn dummy_identity() -> SpikeTextShapingIdentity { + SpikeTextShapingIdentity { + faces: Vec::new(), + shaper: SpikeShaperId("rustybuzz".to_string()), + shaper_version: SemVerRecord { + major: 0, + minor: 20, + patch: 1, + }, + features: Vec::new(), + unicode_bidi: SpikeUnicodeComponent { + impl_name: "unicode-bidi".to_string(), + crate_version: "0.3.18".to_string(), + unicode_version: Some("16.0.0".to_string()), + }, + unicode_segmentation: SpikeUnicodeComponent { + impl_name: "unicode-segmentation".to_string(), + crate_version: "1.13.3".to_string(), + unicode_version: Some("17.0.0".to_string()), + }, + } + } + + fn dummy_provenance() -> SpikeProvenance { + SpikeProvenance { + source: crate::types::SpikeTypedObjectId { + discriminant: 0, + canonical_bytes_hex: "00".repeat(18), + }, + synthesis: None, + dependencies: Vec::new(), + stable_id: 0, + } + } + + /// A minimal, valid two-byte "ab" run: one segment, one cluster per + /// grapheme, both invariants-satisfying. Every mutation test below + /// starts from this and breaks exactly one thing. + fn minimal_valid() -> SpikeResolvedText { + SpikeResolvedText { + provenance: dummy_provenance(), + text: "ab".to_string(), + shaping: dummy_identity(), + segments: vec![SpikeShapedSegment { + face: Some(0), + glyphs: vec![ + SpikePositionedGlyph { + glyph_id: 1, + offset: SpikePoint::new(0.0, 0.0), + transform: None, + }, + SpikePositionedGlyph { + glyph_id: 2, + offset: SpikePoint::new(0.5, 0.0), + transform: None, + }, + ], + source: 0..2, + direction: SpikeTextDirection::Ltr, + script: SpikeScriptTag("Latn".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + }], + clusters: SpikeClusterMap { + clusters: vec![ + SpikeCluster { + source: 0..1, + segment: 0, + glyph_indices: vec![0], + resolved: true, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: 0, + position: SpikePoint::new(0.0, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }, + SpikeCluster { + source: 1..2, + segment: 0, + glyph_indices: vec![1], + resolved: true, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: 1, + position: SpikePoint::new(0.5, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }, + ], + }, + bounds: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + reserved_box: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + // Recipe §3's nominal `1.6` is not itself on the `1/1024` grid + // (see `crate::quantize`'s doc comment) — this test fixture uses + // the honestly-quantized value, `1638/1024`, exactly as + // `crate::fixtures::build_fixture` does for the real run. + origin: SpikePoint::new(1638.0 / 1024.0, 0.0), + align: SpikeTextAlign::Start, + style: SpikeGlyphStyle { rgba: 0x0000_00ff }, + layer: 0, + } + } + + #[test] + fn minimal_valid_passes_every_invariant() { + let rt = minimal_valid(); + assert_utf8_boundaries("T", &rt).unwrap(); + assert_segments_partition_totally("T", &rt).unwrap(); + assert_clusters_carry_required_fields("T", &rt).unwrap(); + assert_unresolved_clusters_are_diagnostic("T", &rt, false).unwrap(); + assert_positions_quantized("T", &rt).unwrap(); + } + + // ---- Invariant 1: UTF-8 boundary mutations ---- + + #[test] + fn invariant_1_kills_a_mid_char_segment_bound() { + let mut rt = minimal_valid(); + rt.text = "é".to_string(); // 2-byte UTF-8, so offset 1 is mid-char + rt.segments[0].source = 0..1; // deliberately not a char boundary + let err = assert_utf8_boundaries("T", &rt).unwrap_err(); + assert!(err.contains("invariant 1"), "{err}"); + } + + #[test] + fn invariant_1_kills_an_out_of_range_cluster_offset() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].caret_stops[0].source_offset = 99; + let err = assert_utf8_boundaries("T", &rt).unwrap_err(); + assert!(err.contains("invariant 1"), "{err}"); + } + + // ---- Invariant 2: partition mutations ---- + + #[test] + fn invariant_2_kills_a_gap_between_segments() { + let mut rt = minimal_valid(); + rt.segments.push(SpikeShapedSegment { + face: Some(0), + glyphs: vec![], + source: 2..2, // no-op segment, but let's actually make a real gap instead + direction: SpikeTextDirection::Ltr, + script: SpikeScriptTag("Latn".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + }); + // Force a genuine gap: first segment now claims to stop at byte 1, + // leaving byte 1..2 uncovered. + rt.segments[0].source = 0..1; + rt.segments[1].source = 2..2; + let err = assert_segments_partition_totally("T", &rt).unwrap_err(); + assert!(err.contains("invariant 2"), "{err}"); + } + + #[test] + fn invariant_2_kills_coverage_stopping_short_of_the_string_end() { + let mut rt = minimal_valid(); + rt.segments[0].source = 0..1; // string is 2 bytes; this covers only 1 + let err = assert_segments_partition_totally("T", &rt).unwrap_err(); + assert!(err.contains("invariant 2"), "{err}"); + } + + // ---- Invariant 3: cluster field mutations ---- + + #[test] + fn invariant_3_kills_a_cluster_with_no_caret_stops() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].caret_stops.clear(); + let err = assert_clusters_carry_required_fields("T", &rt).unwrap_err(); + assert!(err.contains("invariant 3"), "{err}"); + } + + #[test] + fn invariant_3_kills_a_glyph_index_out_of_range() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].glyph_indices = vec![99]; + let err = assert_clusters_carry_required_fields("T", &rt).unwrap_err(); + assert!(err.contains("invariant 3"), "{err}"); + } + + // ---- Invariant 4: unresolved-marker mutations ---- + + #[test] + fn invariant_4_kills_a_dropped_unresolved_cluster() { + let rt = minimal_valid(); + // This fixture carries zero unresolved clusters; asking the checker + // to require one (as F-C's caller does) must fail. + let err = assert_unresolved_clusters_are_diagnostic("T", &rt, true).unwrap_err(); + assert!(err.contains("invariant 4"), "{err}"); + } + + #[test] + fn invariant_4_kills_an_unresolved_cluster_that_still_carries_glyphs() { + let mut rt = minimal_valid(); + rt.segments.push(SpikeShapedSegment { + face: None, + glyphs: vec![], + source: 2..2, + direction: SpikeTextDirection::Ltr, + script: SpikeScriptTag("Zzzz".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + }); + rt.clusters.clusters.push(SpikeCluster { + source: 2..2, + segment: 1, + glyph_indices: vec![0], // a marker that also claims a glyph — invalid + resolved: false, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: 2, + position: SpikePoint::new(1.0, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }); + let err = assert_unresolved_clusters_are_diagnostic("T", &rt, true).unwrap_err(); + assert!(err.contains("invariant 4"), "{err}"); + } + + #[test] + fn invariant_4_kills_an_unresolved_marker_whose_segment_names_a_face() { + let mut rt = minimal_valid(); + rt.segments.push(SpikeShapedSegment { + face: Some(0), // should be None for an unresolved marker + glyphs: vec![], + source: 2..2, + direction: SpikeTextDirection::Ltr, + script: SpikeScriptTag("Zzzz".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + }); + rt.clusters.clusters.push(SpikeCluster { + source: 2..2, + segment: 1, + glyph_indices: vec![], + resolved: false, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: 2, + position: SpikePoint::new(1.0, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }); + let err = assert_unresolved_clusters_are_diagnostic("T", &rt, true).unwrap_err(); + assert!(err.contains("invariant 4"), "{err}"); + } + + // ---- Invariant 5: quantization mutations ---- + + #[test] + fn invariant_5_kills_an_off_grid_glyph_offset() { + let mut rt = minimal_valid(); + rt.segments[0].glyphs[0].offset.x = 0.1234567; // not a multiple of 1/1024 + let err = assert_positions_quantized("T", &rt).unwrap_err(); + assert!(err.contains("invariant 5"), "{err}"); + } + + #[test] + fn invariant_5_kills_an_off_grid_caret_stop() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].caret_stops[0].position.y = 0.0009; + let err = assert_positions_quantized("T", &rt).unwrap_err(); + assert!(err.contains("invariant 5"), "{err}"); + } + + // ---- Direction-boundary distinctness ---- + + #[test] + fn boundary_check_kills_two_stops_at_the_same_position() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].caret_stops.push(SpikeCaretStop { + source_offset: 0, + position: SpikePoint::new(0.0, 0.0), // identical to the existing stop at offset 0 + affinity: SpikeCaretAffinity::Upstream, + }); + let err = assert_direction_boundary_stops_differ("T", &rt, 0).unwrap_err(); + assert!(err.contains("identical position"), "{err}"); + } + + #[test] + fn boundary_check_kills_two_stops_sharing_one_affinity() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].caret_stops.push(SpikeCaretStop { + source_offset: 0, + position: SpikePoint::new(9.0, 0.0), // different position... + affinity: SpikeCaretAffinity::Downstream, // ...but same affinity as the existing one + }); + let err = assert_direction_boundary_stops_differ("T", &rt, 0).unwrap_err(); + assert!(err.contains("share affinity"), "{err}"); + } + + #[test] + fn boundary_check_kills_a_missing_second_stop() { + let rt = minimal_valid(); + // Only one stop exists at offset 0. + let err = assert_direction_boundary_stops_differ("T", &rt, 0).unwrap_err(); + assert!(err.contains("exactly 2"), "{err}"); + } + + #[test] + fn boundary_check_accepts_two_distinct_stops() { + let mut rt = minimal_valid(); + rt.clusters.clusters[0].caret_stops.push(SpikeCaretStop { + source_offset: 0, + position: SpikePoint::new(-1.0, 0.0), + affinity: SpikeCaretAffinity::Upstream, + }); + assert_direction_boundary_stops_differ("T", &rt, 0).unwrap(); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/lib.rs b/spikes/editor-toolkit/round2-textkit/src/lib.rs new file mode 100644 index 0000000..c723a55 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/lib.rs @@ -0,0 +1,162 @@ +//! # round2-textkit — Packet 2A-i: the candidate-neutral text fixture set. +//! +//! **Nothing in this crate is canonical, and nothing here pre-empts the +//! `.tex` amendment.** `spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` (W3) §3E and §4 +//! propose `ResolvedText` as a fourth resolved-layout primitive; that +//! amendment has not landed. Every type in [`types`] is a **non-canonical +//! spike mirror**, prefixed `Spike*`, that exists only so this crate — the +//! shape's first consumer (`ROUND2_TEXT_RECIPE.md` intro) — can exercise it +//! before the real thing exists. A type here proving awkward to populate is +//! itself the deliverable: pin 8 routes that awkwardness back to the +//! amendment as a finding (see [`findings`]). +//! +//! ## What this crate does +//! +//! 1. Resolves the two declared faces from an explicit path list, hashing +//! their bytes and failing loudly on any mismatch against the recipe's +//! recorded hashes ([`faces`]). +//! 2. Records every pin-9 shaping-identity field, including the two +//! separately-versioned Unicode components W3-F2 names ([`identity`]). +//! 3. Itemizes, shapes, and clusters the five committed fixture strings — +//! `unicode-bidi` for bidi itemization, `rustybuzz` for shaping in the +//! resolved face, `unicode-segmentation` for grapheme caret stops +//! ([`shape`], [`fixtures`]). +//! 4. Asserts every W3 §5 invariant on the result, in code, naming the +//! fixture and the invariant on failure ([`invariants`]). +//! 5. Writes `fixtures.json` and `FIXTURES_SUMMARY.md`, and exposes +//! [`output::FixtureFile::validate`] so a later packet can check the +//! loaded fixtures against literals restated here rather than against the +//! file's own other fields — the same discipline +//! `round1-candidates/harness`'s `OracleFile::validate` uses. +//! 6. Builds and validates the recipe §7 closing-paragraph hit-test *probe +//! table* (`(device point) -> (byte offset, affinity)`), from an +//! already-loaded, already-valid `fixtures.json` — [`hittest`], +//! `bin/generate_hittest.rs`, `hittest_probes.json`. This is +//! candidate-testing apparatus, not part of the candidate-neutral §3E +//! mirror, which is why it is a sibling file rather than extra fields on +//! `fixtures.json`'s own records. +//! +//! ## What this crate does not do +//! +//! It renders nothing. The SVG reference emitter (recipe §9) and the bounded +//! visual differential (recipe §10/§11) are separate packets (`round2-diff` +//! is the differential, built in parallel). + +pub mod a11y; +pub mod faces; +pub mod fixtures; +pub mod hittest; +pub mod identity; +pub mod invariants; +pub mod output; +pub mod quantize; +pub mod shape; +pub mod types; + +/// Em size, fixed for every fixture (recipe §3, **amended from the +/// original `0.64`**): `1.28` staff spaces = 128 device px at `scale = 100`. +/// +/// The recipe originally pinned `0.64` (64 device px). That was wrong: +/// measured against TeX Gyre Pagella at 64 px em, the mid-height stem width +/// of a lowercase vertical (`l`/`i`/`n`, 84 font units at `upem = 1000`) is +/// 5.4 device px — inside `round2-diff`'s `EDGE_BAND_PX = 2` on *each* side +/// of an edge, i.e. a stem narrow enough that the whole stroke sits in the +/// antialiased band D1 is defined to be blind to. Doubling to `1.28` (128 px +/// em) doubles every stem to ~10.8 px (round strokes like `o`/`e` to ~12 px), +/// which leaves interior pixels D1 can actually decide. All five fixtures +/// still clear the 1920 px target at this size — measured device right edges +/// from the generated `fixtures.json`: F-A 1715.1, F-D 1290.7, F-E 1023.0, +/// F-B 809.1, F-C 597.0. F-A is the longest and clears the frame by ~205 px. +pub const EM_SIZE_STAFF_SPACE: f64 = 1.28; + +/// The run's baseline origin in staff space (recipe §3): `1638/1024 = +/// 1.599609375`, i.e. device `(159.9609375, 540)` under `scale = 100` device +/// px per staff space, `target = 1920x1080`. +/// +/// **Stated on the grid, not rounded onto it.** Invariant 5 requires every +/// position to sit exactly on the `1/1024` staff-space grid, and the recipe's +/// original `1.6` is not representable there (`1.6 × 1024 = 1638.4`). The +/// value used to be `1.6` and [`quantize::quantize_component`] moved it onto +/// the grid on the way past — which worked, and left the *stated* constant in +/// violation of the *stated* invariant. `1638.0 / 1024.0` is exact in `f32` +/// (1638 needs 11 mantissa bits) and in `f64`, so quantization is now a +/// no-op for it and the two agree at the source. Every fixture position is +/// numerically unchanged by this; it is the constant that was wrong, not the +/// output. +pub const RUN_ORIGIN_STAFF: (f32, f32) = (1638.0 / 1024.0, 0.0); + +/// Pin 4's offscreen target, restated here (not read from any file) so a +/// validator checks it against a literal, exactly as +/// `round1-candidates/harness`'s `TARGET_WIDTH`/`TARGET_HEIGHT` do. +pub const TARGET_WIDTH: f64 = 1920.0; +pub const TARGET_HEIGHT: f64 = 1080.0; + +/// Device pixels per staff space (recipe §3): `scale = 100`. Used by +/// [`hittest`] to convert caret-stop positions to device space, and (for the +/// same reason every other geometric constant here is a literal) restated +/// rather than derived. +pub const DEVICE_SCALE: f64 = 100.0; + +/// The canonical-layout quantization grid this text mirror reuses (W3 §3E +/// invariant 5): `1/1024` staff space per unit, identical to +/// `epiphany_determinism::QuantizedCoord`. +pub const QUANTIZE_GRID: f64 = 1024.0; + +/// Findings routed back to the W3 `.tex` amendment (recipe §12), discovered +/// during this packet's implementation. `W3_F1`/`W3_F2` are the two the +/// recipe already named before implementation began; anything past those is +/// new to this packet. +pub mod findings { + /// `TextFaceIdentity::version: Option` is the wrong type — real + /// font versions are not semver (recipe §12, precommitted). This crate's + /// [`crate::identity::SpikeTextFaceIdentity::version`] carries the raw + /// name-table string instead, `Option`. + pub const W3_F1: &str = "TextFaceIdentity::version must be Option (raw name-table \ + text), not Option — real font versions are not semver."; + + /// One `unicode_version` field cannot honestly name two independently + /// versioned components (bidi itemization, grapheme segmentation) (recipe + /// §12, precommitted). This crate's + /// [`crate::identity::SpikeTextShapingIdentity`] carries + /// `unicode_bidi` and `unicode_segmentation` as two separate fields. + pub const W3_F2: &str = "unicode_version must be two fields (bidi, segmentation), each \ + naming its own implementation, crate version, and Unicode-data version — a single \ + field cannot honestly name two independently-versioned components."; + + /// **New in this packet.** `ShapedSegment::face: u32` has no value to + /// record when a run's codepoints are covered by *no* declared face + /// (F-C, U+0627). Invariant 2 requires segment source ranges to + /// *totally* partition the string — visual order may differ from + /// logical order, but the partition must be total — so an unresolved + /// span cannot simply have no segment; it needs a segment whose `face` + /// field can say "none of the chain." This crate's + /// [`crate::types::SpikeShapedSegment::face`] is `Option`, not + /// `u32`, for exactly this reason: `None` names "resolution walked the + /// whole declared chain and nothing covered this span," carrying the + /// same information invariant 4's unresolved-cluster marker carries, at + /// the segment granularity invariant 2 needs it at. + pub const W3_F3: &str = "ShapedSegment::face must be Option, not u32 — an unresolved \ + span (no face in the declared chain covers it) still needs a segment for invariant 2's \ + total partition, and that segment has no face index to report."; + + /// **New in this packet, found by writing the wire format §3E does not + /// have.** `epiphany-layout-ir` has no `serde` dependency, so nothing in + /// §3E — `ResolvedText` included — can be serialized. Every consumer that + /// needs to persist, cache, dump, or send one across a process boundary + /// therefore has to hand-write a mirror, and the very first consumer to do + /// it (this crate) wrote one that was quietly lossy for two `Provenance` + /// fields until it was caught in review: a `Debug` rendering in place of + /// `source`, and a length in place of `dependencies`. + /// + /// The mirror is fixed here (see [`crate::types::SpikeProvenance`]), but + /// the *shape of the mistake* is what routes back: an incremental-layout + /// cache and an out-of-process renderer are both plainly in W3's future, + /// each needs this exact conversion, and each will write it independently. + /// The amendment should say what `ResolvedText`'s serialized form is — + /// derive it, or specify a canonical byte form as Chapter 5 does for + /// `TypedObjectId` — rather than leave one per consumer. + pub const W3_F6: &str = "§3E defines no serialized form for ResolvedText, and \ + epiphany-layout-ir carries no serde at all, so every consumer that must persist or send \ + one hand-writes its own mirror — the first one written (this spike's) was lossy for two \ + Provenance fields. The amendment should specify the serialized form once."; +} diff --git a/spikes/editor-toolkit/round2-textkit/src/output.rs b/spikes/editor-toolkit/round2-textkit/src/output.rs new file mode 100644 index 0000000..013f150 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/output.rs @@ -0,0 +1,967 @@ +//! `fixtures.json`'s root document and its validator. +//! +//! [`FixtureFile::validate`] follows `round1-candidates/harness`'s +//! `OracleFile::validate` discipline exactly: every check compares the +//! loaded data against a **literal restated in this function**, never +//! against the file's own other fields. A validator that only checks a file +//! against itself accepts any self-consistent file — including one with a +//! fixture deleted, a text silently normalized, or a face hash quietly +//! changed to match a swapped-in font. + +use serde::{Deserialize, Serialize}; + +use crate::a11y::{self, SpikeAccessibilityExpectation}; +use crate::faces::LoadedFace; +use crate::identity::SpikeTextFaceIdentity; +use crate::types::SpikeResolvedText; +use crate::{invariants, EM_SIZE_STAFF_SPACE, QUANTIZE_GRID, TARGET_HEIGHT, TARGET_WIDTH}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenderTarget { + pub width: f64, + pub height: f64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceRecord { + pub chain_index: usize, + pub path: String, + pub identity: SpikeTextFaceIdentity, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureRecord { + pub id: String, + pub purpose: String, + pub resolved: SpikeResolvedText, + /// The precommitted W3 §5 check-5 oracle for this fixture (recipe §8, + /// [`crate::a11y`]). Check 5 is disqualifying, so its expectation is + /// encoded in the artifact candidates consume rather than left as prose + /// that would be interpreted after a tree had been seen. + pub accessibility: SpikeAccessibilityExpectation, +} + +/// `fixtures.json`'s root. Field order here is JSON key order (serde_json's +/// struct serialization is declaration order, not sorted), which is what +/// makes the output deterministic across regenerations without needing a +/// separate key-sort pass. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureFile { + pub contract: String, + pub recipe: String, + pub em_size_staff_space: f64, + pub quantize_grid: f64, + pub target: RenderTarget, + pub faces: Vec, + pub fixtures: Vec, +} + +/// Fails rather than defaults if a fixture has no precommitted accessibility +/// note ([`crate::a11y::note_for`]) — a sixth fixture must not be able to +/// arrive with a blank check-5 expectation. +pub fn build_fixture_file( + faces: &[LoadedFace], + fixtures: Vec<(String, String, SpikeResolvedText)>, +) -> Result { + let mut records = Vec::with_capacity(fixtures.len()); + for (id, purpose, resolved) in fixtures { + let accessibility = a11y::build_expectation(&id, &resolved.text)?; + records.push(FixtureRecord { + id, + purpose, + resolved, + accessibility, + }); + } + Ok(FixtureFile { + contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14".to_string(), + recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md".to_string(), + em_size_staff_space: EM_SIZE_STAFF_SPACE, + quantize_grid: QUANTIZE_GRID, + target: RenderTarget { + width: TARGET_WIDTH, + height: TARGET_HEIGHT, + }, + faces: faces + .iter() + .enumerate() + .map(|(i, f)| FaceRecord { + chain_index: i, + path: f.path.display().to_string(), + identity: f.identity.clone(), + }) + .collect(), + fixtures: records, + }) +} + +/// The two declared faces' SHA-256 hashes, restated as literals (recipe §1 +/// table) — the same pair `crate::faces::DECLARED_CHAIN` hard-codes, kept +/// independently here so a validator run against a *different* build of +/// this crate (or a hand-edited `fixtures.json`) still catches a hash that +/// silently drifted from the recipe, not just from this crate's own +/// current source. +const EXPECTED_FACE_HASHES_HEX: [&str; 2] = [ + "44e64260716d8f2bbe412baa1ee99b7c995190ac4573177c24def0b9200438c7", + "058ea80864aef09a23f45cbec2bb5400bc3dfbdea01c3f10538a21fcb497fb74", +]; + +/// The exact five fixture ids, in order, and their verbatim literals +/// (recipe §2 table) — restated, not read from `crate::fixtures::FIXTURES`, +/// so a validator built from a *different* copy of this crate still catches +/// drift (the same reasoning `round1-candidates/harness`'s `ROUND1_ROSTER` +/// documents for not reading the roster back out of the oracle it checks). +const EXPECTED_FIXTURES: [(&str, &str); 5] = [ + ("F-A", "Allegro affettuoso \u{2014} al fine"), + ("F-B", "Coro \u{05D0}\u{05D1}\u{05D2}"), + ("F-C", "Coro \u{0627}"), + ("F-D", "Allegro \u{05D0}\u{05D1}\u{05D2} con brio"), + ("F-E", "Cafe\u{301} \u{2014} resume\u{301}"), +]; + +/// Each fixture's **purpose string**, restated verbatim — because this string +/// is not decoration. It is what `fixtures.json` carries, what +/// `FIXTURES_SUMMARY.md` prints, and what every generator writes to the +/// console next to that fixture's numbers, so it is the label a reader will +/// use when transcribing results into the Round 2 criterion table. +/// +/// **F-D is the reason this check exists.** It used to read `"check 3 +/// (bidi)"`, and after the 2026-07-29 ruling (recipe §1.2) that is a +/// contradiction the artifacts assert on every run: check 3 is `NOT RUN` for +/// every candidate (no Arabic-capable face; pin 9), and F-D is scored on a +/// separate **Supplementary** row that must never upgrade check 3 to PASS. A +/// ruling recorded only in the recipe, while the machine-readable artifact and +/// the console output still say "check 3", is a ruling that will be +/// contradicted by whichever record someone happens to read. +const EXPECTED_PURPOSES: [(&str, &str); 5] = [ + ( + "F-A", + "check 1 (faithful consumption), check 5 (accessibility)", + ), + ("F-B", "check 2 (fallback, forced)"), + ("F-C", "check 2 (uncovered codepoint)"), + ( + "F-D", + "SUPPLEMENTARY bidi evidence (Hebrew/Latin) — check 3 remains NOT RUN (no Arabic-capable \ + face; recipe §1.2)", + ), + ("F-E", "check 4 (hit testing / caret)"), +]; + +/// Recipe §4's measured glyph/segment counts, restated (not derived from +/// `crate::fixtures::check_against_recipe`, which runs at generation time +/// against the *live* shaped result — this validates the *serialized* +/// file, which may have been produced by a different run or hand-inspected +/// copy). +/// +/// Revision 1 listed only F-A, F-B and F-E, on the correct reasoning that a +/// validator must not invent a number the recipe never committed to. Recipe +/// revision 2 states the other two measured counts instead — every measured +/// count belongs on the record, and leaving two of five fixtures uncovered by +/// the one check that catches shaped-output drift was the worse of the two +/// options. Revision 2 adds F-C's 5 and F-D's 20, which revision 1 left +/// unstated in prose. The right fix was to state them in the recipe — every +/// measured count belongs on the record — rather than to leave two of five +/// fixtures uncovered by the one check that catches a shaped-output drift. +const EXPECTED_GLYPH_COUNTS: [(&str, usize); 5] = [ + ("F-A", 26), + ("F-B", 8), + ("F-C", 5), + ("F-D", 20), + ("F-E", 13), +]; + +/// Only F-B ("Two segments, two faces") and F-D ("Three segments") are +/// stated in prose; F-A and F-E's single-segment shape is a natural +/// consequence of unidirectional, single-face text but is not a number §4 +/// writes down, so it is not asserted here as a "recipe §4" literal (it is +/// still checked at generation time in `crate::fixtures::check_f_a`/`_e`, +/// documented there as a derived — not quoted — expectation). +const EXPECTED_SEGMENT_COUNTS: [(&str, usize); 2] = [("F-B", 2), ("F-D", 3)]; + +/// SHA-256 over the whole artifact's canonical JSON — **the check that makes +/// this validator complete**, and the reason the checks above are no longer +/// the only thing standing between a tampered file and a candidate run. +/// +/// Every check before this one is a *named* property: the em size, the target, +/// the face hashes, the five texts, the glyph and segment counts, the five W3 +/// invariants, the check-5 oracle. Together they still accept an enormous +/// space of wrong files, because they say nothing about the individual glyph +/// ids, the individual quantized offsets, or the caret-stop positions — +/// tens of thousands of numbers that a candidate in packet 2B will be scored +/// against. Change F-A's ligature glyph id while keeping counts and indices +/// valid and every check above passes; the candidate then renders faithfully, +/// disagrees with the unchanged reference raster, and fails a test it should +/// have passed. That is not a hypothetical: it is the review finding this +/// constant exists to close. +/// +/// **What it binds, and what that costs.** The digest covers the complete +/// serialized `FixtureFile`, `faces[].path` included — so it is bound to the +/// absolute font paths on the machine that generated it. That is deliberate +/// (pin 9 requires faces to be resolved from an *explicit path list*, and a +/// file whose paths changed is a file that may have been generated against +/// different bytes), and it means regenerating on a machine with fonts +/// installed elsewhere will legitimately produce a different digest. When that +/// happens the fix is to re-record the digest **after** confirming the two +/// face hashes are unchanged — never to relax the check. +/// +/// Regenerate with `cargo run -p round2-textkit --bin generate`, which prints +/// the digest it produced next to the one compiled in. +const EXPECTED_ARTIFACT_DIGEST_HEX: &str = + "acc13c0d02624a0741cca5dffa7470a8971d3ecef5c6fb6f9e533ded684e7ed1"; + +/// The canonical serialization the digest is taken over: compact JSON, in +/// declaration order (serde's struct order), with no whitespace. Kept separate +/// from the pretty-printed form written to disk so that reformatting the file +/// on disk — which a human might do — does not change the identity, while +/// changing any *value* does. +pub fn canonical_bytes(file: &FixtureFile) -> Vec { + serde_json::to_vec(file).expect("FixtureFile is always serializable") +} + +/// SHA-256 of [`canonical_bytes`], lowercase hex. +pub fn artifact_digest(file: &FixtureFile) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(canonical_bytes(file)); + hex_encode(&h.finalize()) +} + +/// The digest this build expects, for `bin/generate` to print alongside the +/// one it just computed. +pub fn expected_artifact_digest() -> &'static str { + EXPECTED_ARTIFACT_DIGEST_HEX +} + +/// Checks one fixture's precommitted check-5 oracle (recipe §8). +/// +/// `expected_text` is the recipe §2 literal restated in [`EXPECTED_FIXTURES`] +/// — deliberately **not** `f.resolved.text`, so a file whose text and +/// accessible name were drifted together still fails here. The role tables +/// come from [`crate::a11y`]'s own constants, which are source literals in +/// this crate rather than fields of the file being checked, and so are the +/// same class of authority as `TARGET_WIDTH` or `QUANTIZE_GRID` above. +fn check_accessibility( + id: &str, + expected_text: &str, + a: &SpikeAccessibilityExpectation, +) -> Result<(), String> { + if a.name != expected_text { + return Err(format!( + "{id}: accessible name is {:?}, recipe §2's source string is {expected_text:?} — the \ + accessibility tree must carry the source string, so this is where a normalized or \ + re-shaped name is caught", + a.name + )); + } + let expected_hex = a11y::hex_lower(expected_text.as_bytes()); + if a.name_bytes_hex != expected_hex { + return Err(format!( + "{id}: accessible name_bytes_hex is {} , expected {expected_hex}", + a.name_bytes_hex + )); + } + if a.name_byte_len != expected_text.len() { + return Err(format!( + "{id}: accessible name_byte_len is {}, expected {}", + a.name_byte_len, + expected_text.len() + )); + } + if a.name_composition != a11y::NAME_COMPOSITION { + return Err(format!("{id}: accessible name_composition rule drifted")); + } + if a.accepted_roles != a11y::accepted_roles() { + return Err(format!( + "{id}: accepted_roles disagrees with crate::a11y::ACCEPTED_ROLE_TABLE — check 5 is \ + disqualifying, and a widened accepted set is how a disqualifying check quietly stops \ + disqualifying anything" + )); + } + if a.prohibited_roles != a11y::prohibited_roles() { + return Err(format!( + "{id}: prohibited_roles disagrees with crate::a11y::PROHIBITED_ROLE_TABLE" + )); + } + let expected_outcomes: Vec = a11y::PROHIBITED_OUTCOMES + .iter() + .map(|s| s.to_string()) + .collect(); + if a.prohibited_outcomes != expected_outcomes { + return Err(format!( + "{id}: prohibited_outcomes disagrees with crate::a11y::PROHIBITED_OUTCOMES" + )); + } + if a.note != a11y::note_for(id)? { + return Err(format!( + "{id}: accessibility note disagrees with crate::a11y::note_for" + )); + } + Ok(()) +} + +impl FixtureFile { + /// Checks the loaded file against literals restated in this function — + /// never against the file's own other fields (see the module doc + /// comment). Returns the first disagreement found. + pub fn validate(&self) -> Result<(), String> { + if self.em_size_staff_space != EM_SIZE_STAFF_SPACE { + return Err(format!( + "em_size_staff_space is {}, not {EM_SIZE_STAFF_SPACE}", + self.em_size_staff_space + )); + } + if self.quantize_grid != QUANTIZE_GRID { + return Err(format!( + "quantize_grid is {}, not {QUANTIZE_GRID}", + self.quantize_grid + )); + } + if self.target.width != TARGET_WIDTH || self.target.height != TARGET_HEIGHT { + return Err(format!( + "target is {}x{}, not pin 4's {TARGET_WIDTH}x{TARGET_HEIGHT}", + self.target.width, self.target.height + )); + } + + if self.faces.len() != EXPECTED_FACE_HASHES_HEX.len() { + return Err(format!( + "{} faces recorded, recipe §1 declares {}", + self.faces.len(), + EXPECTED_FACE_HASHES_HEX.len() + )); + } + for (i, expected_hex) in EXPECTED_FACE_HASHES_HEX.iter().enumerate() { + let face = &self.faces[i]; + let actual_hex = hex_encode(&face.identity.file_hash); + if &actual_hex != expected_hex { + return Err(format!( + "face[{i}] hash {actual_hex} disagrees with recipe §1's recorded {expected_hex}" + )); + } + } + + if self.fixtures.len() != EXPECTED_FIXTURES.len() { + return Err(format!( + "{} fixtures recorded, recipe §2 names {}", + self.fixtures.len(), + EXPECTED_FIXTURES.len() + )); + } + for (i, (expected_id, expected_text)) in EXPECTED_FIXTURES.iter().enumerate() { + let f = &self.fixtures[i]; + if f.id != *expected_id { + return Err(format!( + "fixtures[{i}] id is {:?}, recipe §2 names {expected_id:?}", + f.id + )); + } + if f.resolved.text != *expected_text { + return Err(format!( + "{}: text is {:?}, recipe §2's verbatim literal is {:?} — a silent \ + normalization would show up exactly here", + f.id, f.resolved.text, expected_text + )); + } + let (_, expected_purpose) = EXPECTED_PURPOSES[i]; + if f.purpose != expected_purpose { + return Err(format!( + "{}: purpose is {:?}, expected {:?}. This string is the label a reader \ + transcribes into the Round 2 criterion table, so a drifted purpose \ + misclassifies a result even when every number below it is right — see \ + EXPECTED_PURPOSES. For F-D specifically, restoring \"check 3 (bidi)\" \ + contradicts the 2026-07-29 ruling (recipe §1.2): check 3 is NOT RUN, and \ + F-D is a separate Supplementary row that must not upgrade it to PASS.", + f.id, f.purpose, expected_purpose + )); + } + check_accessibility(&f.id, expected_text, &f.accessibility)?; + } + + for (id, expected_segments) in EXPECTED_SEGMENT_COUNTS { + let f = self + .fixtures + .iter() + .find(|f| f.id == id) + .ok_or_else(|| format!("missing fixture {id}"))?; + if f.resolved.segments.len() != expected_segments { + return Err(format!( + "{id}: {} segments, recipe §4 records {expected_segments}", + f.resolved.segments.len() + )); + } + } + for (id, expected_glyphs) in EXPECTED_GLYPH_COUNTS { + let f = self + .fixtures + .iter() + .find(|f| f.id == id) + .ok_or_else(|| format!("missing fixture {id}"))?; + let actual: usize = f.resolved.segments.iter().map(|s| s.glyphs.len()).sum(); + if actual != expected_glyphs { + return Err(format!( + "{id}: {actual} total glyphs, recipe §4 records {expected_glyphs}" + )); + } + } + + // Re-run every W3 §5 invariant on every loaded fixture — the + // structural checks above establish this is *the recipe's* data; + // this establishes it is still *internally coherent* data. + for f in &self.fixtures { + let expect_unresolved = f.id == "F-C"; + invariants::assert_utf8_boundaries(&f.id, &f.resolved)?; + invariants::assert_segments_partition_totally(&f.id, &f.resolved)?; + invariants::assert_clusters_carry_required_fields(&f.id, &f.resolved)?; + invariants::assert_unresolved_clusters_are_diagnostic( + &f.id, + &f.resolved, + expect_unresolved, + )?; + invariants::assert_positions_quantized(&f.id, &f.resolved)?; + } + + // Last, on purpose. Every check above reports a *specific* + // disagreement ("F-A: 25 total glyphs, recipe §4 records 26"), which + // is far more useful than "the digest changed"; the digest is the + // backstop that catches everything the named checks do not name, so it + // must not pre-empt them. + let actual = artifact_digest(self); + if actual != EXPECTED_ARTIFACT_DIGEST_HEX { + return Err(format!( + "artifact digest is {actual}, expected {EXPECTED_ARTIFACT_DIGEST_HEX} — some \ + field of this file differs from the recorded artifact in a way none of the \ + named checks above covers (a glyph id, a quantized offset, a caret-stop \ + position, a face path). Re-record the digest only after establishing why it \ + changed." + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::faces::{resolve_declared_chain, FaceResolution}; + use crate::fixtures::{build_fixture, FIXTURES}; + + /// Builds a real, valid `FixtureFile` end to end against the actual + /// declared faces on this machine — the same path `bin/generate.rs` + /// takes. If either declared face is absent, the test is skipped rather + /// than failed (pin 14: an environment absence, not a failure); on this + /// development machine both are present, so every mutation test below + /// actually runs. + fn real_valid_file() -> Option { + let resolved = resolve_declared_chain(); + let mut loaded = Vec::new(); + for r in resolved { + match r { + FaceResolution::Loaded(lf) => loaded.push(lf), + FaceResolution::Missing { .. } => return None, + } + } + let built: Vec<(String, String, SpikeResolvedText)> = FIXTURES + .iter() + .enumerate() + .map(|(i, def)| { + let rt = build_fixture(def, &loaded, i as u64); + (def.id.to_string(), def.purpose.to_string(), rt) + }) + .collect(); + Some(build_fixture_file(&loaded, built).expect("every fixture has a precommitted note")) + } + + fn require_file() -> FixtureFile { + real_valid_file().expect( + "this test requires the two declared faces to be present on the machine running it", + ) + } + + #[test] + fn a_freshly_built_file_validates() { + require_file().validate().unwrap(); + } + + #[test] + fn validate_kills_a_wrong_em_size() { + let mut f = require_file(); + f.em_size_staff_space = 0.64; // the recipe's original, now-superseded value + let err = f.validate().unwrap_err(); + assert!(err.contains("em_size_staff_space"), "{err}"); + } + + #[test] + fn validate_kills_a_wrong_quantize_grid() { + let mut f = require_file(); + f.quantize_grid = 256.0; + let err = f.validate().unwrap_err(); + assert!(err.contains("quantize_grid"), "{err}"); + } + + #[test] + fn validate_kills_a_wrong_target_size() { + let mut f = require_file(); + f.target.width = 1280.0; + let err = f.validate().unwrap_err(); + assert!(err.contains("target"), "{err}"); + } + + #[test] + fn validate_kills_a_missing_face() { + let mut f = require_file(); + f.faces.pop(); + let err = f.validate().unwrap_err(); + assert!(err.contains("faces"), "{err}"); + } + + #[test] + fn validate_kills_a_tampered_face_hash() { + let mut f = require_file(); + f.faces[0].identity.file_hash[0] ^= 0xFF; // simulate a swapped-in font + let err = f.validate().unwrap_err(); + assert!(err.contains("hash"), "{err}"); + } + + #[test] + fn validate_kills_a_missing_fixture() { + let mut f = require_file(); + f.fixtures.pop(); + let err = f.validate().unwrap_err(); + assert!(err.contains("fixtures"), "{err}"); + } + + #[test] + fn validate_kills_a_wrong_fixture_id() { + let mut f = require_file(); + f.fixtures[0].id = "F-Z".to_string(); + let err = f.validate().unwrap_err(); + assert!(err.contains("id"), "{err}"); + } + + #[test] + fn validate_kills_a_silently_normalized_text() { + let mut f = require_file(); + // F-E's whole reason for existing is NFD; simulate an implementation + // that quietly normalized it to NFC before storing. + f.fixtures[4].resolved.text = "Café \u{2014} resumé".to_string(); + let err = f.validate().unwrap_err(); + assert!(err.contains("normalization"), "{err}"); + } + + #[test] + fn validate_kills_a_wrong_glyph_count() { + let mut f = require_file(); + f.fixtures[0].resolved.segments[0].glyphs.pop(); // drop the last glyph of F-A + let err = f.validate().unwrap_err(); + assert!(err.contains("glyphs"), "{err}"); + } + + #[test] + fn validate_kills_a_wrong_segment_count() { + let mut f = require_file(); + // F-D (index 3) must have 3 segments. Duplicate its last (empty-face) + // segment rather than popping one, so total glyph count is untouched + // and this mutation is caught specifically by the segment-count + // check, not incidentally by the earlier glyph-count check. + let dup = f.fixtures[3].resolved.segments.last().unwrap().clone(); + f.fixtures[3].resolved.segments.push(dup); + let err = f.validate().unwrap_err(); + assert!(err.contains("segments"), "{err}"); + } + + #[test] + fn validate_kills_a_broken_invariant_on_a_loaded_fixture() { + let mut f = require_file(); + f.fixtures[0].resolved.clusters.clusters[0] + .caret_stops + .clear(); + let err = f.validate().unwrap_err(); + assert!(err.contains("invariant 3"), "{err}"); + } + + // ---- the check-5 accessibility oracle (recipe §8) ---- + + #[test] + fn validate_kills_a_normalized_accessible_name() { + let mut f = require_file(); + // The exact failure check 5 exists to catch: the tree exposes NFC for + // an NFD source. Both the string and its hex are mutated together, so + // this is not caught by the redundant-hex check but by the name check + // against the recipe literal. + f.fixtures[4].accessibility.name = "Caf\u{e9} \u{2014} resum\u{e9}".to_string(); + f.fixtures[4].accessibility.name_bytes_hex = + a11y::hex_lower(f.fixtures[4].accessibility.name.as_bytes()); + f.fixtures[4].accessibility.name_byte_len = f.fixtures[4].accessibility.name.len(); + let err = f.validate().unwrap_err(); + assert!(err.contains("accessible name"), "{err}"); + } + + #[test] + fn validate_kills_a_name_whose_hex_no_longer_matches_it() { + let mut f = require_file(); + f.fixtures[0] + .accessibility + .name_bytes_hex + .replace_range(0..2, "ff"); + let err = f.validate().unwrap_err(); + assert!(err.contains("name_bytes_hex"), "{err}"); + } + + #[test] + fn validate_kills_an_accepted_role_set_widened_to_admit_an_image() { + let mut f = require_file(); + f.fixtures[0].accessibility.accepted_roles[0] + .tokens + .push("Image".to_string()); + let err = f.validate().unwrap_err(); + assert!(err.contains("accepted_roles"), "{err}"); + } + + #[test] + fn validate_kills_a_dropped_prohibited_outcome() { + let mut f = require_file(); + // Dropping "absent-from-tree" is the mutation that matters: it is the + // outcome a canvas toolkit produces by default, so removing it would + // let the most likely real failure through. + f.fixtures[0] + .accessibility + .prohibited_outcomes + .retain(|o| o != "absent-from-tree"); + let err = f.validate().unwrap_err(); + assert!(err.contains("prohibited_outcomes"), "{err}"); + } + + #[test] + fn validate_kills_a_swapped_accessibility_note() { + let mut f = require_file(); + let other = f.fixtures[1].accessibility.note.clone(); + f.fixtures[0].accessibility.note = other; + let err = f.validate().unwrap_err(); + assert!(err.contains("note"), "{err}"); + } + + /// The exact mutation the 2026-07-29 ruling forbids: F-D relabelled back + /// to "check 3 (bidi)" in the artifact candidates and readers consume. + /// Everything else about the file stays valid, which is precisely why this + /// needs its own named check — the numbers would all be right and the + /// classification would be wrong. + #[test] + fn validate_kills_f_d_relabelled_as_check_3() { + let mut f = require_file(); + assert_eq!(f.fixtures[3].id, "F-D", "anchor: index 3 must be F-D"); + f.fixtures[3].purpose = "check 3 (bidi)".to_string(); + let err = f.validate().unwrap_err(); + assert!(err.contains("purpose"), "{err}"); + assert!(err.contains("NOT RUN"), "{err}"); + } + + #[test] + fn validate_kills_any_swapped_purpose() { + let mut f = require_file(); + let other = f.fixtures[1].purpose.clone(); + f.fixtures[0].purpose = other; + let err = f.validate().unwrap_err(); + assert!(err.contains("purpose"), "{err}"); + } + + /// The roster restated in this module must agree with the one + /// `crate::fixtures` builds from — two hand-maintained lists that silently + /// disagreed would make the validator check the file against a label no + /// generator ever writes. + #[test] + fn the_restated_purposes_match_the_fixture_definitions() { + for (i, def) in FIXTURES.iter().enumerate() { + let (id, purpose) = EXPECTED_PURPOSES[i]; + assert_eq!(def.id, id); + assert_eq!(def.purpose, purpose, "purpose drift for {id}"); + } + } + + // ---- the whole-artifact digest ---- + + #[test] + fn validate_kills_a_changed_glyph_id() { + let mut f = require_file(); + // The mutation the review named: F-A's ligature glyph replaced while + // every count, index and header stays valid. Nothing above this line + // sees it; the digest does. + let g = &mut f.fixtures[0].resolved.segments[0].glyphs[3]; + assert_ne!( + g.glyph_id, 9999, + "anchor: the fixture must not already be 9999" + ); + g.glyph_id = 9999; + let err = f.validate().unwrap_err(); + assert!(err.contains("digest"), "{err}"); + } + + #[test] + fn validate_kills_a_moved_quantized_offset() { + let mut f = require_file(); + // One grid unit — the smallest legal move. Still on the 1/1024 grid, + // so invariant 5 accepts it; the count checks accept it; the digest + // does not. + let g = &mut f.fixtures[0].resolved.segments[0].glyphs[3]; + g.offset.x += 1.0 / crate::QUANTIZE_GRID; + let err = f.validate().unwrap_err(); + assert!(err.contains("digest"), "{err}"); + } + + #[test] + fn validate_kills_a_changed_caret_stop_position() { + let mut f = require_file(); + let s = &mut f.fixtures[0].resolved.clusters.clusters[0].caret_stops[0]; + s.position.x += 1.0 / crate::QUANTIZE_GRID; + let err = f.validate().unwrap_err(); + assert!(err.contains("digest"), "{err}"); + } + + #[test] + fn validate_kills_a_changed_face_path() { + let mut f = require_file(); + f.faces[0].path = "/somewhere/else/texgyrepagella-regular.otf".to_string(); + let err = f.validate().unwrap_err(); + assert!(err.contains("digest"), "{err}"); + } + + #[test] + fn json_round_trip_preserves_validity() { + let f = require_file(); + let json = serde_json::to_string_pretty(&f).unwrap(); + let reloaded: FixtureFile = serde_json::from_str(&json).unwrap(); + reloaded.validate().unwrap(); + } + + #[test] + fn summary_markdown_mentions_every_fixture_and_the_unicode_disagreement() { + let f = require_file(); + let md = render_summary_markdown(&f); + for def in FIXTURES { + assert!(md.contains(def.id), "summary must mention {}", def.id); + } + assert!( + md.contains("disagree"), + "summary must surface the bidi/segmentation Unicode-version disagreement \ + (crate::findings::W3_F2)" + ); + } +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// A short, human-readable render of `FixtureFile` for `FIXTURES_SUMMARY.md` +/// — every fixture's segment/face/glyph/cluster facts, and the shaping +/// identity's two Unicode-component versions (recipe §6's "recorded twice +/// on purpose"). +pub fn render_summary_markdown(file: &FixtureFile) -> String { + let mut out = String::new(); + out.push_str("# Round 2 text fixtures — measured summary\n\n"); + out.push_str(&format!( + "Generated by `round2-textkit`. Em size: `{}` staff space. Target: {}x{}.\n\n", + file.em_size_staff_space, file.target.width, file.target.height + )); + + out.push_str("## Faces\n\n"); + for f in &file.faces { + out.push_str(&format!( + "- chain[{}] `{}` — family `{}`, version `{:?}`, sha256 `{}`\n", + f.chain_index, + f.path, + f.identity.family, + f.identity.version, + hex_encode(&f.identity.file_hash) + )); + } + out.push('\n'); + + out.push_str("## Shaping identity (from the first fixture; identical for all)\n\n"); + if let Some(first) = file.fixtures.first() { + let s = &first.resolved.shaping; + out.push_str(&format!( + "- shaper: `{}` `{}.{}.{}`\n", + s.shaper.0, s.shaper_version.major, s.shaper_version.minor, s.shaper_version.patch + )); + out.push_str(&format!( + "- unicode-bidi: crate `{}`, Unicode `{}`\n", + s.unicode_bidi.crate_version, + s.unicode_bidi + .unicode_version + .as_deref() + .unwrap_or("(none reported)") + )); + out.push_str(&format!( + "- unicode-segmentation: crate `{}`, Unicode `{}`\n", + s.unicode_segmentation.crate_version, + s.unicode_segmentation + .unicode_version + .as_deref() + .unwrap_or("(none reported)") + )); + if s.unicode_bidi.unicode_version != s.unicode_segmentation.unicode_version { + out.push_str( + "- **the two Unicode-data versions disagree** — reported, not reconciled \ + (`crate::findings::W3_F2`).\n", + ); + } + } + out.push('\n'); + + out.push_str("## Fixtures\n\n"); + for f in &file.fixtures { + let r = &f.resolved; + let total_glyphs: usize = r.segments.iter().map(|s| s.glyphs.len()).sum(); + out.push_str(&format!("### {} — {}\n\n", f.id, f.purpose)); + out.push_str(&format!("text: `{:?}`\n\n", r.text)); + out.push_str(&format!( + "{} codepoints, {} bytes, {} segments, {} glyphs, {} clusters\n\n", + r.text.chars().count(), + r.text.len(), + r.segments.len(), + total_glyphs, + r.clusters.clusters.len() + )); + out.push_str("| segment | source | face | direction | script | glyphs |\n"); + out.push_str("|---|---|---|---|---|---|\n"); + for (i, seg) in r.segments.iter().enumerate() { + out.push_str(&format!( + "| {i} | {}..{} | {:?} | {:?} | {} | {} |\n", + seg.source.start, + seg.source.end, + seg.face, + seg.direction, + seg.script.0, + seg.glyphs.len() + )); + } + out.push('\n'); + out.push_str(&format!( + "check 5 (accessibility, disqualifying) — name must be exactly `{}` \ + ({} bytes, hex `{}`); {}\n\n", + f.accessibility.name, + f.accessibility.name_byte_len, + f.accessibility.name_bytes_hex, + f.accessibility.note + )); + } + + out.push_str("## Check-5 role vocabulary (identical for every fixture)\n\n"); + if let Some(first) = file.fixtures.first() { + out.push_str("| platform | accepted | prohibited |\n|---|---|---|\n"); + for (acc, pro) in first + .accessibility + .accepted_roles + .iter() + .zip(first.accessibility.prohibited_roles.iter()) + { + out.push_str(&format!( + "| {} | {} | {} |\n", + acc.platform, + acc.tokens.join(", "), + pro.tokens.join(", ") + )); + } + out.push_str(&format!( + "\nName composition: {}\n\nProhibited outcomes, whatever the role: {}\n", + first.accessibility.name_composition, + first.accessibility.prohibited_outcomes.join(", ") + )); + } + + out +} + +/// Loads `fixtures.json` **and validates it**, in one call, because those two +/// steps must never be separable. +/// +/// Round 1 learned this the hard way: the oracle's Rust types deserialized a +/// tampered file cleanly, and the fix was `deny_unknown_fields` on every type +/// *plus* a semantic `validate()` that every consumer calls before it renders. +/// Round 2's consumers arrive in packet 2B, so the entry point exists now, +/// before there is any consumer that could have forgotten it: +/// +/// - `deny_unknown_fields` (on every deserialized type in this crate) catches +/// **structural** drift — serde ignores unknown fields by default, so +/// without it a field added to the file would load silently; +/// - [`FixtureFile::validate`] catches **semantic** drift, which is the +/// dangerous kind: a file that deserializes cleanly but no longer means what +/// Round 2 requires would be consumed faithfully and pass. +/// +/// There is deliberately no public "load without validating". +pub fn load_fixtures(path: &std::path::Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read fixtures at {}: {e}", path.display()))?; + let file: FixtureFile = serde_json::from_str(&text) + .map_err(|e| format!("failed to parse fixtures at {}: {e}", path.display()))?; + file.validate() + .map_err(|e| format!("fixtures at {} failed validation: {e}", path.display()))?; + Ok(file) +} + +#[cfg(test)] +mod load_tests { + use super::*; + + fn fixtures_path() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures.json") + } + + #[test] + fn the_committed_file_loads_and_validates() { + let p = fixtures_path(); + if !p.exists() { + eprintln!( + "NOT RUN: {} absent — run `cargo run -p round2-textkit --bin generate` first", + p.display() + ); + return; + } + load_fixtures(&p).unwrap(); + } + + /// Mutation: an unknown field must be refused, not ignored. Without + /// `deny_unknown_fields` serde drops it silently and the "drift is a load + /// error" claim above would be false. + #[test] + fn an_unknown_field_is_refused() { + let p = fixtures_path(); + if !p.exists() { + return; + } + let mut v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + v.as_object_mut() + .unwrap() + .insert("smuggled_field".into(), serde_json::json!(1)); + let err = serde_json::from_value::(v) + .unwrap_err() + .to_string(); + assert!(err.contains("smuggled_field"), "{err}"); + } + + /// Mutation: a semantically drifted file must be refused by `validate` + /// even though it deserializes perfectly. + #[test] + fn a_semantically_drifted_file_is_refused() { + let p = fixtures_path(); + if !p.exists() { + return; + } + let mut v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(); + v["em_size_staff_space"] = serde_json::json!(0.64); + let file: FixtureFile = serde_json::from_value(v).unwrap(); + let err = file.validate().unwrap_err(); + assert!(err.contains("em_size_staff_space"), "{err}"); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/quantize.rs b/spikes/editor-toolkit/round2-textkit/src/quantize.rs new file mode 100644 index 0000000..cf492cb --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/quantize.rs @@ -0,0 +1,232 @@ +//! The one place this crate quantizes an `f64` staff-space value onto the +//! `1/1024` grid (W3 §5 invariant 5). Every position this crate constructs +//! — glyph offsets, caret stops, the run's `origin`, `bounds`, and +//! `reserved_box` — goes through this function, so there is exactly one +//! rounding rule to audit rather than one per call site. +//! +//! ## It is the *canonical* rounding rule, not a second one +//! +//! W3 §5 is explicit that this must not be a separate convention: +//! +//! > Positions are staff-space y-up, quantized on the same 1/1024 grid as +//! > glyph positions (`resolved.rs:11-13`), **so text quantization is not a +//! > second convention.** +//! +//! Revision 2 of this module implemented the grid arithmetic locally as +//! `(v * 1024.0).round() / 1024.0` and *named* the divergence in a doc comment +//! — `f64::round` is round-half-**away-from-zero**, while +//! `epiphany_determinism::QuantizedCoord::from_staff_spaces` is round-half-to- +//! **even** (Appendix D) — with the reasoning that this spike's values never +//! land on a tie in practice. That reasoning was wrong twice over. It is not +//! checkable (nothing asserted that no value ever ties, and a font metric or a +//! padding constant could land on one at any time), and more importantly W3's +//! requirement is about the *convention*, not about whether the two conventions +//! happen to agree on today's inputs. Naming a divergence is not the same as +//! being allowed to take it. +//! +//! So [`quantize_component`] now routes through `QuantizedCoord` itself. There +//! is one quantizer in this project and this module calls it; the tie-break is +//! whatever Appendix D says it is, today and after any future change to it. + +use epiphany_determinism::QuantizedCoord; + +/// Rounds `v` onto the canonical `1/1024` staff-space grid, using the +/// project's own quantizer — **round-to-nearest, ties-to-even**, per +/// Appendix D. +/// +/// # Panics +/// +/// If `v` is NaN, infinite, or so large that its scaled unit count leaves +/// `i64` range. `QuantizedCoord::from_staff_spaces` returns `None` for those +/// rather than saturating, and this crate deliberately does not paper over it +/// with a default: a position that cannot be placed on the canonical grid is a +/// bug in whatever computed it, and the fixture generator is the right place +/// for it to stop. No such value occurs in this recipe — every input is a font +/// metric or a stated constant — so this panic is a guard, not a code path. +pub fn quantize_component(v: f64) -> f64 { + QuantizedCoord::from_staff_spaces(v) + .unwrap_or_else(|| { + panic!( + "{v} cannot be placed on the canonical 1/1024 staff-space grid (NaN, infinite, or \ + outside i64 range) — see epiphany_determinism::QuantizedCoord::from_staff_spaces" + ) + }) + .to_staff_spaces() +} + +/// Whether `v` already sits **exactly** on the grid. +/// +/// Revision 2 tested `(scaled - scaled.round()).abs() < 1e-6`, which claimed +/// exactness in its own doc comment while accepting anything within a +/// tolerance — the precise defect it was written to prevent, since "within +/// some tolerance of the grid" is what a fixed grid exists to rule out. +/// +/// The test is now a round-trip through the canonical quantizer: `v` is on the +/// grid iff quantizing it changes nothing. That is exact by construction (the +/// multiply and divide by `1024` are exact for every value this crate handles, +/// `1024` being a power of two), and it rejects NaN, infinity and +/// out-of-range values as off-grid rather than passing them to a subtraction +/// that would produce `NaN < 1e-6 == false` by accident. +pub fn is_on_grid(v: f64) -> bool { + QuantizedCoord::from_staff_spaces(v).map(|q| q.to_staff_spaces()) == Some(v) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The recipe's original nominal origin. Kept as a permanent record of the + /// case even though §3 now states the quantized value: the property that + /// matters is that an unrepresentable literal is *moved*, not that this + /// particular one has since been fixed. + #[test] + fn quantizing_the_recipes_nominal_origin_lands_on_grid() { + let q = quantize_component(1.6); + assert!(is_on_grid(q)); + assert_eq!(q, 1638.0 / 1024.0); + } + + /// The constant the recipe now states must already be on the grid, so + /// that quantizing it changes nothing. If someone re-edits + /// `RUN_ORIGIN_STAFF` back to an unrepresentable literal, this fails + /// rather than being repaired in silence. + #[test] + fn the_declared_run_origin_is_already_on_grid() { + let (ox, oy) = crate::RUN_ORIGIN_STAFF; + assert!( + is_on_grid(ox as f64), + "origin x {ox} is off the 1/1024 grid" + ); + assert!( + is_on_grid(oy as f64), + "origin y {oy} is off the 1/1024 grid" + ); + assert_eq!(quantize_component(ox as f64), ox as f64); + assert_eq!(quantize_component(oy as f64), oy as f64); + } + + #[test] + fn the_unquantized_literal_is_not_on_grid() { + // This is exactly the discrepancy the module doc comment names — + // proven here so it cannot silently stop being true. + assert!(!is_on_grid(1.6)); + } + + #[test] + fn is_on_grid_kills_a_value_one_unit_off() { + let q = quantize_component(1.6); + assert!(!is_on_grid(q + 1.0 / 2048.0)); + } + + // ---- ties-to-even, the convention W3 requires ---- + // + // Each case below is a half-grid value where ties-to-even and + // ties-away-from-zero DISAGREE, so reverting `quantize_component` to + // `(v * 1024.0).round() / 1024.0` fails every one of them. A tie that both + // rules resolve the same way (scaled = 1.5, where both give 2) would prove + // nothing, and is checked separately below so this distinction is on the + // record rather than implied. + + /// `0.5` grid units, positive: ties-to-even rounds **down to 0**; + /// ties-away-from-zero would round up to 1 unit. + #[test] + fn positive_half_grid_ties_to_even_down() { + let v = 1.0 / 2048.0; // scaled = 0.5 + assert_eq!(quantize_component(v), 0.0); + assert_ne!(quantize_component(v), 1.0 / 1024.0); + } + + /// `2.5` grid units, positive: ties-to-even rounds **down to 2**; + /// ties-away-from-zero would round up to 3. + #[test] + fn positive_half_grid_ties_to_even_at_two_and_a_half() { + let v = 5.0 / 2048.0; // scaled = 2.5 + assert_eq!(quantize_component(v), 2.0 / 1024.0); + assert_ne!(quantize_component(v), 3.0 / 1024.0); + } + + /// `-0.5` grid units: ties-to-even rounds **towards zero**; + /// ties-away-from-zero would round to -1 unit. The sign matters because + /// `f64::round`'s bias is away from zero in *both* directions, so a + /// positive-only test would miss half of the divergence. + #[test] + fn negative_half_grid_ties_to_even_towards_zero() { + let v = -1.0 / 2048.0; // scaled = -0.5 + assert_eq!(quantize_component(v), 0.0); + assert_ne!(quantize_component(v), -1.0 / 1024.0); + } + + /// `-2.5` grid units: ties-to-even rounds **to -2**; + /// ties-away-from-zero would round to -3. + #[test] + fn negative_half_grid_ties_to_even_at_minus_two_and_a_half() { + let v = -5.0 / 2048.0; // scaled = -2.5 + assert_eq!(quantize_component(v), -2.0 / 1024.0); + assert_ne!(quantize_component(v), -3.0 / 1024.0); + } + + /// A tie the two rules agree on, stated so the four tests above are + /// understood as testing the *disagreement* and not merely "ties round + /// somewhere". + #[test] + fn a_tie_the_two_conventions_agree_on_is_not_evidence() { + let v = 3.0 / 2048.0; // scaled = 1.5; ties-even -> 2, ties-away -> 2 + assert_eq!(quantize_component(v), 2.0 / 1024.0); + assert_eq!((v * 1024.0).round() / 1024.0, 2.0 / 1024.0); + } + + /// This module must not have its own arithmetic at all: quantizing agrees + /// with the canonical type exactly, for ties and non-ties alike. + #[test] + fn quantizing_is_the_canonical_quantizer() { + for v in [ + 0.0, + 1.6, + -1.6, + 1638.0 / 1024.0, + 1.0 / 2048.0, + -1.0 / 2048.0, + 5.0 / 2048.0, + -5.0 / 2048.0, + 17.151_367_187_5, + -0.362_304_687_5, + ] { + let canonical = QuantizedCoord::from_staff_spaces(v) + .unwrap() + .to_staff_spaces(); + assert_eq!(quantize_component(v), canonical, "disagreement at {v}"); + } + } + + /// `is_on_grid` is exact, not tolerant. A value one part in `2^40` off the + /// grid is off the grid; revision 2's `1e-6` tolerance accepted it. + #[test] + fn is_on_grid_is_exact_not_tolerant() { + let on = 1638.0 / 1024.0; + assert!(is_on_grid(on)); + let barely_off = f64::from_bits(on.to_bits() + 1); + assert_ne!( + barely_off, on, + "anchor: the perturbation must change the value" + ); + assert!( + (barely_off * 1024.0 - (barely_off * 1024.0).round()).abs() < 1e-6, + "anchor: revision 2's tolerant test would have ACCEPTED this value" + ); + assert!( + !is_on_grid(barely_off), + "one ULP off the grid is off the grid" + ); + } + + /// Non-finite and out-of-range values are off-grid, not accidentally + /// on-grid. Revision 2's subtraction produced `NaN`, and `NaN < 1e-6` is + /// `false`, so NaN came out off-grid by luck rather than by rule. + #[test] + fn non_finite_values_are_off_grid_by_rule() { + assert!(!is_on_grid(f64::NAN)); + assert!(!is_on_grid(f64::INFINITY)); + assert!(!is_on_grid(f64::NEG_INFINITY)); + assert!(!is_on_grid(1e30)); // scaled well past i64 range + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/shape.rs b/spikes/editor-toolkit/round2-textkit/src/shape.rs new file mode 100644 index 0000000..0d9acfd --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/shape.rs @@ -0,0 +1,462 @@ +//! Itemization, shaping, and cluster/caret construction (recipe §2, §3, §4, +//! §7): `unicode-bidi` itemizes the run into direction-level spans, +//! `rustybuzz` shapes each face-resolved span, `unicode-segmentation` gives +//! the grapheme boundaries caret stops are built from. +//! +//! ## Determinism: why `language` is always unset +//! +//! `rustybuzz::UnicodeBuffer::guess_segment_properties` fills in `script` +//! (from each character's Unicode `Script` property — a pure function of the +//! codepoints, no host state) and `direction` (derived from that script) when +//! they are unset, but leaves `language` alone — its own source marks this +//! `// TODO: language must be set` (`rustybuzz` 0.20.1, +//! `src/hb/buffer.rs`). Real HarfBuzz falls back to the *host locale* for an +//! unset language; `rustybuzz`'s port never implements that fallback, so a +//! buffer that never calls `set_language` simply ships with `language = +//! None` — deterministically, not by omission on this crate's part. This +//! crate never calls `set_language`, so every fixture's `language` field is +//! `None`, exactly and reproducibly, on every machine. +//! +//! ## Caret-stop geometry: the rule, stated once +//! +//! Every *ordinary* caret stop (recipe §7) reads its position directly off +//! where shaping placed the grapheme's glyph(s), in the order the shaper's +//! output array gives them — no direction-dependent reinterpretation. For a +//! shaping cluster spanning `G` graphemes (a ligature has `G > 1`; recipe +//! §7's interpolation rule), grapheme `k` (0-indexed, in ascending +//! byte-offset order) sits at `cluster_origin + (k / G) * cluster_advance`, +//! where `cluster_origin`/`cluster_advance` are the pen position recorded +//! when that cluster's glyphs were walked and their summed advance. This is +//! the *only* rule; it does not vary by segment direction, so F-A's `ff` +//! interpolation and a plain Hebrew character's stop use the identical +//! formula. +//! +//! At a **direction-run boundary** (recipe §7: F-D bytes 8 and 14), the +//! ordinary rule above already produces one stop — associated with the run +//! *starting* at that offset, affinity `Downstream`. A second stop, affinity +//! `Upstream`, is added for the run *ending* there: its position is that +//! run's own **trailing pen**, i.e. the edge where *that run's own reading +//! direction* terminates — `end_pen` for an `Ltr` run (reading terminates at +//! its right edge, the ordinary continuous-pen value) but `start_pen` for an +//! `Rtl` run (reading terminates at its *left* edge, since RTL reading +//! proceeds toward decreasing x). This is the one place direction enters the +//! geometry, and it is what makes F-D's two byte-8 stops (`Upstream` at the +//! end of "Allegro ", `Downstream` at wherever the Hebrew run's first glyph +//! landed) land at genuinely different x — see `FIXTURES_SUMMARY.md` for the +//! as-measured values. +//! +//! This is a documented, defensible choice, not a claim to have solved +//! general UAX#9-adjacent bidi caret placement — real editors debate this +//! territory extensively. What matters for this recipe is that the two +//! stops are geometrically distinct and each is traceable to an actual +//! shaped position, which `invariants::assert_direction_boundary_stops_differ` +//! checks. + +use std::ops::Range; + +use rustybuzz::{Direction as RbDirection, Face as RbFace, UnicodeBuffer}; +use unicode_bidi::{BidiInfo, Level}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::faces::LoadedFace; +use crate::types::{ + SpikeCaretAffinity, SpikeCaretStop, SpikeCluster, SpikeClusterMap, SpikeLanguageTag, + SpikePoint, SpikeScriptTag, SpikeShapedSegment, SpikeStaffSpace, SpikeTextDirection, +}; +use crate::EM_SIZE_STAFF_SPACE; + +/// A maximal span of text that itemization treats as one shaping unit: a +/// single bidi embedding level, resolved (if at all) to a single face. +struct ItemSpan { + range: Range, + level: Level, + /// `None` if resolution walked the whole declared chain and no face + /// covers every codepoint in this span (`crate::findings::W3_F3`). + face: Option, +} + +fn direction_of(level: Level) -> SpikeTextDirection { + if level.is_rtl() { + SpikeTextDirection::Rtl + } else { + SpikeTextDirection::Ltr + } +} + +/// Splits `text` into bidi-level runs (paragraph-relative, single paragraph +/// assumed — every fixture is one line with no paragraph separator), then +/// further splits each run into maximal spans sharing one face resolution. +/// Both splits only ever occur on character boundaries. +fn itemize(text: &str, faces: &[LoadedFace]) -> Vec { + let bidi = BidiInfo::new(text, None); + assert_eq!( + bidi.paragraphs.len(), + 1, + "fixture text must be exactly one bidi paragraph (no paragraph separator); got {}", + bidi.paragraphs.len() + ); + let levels = &bidi.levels; + assert_eq!( + levels.len(), + text.len(), + "bidi levels must cover every byte" + ); + + // Pass 1: contiguous same-level runs, at character boundaries. + let mut level_runs: Vec<(Range, Level)> = Vec::new(); + for (i, ch) in text.char_indices() { + let level = levels[i]; + let end = (i + ch.len_utf8()) as u32; + let start = i as u32; + match level_runs.last_mut() { + Some((r, lvl)) if *lvl == level && r.end == start => r.end = end, + _ => level_runs.push((start..end, level)), + } + } + + // Pass 2: within each level run, further split by face resolution. + let mut spans = Vec::new(); + for (range, level) in level_runs { + let sub = &text[range.start as usize..range.end as usize]; + for (i, ch) in sub.char_indices() { + let abs_start = range.start + i as u32; + let abs_end = abs_start + ch.len_utf8() as u32; + let face = resolve_face_for_char(ch, faces); + match spans.last_mut() { + Some(ItemSpan { + range: r, + level: lvl, + face: f, + }) if *lvl == level && *f == face && r.end == abs_start => { + r.end = abs_end; + } + _ => spans.push(ItemSpan { + range: abs_start..abs_end, + level, + face, + }), + } + } + } + spans +} + +/// Walks the declared chain **in order**, never the host: the first face +/// whose `cmap` covers `ch` wins. `None` if none do (F-C's Arabic letter). +fn resolve_face_for_char(ch: char, faces: &[LoadedFace]) -> Option { + faces + .iter() + .position(|f| f.face().glyph_index(ch).is_some()) +} + +/// Detects `ch`'s script the same deterministic way `guess_segment_properties` +/// does internally (a pure function of the Unicode `Script` property table, +/// no locale), exposed here only for the diagnostic `script` field. +fn detect_script_tag(sub: &str) -> String { + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(sub); + buffer.guess_segment_properties(); + let bytes = buffer.script().tag().to_bytes(); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn quantized_point(x: f64, y: f64) -> SpikePoint { + SpikePoint::new( + crate::quantize::quantize_component(x), + crate::quantize::quantize_component(y), + ) +} + +/// One resolved segment's overall pen span, tracked alongside `segments` so +/// the direction-boundary post-pass (see the module doc comment) can read +/// each run's own leading/trailing edge without re-walking its glyphs. +#[derive(Copy, Clone)] +struct PenSpan { + start_pen: f64, + end_pen: f64, + direction: SpikeTextDirection, +} + +impl PenSpan { + /// The edge where *this run's own reading direction* terminates — see + /// the module doc comment's caret-stop geometry section. + fn trailing_pen(&self) -> f64 { + match self.direction { + SpikeTextDirection::Ltr => self.end_pen, + SpikeTextDirection::Rtl => self.start_pen, + } + } +} + +/// The result of shaping one fixture string: its segments and cluster map, +/// ready to become a [`crate::types::SpikeResolvedText`]. +pub struct ShapeResult { + pub segments: Vec, + pub clusters: SpikeClusterMap, +} + +/// Itemizes, shapes, and clusters `text` against the resolved `faces` +/// (recipe §2/§3/§4/§7). This is the single entry point `crate::fixtures` +/// calls per fixture. +pub fn shape_text(text: &str, faces: &[LoadedFace]) -> ShapeResult { + let spans = itemize(text, faces); + let graphemes: Vec<(u32, u32)> = text + .grapheme_indices(true) + .map(|(i, g)| (i as u32, (i + g.len()) as u32)) + .collect(); + + let mut segments: Vec = Vec::new(); + let mut pen_spans: Vec = Vec::new(); + let mut clusters: Vec = Vec::new(); + let mut pen_staff_x: f64 = 0.0; + + for span in spans { + let direction = direction_of(span.level); + let start_pen = pen_staff_x; + match span.face { + None => { + let seg_index = segments.len(); + segments.push(SpikeShapedSegment { + face: None, + glyphs: Vec::new(), + source: span.range.clone(), + direction, + script: SpikeScriptTag(detect_script_tag( + &text[span.range.start as usize..span.range.end as usize], + )), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(EM_SIZE_STAFF_SPACE), + }); + // No shaping is attempted (see the type's doc comment: doing + // so would silently draw `.notdef`). Each grapheme in the + // span becomes its own diagnostic, unresolved cluster; + // contributes no advance. + for &(g_start, g_end) in graphemes + .iter() + .filter(|&&(s, _)| s >= span.range.start && s < span.range.end) + { + clusters.push(SpikeCluster { + source: g_start..g_end, + segment: seg_index, + glyph_indices: Vec::new(), + resolved: false, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: g_start, + position: quantized_point(pen_staff_x, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }); + } + pen_spans.push(PenSpan { + start_pen, + end_pen: pen_staff_x, + direction, + }); + } + Some(face_idx) => { + let loaded = &faces[face_idx]; + let rb_face: RbFace = loaded.face(); + let upem = rb_face.units_per_em() as f64; + let scale = EM_SIZE_STAFF_SPACE / upem; + + let sub_text = &text[span.range.start as usize..span.range.end as usize]; + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(sub_text); + buffer.guess_segment_properties(); + let script_tag = { + let bytes = buffer.script().tag().to_bytes(); + String::from_utf8_lossy(&bytes).into_owned() + }; + buffer.set_direction(match direction { + SpikeTextDirection::Ltr => RbDirection::LeftToRight, + SpikeTextDirection::Rtl => RbDirection::RightToLeft, + }); + // `language` is deliberately never set — see the module doc + // comment. + let glyph_buffer = rustybuzz::shape(&rb_face, &[], buffer); + let infos = glyph_buffer.glyph_infos(); + let positions = glyph_buffer.glyph_positions(); + assert_eq!(infos.len(), positions.len()); + + let seg_index = segments.len(); + let mut seg_glyphs = Vec::with_capacity(infos.len()); + + let mut distinct: Vec = infos.iter().map(|i| i.cluster).collect(); + distinct.sort_unstable(); + distinct.dedup(); + + let mut cluster_glyph_indices: Vec<(u32, Vec)> = + distinct.iter().map(|&c| (c, Vec::new())).collect(); + let mut cluster_origin: Vec<(u32, f64)> = + distinct.iter().map(|&c| (c, 0.0)).collect(); + let mut cluster_advance: Vec<(u32, f64)> = + distinct.iter().map(|&c| (c, 0.0)).collect(); + let mut cluster_first_seen: Vec = vec![false; distinct.len()]; + + let index_of = |c: u32| { + distinct + .binary_search(&c) + .expect("cluster id must be in the distinct set") + }; + + for (info, pos) in infos.iter().zip(positions.iter()) { + let ci = index_of(info.cluster); + if !cluster_first_seen[ci] { + cluster_origin[ci].1 = pen_staff_x; + cluster_first_seen[ci] = true; + } + let gx = pen_staff_x + pos.x_offset as f64 * scale; + let gy = pos.y_offset as f64 * scale; + let glyph_index_in_seg = seg_glyphs.len() as u32; + seg_glyphs.push(crate::types::SpikePositionedGlyph { + glyph_id: info.glyph_id, + offset: quantized_point(gx, gy), + transform: None, + }); + cluster_glyph_indices[ci].1.push(glyph_index_in_seg); + let adv = pos.x_advance as f64 * scale; + pen_staff_x += adv; + cluster_advance[ci].1 += adv; + } + + segments.push(SpikeShapedSegment { + face: Some(face_idx as u32), + glyphs: seg_glyphs, + source: span.range.clone(), + direction, + script: SpikeScriptTag(script_tag), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(EM_SIZE_STAFF_SPACE), + }); + pen_spans.push(PenSpan { + start_pen, + end_pen: pen_staff_x, + direction, + }); + + let span_len = span.range.end - span.range.start; + for (k, &cid) in distinct.iter().enumerate() { + let rel_start = cid; + let rel_end = if k + 1 < distinct.len() { + distinct[k + 1] + } else { + span_len + }; + let abs_start = span.range.start + rel_start; + let abs_end = span.range.start + rel_end; + let origin = cluster_origin[k].1; + let advance = cluster_advance[k].1; + let cluster_graphemes: Vec<(u32, u32)> = graphemes + .iter() + .copied() + .filter(|&(s, _)| s >= abs_start && s < abs_end) + .collect(); + let gcount = cluster_graphemes.len().max(1) as u32; + let mut stops = Vec::with_capacity(cluster_graphemes.len()); + for (gk, &(g_start, _g_end)) in cluster_graphemes.iter().enumerate() { + let frac = gk as f64 / gcount as f64; + let sx = origin + frac * advance; + stops.push(SpikeCaretStop { + source_offset: g_start, + position: quantized_point(sx, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }); + } + clusters.push(SpikeCluster { + source: abs_start..abs_end, + segment: seg_index, + glyph_indices: cluster_glyph_indices[k].1.clone(), + resolved: true, + grapheme_count: gcount, + caret_stops: stops, + }); + } + } + } + } + + inject_direction_boundary_stops(&segments, &pen_spans, &mut clusters); + + ShapeResult { + segments, + clusters: SpikeClusterMap { clusters }, + } +} + +/// The Upstream half of the direction-boundary rule (see the module doc +/// comment): for every pair of adjacent segments whose `direction` differs, +/// finds the cluster that starts at the boundary offset (already carrying +/// its ordinary `Downstream` stop) and appends the `Upstream` stop computed +/// from the *preceding* segment's own trailing pen. +fn inject_direction_boundary_stops( + segments: &[SpikeShapedSegment], + pen_spans: &[PenSpan], + clusters: &mut [SpikeCluster], +) { + for i in 0..segments.len().saturating_sub(1) { + let a = &segments[i]; + let b = &segments[i + 1]; + if a.source.end != b.source.start { + continue; // segments must be contiguous; a gap is a bug elsewhere. + } + if a.direction == b.direction { + continue; + } + let boundary = a.source.end; + let upstream_pos = pen_spans[i].trailing_pen(); + let cluster = clusters + .iter_mut() + .find(|c| c.source.start == boundary) + .unwrap_or_else(|| { + panic!("no cluster starts exactly at direction boundary byte {boundary}") + }); + cluster.caret_stops.push(SpikeCaretStop { + source_offset: boundary, + position: quantized_point(upstream_pos, 0.0), + affinity: SpikeCaretAffinity::Upstream, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A tiny synthetic pen span, used to test `trailing_pen` directly + /// without needing real shaping. + #[test] + fn trailing_pen_uses_the_far_edge_for_rtl() { + let ltr = PenSpan { + start_pen: 1.0, + end_pen: 5.0, + direction: SpikeTextDirection::Ltr, + }; + assert_eq!(ltr.trailing_pen(), 5.0); + let rtl = PenSpan { + start_pen: 1.0, + end_pen: 5.0, + direction: SpikeTextDirection::Rtl, + }; + assert_eq!(rtl.trailing_pen(), 1.0); + } + + /// Mutation-first: if `trailing_pen` used `end_pen` unconditionally + /// (the bug this method exists to avoid), Upstream and Downstream would + /// coincide at an Ltr-preceding-Rtl boundary in exactly the way the + /// module doc comment says they must not. + #[test] + fn trailing_pen_would_collide_with_downstream_if_direction_were_ignored() { + let rtl = PenSpan { + start_pen: 2.0, + end_pen: 9.0, + direction: SpikeTextDirection::Rtl, + }; + let naive_wrong = rtl.end_pen; // what a direction-blind implementation would use + assert_ne!( + rtl.trailing_pen(), + naive_wrong, + "the direction-aware trailing edge must differ from the naive end_pen for Rtl" + ); + } +} diff --git a/spikes/editor-toolkit/round2-textkit/src/types.rs b/spikes/editor-toolkit/round2-textkit/src/types.rs new file mode 100644 index 0000000..e4ad8c9 --- /dev/null +++ b/spikes/editor-toolkit/round2-textkit/src/types.rs @@ -0,0 +1,494 @@ +//! The `SpikeResolvedText` type family — a complete, non-canonical mirror of +//! W3 §3E (`spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` lines 369-495). +//! +//! **Not the `.tex` amendment.** Every type here is prefixed `Spike*` (or, for +//! the local stand-ins §3E names but does not yet define anywhere in-tree, +//! documented as a stand-in below) and lives only in this throwaway spike +//! workspace (`spikes/editor-toolkit/`, excluded from the repo-root +//! workspace). Pin 8: mirroring a *subset* of §3E and calling it §3E would +//! test a shape the amendment is not going to have, so every field §3E names +//! is present, under W3's own field names, on every type. Two fields +//! deliberately deviate from §3E's stated Rust types — [`W3_F1`]-driven +//! `version: Option` and the new [`W3_F3`]-driven +//! `face: Option` — and both deviations are named in +//! `crate::findings` rather than silently taken. +//! +//! [`W3_F1`]: crate::findings::W3_F1 +//! [`W3_F3`]: crate::findings::W3_F3 +//! [`W3_F6`]: crate::findings::W3_F6 +//! +//! ## Named pin-8 deviation: this is a wire mirror, not the runtime type +//! +//! The "reused directly" claim in the table below is true of the values this +//! crate *computes* with — `crate::shape` and `crate::hittest` work in +//! `epiphany_layout_ir::{Point, BoundingBox, StaffSpace, Transform2D, +//! GlyphStyle}` and `epiphany_layout_ir::Provenance` throughout. It is **not** +//! true of what [`SpikeResolvedText`] stores: `epiphany-layout-ir` carries no +//! `serde` dependency at all, so every one of those types is stored here as a +//! `Spike*` serde mirror with an infallible `From` conversion. +//! +//! That is a real deviation from pin 8's "complete §3E mirror", and it is +//! recorded rather than glossed. Its consequences, in order of seriousness: +//! +//! 1. **§3E's `ResolvedText` is not serializable, and the first consumer to +//! need it wrote a lossy mirror.** That is [`W3_F6`] — a finding about the +//! amendment, not about this crate. +//! 2. The numeric mirrors ([`SpikePoint`], [`SpikeBoundingBox`], +//! [`SpikeStaffSpace`]) widen `f32` to `f64`, which is exact, and +//! [`SpikeTransform2D`] keeps `f32` verbatim. Nothing is lost. +//! 3. [`SpikeProvenance`] used to be genuinely lossy — see its own doc +//! comment for what it carried and why that was wrong. It now carries +//! `source` and `dependencies` as canonical byte forms under W3's field +//! names, so the only surviving gap is `synthesis`, a `Debug` rendering of +//! a closed enum that is `None` on every fixture here and is labelled as a +//! rendering at its definition. +//! +//! A consumer that needs the runtime types rather than the wire types builds +//! them from these by the same `From` conversions run backwards — which no +//! packet has needed yet, and which is exactly the work [`W3_F6`] says the +//! amendment should not be leaving to its consumers. +//! +//! ## Real types reused, local stand-ins defined +//! +//! Where `epiphany-layout-ir` already defines a public type §3E also names, +//! this module reuses it directly rather than reproducing it (the same +//! judgment call `round1-candidates/harness` makes for `PathCommand`): +//! +//! | §3E name | reused as | +//! |---|---| +//! | `Provenance` | [`epiphany_layout_ir::Provenance`] | +//! | `Point` | [`epiphany_layout_ir::Point`] | +//! | `BoundingBox` | [`epiphany_layout_ir::BoundingBox`] | +//! | `Transform2D` | [`epiphany_layout_ir::Transform2D`] | +//! | `StaffSpace` | [`epiphany_layout_ir::StaffSpace`] | +//! | `GlyphStyle` | [`epiphany_layout_ir::GlyphStyle`] | +//! | `FontId` (on `TextFaceIdentity::family`) | [`epiphany_layout_ir::FontId`] | +//! +//! §3E names four more types the crate does not define anywhere yet, so this +//! module defines local stand-ins, documented at each definition below: +//! `TextAlign`, `TextDirection`, `ScriptTag`, `LanguageTag`. (`ShaperId`, +//! `UnicodeVersion`, and `FeatureSetting` — named by §3E's +//! `TextShapingIdentity`, not `ResolvedText` itself — are stand-ins defined +//! in [`crate::identity`] instead, next to the rest of the shaping identity.) + +use std::ops::Range; + +use epiphany_layout_ir::{BoundingBox, GlyphStyle, Point, Provenance, StaffSpace, Transform2D}; +use serde::{Deserialize, Serialize}; + +use crate::identity::SpikeTextShapingIdentity; + +/// Stand-in for §3E's `TextAlign`. Every fixture in this recipe uses `Start` +/// (recipe §3): "`align` is `Start`; per W3, `PositionedGlyph::offset` has +/// alignment already applied — a consumer places the run by `origin` alone." +/// The other variants are carried so the type is not vacuously a unit struct +/// masquerading as an enum with one option no one chose. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SpikeTextAlign { + Start, + Center, + End, +} + +/// Stand-in for §3E's `TextDirection`. Derived from the bidi embedding +/// level's parity (even = `Ltr`, odd = `Rtl`) for every segment this crate +/// itemizes — never guessed from script alone, so a segment's declared +/// direction is always traceable to the bidi algorithm that produced it. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SpikeTextDirection { + Ltr, + Rtl, +} + +/// Stand-in for §3E's `ScriptTag`: the 4-letter OpenType/ISO-15924 script +/// tag `rustybuzz` inferred for a segment (e.g. `"Latn"`, `"Hebr"`), read +/// from `rustybuzz::Script::tag` — never from a locale or a caller-supplied +/// guess, so it is reproducible across machines. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeScriptTag(pub String); + +/// Stand-in for §3E's `LanguageTag`. This recipe's fixtures set no explicit +/// language (recipe §6 does not name one, and `rustybuzz` does not infer a +/// default from the host locale — see `crate::shape`'s doc comment on why +/// that omission is deliberate and deterministic rather than an oversight). +/// `None` on every segment this crate produces. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeLanguageTag(pub Option); + +/// One glyph, positioned (§3E `PositionedGlyph`). +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikePositionedGlyph { + pub glyph_id: u32, + /// Offset from the run's `origin`, staff-space, y-up, quantized to + /// `crate::QUANTIZE_GRID`, with alignment already applied (§3E: + /// "a consumer places the run by `origin` alone and never re-derives + /// from `align`"). + pub offset: SpikePoint, + pub transform: Option, +} + +/// One itemized, shaped run of text within a `SpikeResolvedText` (§3E +/// `ShapedSegment`). +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeShapedSegment { + /// Index into `SpikeTextShapingIdentity::faces` this segment resolved + /// to, or `None` if resolution walked the whole declared chain and no + /// face covered this span (`crate::findings::W3_F3`; F-C's uncovered + /// Arabic letter is the fixture that forces this variant to exist). A + /// `None`-face segment carries no glyphs — shaping is never attempted + /// against a face that cannot represent the codepoint, because doing so + /// would silently draw the `.notdef` glyph, exactly the ambient + /// substitution pin 9 forbids. + pub face: Option, + pub glyphs: Vec, + /// Half-open UTF-8 byte offsets into the owning `SpikeResolvedText::text`. + pub source: Range, + pub direction: SpikeTextDirection, + pub script: SpikeScriptTag, + pub language: SpikeLanguageTag, + /// Em size in staff spaces — `crate::EM_SIZE_STAFF_SPACE` on every + /// segment in this recipe (recipe §3). + pub size: SpikeStaffSpace, +} + +/// Which side of a possible direction boundary a caret stop's geometric +/// position was read from. Most stops sit inside a single run, where both +/// readings coincide and this crate always records `Downstream`; a stop at +/// an offset that is *also* a direction-run boundary gets two `SpikeCaretStop`s, +/// one per affinity, at two different geometric positions (recipe §7; W3 §5 +/// check 4). See `crate::shape` for the construction rule. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SpikeCaretAffinity { + /// Read from the run *ending* at this offset — the visual position + /// immediately after that run's last glyph. + Upstream, + /// Read from the run *starting* at this offset — the visual position + /// immediately before that run's first glyph. The position every + /// ordinary (non-boundary) caret stop uses. + Downstream, +} + +/// One caret stop: a grapheme-cluster boundary's geometric position and +/// bidi affinity (recipe §7; W3 §5 check 4). +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeCaretStop { + /// UTF-8 byte offset into `SpikeResolvedText::text` — the boundary this + /// stop names. Always a grapheme-cluster start, from + /// `unicode-segmentation`, never a codepoint or glyph boundary. + pub source_offset: u32, + pub position: SpikePoint, + pub affinity: SpikeCaretAffinity, +} + +/// One shaping cluster: a maximal span of source bytes shaping treated as +/// indivisible (one HarfBuzz cluster id), together with the caret stops +/// (one per grapheme the span covers) and, for a covered span, the glyph +/// indices that drew it (§3E: "`ClusterMap` carries, per cluster: its source +/// range, its glyph indices, and its caret stops"). +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeCluster { + /// Half-open UTF-8 byte offsets into `SpikeResolvedText::text`. + pub source: Range, + /// Index into `SpikeResolvedText::segments` this cluster's glyphs (if + /// any) belong to. Always `Some` — even an unresolved cluster belongs + /// to its `face: None` segment (`crate::findings::W3_F3`). + pub segment: usize, + /// Indices into `segments[segment].glyphs`, in that segment's stored + /// (visual) order. Empty for an unresolved cluster. + pub glyph_indices: Vec, + /// `false` for a codepoint no declared face covers (W3 §5 invariant 4: + /// "a cluster that shaping could not resolve is represented + /// diagnostically... never dropped"). F-C is the fixture that exercises + /// this. + pub resolved: bool, + /// How many `unicode-segmentation` extended grapheme clusters this + /// shaping cluster covers. `1` for an ordinary character, `2` for a + /// ligature (F-A's `ff`/`fi`) — the denominator the interpolation rule + /// (recipe §7) divides a ligature's advance by. + pub grapheme_count: u32, + pub caret_stops: Vec, +} + +/// The complete cluster/caret map for one `SpikeResolvedText` (§3E +/// `ClusterMap`): every shaping cluster, in ascending source-byte order. +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeClusterMap { + pub clusters: Vec, +} + +/// The shaped text run (§3E `ResolvedText`) — the complete mirror this +/// module's doc comment describes. **Non-canonical; not the `.tex` +/// amendment.** +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeResolvedText { + pub provenance: SpikeProvenance, + /// The source string, verbatim — not normalized, not the shaped ink. + /// F-E's NFD text is exercised precisely because this field must equal + /// its input byte-for-byte (recipe §8). + pub text: String, + pub shaping: SpikeTextShapingIdentity, + pub segments: Vec, + pub clusters: SpikeClusterMap, + pub bounds: SpikeBoundingBox, + pub reserved_box: SpikeBoundingBox, + pub origin: SpikePoint, + pub align: SpikeTextAlign, + pub style: SpikeGlyphStyle, + pub layer: i32, +} + +// --------------------------------------------------------------------- +// Serde-friendly mirrors of the real epiphany-layout-ir types. +// +// `epiphany_layout_ir::{Point, BoundingBox, Transform2D, StaffSpace, +// GlyphStyle, Provenance}` are the REAL types (see the module doc table +// above) and are what this crate computes with internally throughout +// `crate::shape`. None of them derive `serde::{Serialize, Deserialize}` +// (they are working IR types, not wire types — `epiphany-layout-ir` has no +// serde dependency at all), so `fixtures.json` needs a serializable mirror +// at the boundary. Each mirror is a plain data transcription with an +// infallible `From` conversion; nothing is recomputed or re-derived on the +// way through, so a mirror disagreeing with its source would be a bug in +// the `From` impl, not a second source of truth. +// --------------------------------------------------------------------- + +#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeStaffSpace(pub f64); + +impl From for SpikeStaffSpace { + fn from(s: StaffSpace) -> Self { + SpikeStaffSpace(s.0 as f64) + } +} + +#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikePoint { + pub x: f64, + pub y: f64, +} + +impl From for SpikePoint { + fn from(p: Point) -> Self { + SpikePoint { + x: p.x.0 as f64, + y: p.y.0 as f64, + } + } +} + +impl SpikePoint { + pub const fn new(x: f64, y: f64) -> Self { + SpikePoint { x, y } + } +} + +#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeBoundingBox { + pub left: f64, + pub bottom: f64, + pub right: f64, + pub top: f64, +} + +impl From for SpikeBoundingBox { + fn from(b: BoundingBox) -> Self { + SpikeBoundingBox { + left: b.left.0 as f64, + bottom: b.bottom.0 as f64, + right: b.right.0 as f64, + top: b.top.0 as f64, + } + } +} + +#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeTransform2D { + pub matrix: [[f32; 3]; 3], +} + +impl From for SpikeTransform2D { + fn from(t: Transform2D) -> Self { + SpikeTransform2D { matrix: t.matrix } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeGlyphStyle { + pub rgba: u32, +} + +impl From for SpikeGlyphStyle { + fn from(s: GlyphStyle) -> Self { + SpikeGlyphStyle { rgba: s.rgba } + } +} + +/// Serializable mirror of `epiphany_core::ids::TypedObjectId`, carrying the +/// **canonical** identity rather than a rendering of it. +/// +/// `TypedObjectId::canonical_bytes()` *is* the identity — Chapter 5 defines +/// equality, ordering and hashing over exactly those bytes — so mirroring +/// them (plus the variant discriminant, which they already contain, restated +/// for readability) is lossless for every variant, present and future, +/// without a 28-arm match that would silently miss the next one. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeTypedObjectId { + /// `TypedObjectId::discriminant()` — the 16-bit variant tag. + pub discriminant: u16, + /// `TypedObjectId::canonical_bytes()`, lowercase hex. The identity. + pub canonical_bytes_hex: String, +} + +impl From<&epiphany_core::TypedObjectId> for SpikeTypedObjectId { + fn from(id: &epiphany_core::TypedObjectId) -> Self { + SpikeTypedObjectId { + discriminant: id.discriminant(), + canonical_bytes_hex: hex_lower(&id.canonical_bytes()), + } + } +} + +fn hex_lower(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Serializable mirror of `epiphany_layout_ir::Provenance`, **under W3's own +/// field names, carrying every field**. +/// +/// Revision 2 of this file carried `source_debug: String` and +/// `dependency_count: usize` instead of `source` and `dependencies`. That was +/// a real deviation from pin 8 wearing the clothes of a faithful mirror: the +/// module doc claimed "every field §3E names is present, under W3's own field +/// names, on every type" while this type renamed one field into a `Debug` +/// rendering and replaced another with its length. `Debug` output is not an +/// encoding — it has no stability contract and cannot be parsed back — and a +/// dependency *count* discards the invalidation set that is the field's entire +/// purpose (Chapter 7 §"Invalidation Rules"). It happened to lose nothing +/// measurable here only because these fixtures' dependency lists are empty, +/// which is an accident of the fixtures, not a property of the mirror. +/// +/// These fixtures are synthetic (recipe §5: "Bidi and fallback are exercised +/// in the spike through synthetic `ResolvedText` fixtures, which need no model +/// work"), so there is no real score-graph object behind them; `source` +/// records the placeholder `TypedObjectId` `crate::fixtures` constructs (an +/// `Event` id derived from the fixture's own ordinal) purely for traceability, +/// not as a claim that a score-graph object exists. +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpikeProvenance { + pub source: SpikeTypedObjectId, + /// `SynthesisKind` is a closed C-like enum with one payload-carrying + /// variant (`Registered(SynthesisRegistryId)`) and no canonical byte form + /// of its own, so this is its `Debug` rendering — named as such rather + /// than presented as an encoding. `None` on every fixture in this recipe. + pub synthesis: Option, + pub dependencies: Vec, + /// **Serialized as a decimal string, not a JSON number, and that is not + /// cosmetic.** A `u128` stable id renders as up to 39 digits, which + /// exceeds every numeric type a generic JSON parser offers: round-tripping + /// this file through `serde_json::Value` — or Python's `json`, or any + /// JavaScript consumer — silently converts it to an `f64` and destroys the + /// low bits. Measured before this fix: + /// `82875741697311382809239399464544864365` came back as + /// `8.287574169731139e+37`. + /// + /// A provenance id that changes when a tool merely reads and rewrites the + /// file is not an identity. The canonical wire format is binary and has no + /// such problem, so this is a constraint on **JSON artifacts** — this + /// file, and any debug or fixture dump carrying an id of this width. + #[serde(with = "u128_as_string")] + pub stable_id: u128, +} + +/// Serializes a `u128` losslessly as a decimal string. See +/// [`SpikeProvenance::stable_id`] for why a bare JSON number is not safe. +mod u128_as_string { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &u128, s: S) -> Result { + s.serialize_str(&v.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let s = String::deserialize(d)?; + s.parse::().map_err(serde::de::Error::custom) + } +} + +impl From<&Provenance> for SpikeProvenance { + fn from(p: &Provenance) -> Self { + SpikeProvenance { + source: (&p.source).into(), + synthesis: p.synthesis.map(|s| format!("{s:?}")), + dependencies: p + .dependencies + .iter() + .map(SpikeTypedObjectId::from) + .collect(), + stable_id: p.stable_id.0, + } + } +} + +#[cfg(test)] +mod provenance_tests { + use super::*; + use epiphany_core::{EventId, PitchId, TypedObjectId}; + + /// The mirror must carry the dependency *set*, not its length. Revision 2 + /// carried `dependency_count: usize`; this is the test that would have + /// failed then, and it is here so the field cannot quietly become a count + /// again. + #[test] + fn the_mirror_carries_every_dependency_not_a_count() { + let source = TypedObjectId::Event(EventId::from_raw(7)); + let deps = vec![ + TypedObjectId::Pitch(PitchId::from_raw(11)), + TypedObjectId::Event(EventId::from_raw(13)), + ]; + let real = Provenance::projected(source, deps.clone()); + let mirror = SpikeProvenance::from(&real); + assert_eq!(mirror.dependencies.len(), 2); + for (m, d) in mirror.dependencies.iter().zip(deps.iter()) { + assert_eq!(m, &SpikeTypedObjectId::from(d)); + } + // Two ids that differ ONLY in variant must mirror differently — a + // count, or a payload-only mirror, could not tell these apart. + let a = SpikeTypedObjectId::from(&TypedObjectId::Event(EventId::from_raw(11))); + let b = SpikeTypedObjectId::from(&TypedObjectId::Pitch(PitchId::from_raw(11))); + assert_ne!(a, b); + } + + /// The mirrored bytes must be `canonical_bytes()` itself, not a rendering + /// that merely looks like it. + #[test] + fn the_mirrored_id_is_the_canonical_byte_form() { + let id = TypedObjectId::Event(EventId::from_raw(0xF00D_0000)); + let m = SpikeTypedObjectId::from(&id); + assert_eq!(m.discriminant, id.discriminant()); + assert_eq!(m.canonical_bytes_hex, hex_lower(&id.canonical_bytes())); + assert_eq!(m.canonical_bytes_hex.len(), id.canonical_bytes().len() * 2); + } +}