Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math

This commit is contained in:
2026-10-13 17:16:50 +08:00
parent c4efbed9b0
commit d671e412ae
26 changed files with 1210 additions and 78 deletions
+159
View File
@@ -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