Packet 2A: the candidate-neutral text fixtures, emitter, and differential
Four crates, none of which knows anything about a candidate. This is the apparatus Round 2 will judge with, built and mutation-tested before either candidate exists — the role Round 1's oracle tranche played. round2-textkit — the W3 3E stand-in. Resolves two declared faces from an explicit path list (pin 9), shapes and itemizes five fixtures with rustybuzz/unicode-bidi/unicode-segmentation, asserts all five W3 section 5 invariants, and emits fixtures.json + an 80-probe hit-test table (none dropped). round2-svgref — pin 10's explicit-glyph SVG emitter. Never emits <text>, and asserts it: a <text> element would let the viewer's shaper pick glyphs and reintroduce exactly the re-shaping this round exists to forbid. round2-diff — the bounded visual differential, D1-D4, zero dependencies. round2-reference — composes the three into reference rasters and D4 regions, and executes the text mutations M4/M5/M6 against the real frozen fixtures. Defects found by testing rather than by reading, and what each cost: * The stand-in had its own quantizer. `(v*1024).round()/1024` is ties-away-from-zero; QuantizedCoord is ties-to-even (Appendix D). W3 section 5 says text quantization "is not a second convention", and naming the divergence in a doc comment is not the same as being allowed to take it. Now routed through QuantizedCoord, with four half-grid tests at +/-0.5 and +/-2.5 units chosen because the conventions DISAGREE there, plus one recording a tie where they agree. is_on_grid claimed exactness while accepting anything within 1e-6; it is now an exact round-trip. * The validator accepted any self-consistent file. Named checks say nothing about individual glyph ids or quantized offsets — tens of thousands of numbers a candidate will be scored against. A whole-artifact SHA-256 closes it; four tests fail without it. * stable_id: u128 did not survive JSON. Measured, 82875741697311382809239399464544864365 came back as 8.287574169731139e+37. A provenance id that changes when a tool reads and rewrites a file is not an identity. Now a decimal string (W3-F5). * SpikeProvenance was lossy while claiming to mirror every 3E field: a Debug rendering for `source`, a length for `dependencies`. It lost nothing measurable only because these fixtures' dependency lists are empty, which is an accident of the fixtures. Now canonical_bytes under W3's own field names (W3-F6). * A cross-crate string-slice: the reference composer extracted <path> fragments by searching for another crate's markers. It worked, and a formatting change there would have broken it silently. Now an API call, so a rename is a build failure. Verified byte-identical across the refactor. * Two harnesses printed their own bad news and exited 0. M4/M5/M6 measured: M4 kills D1 at 221 px (D4 blind at 1.20%); M5 kills D4 at 13.28%/13.19% on the two composed-acute regions; M6's emitter refusal fires on U+05D0 before any raster, and forced past it D4 reads 95.37/71.26/34.69%. F-D is labelled SUPPLEMENTARY in code, in fixtures.json, in the summary, and in every generator's output, with check 3 NOT RUN — enforced by the validator, because a ruling recorded only in prose loses to whichever record the reader opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz
This commit is contained in:
parent
565b0f8f79
commit
ffe313cd73
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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<usize>) -> Vec<Shape> {
|
||||
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<f32> {
|
||||
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<u8> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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<GlyphRegion> {
|
||||
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<String> = Vec::new();
|
||||
let mut observations: Vec<String> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f64, String> {
|
||||
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<Option<(f64, f64)>, 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<Vec<bool>, 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<bool>,
|
||||
|
||||
/// 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<RegionMass>,
|
||||
pub d4_pass: bool,
|
||||
/// The single worst region, for the one-line summary a report leads with.
|
||||
pub d4_worst: Option<RegionMass>,
|
||||
}
|
||||
|
||||
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<f64, String> {
|
||||
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<DiffReport, String> {
|
||||
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<u8> {
|
||||
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<GlyphRegion> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -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
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
|
|
@ -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
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><rect x="0" y="0" width="1920" height="1080" fill="#ffffff"/><path fill="#000000" fill-rule="nonzero" d="M162.7769 496.2240 C162.7769 523.8720 183.0009 542.5600 213.0809 542.5600 C223.7049 542.5600 233.3049 540.2560 242.1369 535.6480 L245.7209 529.2480 L244.5689 527.9680 C236.6329 533.0880 227.8009 535.6480 218.0729 535.6480 C193.1129 535.6480 176.4729 519.0080 176.4729 493.7920 C176.4729 470.2400 191.0649 454.6240 213.0809 454.6240 C225.6249 454.6240 238.5529 459.7440 238.5529 464.7360 L238.5529 473.9520 L242.5209 473.9520 C242.6489 467.5520 243.1609 462.9440 244.9529 454.4960 C234.0729 450.9120 224.7289 449.2480 215.3849 449.2480 C204.2489 449.2480 193.6249 451.9360 185.0489 456.9280 C170.4569 465.5040 162.7769 478.9440 162.7769 496.2240 Z"/><path fill="#000000" fill-rule="nonzero" d="M254.7796 512.2240 C254.7796 530.2720 266.1716 542.5600 282.6836 542.5600 C302.3956 542.5600 316.4756 528.8640 316.4756 509.6640 C316.4756 492.3840 303.9316 479.9680 286.3956 479.9680 C267.7076 479.9680 254.7796 493.1520 254.7796 512.2240 Z M266.6836 507.8720 C266.6836 492.5120 272.8276 484.1920 284.0916 484.1920 C295.8676 484.1920 304.5716 496.9920 304.5716 514.4000 C304.5716 529.1200 298.0436 538.3360 287.6756 538.3360 C275.1316 538.3360 266.6836 526.0480 266.6836 507.8720 Z"/><path fill="#000000" fill-rule="nonzero" d="M323.2935 540.3840 C328.5415 540.1280 333.7895 540.0000 339.0375 540.0000 C345.1815 540.0000 351.1975 540.1280 357.3415 540.3840 L357.3415 536.5440 L350.6855 536.1600 C344.9255 535.7760 344.6695 535.3920 344.6695 526.9440 L344.6695 504.9280 C344.6695 497.2480 349.7895 490.8480 356.0615 490.8480 C359.9015 490.8480 362.5895 492.6400 364.6375 496.7360 L367.3255 496.7360 L368.4775 481.3760 C367.0695 480.4800 364.7655 479.9680 362.4615 479.9680 C358.6215 479.9680 354.5255 482.0160 351.8375 485.2160 L344.6695 493.6640 L344.6695 480.4800 L343.5175 479.9680 C336.9895 482.6560 330.3335 484.4480 323.5495 485.2160 L323.5495 488.8000 L328.2855 488.8000 C333.4055 488.8000 333.9175 489.6960 333.9175 498.1440 L333.9175 526.9440 C333.9175 535.0080 333.5335 535.9040 329.1815 536.1600 L323.2935 536.5440 Z"/><path fill="#000000" fill-rule="nonzero" d="M375.2874 512.2240 C375.2874 530.2720 386.6794 542.5600 403.1914 542.5600 C422.9034 542.5600 436.9834 528.8640 436.9834 509.6640 C436.9834 492.3840 424.4394 479.9680 406.9034 479.9680 C388.2154 479.9680 375.2874 493.1520 375.2874 512.2240 Z M387.1914 507.8720 C387.1914 492.5120 393.3354 484.1920 404.5994 484.1920 C416.3754 484.1920 425.0794 496.9920 425.0794 514.4000 C425.0794 529.1200 418.5514 538.3360 408.1834 538.3360 C395.6394 538.3360 387.1914 526.0480 387.1914 507.8720 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -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
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -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
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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 `<text>`, 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<LoadedFace> = 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<round2_reference::RegionRecord> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LoadedFace> = 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<String> = 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<String>) {
|
||||
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<usize> = 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<String>) {
|
||||
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<String>) {
|
||||
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!();
|
||||
}
|
||||
|
|
@ -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 `<path>` fragment out of `round2_svgref::emit_svg`'s complete document
|
||||
/// by searching for that crate's background-rect and `</svg>` 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<Option<&'a DrawnBounds>> {
|
||||
let empty_set: HashSet<u16> = 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<u32>,
|
||||
) -> 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<u8>,
|
||||
pub regions: Vec<GlyphRegion>,
|
||||
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<FixtureRasterResult, String> {
|
||||
struct Entry {
|
||||
seg_idx: usize,
|
||||
glyph_idx: usize,
|
||||
draw: DrawGlyph,
|
||||
}
|
||||
|
||||
let mut by_face: BTreeMap<u32, Vec<Entry>> = 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<DrawGlyph> = 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![
|
||||
"<path fill=\"#000000\" d=\"M0 0 L1 1 Z\"/>".to_string(),
|
||||
"<path fill=\"#000000\" d=\"M2 2 L3 3 Z\"/>".to_string(),
|
||||
];
|
||||
let doc = round2_svgref::wrap_document(64, 32, &paths);
|
||||
assert!(doc.starts_with("<svg"));
|
||||
assert!(doc.ends_with("</svg>"));
|
||||
assert_eq!(doc.matches("<path").count(), 2);
|
||||
assert!(round2_svgref::assert_no_text_elements(&doc).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlate_bounds_matches_empty_and_nonempty_glyphs_by_id() {
|
||||
let glyphs = [
|
||||
DrawGlyph {
|
||||
glyph_id: 5,
|
||||
origin_x: 0.0,
|
||||
origin_y: 0.0,
|
||||
em_px: 10.0,
|
||||
},
|
||||
DrawGlyph {
|
||||
glyph_id: 1,
|
||||
origin_x: 1.0,
|
||||
origin_y: 0.0,
|
||||
em_px: 10.0,
|
||||
}, // empty (e.g. space)
|
||||
DrawGlyph {
|
||||
glyph_id: 5,
|
||||
origin_x: 2.0,
|
||||
origin_y: 0.0,
|
||||
em_px: 10.0,
|
||||
},
|
||||
];
|
||||
let bounds = vec![
|
||||
DrawnBounds {
|
||||
glyph_id: 5,
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
x1: 1.0,
|
||||
y1: 1.0,
|
||||
},
|
||||
DrawnBounds {
|
||||
glyph_id: 5,
|
||||
x0: 2.0,
|
||||
y0: 0.0,
|
||||
x1: 3.0,
|
||||
y1: 1.0,
|
||||
},
|
||||
];
|
||||
let empty = vec![1u16];
|
||||
let correlated = correlate_bounds(&glyphs, &bounds, &empty);
|
||||
assert_eq!(correlated.len(), 3);
|
||||
assert!(correlated[0].is_some());
|
||||
assert!(correlated[1].is_none());
|
||||
assert!(correlated[2].is_some());
|
||||
assert!((correlated[2].unwrap().x0 - 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_ink_pixels_matches_a_hand_built_buffer() {
|
||||
// 2x2: one black (ink), three white (background).
|
||||
let mut rgba = vec![255u8; 2 * 2 * 4];
|
||||
rgba[0] = 0;
|
||||
rgba[1] = 0;
|
||||
rgba[2] = 0;
|
||||
assert_eq!(count_ink_pixels(&rgba), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "round2-svgref"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
# The explicit-glyph SVG reference emitter (pin 10 / ROUND2_TEXT_RECIPE.md §9).
|
||||
#
|
||||
# `ttf-parser` comes from rustybuzz's own tree, so the outlines emitted here
|
||||
# are read from the same parser that shaped the run — not a second, possibly
|
||||
# disagreeing, font implementation.
|
||||
[dependencies]
|
||||
ttf-parser = "0.25.1"
|
||||
resvg = "0.45"
|
||||
usvg = "0.45"
|
||||
tiny-skia = "0.11"
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
//! The explicit-glyph SVG reference emitter (pin 10, `ROUND2_TEXT_RECIPE.md`
|
||||
//! §9), plus its rasterizer.
|
||||
//!
|
||||
//! **Why this crate has to exist at all.** Today's exporter cannot draw a
|
||||
//! `SpikeResolvedText`. An SVG `<text>` 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 `é`)
|
||||
//! `<text>` 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 `<path>`**, 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 `<text>`, and [`assert_no_text_elements`] enforces that
|
||||
//! structurally** rather than leaving it to a threshold. A `<text>` 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
|
||||
/// `<path>`, 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<DrawnBounds>, Vec<u16>), String> {
|
||||
let (paths, bounds, empty) = emit_glyph_paths(face_bytes, face_index, glyphs)?;
|
||||
Ok((wrap_document(width, height, &paths), bounds, empty))
|
||||
}
|
||||
|
||||
/// Wraps already-emitted `<path>` 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,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" \
|
||||
viewBox=\"0 0 {width} {height}\">\
|
||||
<rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"#ffffff\"/>"
|
||||
);
|
||||
for p in paths {
|
||||
svg.push_str(p);
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
}
|
||||
|
||||
/// Emits one `<path>` 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 `<rect>` and `</svg>` 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<String>, Vec<DrawnBounds>, Vec<u16>), 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!(
|
||||
"<path fill=\"#000000\" fill-rule=\"nonzero\" d=\"{}\"/>",
|
||||
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
|
||||
/// `<text>`, `<tspan>`, `<textPath>`, or `<font-face>` construct.
|
||||
///
|
||||
/// This is deliberately a check on the *source*, run before anything is
|
||||
/// rasterized. A `<text>`-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 ["<text", "<tspan", "<textPath", "font-face", "@font-face"] {
|
||||
if svg.contains(forbidden) {
|
||||
return Err(format!(
|
||||
"emitted SVG contains {forbidden:?} — the reference must address glyphs by \
|
||||
font-internal id via <path>, never by character. A <text> 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<Vec<u8>, 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 `<rect>` 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<Vec<u8>> {
|
||||
std::fs::read(PAGELLA).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_elements_are_refused_at_the_source_not_the_raster() {
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><text x=\"0\" y=\"0\">fi</text></svg>";
|
||||
let err = assert_no_text_elements(svg).unwrap_err();
|
||||
assert!(err.contains("<text"), "{err}");
|
||||
// And rasterize must refuse it too, so the check cannot be bypassed
|
||||
// by calling the rasterizer directly.
|
||||
assert!(rasterize(svg, 16, 16).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_is_refused_too() {
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><style>@font-face{}</style></svg>";
|
||||
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("<path"), "must draw an explicit outline");
|
||||
assert!(assert_no_text_elements(&svg).is_ok());
|
||||
assert_eq!(bounds.len(), 1);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let rgba = rasterize(&svg, 400, 400).unwrap();
|
||||
assert_eq!(rgba.len(), 400 * 400 * 4);
|
||||
assert!(rgba.chunks(4).all(|p| p[3] == 255));
|
||||
// There must actually be ink: a reference that renders blank would
|
||||
// make every candidate's ink points fail and every background point
|
||||
// pass, the exact false shape Round 1 hit with an unregistered
|
||||
// texture.
|
||||
let ink = rgba
|
||||
.chunks(4)
|
||||
.filter(|p| (299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000 < 128)
|
||||
.count();
|
||||
assert!(ink > 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<SpikeRoleMapping>,
|
||||
pub prohibited_roles: Vec<SpikeRoleMapping>,
|
||||
pub prohibited_outcomes: Vec<String>,
|
||||
/// What this fixture is load-bearing for in check 5, in one line.
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
pub fn accepted_roles() -> Vec<SpikeRoleMapping> {
|
||||
ACCEPTED_ROLE_TABLE
|
||||
.iter()
|
||||
.map(|(p, t)| mapping(p, t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn prohibited_roles() -> Vec<SpikeRoleMapping> {
|
||||
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<SpikeAccessibilityExpectation, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: <path>` 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<LoadedFace> = 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());
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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<u8>,
|
||||
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<FaceResolution> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<LoadedFace>> {
|
||||
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<LoadedFace> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DevicePoint> {
|
||||
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<GraphemeEntry> {
|
||||
let mut v: Vec<GraphemeEntry> = 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<HitTestProbe>,
|
||||
}
|
||||
|
||||
/// `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<FixtureProbeTable>,
|
||||
pub dropped: Vec<DroppedProbe>,
|
||||
}
|
||||
|
||||
/// 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<HitTestProbe>,
|
||||
dropped: &mut Vec<DroppedProbe>,
|
||||
) {
|
||||
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<DroppedProbe>) {
|
||||
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<FixtureProbeTable>, Vec<DroppedProbe>) {
|
||||
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<HitTestProbeFile, String> {
|
||||
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<FixtureFile> {
|
||||
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::<HitTestProbeFile>(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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>`, not
|
||||
//! `Option<SemVer>` (`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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<SpikeTextFaceIdentity>,
|
||||
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<SpikeFeatureSetting>,
|
||||
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<SemVer> 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<SpikeTextFaceIdentity>) -> 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()
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SemVer>` 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<String>`.
|
||||
pub const W3_F1: &str = "TextFaceIdentity::version must be Option<String> (raw name-table \
|
||||
text), not Option<SemVer> — 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<u32>`, 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<u32>, 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.";
|
||||
}
|
||||
|
|
@ -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<FaceRecord>,
|
||||
pub fixtures: Vec<FixtureRecord>,
|
||||
}
|
||||
|
||||
/// 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<FixtureFile, String> {
|
||||
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<u8> {
|
||||
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<String> = 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<FixtureFile> {
|
||||
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<FixtureFile, String> {
|
||||
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::<FixtureFile>(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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u32>,
|
||||
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<usize>,
|
||||
}
|
||||
|
||||
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<ItemSpan> {
|
||||
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<u32>, 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<usize> {
|
||||
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<SpikeShapedSegment>,
|
||||
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<SpikeShapedSegment> = Vec::new();
|
||||
let mut pen_spans: Vec<PenSpan> = Vec::new();
|
||||
let mut clusters: Vec<SpikeCluster> = 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<u32> = infos.iter().map(|i| i.cluster).collect();
|
||||
distinct.sort_unstable();
|
||||
distinct.dedup();
|
||||
|
||||
let mut cluster_glyph_indices: Vec<(u32, Vec<u32>)> =
|
||||
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<bool> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>` and the new [`W3_F3`]-driven
|
||||
//! `face: Option<u32>` — 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<String>);
|
||||
|
||||
/// 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<SpikeTransform2D>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
pub glyphs: Vec<SpikePositionedGlyph>,
|
||||
/// Half-open UTF-8 byte offsets into the owning `SpikeResolvedText::text`.
|
||||
pub source: Range<u32>,
|
||||
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<u32>,
|
||||
/// 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<u32>,
|
||||
/// `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<SpikeCaretStop>,
|
||||
}
|
||||
|
||||
/// 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<SpikeCluster>,
|
||||
}
|
||||
|
||||
/// 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<SpikeShapedSegment>,
|
||||
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<StaffSpace> 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<Point> 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<BoundingBox> 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<Transform2D> 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<GlyphStyle> 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<String>,
|
||||
pub dependencies: Vec<SpikeTypedObjectId>,
|
||||
/// **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<S: Serializer>(v: &u128, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&v.to_string())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u128, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
s.parse::<u128>().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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue