"""IMP-56 (#90) u4 — text_override resolver (validator + apply). Step-22 user text-edit persist axis. Consumed by Step 12 (u5 wiring) so a prior render's text edits re-apply to the next render without re-clicking. Schema (defined verbatim in ``src/user_overrides_io.py:29`` u1) :: text_overrides = { : {: }, ... } ``text_path`` is the ``{slot_key}.{line_index}`` stamp emitted at Step 13 by the u8 ``text_path_stamper`` (pending unit) and surfaced to the frontend SlideCanvas (u12) as ``data-text-path`` attributes on editable text nodes. The ``{slot_key}`` is a frame contract slot identifier (e.g., ``slot_title``); the ``{line_index}`` is the 0-based ordinal of the line within that slot's rendered text (typically one bullet / one paragraph). API (deterministic, no AI) : - ``parse_text_path(text_path)`` → ``(slot_key, line_index)`` or raises. - ``validate_text_overrides(overrides)`` → sanitized copy (drops malformed per-entry; never rejects the whole batch — mirrors the per-entry tolerance contract of ``src.image_id_stamper.build_image_overrides_style`` IMP-51 #79 u7). - ``apply_text_override(zone, text_path, value)`` → ``True`` on in-place mutation; ``False`` if the path is absent / out-of-range. The ``zone`` argument is the slot-lines mapping at Step 12 — i.e. a mutable mapping where ``zone[slot_key]`` is a ``list[str]`` of line strings. Wiring at Step 12 (u5) is responsible for extracting that mapping from whatever composition object holds it; this resolver is decoupled from the wrapper shape so it can be re-targeted at Stage 5 (Step 12) layer-A or layer-B composition data without an API rev. Guardrails (refs : Stage 1 binding contract, Stage 2 unit u4) : - raw_content preservation is a wiring-layer (u5) responsibility — the resolver itself only ever mutates the lines mapping it was handed. - AI-isolation : 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 from typing import Any, Mapping, MutableMapping class InvalidTextOverride(ValueError): """Raised when a ``text_path`` is malformed (parse-time).""" def parse_text_path(text_path: str) -> tuple[str, int]: """Parse ``{slot_key}.{line_index}`` into ``(slot_key, line_index)``. ``slot_key`` may itself contain ``.`` (e.g., compound keys), so the parse splits on the LAST ``.`` only — ``rpartition`` semantics. """ if not isinstance(text_path, str) or not text_path: raise InvalidTextOverride( f"text_path must be a non-empty string, got: {text_path!r}" ) if "." not in text_path: raise InvalidTextOverride( f"text_path must contain '.' separator, got: {text_path!r}" ) slot_key, _, idx_str = text_path.rpartition(".") if not slot_key or not idx_str: raise InvalidTextOverride( f"text_path slot_key and line_index must both be non-empty, " f"got: {text_path!r}" ) try: idx = int(idx_str) except ValueError as exc: raise InvalidTextOverride( f"text_path line_index must be int, got: {text_path!r}" ) from exc if idx < 0: raise InvalidTextOverride( f"text_path line_index must be >= 0, got: {idx} in {text_path!r}" ) return slot_key, idx def validate_text_overrides(overrides: Any) -> dict[str, dict[str, str]]: """Return a sanitized copy of ``overrides`` (per-entry tolerant). Drops: - non-string or empty zone_ids, - non-mapping per-zone payloads, - non-string text_path keys, non-string values, - text_paths that fail :func:`parse_text_path`. Returns a fresh ``dict`` so callers can mutate without aliasing the persisted payload from ``user_overrides_io.load``. """ if not isinstance(overrides, Mapping): return {} out: dict[str, dict[str, str]] = {} for zone_id, mapping in overrides.items(): if not isinstance(zone_id, str) or not zone_id: continue if not isinstance(mapping, Mapping): continue zone_out: dict[str, str] = {} for text_path, value in mapping.items(): if not isinstance(text_path, str) or not isinstance(value, str): continue try: parse_text_path(text_path) except InvalidTextOverride: continue zone_out[text_path] = value if zone_out: out[zone_id] = zone_out return out def apply_text_override( zone: MutableMapping[str, Any], text_path: str, value: str, ) -> bool: """Apply ONE text override to ``zone`` in-place. ``zone`` is the slot-lines mapping at Step 12 — i.e. a mutable mapping where ``zone[slot_key]`` is a ``list[str]`` of line strings. Returns ``True`` when the value was replaced. Returns ``False`` (no mutation) when the ``slot_key`` is absent, the slot is not a list, or ``line_index`` is out of range. Out-of-range / absent paths are NOT an error — they happen naturally when a prior render's overrides target a slot the new render no longer emits (frame swap, layout regression). """ slot_key, idx = parse_text_path(text_path) if slot_key not in zone: return False lines = zone[slot_key] if not isinstance(lines, list) or idx >= len(lines): return False lines[idx] = value return True