feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)

This commit is contained in:
2026-05-22 21:54:38 +09:00
parent bd8bcf748b
commit 6f1c7367e0
18 changed files with 2311 additions and 32 deletions

264
src/image_id_stamper.py Normal file
View 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

View File

@@ -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 (0100, 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 (0100, 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 (0100, "
"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
# 0100; 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,
)

View File

@@ -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():