Files
C.E.L_Slide_test2/src/image_id_stamper.py
T

265 lines
10 KiB
Python

"""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