feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)
This commit is contained in:
264
src/image_id_stamper.py
Normal file
264
src/image_id_stamper.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
|
||||
|
||||
Annotates user-content ``<img>`` elements with a stable id + role
|
||||
attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize
|
||||
handles and the backend CSS injector (u7) can re-apply persisted geometry
|
||||
on the next render.
|
||||
|
||||
DOM selector contract (single point of truth shared across the axis) :
|
||||
|
||||
.slide img[data-image-role="user-content"]
|
||||
|
||||
This selector is mirrored verbatim in :
|
||||
|
||||
- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target)
|
||||
- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference)
|
||||
- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit)
|
||||
|
||||
Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative
|
||||
icons) are NOT stamped, so they are NOT matched by the selector and remain
|
||||
unaffected. The allowlist that decides "what counts as user-content" is
|
||||
passed in by the caller (typically ``stage0_normalized_assets["images"]``);
|
||||
this module does not encode the source-of-truth itself.
|
||||
|
||||
Stable id contract :
|
||||
|
||||
image_id = "img-" + sha1(src)[:10]
|
||||
|
||||
Deterministic across renders so persisted ``image_overrides`` entries
|
||||
(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply
|
||||
automatically. Duplicate srcs in the same slide get an ordinal suffix
|
||||
("-1", "-2", ...) appended in DOM order; the first occurrence has no
|
||||
suffix.
|
||||
|
||||
Forward-compat : current Phase Z final.html emits zero user-content
|
||||
``<img>`` elements (``stage0_normalized_assets["images"]`` is empty across
|
||||
all recent verify runs). ``stamp_user_content_images(html, sources=())``
|
||||
is a pure no-op in that case — returns ``(html, [])`` without scanning.
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : the allowlist is caller-supplied, never inferred from
|
||||
sample filenames or path heuristics.
|
||||
- Idempotent : stamping a previously-stamped tag is a no-op (the
|
||||
``data-image-role`` probe short-circuits before re-injecting).
|
||||
- AI-isolation : this module is pure deterministic Python; no LLM calls.
|
||||
- Carve-out (IMP-46 #62) : brand-new module, does not touch the
|
||||
#76 commit ``1186ad8`` cache region.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]'
|
||||
|
||||
IMAGE_ROLE_ATTR: str = "data-image-role"
|
||||
IMAGE_ROLE_VALUE: str = "user-content"
|
||||
IMAGE_ID_ATTR: str = "data-image-id"
|
||||
|
||||
# Matches a single ``<img ...>`` tag. Permissive on attribute order and
|
||||
# whitespace; captures the inner attribute string + an optional XHTML
|
||||
# self-close slash. Phase Z renders well-formed Jinja2 output (no inline
|
||||
# ``<`` in attribute values), so a regex is safe here without pulling in
|
||||
# an HTML parser.
|
||||
_IMG_TAG_RE = re.compile(
|
||||
r"<img\b([^>]*?)(/?)>",
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double,
|
||||
# group 2 = single. Quote style is preserved by callers that re-emit the
|
||||
# tag verbatim.
|
||||
_SRC_ATTR_RE = re.compile(
|
||||
r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Probe for an existing ``data-image-role`` attribute (any value, any
|
||||
# quote) so re-stamping is idempotent.
|
||||
_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def stable_image_id(src: str, ordinal: int = 0) -> str:
|
||||
"""Return the deterministic ``image_id`` for ``src``.
|
||||
|
||||
``ordinal`` disambiguates repeated occurrences of the same ``src`` in
|
||||
the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...).
|
||||
"""
|
||||
if not isinstance(src, str):
|
||||
raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}")
|
||||
if ordinal < 0:
|
||||
raise ValueError(f"ordinal must be >= 0, got {ordinal}")
|
||||
digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10]
|
||||
base = f"img-{digest}"
|
||||
return base if ordinal == 0 else f"{base}-{ordinal}"
|
||||
|
||||
|
||||
def stamp_user_content_images(
|
||||
html: str,
|
||||
sources: Iterable[str] = (),
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Stamp user-content ``<img>`` tags in ``html`` with role + stable id.
|
||||
|
||||
``sources`` is the allowlist of ``src`` attribute values that count as
|
||||
user-content (typically ``stage0_normalized_assets["images"]``). Any
|
||||
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
|
||||
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
|
||||
Other ``<img>`` tags (decorative, figma, frame-internal) are left
|
||||
unchanged byte-for-byte.
|
||||
|
||||
Returns ``(modified_html, stamped_image_ids)`` where the id list is
|
||||
in DOM (left-to-right) order. The list may contain duplicates only
|
||||
via the ordinal-suffix path (``img-<hash>``, ``img-<hash>-1``, ...);
|
||||
ordering is what the caller persists as the canonical key sequence.
|
||||
|
||||
Forward-compat : empty / all-non-string ``sources`` → pure no-op
|
||||
(``html`` returned unchanged, empty list). This is the current Phase
|
||||
Z state since ``stage0_normalized_assets["images"]`` is empty.
|
||||
"""
|
||||
allow = {s for s in sources if isinstance(s, str) and s}
|
||||
if not allow:
|
||||
return html, []
|
||||
|
||||
stamped: list[str] = []
|
||||
seen_ordinal: dict[str, int] = {}
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
attrs = match.group(1) or ""
|
||||
self_close = match.group(2) or ""
|
||||
src_match = _SRC_ATTR_RE.search(attrs)
|
||||
if src_match is None:
|
||||
return match.group(0)
|
||||
src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2)
|
||||
if src not in allow:
|
||||
return match.group(0)
|
||||
if _ROLE_ATTR_RE.search(attrs):
|
||||
return match.group(0)
|
||||
ordinal = seen_ordinal.get(src, 0)
|
||||
seen_ordinal[src] = ordinal + 1
|
||||
image_id = stable_image_id(src, ordinal=ordinal)
|
||||
stamped.append(image_id)
|
||||
injected = (
|
||||
f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"'
|
||||
f' {IMAGE_ID_ATTR}="{image_id}"'
|
||||
)
|
||||
return f"<img{injected}{attrs}{self_close}>"
|
||||
|
||||
new_html = _IMG_TAG_RE.sub(_replace, html)
|
||||
return new_html, stamped
|
||||
|
||||
|
||||
# ─── IMP-51 (#79) u7 — render-time CSS injection ──────────────────────────
|
||||
|
||||
# Marker comments wrap the injected ``<style>`` block so re-injection on a
|
||||
# previously-injected document is idempotent (the wrapper is found by a
|
||||
# simple substring probe and the inner CSS is replaced in place).
|
||||
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
|
||||
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
|
||||
|
||||
_IMP51_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
|
||||
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_image_overrides_style(
|
||||
image_overrides: dict,
|
||||
stamped_ids: Iterable[str],
|
||||
) -> str:
|
||||
"""Build CSS rule text for persisted ``image_overrides``.
|
||||
|
||||
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
|
||||
order of stamps returned by :func:`stamp_user_content_images`) AND
|
||||
``image_overrides`` (the persisted geometry mapping from ``u1``
|
||||
``user_overrides_io``), emit one absolute-position rule of the form ::
|
||||
|
||||
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
|
||||
position: absolute;
|
||||
left: <x>%; top: <y>%;
|
||||
width: <w>%; height: <h>%;
|
||||
}
|
||||
|
||||
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
|
||||
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
|
||||
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
|
||||
resolve against the slide frame.
|
||||
|
||||
Rules are emitted in ``stamped_ids`` order so the output is
|
||||
byte-deterministic across renders (critical for diff-based verifiers).
|
||||
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
|
||||
those keys cannot be produced via the SlideCanvas pathway (the
|
||||
frontend only knows the ids actually present in the DOM). Per-entry
|
||||
malformed geometries (non-dict / missing axis / non-coercible value)
|
||||
are dropped silently; the whole batch is never rejected.
|
||||
|
||||
Returns ``""`` when no rules are emitted so the caller can skip
|
||||
``<style>`` injection entirely (forward-compat no-op when Phase Z
|
||||
final.html still emits zero user-content imgs).
|
||||
"""
|
||||
if not image_overrides:
|
||||
return ""
|
||||
rules: list[str] = []
|
||||
for iid in stamped_ids:
|
||||
geom = image_overrides.get(iid)
|
||||
if not isinstance(geom, dict):
|
||||
continue
|
||||
try:
|
||||
x = float(geom["x"])
|
||||
y = float(geom["y"])
|
||||
w = float(geom["w"])
|
||||
h = float(geom["h"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
rules.append(
|
||||
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
|
||||
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
|
||||
f"position: absolute; "
|
||||
f"left: {x}%; top: {y}%; "
|
||||
f"width: {w}%; height: {h}%; "
|
||||
f"}}"
|
||||
)
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
def inject_image_overrides_style(html: str, css: str) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
|
||||
preserves the byte-for-byte identity of forward-compat renders where
|
||||
no overrides apply.
|
||||
|
||||
When a previously-injected marker block is present, its inner CSS is
|
||||
replaced in place (idempotent re-injection — second call with the
|
||||
same overrides produces an identical document).
|
||||
|
||||
Injection precedence when no existing marker is found :
|
||||
|
||||
1. Before the first ``</head>`` (case-insensitive)
|
||||
2. Immediately after the first ``<body ...>`` open tag
|
||||
3. At the start of the document
|
||||
|
||||
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
|
||||
for production renders; paths 2/3 are defensive fallbacks for
|
||||
unusual fragment inputs (tests, partials).
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP51_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP51_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP51_STYLE_MARKER_OPEN in html:
|
||||
return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1)
|
||||
head_close = _HEAD_CLOSE_RE.search(html)
|
||||
if head_close is not None:
|
||||
idx = head_close.start()
|
||||
return html[:idx] + block + "\n" + html[idx:]
|
||||
body_open = _BODY_OPEN_RE.search(html)
|
||||
if body_open is not None:
|
||||
idx = body_open.end()
|
||||
return html[:idx] + "\n" + block + html[idx:]
|
||||
return block + "\n" + html
|
||||
@@ -3406,6 +3406,7 @@ def run_phase_z2_mvp1(
|
||||
override_frames: Optional[dict[str, str]] = None,
|
||||
override_zone_geometries: Optional[dict[str, dict]] = None,
|
||||
override_section_assignments: Optional[dict[str, list[str]]] = None,
|
||||
override_image_overrides: Optional[dict[str, dict]] = None,
|
||||
) -> Path:
|
||||
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
|
||||
|
||||
@@ -3419,6 +3420,15 @@ def run_phase_z2_mvp1(
|
||||
으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1"
|
||||
또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면
|
||||
그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경.
|
||||
override_image_overrides : {image_id: {x, y, w, h}} — IMP-51 (#79) u5 axis.
|
||||
image_id = stable id stamped on user-content `<img>` tags by
|
||||
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
|
||||
coordinates (0–100, slide-absolute). Forward-compat kwarg: the
|
||||
render-time CSS injection that consumes this mapping lands in
|
||||
u7; until u7 wires the consumer, accepting the kwarg keeps the
|
||||
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
|
||||
client u3 + stamper u4) end-to-end addressable from CLI without
|
||||
diverging the function signature.
|
||||
"""
|
||||
mdx_path = Path(mdx_path)
|
||||
if run_id is None:
|
||||
@@ -5375,6 +5385,36 @@ def run_phase_z2_mvp1(
|
||||
# 7. Render single slide
|
||||
html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css)
|
||||
|
||||
# IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id /
|
||||
# role attrs, then inject persisted `image_overrides` CSS so the
|
||||
# next render re-applies the user-edited geometry.
|
||||
#
|
||||
# Forward-compat: `stage0_normalized_assets["images"]` is empty in
|
||||
# every current Phase Z run (Q1 = A confirmed at Stage 1), so the
|
||||
# stamper returns an empty `stamped_image_ids` list and the CSS
|
||||
# builder short-circuits to "". The HTML is therefore byte-for-byte
|
||||
# identical to the pre-IMP-51 output until Phase Z starts emitting
|
||||
# user-content imgs (separate axis, out of scope for #79).
|
||||
from src.image_id_stamper import (
|
||||
build_image_overrides_style,
|
||||
inject_image_overrides_style,
|
||||
stamp_user_content_images,
|
||||
)
|
||||
_user_content_image_srcs = [
|
||||
(entry.get("path") or entry.get("src") or "")
|
||||
for entry in (stage0_normalized_assets.get("images") or [])
|
||||
if isinstance(entry, dict)
|
||||
]
|
||||
html, _stamped_image_ids = stamp_user_content_images(
|
||||
html, sources=_user_content_image_srcs,
|
||||
)
|
||||
if override_image_overrides:
|
||||
_image_overrides_css = build_image_overrides_style(
|
||||
override_image_overrides, _stamped_image_ids,
|
||||
)
|
||||
if _image_overrides_css:
|
||||
html = inject_image_overrides_style(html, _image_overrides_css)
|
||||
|
||||
# 8. Write final.html
|
||||
out_path = run_dir / "final.html"
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
@@ -5844,6 +5884,27 @@ if __name__ == "__main__":
|
||||
"--override-section-assignment bottom=03-2,03-3"
|
||||
),
|
||||
)
|
||||
# IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped
|
||||
# on user-content `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
|
||||
# percent-of-slide coordinates (0–100, slide-absolute), consistent with
|
||||
# the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and
|
||||
# the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time
|
||||
# CSS injection consuming this mapping lands in u7; u5 is the CLI surface.
|
||||
parser.add_argument(
|
||||
"--override-image",
|
||||
dest="override_image_overrides",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="IMAGE_ID=X,Y,W,H",
|
||||
help=(
|
||||
"user-content image 의 slide-absolute geometry 강제. IMAGE_ID = "
|
||||
"src/image_id_stamper.py 가 stamp 한 `data-image-id` value "
|
||||
"(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0–100, "
|
||||
"slide-absolute) — typed client ImageOverride shape 와 일치. "
|
||||
"multiple flags: --override-image img-abc=10,15,30,25 "
|
||||
"--override-image img-def=50,15,40,40"
|
||||
),
|
||||
)
|
||||
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
|
||||
# bypasses the ``user_approved`` gate only (``visual_check_passed``
|
||||
# is never bypassable). Source of truth is
|
||||
@@ -5943,6 +6004,54 @@ if __name__ == "__main__":
|
||||
_seen_sections_across_zones[sid] = zid
|
||||
overrides_section_assignments[zid] = section_ids
|
||||
|
||||
# IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]].
|
||||
# Mirrors --override-zone-geometry parsing pattern: each flag is
|
||||
# IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors
|
||||
# on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID
|
||||
# / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed
|
||||
# client (u3 ImageOverride) both expect percent-of-slide values in
|
||||
# 0–100; the CLI accepts floats without range clamping here so the
|
||||
# error remains the user's mistake to read rather than a silent shift.
|
||||
overrides_images: dict[str, dict[str, float]] = {}
|
||||
for ov in args.override_image_overrides:
|
||||
if "=" not in ov:
|
||||
print(
|
||||
f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
iid, vals = ov.split("=", 1)
|
||||
iid = iid.strip()
|
||||
if not iid:
|
||||
print(
|
||||
f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if iid in overrides_images:
|
||||
print(
|
||||
f"[error] --override-image duplicate IMAGE_ID '{iid}' "
|
||||
f"(first assignment kept). Provide each image only once.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
parts = vals.split(",")
|
||||
if len(parts) != 4:
|
||||
print(
|
||||
f"[error] --override-image expects 4 floats X,Y,W,H, got: '{vals}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
try:
|
||||
x, y, w, h = (float(p) for p in parts)
|
||||
except ValueError:
|
||||
print(
|
||||
f"[error] --override-image floats parse fail: '{vals}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
|
||||
|
||||
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
|
||||
# After argparse fully parses CLI flags, fill ONLY the axes the user
|
||||
# did NOT pass on the command line. CLI payload always wins over the
|
||||
@@ -6013,6 +6122,30 @@ if __name__ == "__main__":
|
||||
if _sids:
|
||||
_accepted_sec[_zid] = _sids
|
||||
overrides_section_assignments = _accepted_sec
|
||||
# image_overrides — CLI empty → fill from file (dict[str, dict]).
|
||||
# IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept
|
||||
# mappings of {image_id: {x,y,w,h}} with float-coercible values.
|
||||
if not overrides_images:
|
||||
_file_images = _persisted.get("image_overrides")
|
||||
if isinstance(_file_images, dict):
|
||||
_accepted_img: dict[str, dict] = {}
|
||||
for _iid, _g in _file_images.items():
|
||||
if (
|
||||
isinstance(_iid, str)
|
||||
and _iid
|
||||
and isinstance(_g, dict)
|
||||
and all(k in _g for k in ("x", "y", "w", "h"))
|
||||
):
|
||||
try:
|
||||
_accepted_img[_iid] = {
|
||||
"x": float(_g["x"]),
|
||||
"y": float(_g["y"]),
|
||||
"w": float(_g["w"]),
|
||||
"h": float(_g["h"]),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
overrides_images = _accepted_img
|
||||
|
||||
run_phase_z2_mvp1(
|
||||
args.mdx_path,
|
||||
@@ -6021,4 +6154,5 @@ if __name__ == "__main__":
|
||||
override_frames=overrides_frames or None,
|
||||
override_zone_geometries=overrides_geoms or None,
|
||||
override_section_assignments=overrides_section_assignments or None,
|
||||
override_image_overrides=overrides_images or None,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
"""IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO).
|
||||
|
||||
Persists the four CLI-wired override axes per MDX so a subsequent render
|
||||
Persists the CLI-wired override axes per MDX so a subsequent render
|
||||
auto-restores user choices without re-clicking. Source of truth = MDX-keyed
|
||||
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
|
||||
run_id per ``/api/run`` invocation.
|
||||
|
||||
Schema (4 axes; stable order):
|
||||
Schema (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``):
|
||||
|
||||
{
|
||||
"layout": <string|null>,
|
||||
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
||||
"frames": {<unit_id>: <template_id>}
|
||||
"frames": {<unit_id>: <template_id>},
|
||||
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}}
|
||||
}
|
||||
|
||||
``image_id`` is the stable identifier emitted by the user-content image
|
||||
stamper (IMP-51 u4) and matched via the selector
|
||||
``.slide img[data-image-role="user-content"]``. Coordinates are
|
||||
percent-of-slide (zone-agnostic, slide-absolute) to match the SlideCanvas
|
||||
edit-mode handle conventions in IMP-51 u8~u11.
|
||||
|
||||
``unit_id`` is the convention already used by ``--override-frame`` :
|
||||
``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``).
|
||||
|
||||
@@ -46,10 +53,17 @@ from typing import Any, Optional
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# The four in-scope axes. Any other top-level key in the file is preserved
|
||||
# but ignored by callers — keeps the file forward-compatible with future
|
||||
# axes (e.g., zone_sizes, image_overrides) without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = ("layout", "zone_geometries", "zone_sections", "frames")
|
||||
# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any
|
||||
# other top-level key in the file is preserved but ignored by callers —
|
||||
# keeps the file forward-compatible with future axes (e.g., zone_sizes)
|
||||
# without a schema bump here.
|
||||
KNOWN_AXES: tuple[str, ...] = (
|
||||
"layout",
|
||||
"zone_geometries",
|
||||
"zone_sections",
|
||||
"frames",
|
||||
"image_overrides",
|
||||
)
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
# alphanumerics, underscore, hyphen, and dot in the middle (sample stems
|
||||
@@ -92,7 +106,7 @@ def load(key: str, root: Optional[Path] = None) -> dict[str, Any]:
|
||||
|
||||
Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``.
|
||||
Returns the raw mapping (including any foreign keys); callers should
|
||||
pick the four KNOWN_AXES they care about.
|
||||
pick the KNOWN_AXES they care about.
|
||||
"""
|
||||
path = override_path(key, root=root)
|
||||
if not path.exists():
|
||||
|
||||
Reference in New Issue
Block a user