158 lines
8.6 KiB
Python
158 lines
8.6 KiB
Python
"""Read-only native SVG conservation check. Python 3 stdlib; never overwrite a receipt.
|
|
|
|
Usage: check-wrapped-svg.py BASELINE.svg CANDIDATE.svg E|F NEW-OUTPUT.json
|
|
Both native export directories must retain their original eight-card.json input.
|
|
"""
|
|
from pathlib import Path
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
TARGET = "63009b5f-539e-5914-b630-ec6b7fefb4d5"
|
|
NAME = "Technician service conversation"
|
|
FIXTURE_SHA = "9db1015369f450d5810c96a01fddb8a009d57f6aafbda5182703f15ded23d9c8"
|
|
NS = "{http://www.w3.org/2000/svg}"
|
|
# Independent expected logical metrics printed by the pinned Rust/Iced tests at
|
|
# ebb6a9980b11c4d4df2f7e413c6b135a337f6f6d. Never infer these from candidate SVG.
|
|
# Lines: canonical display text, left x, top y, shaped baseline y, glyph advance.
|
|
METRICS = {
|
|
"E": {"rect": [1102.4126, 254.5, 119.174805, 40.0], "lines": [
|
|
["Technician service", 1110.4126, 258.5, 271.8828, 103.174805],
|
|
["conversation", 1125.4089, 274.5, 287.8828, 73.18213],
|
|
]},
|
|
"F": {"rect": [1180.0, 226.2, 89.18213, 56.0], "lines": [
|
|
["Technician", 1194.2905, 230.2, 243.58281, 60.601074],
|
|
["service", 1205.0847, 246.2, 259.58282, 39.012695],
|
|
["conversation", 1188.0, 262.2, 275.58282, 73.18213],
|
|
]},
|
|
}
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise ValueError(message)
|
|
|
|
def digest(data):
|
|
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
|
|
|
def close(actual, expected, label):
|
|
value = float(actual)
|
|
require(math.isfinite(value) and abs(value - expected) <= 0.0002,
|
|
f"{label}: expected {expected}, got {actual}")
|
|
|
|
def element_attrs(element, tag, fixed, numeric):
|
|
require(element.tag == NS + tag, f"Expected {tag}, got {element.tag}")
|
|
require(set(element.attrib) == set(fixed) | set(numeric), f"Unexpected attributes on {tag}")
|
|
for key, value in fixed.items():
|
|
require(element.get(key) == value, f"{tag}.{key} changed")
|
|
for key, value in numeric.items():
|
|
close(element.get(key), value, tag + "." + key)
|
|
|
|
def strip_immediate_label(raw, candidate):
|
|
edge = re.compile(rb'<path\b[^>]*\bdata-edge="' + TARGET.encode() + rb'"[^>]*>.*?</path>', re.DOTALL)
|
|
matches = list(edge.finditer(raw))
|
|
require(len(matches) == 1, "Target edge must occur exactly once")
|
|
start = matches[0].end()
|
|
pattern = (rb'<g data-label-experiment="wrapped">.*?</g>' if candidate
|
|
else rb'<rect\b[^>]*/><text\b[^>]*>[^<]*</text>')
|
|
label = re.compile(pattern, re.DOTALL).match(raw, start)
|
|
require(label is not None, "Target label must immediately follow its edge")
|
|
return raw[:start] + raw[label.end():], raw[start:label.end()]
|
|
|
|
def check(args, report):
|
|
baseline, candidate = Path(args.baseline), Path(args.candidate)
|
|
before, after = baseline.read_bytes(), candidate.read_bytes()
|
|
report["inputs"] = {"baseline_svg": {"path": str(baseline), **digest(before)},
|
|
"candidate_svg": {"path": str(candidate), **digest(after)}}
|
|
fixture_bytes = []
|
|
for key, svg in [("baseline_project", baseline), ("candidate_project", candidate)]:
|
|
path = svg.parent / "eight-card.json"
|
|
data = path.read_bytes()
|
|
report["inputs"][key] = {"path": str(path), **digest(data)}
|
|
require(digest(data)["sha256"] == FIXTURE_SHA, key + " differs from pinned full Project")
|
|
fixture_bytes.append(data)
|
|
require(fixture_bytes[0] == fixture_bytes[1], "Full input Projects differ")
|
|
roots = [ET.fromstring(data) for data in [before, after]]
|
|
for root in roots:
|
|
require(root.tag == NS + "svg", "Expected SVG root")
|
|
require(roots[0].attrib == roots[1].attrib, "Root attributes/viewBox changed")
|
|
require(roots[0].get("viewBox") == "24 -12 1566 874", "Pinned full scene viewBox changed")
|
|
edges = [[e for e in root.iter() if e.get("data-edge") is not None] for root in roots]
|
|
for found in edges:
|
|
require(len(found) == 12 and len({e.get("data-edge") for e in found}) == 12,
|
|
"Expected all 12 distinct edge UUIDs")
|
|
require([ET.tostring(e) for e in edges[0]] == [ET.tostring(e) for e in edges[1]],
|
|
"Edge order, UUID, path, orientation/marker, drawing attributes or title changed")
|
|
for root in roots:
|
|
children = list(root)
|
|
target_index = next(i for i, e in enumerate(children) if e.get("data-edge") == TARGET)
|
|
require(children[target_index].find(NS + "title").text == NAME, "Canonical edge title changed")
|
|
bchildren = list(roots[0])
|
|
bi = next(i for i, e in enumerate(bchildren) if e.get("data-edge") == TARGET)
|
|
element_attrs(bchildren[bi + 1], "rect", {"fill": "white"},
|
|
{"x": 1180., "y": 262.5, "width": 233., "height": 24.})
|
|
element_attrs(bchildren[bi + 2], "text", {"text-anchor": "middle", "dominant-baseline": "central",
|
|
"font-family": "sans-serif", "fill": "#637487"}, {"x": 1296.5, "y": 274.5, "font-size": 13.})
|
|
require(bchildren[bi + 2].text == NAME, "Baseline target is not canonical one-line label")
|
|
groups = [e for e in roots[1].iter() if e.get("data-label-experiment") is not None]
|
|
require(len(groups) == 1, "Exactly one experimental label group is allowed")
|
|
group = groups[0]
|
|
element_attrs(group, "g", {"data-label-experiment": "wrapped"}, {})
|
|
cchildren = list(roots[1])
|
|
ci = next(i for i, e in enumerate(cchildren) if e.get("data-edge") == TARGET)
|
|
require(cchildren[ci + 1] is group, "Wrapped group belongs immediately to target edge")
|
|
expected = METRICS[args.profile]
|
|
parts = list(group)
|
|
require(len(parts) == 2 + len(expected["lines"]), "Unexpected wrapped group contents")
|
|
element_attrs(parts[0], "title", {}, {})
|
|
require(parts[0].text == NAME and not list(parts[0]), "Canonical group title changed")
|
|
element_attrs(parts[1], "rect", {"fill": "white"}, dict(zip(["x", "y", "width", "height"], expected["rect"])))
|
|
for actual, (name, x, top, baseline_y, width) in zip(parts[2:], expected["lines"]):
|
|
element_attrs(actual, "text", {"font-family": "Segoe UI", "fill": "#637487"},
|
|
{"x": x, "y": baseline_y, "font-size": 13.})
|
|
require(actual.text == name and not list(actual), "Ordered complete display line changed")
|
|
require(" ".join(e.text for e in parts[2:]) == NAME, "Wrapping altered the complete canonical text")
|
|
close(expected["rect"][2], max(line[4] for line in expected["lines"]) + 16., "Expected advance plus padding")
|
|
close(expected["rect"][3], 8. + 16. * len(expected["lines"]), "Expected line height plus padding")
|
|
bremaining, bremoved = strip_immediate_label(before, False)
|
|
cremaining, cremoved = strip_immediate_label(after, True)
|
|
require(bremaining == cremaining,
|
|
"Raw SVG bytes outside ONLY target's immediate label presentation changed")
|
|
report.update({
|
|
"status": "PASS", "full_project_bytes_equal": True,
|
|
"edge_uuids": [e.get("data-edge") for e in edges[0]],
|
|
"all_12_edge_records_exact": True, "root_and_viewBox_exact": True,
|
|
"all_remaining_svg_bytes_exact": True, "remaining_svg": digest(bremaining),
|
|
"removed_baseline_presentation": digest(bremoved), "removed_candidate_presentation": digest(cremoved),
|
|
"expected_logical_metrics": expected, "numeric_absolute_tolerance": 0.0002,
|
|
"conservation_scope": "All edge/path/orientation/drawing, card, port and unrelated label bytes preserved; only target immediate label presentation removed for comparison.",
|
|
"limits": "Logical SVG conservation and declared typography coordinates only. No browser/font availability, glyph/raster parity, screenshot reading, native input-event history, save/reopen or usability claim.",
|
|
})
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("baseline")
|
|
parser.add_argument("candidate")
|
|
parser.add_argument("profile", choices=["E", "F"])
|
|
parser.add_argument("output")
|
|
args = parser.parse_args()
|
|
report = {"status": "FAIL", "profile": args.profile,
|
|
"script": digest(Path(__file__).read_bytes()),
|
|
"metrics_source": "ebb6a9980b11c4d4df2f7e413c6b135a337f6f6d Rust/Iced test stdout; wrapped-label-regression-first.log"}
|
|
# Exclusive creation precedes reads; a failed prior run is never overwritten.
|
|
with Path(args.output).open("x", encoding="utf-8") as output:
|
|
try:
|
|
check(args, report)
|
|
except Exception as error:
|
|
report["failure"] = {"type": type(error).__name__, "message": str(error)}
|
|
json.dump(report, output, indent=2)
|
|
output.write("\n")
|
|
print(json.dumps(report, indent=2))
|
|
return 0 if report["status"] == "PASS" else 1
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|