"""Resolve and check fixed template spacing, without caller-owned thresholds. Semantic relations name spacing roles from the frozen style. Padding applies to the complete available inner region, not occupied text/ink bounds. Legacy templates read existing fields through fixed paths, without numeric defaults. """ from __future__ import annotations import math _LEGACY_ROLES = { "text_padding": ("padding", ("components", "text_group", "padding_px")), "paragraph": ("gap", ("components", "text_group", "paragraph_gap_px")), "item": ("gap", ("components", "text_group", "item_gap_px")), "columns": ("gap", ("grid", "gutter")), "panel_padding": ("padding", ("components", "panel", "padding_px")), "icon_text": ("gap", ("icons", "text_gap_px")), "basic_table_cell_padding": ("padding", ("components", "table", "cell_padding_px")), "grouped_table_cell_padding": ("padding", ("components", "table_grouped", "cell_padding_px")), **{name: ("gap", ("components", "spacing", name + "_px")) for name in ("heading_body", "content_group", "separator", "formula", "connector_clearance")}, } def _number(value, label): if type(value) not in (int, float) or not math.isfinite(value) or value < 0: raise ValueError(f"{label}: expected nonnegative finite number") return float(value) def _name(value, label): if not isinstance(value, str) or not value.strip(): raise ValueError(f"{label}: expected nonempty ID") return value def _padding(value, label): if type(value) in (int, float): return (_number(value, label),) * 4 if not isinstance(value, (list, tuple)) or len(value) not in (2, 4): raise ValueError(f"{label}: padding requires scalar, [vertical,horizontal] or [top,right,bottom,left]") values = tuple(_number(item, label) for item in value) return (values[0], values[1], values[0], values[1]) if len(values) == 2 else values def resolve_spacing(style: dict, role: str) -> float | tuple[float, float, float, float]: """Read an exact gap/padding from a frozen template's named role. New declarations require spacing.policy == 'fixed' and spacing.roles[role] == {kind:'gap'|'padding', path:[field,...]}. Paths traverse dictionary keys only, never executable JSONPath. Without a spacing section, known legacy roles read only their existing fixed paths. """ if not isinstance(style, dict): raise ValueError("style must be an object") _name(role, "spacing role") if "spacing" in style: spacing = style["spacing"] if not isinstance(spacing, dict) or spacing.get("policy") != "fixed": raise ValueError("spacing.policy must be fixed") if spacing.get("caller_overrides", False) is not False: raise ValueError("fixed spacing forbids caller overrides") roles = spacing.get("roles") if not isinstance(roles, dict) or role not in roles: raise ValueError(f"unknown fixed spacing role: {role}") declaration = roles[role] if not isinstance(declaration, dict) or set(declaration) != {"kind", "path"}: raise ValueError(f"{role}: spacing role requires kind/path only") kind, path = declaration["kind"], declaration["path"] if kind not in ("gap", "padding"): raise ValueError(f"{role}: spacing kind must be gap or padding") if not isinstance(path, list) or not path or any(not isinstance(key, str) or not key for key in path): raise ValueError(f"{role}: spacing path requires nonempty field-name list") else: if role not in _LEGACY_ROLES: raise ValueError(f"unknown legacy spacing role: {role}") kind, path = _LEGACY_ROLES[role] value = style for key in path: if not isinstance(value, dict) or key not in value: raise ValueError(f"{role}: missing spacing field at {'.'.join(path)}") value = value[key] return _number(value, role) if kind == "gap" else _padding(value, role) def check_spacing(boxes: dict, checks: list, *, style: dict) -> list[dict]: """Return deviations from exact template-owned directional gaps/padding. Gap checks use ``{id, kind:'gap', a, b, axis:'x'|'y', role}``, measured from a's right/bottom edge to b's left/top edge. Padding checks use ``{id, kind:'padding', outer, inner, role}``, where inner is the complete usable region. Numeric minimum/expected/override fields are rejected. Negative distances report ``overlap`` (gap) or ``outside`` (padding); other deviations report ``too_narrow`` or ``too_wide``. Absolute tolerance 1e-7 absorbs arithmetic roundoff only, not extra layout space. Invalid inputs, missing IDs or self-relations raise ValueError. These are geometry checks, not font ink measurements or visual acceptance. """ if not isinstance(boxes, dict) or not isinstance(checks, list): raise ValueError("boxes must be a mapping and checks must be a list") geometry = {} for key, bounds in boxes.items(): _name(key, "box ID") if not isinstance(bounds, (list, tuple)) or len(bounds) != 4: raise ValueError(f"{key}: bounds requires [x,y,width,height]") geometry[key] = tuple(_number(value, f"{key}.bounds") for value in bounds) def pair(check, first, second): a, b = _name(check[first], first), _name(check[second], second) if a == b: raise ValueError(f"{check['id']}: relation must use two different boxes") if a not in geometry or b not in geometry: raise ValueError(f"{check['id']}: relation references unknown box") return geometry[a], geometry[b] result, seen = [], set() for check in checks: if not isinstance(check, dict): raise ValueError("each spacing check must be an object") key = _name(check.get("id"), "check ID") if key in seen: raise ValueError(f"duplicate check ID: {key}") seen.add(key) kind = check.get("kind") if kind == "gap": if set(check) != {"id", "kind", "a", "b", "axis", "role"}: raise ValueError(f"{key}: gap requires id/kind/a/b/axis/role only; numeric overrides forbidden") if check["axis"] not in ("x", "y"): raise ValueError(f"{key}: axis must be x or y") a, b = pair(check, "a", "b") expected = resolve_spacing(style, check["role"]) if not isinstance(expected, float): raise ValueError(f"{key}: gap relation requires a gap role") index = 0 if check["axis"] == "x" else 1 actual = b[index] - a[index] - a[index + 2] # Derived line heights can differ by floating-point roundoff only. if not math.isclose(actual, expected, rel_tol=0, abs_tol=1e-7): result.append({**check, "actual": actual, "expected": expected, "code": "overlap" if actual < 0 else "too_narrow" if actual < expected else "too_wide"}) elif kind == "padding": if set(check) != {"id", "kind", "outer", "inner", "role"}: raise ValueError(f"{key}: padding requires id/kind/outer/inner/role only; numeric overrides forbidden") outer, inner = pair(check, "outer", "inner") expected = resolve_spacing(style, check["role"]) if not isinstance(expected, tuple): raise ValueError(f"{key}: padding relation requires a padding role") ox, oy, ow, oh = outer ix, iy, iw, ih = inner actuals = (iy - oy, ox + ow - ix - iw, oy + oh - iy - ih, ix - ox) for side, actual, target in zip(("top", "right", "bottom", "left"), actuals, expected): if not math.isclose(actual, target, rel_tol=0, abs_tol=1e-7): result.append({"id": key, "kind": kind, "outer": check["outer"], "inner": check["inner"], "role": check["role"], "side": side, "actual": actual, "expected": target, "code": "outside" if actual < 0 else "too_narrow" if actual < target else "too_wide"}) else: raise ValueError(f"{key}: unsupported spacing check kind") return result