diff --git a/crates/comp-verbs/src/comp_spec.rs b/crates/comp-verbs/src/comp_spec.rs index b960dc717..cce7295e2 100644 --- a/crates/comp-verbs/src/comp_spec.rs +++ b/crates/comp-verbs/src/comp_spec.rs @@ -12,6 +12,7 @@ use impeccable_comp::raster::{self as r, Image}; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; use crate::util::{self, arg, arg_or, flag, num, r4, r4f, round}; @@ -1040,7 +1041,7 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { io.err("comp-spec: pass --grid to get the coordinate grid, then --regions (or --auto for band regions)\n"); return 1; }; - let spec = match measure_regions(&comp, ®ions_input, comp_path) { + let mut spec = match measure_regions(&comp, ®ions_input, comp_path) { Ok(s) => s, Err(e) => { io.err(&format!("comp-spec: {e}\n")); @@ -1048,6 +1049,17 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { } }; let spec_out = resolve(io, &spec_path); + // Bind cached font evidence to the decoded reference, including dimensions. + // Legacy specs without this identity are deliberately remeasured once. + let mut hasher = Sha256::new(); + hasher.update(comp.width.to_le_bytes()); + hasher.update(comp.height.to_le_bytes()); + hasher.update(&comp.data); + spec["compSha256"] = json!(format!("{:x}", hasher.finalize())); + if let Some(previous) = std::fs::read(&spec_out).ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) { + preserve_typography(&mut spec, &previous); + } if let Some(parent) = spec_out.parent() { let _ = std::fs::create_dir_all(parent); } @@ -1058,6 +1070,25 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 { 0 } +/// Remeasuring an unrelated region must not erase measured font work. Reuse +/// only the existing spec's evidence, never a `type` claim in the input file. +fn preserve_typography(spec: &mut Value, previous: &Value) { + if !spec["compSha256"].is_string() || spec["compSha256"] != previous["compSha256"] { + return; + } + let Some(old_regions) = previous["regions"].as_array() else { return; }; + let Some(regions) = spec["regions"].as_array_mut() else { return; }; + for region in regions { + if !matches!(region["kind"].as_str(), Some("text" | "control")) { continue; } + let Some(old) = old_regions.iter().find(|old| old["id"] == region["id"]) else { continue; }; + if ["kind", "medium", "box", "px", "text"].iter().all(|key| old[*key] == region[*key]) { + if let Some(ty) = old.get("type").filter(|ty| ty.is_object()) { + region["type"] = ty.clone(); + } + } + } +} + #[cfg(test)] mod reference_tests { use super::*; diff --git a/crates/comp-verbs/tests/parity.rs b/crates/comp-verbs/tests/parity.rs index 2855e7feb..c7faafbfc 100644 --- a/crates/comp-verbs/tests/parity.rs +++ b/crates/comp-verbs/tests/parity.rs @@ -120,3 +120,44 @@ fn measure_regions_refuses_oversized_code_region() { let err = comp_spec::measure_regions(&comp, &input, "comp.png").unwrap_err(); assert!(err.contains("covers 100% of the comp") || err.contains("% of the comp"), "got: {err}"); } + +#[test] +fn remeasure_keeps_only_unchanged_reference_typography() { + let dir = std::env::temp_dir().join(format!("impeccable-remeasure-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy(fixtures().join("comp.png"), dir.join("comp.png")).unwrap(); + let mut input = json!({"allowUncovered":true,"regions":[ + {"id":"heading","kind":"text","note":"Main heading text","text":"Welcome","box":{"x":0.1,"y":0.1,"w":0.5,"h":0.1},"snap":false}, + {"id":"other","kind":"text","note":"Other small text","text":"Details","box":{"x":0.1,"y":0.4,"w":0.5,"h":0.1},"snap":false} + ]}); + let run = |input: &Value| { + std::fs::write(dir.join("regions.json"), input.to_string()).unwrap(); + let (mut io, _) = impeccable_common::Io::captured("", dir.clone(), Default::default()); + let args = ["--comp","comp.png","--regions","regions.json","--spec","spec.json"].map(String::from); + assert_eq!(comp_spec::run(&args, &mut io), 0); + serde_json::from_slice::(&std::fs::read(dir.join("spec.json")).unwrap()).unwrap() + }; + let mut first = run(&input); + let measured = json!({"comp":{"capHeightPx":12},"chosen":{"family":"Example","stamp":"existing-stamp"}}); + first["regions"][0]["type"] = measured.clone(); + first["regions"][1]["type"] = measured.clone(); + std::fs::write(dir.join("spec.json"), first.to_string()).unwrap(); + input["regions"][1]["box"]["y"] = json!(0.5); + let next = run(&input); + assert_eq!(next["regions"][0]["type"], measured, "unrelated region edit erased typography"); + assert!(next["regions"][1]["type"].is_null(), "moved region reused stale measurement"); + input["regions"][0]["text"] = json!("Different"); + assert!(run(&input)["regions"][0]["type"].is_null()); + let mut prior = run(&input); + prior["regions"][0]["type"] = measured.clone(); + std::fs::write(dir.join("spec.json"), prior.to_string()).unwrap(); + std::fs::copy(fixtures().join("build_flat.png"), dir.join("comp.png")).unwrap(); + assert!(run(&input)["regions"][0]["type"].is_null(), "replaced comp reused stale measurement"); + // Unbound legacy records must be remeasured once, never guessed current. + let mut legacy = run(&input); + legacy.as_object_mut().unwrap().remove("compSha256"); + legacy["regions"][0]["type"] = measured; + std::fs::write(dir.join("spec.json"), legacy.to_string()).unwrap(); + assert!(run(&input)["regions"][0]["type"].is_null()); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/context/src/component_review/manifest.rs b/crates/context/src/component_review/manifest.rs index 4d140ab0b..4744dc6ef 100644 --- a/crates/context/src/component_review/manifest.rs +++ b/crates/context/src/component_review/manifest.rs @@ -182,8 +182,11 @@ pub fn freeze(project: &Path, input: &Value) -> Result<(Value, BTreeMap= 0, w/h > 0, x+w and y+h <= 1 (tolerance 0.00001).", c["box"])); } for k in ["name", "medium", "note"] { if !c[k].is_string() { diff --git a/crates/context/src/component_review/tests.rs b/crates/context/src/component_review/tests.rs index 2cc7b7fd3..81547b79c 100644 --- a/crates/context/src/component_review/tests.rs +++ b/crates/context/src/component_review/tests.rs @@ -677,3 +677,17 @@ fn review_groups_preserve_instances_and_require_a_shared_code_document() { input["components"][2]["preview"]["path"]=json!("different.html"); assert!(manifest::freeze(&f.project,&input).unwrap_err().contains("share one code document")); } + +#[test] +fn invalid_component_geometry_names_the_component_and_bounds() { + let f = Fixture::new(); + let mut input = f.manifest(); + input["components"][0]["box"]["w"] = json!(2); + let error = manifest::freeze(&f.project, &input).unwrap_err(); + assert!(error.contains("art") && error.contains("box") && error.contains("2") && error.contains("normalized"), "{error}"); + let mut input = f.manifest(); + let duplicate = input["components"][0].clone(); + input["components"].as_array_mut().unwrap().push(duplicate); + let error = manifest::freeze(&f.project, &input).unwrap_err(); + assert!(error.contains("duplicate component id") && error.contains("art"), "{error}"); +} diff --git a/tests/oracle/DELTAS.md b/tests/oracle/DELTAS.md index 328ff2c80..585faedbc 100644 --- a/tests/oracle/DELTAS.md +++ b/tests/oracle/DELTAS.md @@ -176,3 +176,7 @@ stderr are unchanged. The golden was updated to enforce these exact results; this is not an open-ended accepted delta. Frozen function call vectors remain unchanged. The Rust narrow-region regression independently checks that changing only neighbouring pixels leaves the measured crop identical. + +## Recorded 2026-09-17: reference-bound typography reuse + +- `comp-spec-regions`: the written spec adds `compSha256`, a SHA-256 of decoded dimensions and pixels. This binds retained typography to the exact reference when regions are remeasured. Structured comparison verified that only this field changed; stdout, stderr, exit status, regions, palettes and bounds are identical. The failing/passing regression separately verifies preservation and invalidation. diff --git a/tests/oracle/golden/comp-spec-regions.json b/tests/oracle/golden/comp-spec-regions.json index 4d98c44d7..75a047fe9 100644 --- a/tests/oracle/golden/comp-spec-regions.json +++ b/tests/oracle/golden/comp-spec-regions.json @@ -4,6 +4,6 @@ "exit": 0, "signal": null, "files": { - ".impeccable/build/spec.json": "{\n \"tool\": \"comp-spec\",\n \"version\": 1,\n \"createdAt\": \"\",\n \"comp\": \"comp.png\",\n \"warnings\": [],\n \"uncoveredInkCells\": [\n \"A2\",\n \"B2\",\n \"C2\",\n \"E5\",\n \"F5\",\n \"G5\",\n \"H5\",\n \"I5\",\n \"J5\",\n \"E6\",\n \"A9\",\n \"B9\",\n \"C9\"\n ],\n \"compSize\": {\n \"width\": 768,\n \"height\": 512\n },\n \"aspect\": 1.5,\n \"orientation\": \"landscape\",\n \"palette\": [\n {\n \"hex\": \"#f7e8e8\",\n \"coverage\": 0.6868\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.1403\n },\n {\n \"hex\": \"#9a9aa5\",\n \"coverage\": 0.0683\n },\n {\n \"hex\": \"#c5c5d1\",\n \"coverage\": 0.0672\n },\n {\n \"hex\": \"#7c7c82\",\n \"coverage\": 0.0374\n }\n ],\n \"bands\": [\n {\n \"y\": 0.0588,\n \"strength\": 1\n },\n {\n \"y\": 0.0941,\n \"strength\": 0.4087\n },\n {\n \"y\": 0.1412,\n \"strength\": 0.7036\n },\n {\n \"y\": 0.1765,\n \"strength\": 0.6963\n },\n {\n \"y\": 0.2,\n \"strength\": 0.6026\n },\n {\n \"y\": 0.2353,\n \"strength\": 0.6125\n },\n {\n \"y\": 0.4706,\n \"strength\": 0.3982\n },\n {\n \"y\": 0.5059,\n \"strength\": 0.9754\n },\n {\n \"y\": 0.5412,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.5765,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.6,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.6353,\n \"strength\": 0.4894\n },\n {\n \"y\": 0.6588,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.6941,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.7176,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.7529,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.7765,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.8118,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.8353,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.8824,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.9059,\n \"strength\": 0.3028\n },\n {\n \"y\": 0.9765,\n \"strength\": 0.3039\n }\n ],\n \"regions\": [\n {\n \"id\": \"top\",\n \"kind\": \"chrome\",\n \"note\": \"top masthead band area\",\n \"grid\": \"A0:J1\",\n \"box\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 1,\n \"h\": 0.2\n },\n \"px\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 102\n },\n \"aspect\": 7.5294,\n \"palette\": [\n {\n \"hex\": \"#f5e7e8\",\n \"coverage\": 0.392\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.3825\n },\n {\n \"hex\": \"#bdbdc8\",\n \"coverage\": 0.1348\n },\n {\n \"hex\": \"#8b8b96\",\n \"coverage\": 0.0908\n }\n ],\n \"detail\": {\n \"energy\": 39.1367\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n },\n {\n \"id\": \"art\",\n \"kind\": \"plate\",\n \"note\": \"an exploded illustration drawing\",\n \"grid\": \"E2:J4\",\n \"box\": {\n \"x\": 0.4,\n \"y\": 0.2,\n \"w\": 0.6,\n \"h\": 0.3\n },\n \"px\": {\n \"x\": 307,\n \"y\": 102,\n \"w\": 461,\n \"h\": 154\n },\n \"aspect\": 2.9935,\n \"palette\": [\n {\n \"hex\": \"#e6dfe5\",\n \"coverage\": 0.5326\n },\n {\n \"hex\": \"#a2a2ad\",\n \"coverage\": 0.383\n },\n {\n \"hex\": \"#7f7f88\",\n \"coverage\": 0.0844\n }\n ],\n \"detail\": {\n \"energy\": 38.5615\n },\n \"medium\": \"raster\",\n \"plate\": \"assets/plates/art.png\",\n \"text\": null\n },\n {\n \"id\": \"body\",\n \"kind\": \"text\",\n \"note\": \"a paragraph of body text content\",\n \"grid\": \"A5:D8\",\n \"box\": {\n \"x\": 0,\n \"y\": 0.5,\n \"w\": 0.4,\n \"h\": 0.4\n },\n \"px\": {\n \"x\": 0,\n \"y\": 256,\n \"w\": 307,\n \"h\": 205\n },\n \"aspect\": 1.4976,\n \"palette\": [\n {\n \"hex\": \"#f8e8e8\",\n \"coverage\": 0.6826\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.223\n },\n {\n \"hex\": \"#787878\",\n \"coverage\": 0.0848\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0096\n }\n ],\n \"detail\": {\n \"energy\": 32.0304\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n }\n ]\n}" + ".impeccable/build/spec.json": "{\n \"tool\": \"comp-spec\",\n \"version\": 1,\n \"createdAt\": \"\",\n \"comp\": \"comp.png\",\n \"warnings\": [],\n \"uncoveredInkCells\": [\n \"A2\",\n \"B2\",\n \"C2\",\n \"E5\",\n \"F5\",\n \"G5\",\n \"H5\",\n \"I5\",\n \"J5\",\n \"E6\",\n \"A9\",\n \"B9\",\n \"C9\"\n ],\n \"compSize\": {\n \"width\": 768,\n \"height\": 512\n },\n \"aspect\": 1.5,\n \"orientation\": \"landscape\",\n \"palette\": [\n {\n \"hex\": \"#f7e8e8\",\n \"coverage\": 0.6868\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.1403\n },\n {\n \"hex\": \"#9a9aa5\",\n \"coverage\": 0.0683\n },\n {\n \"hex\": \"#c5c5d1\",\n \"coverage\": 0.0672\n },\n {\n \"hex\": \"#7c7c82\",\n \"coverage\": 0.0374\n }\n ],\n \"bands\": [\n {\n \"y\": 0.0588,\n \"strength\": 1\n },\n {\n \"y\": 0.0941,\n \"strength\": 0.4087\n },\n {\n \"y\": 0.1412,\n \"strength\": 0.7036\n },\n {\n \"y\": 0.1765,\n \"strength\": 0.6963\n },\n {\n \"y\": 0.2,\n \"strength\": 0.6026\n },\n {\n \"y\": 0.2353,\n \"strength\": 0.6125\n },\n {\n \"y\": 0.4706,\n \"strength\": 0.3982\n },\n {\n \"y\": 0.5059,\n \"strength\": 0.9754\n },\n {\n \"y\": 0.5412,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.5765,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.6,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.6353,\n \"strength\": 0.4894\n },\n {\n \"y\": 0.6588,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.6941,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.7176,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.7529,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.7765,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.8118,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.8353,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.8824,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.9059,\n \"strength\": 0.3028\n },\n {\n \"y\": 0.9765,\n \"strength\": 0.3039\n }\n ],\n \"regions\": [\n {\n \"id\": \"top\",\n \"kind\": \"chrome\",\n \"note\": \"top masthead band area\",\n \"grid\": \"A0:J1\",\n \"box\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 1,\n \"h\": 0.2\n },\n \"px\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 102\n },\n \"aspect\": 7.5294,\n \"palette\": [\n {\n \"hex\": \"#f5e7e8\",\n \"coverage\": 0.392\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.3825\n },\n {\n \"hex\": \"#bdbdc8\",\n \"coverage\": 0.1348\n },\n {\n \"hex\": \"#8b8b96\",\n \"coverage\": 0.0908\n }\n ],\n \"detail\": {\n \"energy\": 39.1367\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n },\n {\n \"id\": \"art\",\n \"kind\": \"plate\",\n \"note\": \"an exploded illustration drawing\",\n \"grid\": \"E2:J4\",\n \"box\": {\n \"x\": 0.4,\n \"y\": 0.2,\n \"w\": 0.6,\n \"h\": 0.3\n },\n \"px\": {\n \"x\": 307,\n \"y\": 102,\n \"w\": 461,\n \"h\": 154\n },\n \"aspect\": 2.9935,\n \"palette\": [\n {\n \"hex\": \"#e6dfe5\",\n \"coverage\": 0.5326\n },\n {\n \"hex\": \"#a2a2ad\",\n \"coverage\": 0.383\n },\n {\n \"hex\": \"#7f7f88\",\n \"coverage\": 0.0844\n }\n ],\n \"detail\": {\n \"energy\": 38.5615\n },\n \"medium\": \"raster\",\n \"plate\": \"assets/plates/art.png\",\n \"text\": null\n },\n {\n \"id\": \"body\",\n \"kind\": \"text\",\n \"note\": \"a paragraph of body text content\",\n \"grid\": \"A5:D8\",\n \"box\": {\n \"x\": 0,\n \"y\": 0.5,\n \"w\": 0.4,\n \"h\": 0.4\n },\n \"px\": {\n \"x\": 0,\n \"y\": 256,\n \"w\": 307,\n \"h\": 205\n },\n \"aspect\": 1.4976,\n \"palette\": [\n {\n \"hex\": \"#f8e8e8\",\n \"coverage\": 0.6826\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.223\n },\n {\n \"hex\": \"#787878\",\n \"coverage\": 0.0848\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0096\n }\n ],\n \"detail\": {\n \"energy\": 32.0304\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n }\n ],\n \"compSha256\": \"4c640a415d36339b4895de01ef99f1ab7726d8e4cf70029652485137c24d914e\"\n}" } }