"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html. Annotates user-content ```` 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 ```` 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 ```` 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"]*?)(/?)>", 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 ```` 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 ```` whose ``src`` value is in ``sources`` is rewritten to include ``data-image-role="user-content"`` and ``data-image-id=""``. Other ```` 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-``, ``img--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"" 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 ``\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