265 lines
10 KiB
Python
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
|