Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user