feat(#74): IMP-45 u1~u8 slide-level CSS override (frontmatter slide_overrides.css + --override-slide-css/--slide-css-file + idempotent Step 13 injector)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
u1 KNOWN_AXES tuple gains slide_css entry in src/user_overrides_io.py
(snake_case parity with image_overrides); round-trip test extends
to 6 axes.
u2 src/mdx_normalizer.py surfaces nested slide_overrides.css from the
MDX frontmatter into the normalize_mdx_content return dict; absent
key -> {}, non-string css drops. 4 unit cases in tests/test_mdx_normalizer.py
(present / absent / non-string / title-only).
u3 src/slide_css_injector.py NEW (88 lines) mirrors the
inject_image_overrides_style contract from src/image_id_stamper.py:
marker pair <!--IMP45-SLIDE-CSS:OPEN--> / <!--IMP45-SLIDE-CSS:CLOSE-->,
idempotent re-injection, </head> > <body> > document-start three-tier
fallback, empty/None -> unchanged. 8 fixtures in
tests/test_slide_css_injector.py mirror test_image_id_stamper.py.
u4 run_phase_z2_mvp1 accepts override_slide_css: Optional[str] = None;
None -> frontmatter slide_overrides.css fallback. Step 13 calls
inject_slide_css after image override injection and before the
final.html disk write, so CLI/CI/regression renders observe the same
backend artifact.
u5 argparse adds mutually-exclusive --override-slide-css TEXT (inline
CSS, <style> wrapper optional) and --slide-css-file PATH (UTF-8 read,
fail-closed sys.exit(2) on missing path / decode error / both flags
present). Resolved string is forwarded as override_slide_css kwarg.
6 cases in tests/test_phase_z2_cli_overrides.py (inline / file / both
/ missing / non-utf8 / neither).
u6 samples/mdx_batch/04.mdx frontmatter gains slide_overrides.css
block (verbatim of the former MDX04_DEFAULT_OVERRIDE_CSS constant,
no sample/frame gate). Subprocess smoke in
tests/test_phase_z2_slide_css_smoke.py verifies the marker pair and
CSS substring land in final.html.
u7 Front/client removes the sample/frame-gated frontend-only injection:
Home.tsx drops the MDX04_DEFAULT_OVERRIDE_CSS constant and the
sample==="04"+frame==="process_product_two_way" branch (-28 lines);
SlideCanvas.tsx drops the iframe contentDocument.head injection of
that prop (-14 lines). Live preview now reads backend final.html only.
u8 tests/regression/fixtures/89a_pre_baseline_sha.json 04.mdx entry
resyncs to the live SHA ddb6bf2f... / 28042 bytes (overwrites the
earlier 5-byte-drift d02c76fd... / 28047). Other entries untouched.
Note: 01.mdx baseline drift (ad6f16a3... / 29089 -> live f26a7fac...
/ 29084) predates this branch and is split to a follow-up issue per
the closed-issue fresh validation rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -392,6 +392,32 @@ def _clean_text(text: str) -> str:
|
||||
# 메인 함수
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _extract_slide_overrides(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Surface the nested ``slide_overrides`` mapping from frontmatter.
|
||||
|
||||
IMP-45 (#74) u2 — slide-level CSS override axis intake. Returns a
|
||||
plain ``dict`` so callers (Step 13 injector) can read
|
||||
``slide_overrides.get("css")`` without re-parsing frontmatter.
|
||||
|
||||
Rules:
|
||||
- Absent or non-mapping → ``{}``.
|
||||
- Inside the mapping, ``css`` is kept only when it is a ``str``
|
||||
(non-string values dropped to fail-closed against typo'd YAML
|
||||
shapes such as ``css: [".x{}"]``).
|
||||
- Unknown sibling keys (e.g., future ``slide_overrides.js``) are
|
||||
preserved verbatim — generalization deferred per Stage 2 scope.
|
||||
"""
|
||||
raw = metadata.get("slide_overrides")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in raw.items():
|
||||
if k == "css" and not isinstance(v, str):
|
||||
continue
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"""MDX 원본을 4-Layer 파서로 정규화.
|
||||
|
||||
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"headers": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}],
|
||||
"slide_overrides": {"css": str, ...} | {},
|
||||
}
|
||||
"""
|
||||
# ── Layer 1: frontmatter 분리 ──
|
||||
metadata, body = frontmatter.parse(raw_mdx)
|
||||
title = metadata.get("title", "")
|
||||
slide_overrides = _extract_slide_overrides(metadata)
|
||||
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
||||
|
||||
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
||||
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"popups": popups,
|
||||
"tables": tables,
|
||||
"sections": sections,
|
||||
"slide_overrides": slide_overrides,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4843,6 +4843,42 @@ def _run_step0_ai_preflight() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _resolve_slide_css_from_frontmatter(mdx_source_text: str) -> Optional[str]:
|
||||
"""IMP-45 (#74) u4 — minimal frontmatter probe for ``slide_overrides.css``.
|
||||
|
||||
Targeted re-parse of the same YAML frontmatter block that
|
||||
:func:`parse_mdx` reads at line 415-418 — extracts only the
|
||||
nested ``slide_overrides.css`` string when present. Kept inline
|
||||
rather than routed through :func:`src.mdx_normalizer.normalize_mdx_content`
|
||||
so Step 13 render does not depend on the Stage 0 normalize adapter
|
||||
(project lock 2026-05-08).
|
||||
|
||||
Mirrors the validation rules in
|
||||
:func:`src.mdx_normalizer._extract_slide_overrides` (u2) :
|
||||
|
||||
- No frontmatter / unparseable YAML / non-mapping → ``None``.
|
||||
- Missing ``slide_overrides`` mapping → ``None``.
|
||||
- ``slide_overrides.css`` non-string or empty → ``None``.
|
||||
- ``slide_overrides.css`` non-empty ``str`` → that string.
|
||||
"""
|
||||
fm_match = re.match(r"^---\n(.*?)\n---\n", mdx_source_text, re.DOTALL)
|
||||
if fm_match is None:
|
||||
return None
|
||||
try:
|
||||
fm = yaml.safe_load(fm_match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(fm, dict):
|
||||
return None
|
||||
overrides = fm.get("slide_overrides")
|
||||
if not isinstance(overrides, dict):
|
||||
return None
|
||||
css = overrides.get("css")
|
||||
if isinstance(css, str) and css:
|
||||
return css
|
||||
return None
|
||||
|
||||
|
||||
def run_phase_z2_mvp1(
|
||||
mdx_path: Path,
|
||||
run_id: Optional[str] = None,
|
||||
@@ -4852,6 +4888,7 @@ def run_phase_z2_mvp1(
|
||||
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,
|
||||
override_slide_css: Optional[str] = None,
|
||||
reuse_from: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
|
||||
@@ -4875,6 +4912,16 @@ def run_phase_z2_mvp1(
|
||||
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
|
||||
client u3 + stamper u4) end-to-end addressable from CLI without
|
||||
diverging the function signature.
|
||||
override_slide_css : Optional slide-level CSS string — IMP-45 (#74) u4 axis.
|
||||
Marker-wrapped <style> block injected into ``final.html``
|
||||
at Step 13 via :func:`src.slide_css_injector.inject_slide_css`
|
||||
(mirrors the image_id_stamper injection contract). When
|
||||
``None``, the MDX frontmatter ``slide_overrides.css`` value
|
||||
is used as fallback via
|
||||
:func:`_resolve_slide_css_from_frontmatter`. Kwarg always
|
||||
wins over frontmatter when provided. The CLI surface
|
||||
(``--override-slide-css`` inline + ``--slide-css-file``
|
||||
PATH) lands in u5.
|
||||
|
||||
Incremental rerun (IMP-43 #72, u5) :
|
||||
reuse_from : Optional PREV_RUN_ID. When set, Steps 0/1/2/5/6 artifacts
|
||||
@@ -7191,6 +7238,18 @@ def run_phase_z2_mvp1(
|
||||
if _image_overrides_css:
|
||||
html = inject_image_overrides_style(html, _image_overrides_css)
|
||||
|
||||
# IMP-45 (#74) u4 — slide-level CSS override injection.
|
||||
# Kwarg wins; otherwise fall back to MDX frontmatter
|
||||
# ``slide_overrides.css``. Called AFTER image override injection so
|
||||
# editor-authored slide CSS wins ties at the same specificity in the
|
||||
# cascade (slide_css_injector.py module docstring lock).
|
||||
_effective_slide_css = override_slide_css
|
||||
if _effective_slide_css is None:
|
||||
_effective_slide_css = _resolve_slide_css_from_frontmatter(mdx_source_text)
|
||||
if _effective_slide_css:
|
||||
from src.slide_css_injector import inject_slide_css
|
||||
html = inject_slide_css(html, _effective_slide_css)
|
||||
|
||||
# 8. Write final.html
|
||||
out_path = run_dir / "final.html"
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
@@ -7903,6 +7962,44 @@ if __name__ == "__main__":
|
||||
"--override-image img-def=50,15,40,40"
|
||||
),
|
||||
)
|
||||
# IMP-45 (#74) u5 — slide-level CSS override CLI surface. Two mutually
|
||||
# exclusive flags carrying the same axis: ``--override-slide-css TEXT``
|
||||
# passes inline CSS verbatim; ``--slide-css-file PATH`` reads UTF-8
|
||||
# CSS from disk. Both forward into the single ``override_slide_css``
|
||||
# kwarg on :func:`run_phase_z2_mvp1` (u4), which already mirrors the
|
||||
# marker-wrapped image_id_stamper injection contract at Step 13.
|
||||
# Manual mutual-exclusion (not ``add_mutually_exclusive_group``) so the
|
||||
# error stderr can name the axis pair explicitly with ``sys.exit(2)``,
|
||||
# matching sibling override surfaces (--override-frame /
|
||||
# --override-zone-geometry / --override-image).
|
||||
parser.add_argument(
|
||||
"--override-slide-css",
|
||||
dest="override_slide_css",
|
||||
default=None,
|
||||
metavar="CSS_TEXT",
|
||||
help=(
|
||||
"slide-level CSS override (inline). value is passed verbatim "
|
||||
"to the run_phase_z2_mvp1 override_slide_css kwarg; an "
|
||||
"optional outer `<style>` wrapper is allowed because the "
|
||||
"injector wraps the payload in its own marker-bracketed "
|
||||
"`<style>` block regardless. mutually exclusive with "
|
||||
"--slide-css-file."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slide-css-file",
|
||||
dest="slide_css_file",
|
||||
default=None,
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"slide-level CSS override (file). UTF-8-encoded CSS payload "
|
||||
"read from PATH and forwarded to the override_slide_css "
|
||||
"kwarg. fail-closed: missing file / non-UTF-8 bytes exit 2 "
|
||||
"with stderr message. mutually exclusive with "
|
||||
"--override-slide-css."
|
||||
),
|
||||
)
|
||||
# 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
|
||||
@@ -8072,6 +8169,40 @@ if __name__ == "__main__":
|
||||
sys.exit(2)
|
||||
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
|
||||
|
||||
# IMP-45 (#74) u5 — resolve slide-level CSS override CLI surface to a
|
||||
# single string. Mutually exclusive: both flags set → exit 2. File
|
||||
# path read is UTF-8 strict; missing path or non-UTF-8 bytes → exit
|
||||
# 2. When neither flag is set, ``override_slide_css`` stays ``None``
|
||||
# and Step 13 falls back to the MDX ``slide_overrides.css``
|
||||
# frontmatter via ``_resolve_slide_css_from_frontmatter`` (u4).
|
||||
if args.override_slide_css is not None and args.slide_css_file is not None:
|
||||
print(
|
||||
"[error] --override-slide-css and --slide-css-file are mutually "
|
||||
"exclusive; pass only one.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
_final_override_slide_css: Optional[str] = args.override_slide_css
|
||||
if args.slide_css_file is not None:
|
||||
try:
|
||||
_final_override_slide_css = args.slide_css_file.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"[error] --slide-css-file path does not exist: "
|
||||
f"{args.slide_css_file}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
except UnicodeDecodeError as _exc:
|
||||
print(
|
||||
f"[error] --slide-css-file must be UTF-8 encoded "
|
||||
f"({args.slide_css_file}): {_exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# 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
|
||||
@@ -8206,5 +8337,6 @@ if __name__ == "__main__":
|
||||
override_zone_geometries=overrides_geoms or None,
|
||||
override_section_assignments=overrides_section_assignments or None,
|
||||
override_image_overrides=overrides_images or None,
|
||||
override_slide_css=_final_override_slide_css,
|
||||
reuse_from=args.reuse_from,
|
||||
)
|
||||
|
||||
87
src/slide_css_injector.py
Normal file
87
src/slide_css_injector.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""IMP-45 (#74) u3 — slide-level CSS override injector for Phase Z final.html.
|
||||
|
||||
Mirror of :func:`src.image_id_stamper.inject_image_overrides_style` contract
|
||||
(image_id_stamper.py:226-264) for the new ``slide_css`` override axis
|
||||
registered by u1 in :data:`src.user_overrides_io.KNOWN_AXES` and surfaced
|
||||
by u2 in :func:`src.mdx_normalizer.normalize_mdx_content` under the
|
||||
``slide_overrides.css`` frontmatter key.
|
||||
|
||||
Single entry point :
|
||||
|
||||
:func:`inject_slide_css` (html, css) -> str
|
||||
|
||||
Semantics (identical contract to image_overrides injector) :
|
||||
|
||||
- Empty / falsy ``css`` -> ``html`` returned unchanged (no DOM mutation).
|
||||
- Marker-wrapped ``<style>`` block; re-injection replaces inner CSS in
|
||||
place (idempotent on identical input; latest-wins on different input).
|
||||
- Injection precedence : (1) before 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 fragment inputs.
|
||||
|
||||
Marker sentinels (distinct from image_overrides markers so the two
|
||||
injectors can co-exist on the same document without collision; the
|
||||
literal form is pinned by the Stage 2 binding contract for IMP-45 /
|
||||
issue #74) :
|
||||
|
||||
<!--IMP45-SLIDE-CSS:OPEN-->
|
||||
<!--IMP45-SLIDE-CSS:CLOSE-->
|
||||
|
||||
Both injectors target ``</head>`` first, so call order determines DOM
|
||||
order. u4 calls ``inject_image_overrides_style`` first (existing Step 13
|
||||
behavior) and then ``inject_slide_css``, putting slide-level overrides
|
||||
after image overrides in cascade order so the editor-authored slide CSS
|
||||
wins ties at the same specificity (intended by IMP-45 scope).
|
||||
|
||||
Guardrails :
|
||||
|
||||
- No-hardcoding : ``css`` is caller-supplied verbatim. No sample-id or
|
||||
frame-id branches.
|
||||
- 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
|
||||
|
||||
import re
|
||||
|
||||
_IMP45_STYLE_MARKER_OPEN: str = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||
_IMP45_STYLE_MARKER_CLOSE: str = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||
|
||||
_IMP45_STYLE_BLOCK_RE = re.compile(
|
||||
re.escape(_IMP45_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP45_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 inject_slide_css(html: str, css: str | None) -> str:
|
||||
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
|
||||
|
||||
Empty or ``None`` ``css`` -> ``html`` returned unchanged. Re-injection
|
||||
is idempotent : when a previously-injected marker block is present,
|
||||
its inner CSS is replaced in place.
|
||||
|
||||
Injection precedence : ``</head>`` > ``<body ...>`` > document start.
|
||||
"""
|
||||
if not css:
|
||||
return html
|
||||
block = (
|
||||
f"{_IMP45_STYLE_MARKER_OPEN}\n"
|
||||
f"<style>\n{css}\n</style>\n"
|
||||
f"{_IMP45_STYLE_MARKER_CLOSE}"
|
||||
)
|
||||
if _IMP45_STYLE_MARKER_OPEN in html:
|
||||
return _IMP45_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
|
||||
@@ -5,14 +5,16 @@ 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 (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``):
|
||||
Schema (6 axes; stable order; IMP-51 #79 u1 added ``image_overrides``;
|
||||
IMP-45 #74 u1 added ``slide_css``):
|
||||
|
||||
{
|
||||
"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>},
|
||||
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}}
|
||||
"image_overrides": {<image_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||
"slide_css": <string|null>
|
||||
}
|
||||
|
||||
``image_id`` is the stable identifier emitted by the user-content image
|
||||
@@ -53,16 +55,17 @@ from typing import Any, Optional
|
||||
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||
|
||||
# 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.
|
||||
# The six in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
|
||||
# #74 u1 added ``slide_css``). 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",
|
||||
"slide_css",
|
||||
)
|
||||
|
||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||
|
||||
Reference in New Issue
Block a user