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:
@@ -36,10 +36,6 @@ interface SlideCanvasProps {
|
|||||||
userSelection: UserSelection;
|
userSelection: UserSelection;
|
||||||
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
|
/** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
|
||||||
finalHtmlUrl?: string;
|
finalHtmlUrl?: string;
|
||||||
/** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
|
|
||||||
* 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
|
|
||||||
* inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
|
|
||||||
slideOverrideCss?: string;
|
|
||||||
/** 파이프라인 실행 중 표시 (loading state). */
|
/** 파이프라인 실행 중 표시 (loading state). */
|
||||||
isPipelineRunning?: boolean;
|
isPipelineRunning?: boolean;
|
||||||
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
|
/** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
|
||||||
@@ -86,7 +82,6 @@ export default function SlideCanvas({
|
|||||||
slidePlan,
|
slidePlan,
|
||||||
userSelection,
|
userSelection,
|
||||||
finalHtmlUrl,
|
finalHtmlUrl,
|
||||||
slideOverrideCss,
|
|
||||||
isPipelineRunning,
|
isPipelineRunning,
|
||||||
isPendingLayout,
|
isPendingLayout,
|
||||||
pendingLayoutId,
|
pendingLayoutId,
|
||||||
@@ -391,15 +386,6 @@ export default function SlideCanvas({
|
|||||||
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
|
|
||||||
// 2026-05-14 — slide-level override CSS (catalog/template 무변).
|
|
||||||
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
|
|
||||||
if (slideOverrideCss && slideOverrideCss.trim()) {
|
|
||||||
const overrideStyle = doc.createElement("style");
|
|
||||||
overrideStyle.setAttribute("data-purpose", "slide-level-override");
|
|
||||||
overrideStyle.textContent = slideOverrideCss;
|
|
||||||
doc.head.appendChild(overrideStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zone DOM 측정 ──
|
// ── Zone DOM 측정 ──
|
||||||
// backend final.html 의 .zone[data-zone-position="..."] 요소를
|
// backend final.html 의 .zone[data-zone-position="..."] 요소를
|
||||||
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
|
// 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
|
||||||
|
|||||||
@@ -242,22 +242,6 @@ export default function Home() {
|
|||||||
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
|
// 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger.
|
||||||
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
|
const [selectedSample, setSelectedSample] = useState<"03" | "04" | "05" | null>(null);
|
||||||
|
|
||||||
// 2026-05-14 — mdx 별 slide-level CSS override (catalog/template 무변, frontend layer only).
|
|
||||||
// SlideCanvas 의 iframe onLoad 에서 동적 inject. 사용자 룰 : "보고용 슬라이드 결과물 단위"
|
|
||||||
// 변경. mdx04 의 default (rank 1 = process_product_two_way) 일 때만 적용 — 사용자 frame
|
|
||||||
// override 후 (rank 2 = bim_dx_comparison_table 등) 다른 frame 시 무적용.
|
|
||||||
const MDX04_DEFAULT_OVERRIDE_CSS = `
|
|
||||||
.slide-body {
|
|
||||||
grid-template-rows: 0.38fr 0.60fr !important;
|
|
||||||
gap: 1.5% !important;
|
|
||||||
}
|
|
||||||
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
|
|
||||||
.f29b__cell:nth-child(n+3) {
|
|
||||||
padding-top: 3px !important;
|
|
||||||
margin-top: 2px !important;
|
|
||||||
}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
|
const handleSelectSample = useCallback(async (which: "03" | "04" | "05") => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
|
const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`);
|
||||||
@@ -634,17 +618,6 @@ export default function Home() {
|
|||||||
return state.slidePlan;
|
return state.slidePlan;
|
||||||
}, [pendingZones, state.slidePlan, pendingLayout]);
|
}, [pendingZones, state.slidePlan, pendingLayout]);
|
||||||
|
|
||||||
// 2026-05-14 — slide-level CSS override 계산. mdx04 default (rank 1 = process_product_two_way)
|
|
||||||
// 일 때만 적용 (catalog 무변, slide 결과물에만 inject). 사용자 frame override 후 다른
|
|
||||||
// frame 시 무적용 (rank 2 의 frame visual 유지).
|
|
||||||
const slideOverrideCss = useMemo<string | undefined>(() => {
|
|
||||||
if (selectedSample !== "04") return undefined;
|
|
||||||
const zone04_2 = state.slidePlan?.zones.find((z) => z.zone_id === "bottom");
|
|
||||||
const frameId = zone04_2?.internal_regions[0]?.frame_match_strategy.frame_id;
|
|
||||||
if (frameId !== "process_product_two_way") return undefined;
|
|
||||||
return MDX04_DEFAULT_OVERRIDE_CSS;
|
|
||||||
}, [selectedSample, state.slidePlan]);
|
|
||||||
|
|
||||||
// ── Frame 선택 ──
|
// ── Frame 선택 ──
|
||||||
const handleFrameSelect = useCallback((frameId: string) => {
|
const handleFrameSelect = useCallback((frameId: string) => {
|
||||||
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
|
const zone = getSelectedZone(effectiveSlidePlan, state.userSelection);
|
||||||
@@ -805,7 +778,6 @@ export default function Home() {
|
|||||||
normalizedContent={state.normalizedContent}
|
normalizedContent={state.normalizedContent}
|
||||||
userSelection={state.userSelection}
|
userSelection={state.userSelection}
|
||||||
finalHtmlUrl={runMeta?.final_html_url}
|
finalHtmlUrl={runMeta?.final_html_url}
|
||||||
slideOverrideCss={slideOverrideCss}
|
|
||||||
isPipelineRunning={state.isLoading}
|
isPipelineRunning={state.isLoading}
|
||||||
isPendingLayout={!!pendingLayout}
|
isPendingLayout={!!pendingLayout}
|
||||||
pendingLayoutId={pendingLayout}
|
pendingLayoutId={pendingLayout}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
title: DX 지연 요인
|
title: DX 지연 요인
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 03
|
order: 03
|
||||||
|
slide_overrides:
|
||||||
|
css: |
|
||||||
|
.slide-body {
|
||||||
|
grid-template-rows: 0.38fr 0.60fr !important;
|
||||||
|
gap: 1.5% !important;
|
||||||
|
}
|
||||||
|
.f29b__cell .text-line + .text-line { margin-top: 1px !important; }
|
||||||
|
.f29b__cell:nth-child(n+3) {
|
||||||
|
padding-top: 3px !important;
|
||||||
|
margin-top: 2px !important;
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. DX에 대한 인식
|
## 1. DX에 대한 인식
|
||||||
|
|||||||
@@ -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]:
|
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||||
"""MDX 원본을 4-Layer 파서로 정규화.
|
"""MDX 원본을 4-Layer 파서로 정규화.
|
||||||
|
|
||||||
@@ -405,11 +431,13 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
|||||||
"popups": [{"title": str, "content": str}],
|
"popups": [{"title": str, "content": str}],
|
||||||
"tables": [{"headers": list, "rows": list}],
|
"tables": [{"headers": list, "rows": list}],
|
||||||
"sections": [{"level": int, "title": str, "content": str}],
|
"sections": [{"level": int, "title": str, "content": str}],
|
||||||
|
"slide_overrides": {"css": str, ...} | {},
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# ── Layer 1: frontmatter 분리 ──
|
# ── Layer 1: frontmatter 분리 ──
|
||||||
metadata, body = frontmatter.parse(raw_mdx)
|
metadata, body = frontmatter.parse(raw_mdx)
|
||||||
title = metadata.get("title", "")
|
title = metadata.get("title", "")
|
||||||
|
slide_overrides = _extract_slide_overrides(metadata)
|
||||||
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
||||||
|
|
||||||
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
||||||
@@ -437,6 +465,7 @@ def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
|||||||
"popups": popups,
|
"popups": popups,
|
||||||
"tables": tables,
|
"tables": tables,
|
||||||
"sections": sections,
|
"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(
|
def run_phase_z2_mvp1(
|
||||||
mdx_path: Path,
|
mdx_path: Path,
|
||||||
run_id: Optional[str] = None,
|
run_id: Optional[str] = None,
|
||||||
@@ -4852,6 +4888,7 @@ def run_phase_z2_mvp1(
|
|||||||
override_zone_geometries: Optional[dict[str, dict]] = None,
|
override_zone_geometries: Optional[dict[str, dict]] = None,
|
||||||
override_section_assignments: Optional[dict[str, list[str]]] = None,
|
override_section_assignments: Optional[dict[str, list[str]]] = None,
|
||||||
override_image_overrides: Optional[dict[str, dict]] = None,
|
override_image_overrides: Optional[dict[str, dict]] = None,
|
||||||
|
override_slide_css: Optional[str] = None,
|
||||||
reuse_from: Optional[str] = None,
|
reuse_from: Optional[str] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary.
|
"""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
|
backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed
|
||||||
client u3 + stamper u4) end-to-end addressable from CLI without
|
client u3 + stamper u4) end-to-end addressable from CLI without
|
||||||
diverging the function signature.
|
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) :
|
Incremental rerun (IMP-43 #72, u5) :
|
||||||
reuse_from : Optional PREV_RUN_ID. When set, Steps 0/1/2/5/6 artifacts
|
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:
|
if _image_overrides_css:
|
||||||
html = inject_image_overrides_style(html, _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
|
# 8. Write final.html
|
||||||
out_path = run_dir / "final.html"
|
out_path = run_dir / "final.html"
|
||||||
out_path.write_text(html, encoding="utf-8")
|
out_path.write_text(html, encoding="utf-8")
|
||||||
@@ -7903,6 +7962,44 @@ if __name__ == "__main__":
|
|||||||
"--override-image img-def=50,15,40,40"
|
"--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``
|
# IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal``
|
||||||
# bypasses the ``user_approved`` gate only (``visual_check_passed``
|
# bypasses the ``user_approved`` gate only (``visual_check_passed``
|
||||||
# is never bypassable). Source of truth is
|
# is never bypassable). Source of truth is
|
||||||
@@ -8072,6 +8169,40 @@ if __name__ == "__main__":
|
|||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h}
|
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.
|
# IMP-52 (#80) u2 — user_overrides.json persistence fallback.
|
||||||
# After argparse fully parses CLI flags, fill ONLY the axes the user
|
# 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
|
# 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_zone_geometries=overrides_geoms or None,
|
||||||
override_section_assignments=overrides_section_assignments or None,
|
override_section_assignments=overrides_section_assignments or None,
|
||||||
override_image_overrides=overrides_images or None,
|
override_image_overrides=overrides_images or None,
|
||||||
|
override_slide_css=_final_override_slide_css,
|
||||||
reuse_from=args.reuse_from,
|
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
|
file (stem of the MDX path), NOT ``data/runs/<run_id>/`` which mints a fresh
|
||||||
run_id per ``/api/run`` invocation.
|
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>,
|
"layout": <string|null>,
|
||||||
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
|
||||||
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
"zone_sections": {<zone_id>: [<section_id>, ...]},
|
||||||
"frames": {<unit_id>: <template_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
|
``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
|
_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||||
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides"
|
||||||
|
|
||||||
# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any
|
# The six in-scope axes (IMP-51 #79 u1 added ``image_overrides``; IMP-45
|
||||||
# other top-level key in the file is preserved but ignored by callers —
|
# #74 u1 added ``slide_css``). Any other top-level key in the file is
|
||||||
# keeps the file forward-compatible with future axes (e.g., zone_sizes)
|
# preserved but ignored by callers — keeps the file forward-compatible
|
||||||
# without a schema bump here.
|
# with future axes (e.g., zone_sizes) without a schema bump here.
|
||||||
KNOWN_AXES: tuple[str, ...] = (
|
KNOWN_AXES: tuple[str, ...] = (
|
||||||
"layout",
|
"layout",
|
||||||
"zone_geometries",
|
"zone_geometries",
|
||||||
"zone_sections",
|
"zone_sections",
|
||||||
"frames",
|
"frames",
|
||||||
"image_overrides",
|
"image_overrides",
|
||||||
|
"slide_css",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Key validation — MDX stem must be safe for filesystem use. Allow
|
# Key validation — MDX stem must be safe for filesystem use. Allow
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
"04.mdx": {
|
"04.mdx": {
|
||||||
"mdx_file": "04.mdx",
|
"mdx_file": "04.mdx",
|
||||||
"run_id": "89a_baseline_04",
|
"run_id": "89a_baseline_04",
|
||||||
"final_html_size_bytes": 27707,
|
"final_html_size_bytes": 28042,
|
||||||
"sha256": "2bce45041cdcca6518cd92586c1be9e051a5c98f5a0ad61fdde02604618a1d80",
|
"sha256": "ddb6bf2f8d76ca1f56588a50dd4af5aeb5f45e0a83d5241b83b5932d0c66d41c",
|
||||||
"pipeline_exit_code": null
|
"pipeline_exit_code": null
|
||||||
},
|
},
|
||||||
"05.mdx": {
|
"05.mdx": {
|
||||||
|
|||||||
107
tests/test_mdx_normalizer.py
Normal file
107
tests/test_mdx_normalizer.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""IMP-45 (#74) u2 — frontmatter ``slide_overrides`` surfacing.
|
||||||
|
|
||||||
|
Covers ``src.mdx_normalizer.normalize_mdx_content`` and the helper
|
||||||
|
``_extract_slide_overrides``. The Stage 2 plan enumerates four cases:
|
||||||
|
|
||||||
|
1. Present — nested ``slide_overrides.css`` survives normalization verbatim.
|
||||||
|
2. Absent — return dict carries an empty ``slide_overrides`` mapping.
|
||||||
|
3. Non-string ``css`` — dropped (fail-closed against typo'd YAML shapes).
|
||||||
|
4. Title-only frontmatter — no ``slide_overrides`` key in metadata → ``{}``.
|
||||||
|
|
||||||
|
Scope-lock: this unit only adds the new key to the return dict. The four
|
||||||
|
pre-existing return keys (``clean_text``/``title``/``images``/``popups``/
|
||||||
|
``tables``/``sections``) are asserted unchanged at the structural level.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.mdx_normalizer import _extract_slide_overrides, normalize_mdx_content
|
||||||
|
|
||||||
|
|
||||||
|
_CSS_BLOCK = "<style>.f29b__col_right { width: 320px; }</style>"
|
||||||
|
|
||||||
|
|
||||||
|
def _mdx_with_frontmatter(fm_body: str, body: str = "# 제목\n\n본문 한 줄.\n") -> str:
|
||||||
|
return f"---\n{fm_body}---\n{body}"
|
||||||
|
|
||||||
|
|
||||||
|
# -- case 1: present (nested css string survives verbatim) ------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_surfaces_nested_slide_overrides_css():
|
||||||
|
raw = _mdx_with_frontmatter(
|
||||||
|
"title: 04 sample\n"
|
||||||
|
"slide_overrides:\n"
|
||||||
|
f" css: \"{_CSS_BLOCK}\"\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = normalize_mdx_content(raw)
|
||||||
|
|
||||||
|
assert result["slide_overrides"] == {"css": _CSS_BLOCK}
|
||||||
|
# Other axes unaffected by the new key.
|
||||||
|
assert result["title"] == "04 sample"
|
||||||
|
assert "본문" in result["clean_text"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- case 2: absent (no slide_overrides key in frontmatter) -----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_returns_empty_slide_overrides_when_key_absent():
|
||||||
|
raw = _mdx_with_frontmatter("title: 03 sample\n")
|
||||||
|
|
||||||
|
result = normalize_mdx_content(raw)
|
||||||
|
|
||||||
|
assert result["slide_overrides"] == {}
|
||||||
|
# Confirm key is always present (callers can rely on .get without default).
|
||||||
|
assert "slide_overrides" in result
|
||||||
|
|
||||||
|
|
||||||
|
# -- case 3: non-string css (fail-closed drop) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_drops_non_string_css_under_slide_overrides():
|
||||||
|
# YAML list under .css should be dropped; sibling unknown keys survive
|
||||||
|
# so the future generalization path (e.g., slide_overrides.js) stays
|
||||||
|
# forward-compatible per the Stage 2 plan.
|
||||||
|
raw = _mdx_with_frontmatter(
|
||||||
|
"title: typo case\n"
|
||||||
|
"slide_overrides:\n"
|
||||||
|
" css:\n"
|
||||||
|
" - .f29b__col_right { width: 320px; }\n"
|
||||||
|
" note: experimental sibling\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = normalize_mdx_content(raw)
|
||||||
|
|
||||||
|
assert "css" not in result["slide_overrides"]
|
||||||
|
assert result["slide_overrides"].get("note") == "experimental sibling"
|
||||||
|
|
||||||
|
|
||||||
|
# -- case 4: title-only frontmatter (no slide_overrides at all) -------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_title_only_frontmatter_yields_empty_slide_overrides():
|
||||||
|
raw = _mdx_with_frontmatter("title: title only\n")
|
||||||
|
|
||||||
|
result = normalize_mdx_content(raw)
|
||||||
|
|
||||||
|
assert result["title"] == "title only"
|
||||||
|
assert result["slide_overrides"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- direct helper coverage (defensive against future return-shape drift) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_slide_overrides_non_mapping_returns_empty_dict():
|
||||||
|
# Frontmatter parsers can yield odd types if the user writes
|
||||||
|
# ``slide_overrides: 42`` or ``slide_overrides: ".x{}"``. The helper
|
||||||
|
# must coerce to ``{}`` rather than raise.
|
||||||
|
for bad in (None, 42, "literal string", ["css"]):
|
||||||
|
assert _extract_slide_overrides({"slide_overrides": bad}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_slide_overrides_passes_through_unknown_siblings():
|
||||||
|
payload = {"slide_overrides": {"css": ".a{}", "js": "console.log(1)"}}
|
||||||
|
assert _extract_slide_overrides(payload) == {
|
||||||
|
"css": ".a{}",
|
||||||
|
"js": "console.log(1)",
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ def _exec_main_block(
|
|||||||
override_zone_geometries=None,
|
override_zone_geometries=None,
|
||||||
override_section_assignments=None,
|
override_section_assignments=None,
|
||||||
override_image_overrides=None,
|
override_image_overrides=None,
|
||||||
|
override_slide_css=None,
|
||||||
|
reuse_from=None,
|
||||||
):
|
):
|
||||||
captured["mdx_path"] = mdx_path
|
captured["mdx_path"] = mdx_path
|
||||||
captured["run_id"] = run_id
|
captured["run_id"] = run_id
|
||||||
@@ -64,6 +66,8 @@ def _exec_main_block(
|
|||||||
captured["override_zone_geometries"] = override_zone_geometries
|
captured["override_zone_geometries"] = override_zone_geometries
|
||||||
captured["override_section_assignments"] = override_section_assignments
|
captured["override_section_assignments"] = override_section_assignments
|
||||||
captured["override_image_overrides"] = override_image_overrides
|
captured["override_image_overrides"] = override_image_overrides
|
||||||
|
captured["override_slide_css"] = override_slide_css
|
||||||
|
captured["reuse_from"] = reuse_from
|
||||||
|
|
||||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||||
monkeypatch.setattr(sys, "argv", argv)
|
monkeypatch.setattr(sys, "argv", argv)
|
||||||
@@ -346,3 +350,136 @@ def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
|
|||||||
assert captured["override_frames"] is None
|
assert captured["override_frames"] is None
|
||||||
assert captured["override_zone_geometries"] is None
|
assert captured["override_zone_geometries"] is None
|
||||||
assert captured["override_section_assignments"] is None
|
assert captured["override_section_assignments"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# -- IMP-45 (#74) u5 — slide-level CSS override CLI surface ----------------
|
||||||
|
#
|
||||||
|
# Six focused cases mirror the --override-image pattern above:
|
||||||
|
# 1. neither flag → kwarg None (fall-back to MDX frontmatter at u4)
|
||||||
|
# 2. --override-slide-css inline TEXT → kwarg passes verbatim
|
||||||
|
# 3. --slide-css-file PATH UTF-8 read → kwarg = file contents
|
||||||
|
# 4. both flags set → sys.exit(2) with mutual-exclusion stderr
|
||||||
|
# 5. --slide-css-file missing path → sys.exit(2) with not-found stderr
|
||||||
|
# 6. --slide-css-file non-UTF-8 bytes → sys.exit(2) with utf-8 stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_slide_css_override_forwards_none(tmp_path, monkeypatch):
|
||||||
|
"""Neither --override-slide-css nor --slide-css-file → kwarg = None."""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(
|
||||||
|
captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["override_slide_css"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_slide_css_override_forwards_verbatim(tmp_path, monkeypatch):
|
||||||
|
"""--override-slide-css TEXT → kwarg = TEXT (verbatim, no `<style>` wrap)."""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--override-slide-css",
|
||||||
|
".slide { background: red; }",
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["override_slide_css"] == ".slide { background: red; }"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slide_css_file_override_reads_utf8(tmp_path, monkeypatch):
|
||||||
|
"""--slide-css-file PATH → kwarg = UTF-8 decoded file contents."""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
css_payload = ".slide-body { color: #1e293b; } /* 한글 주석 */\n"
|
||||||
|
css_path = tmp_path / "slide_override.css"
|
||||||
|
css_path.write_text(css_payload, encoding="utf-8")
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--slide-css-file",
|
||||||
|
str(css_path),
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["override_slide_css"] == css_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_slide_css_both_flags_set_exits(tmp_path, monkeypatch, capsys):
|
||||||
|
"""--override-slide-css + --slide-css-file → sys.exit(2)."""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
css_path = tmp_path / "slide_override.css"
|
||||||
|
css_path.write_text(".slide { color: red; }\n", encoding="utf-8")
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--override-slide-css",
|
||||||
|
".slide { color: blue; }",
|
||||||
|
"--slide-css-file",
|
||||||
|
str(css_path),
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert excinfo.value.code == 2
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "--override-slide-css and --slide-css-file are mutually exclusive" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_slide_css_file_missing_path_exits(tmp_path, monkeypatch, capsys):
|
||||||
|
"""--slide-css-file with non-existent PATH → sys.exit(2)."""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
missing_path = tmp_path / "does_not_exist.css"
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--slide-css-file",
|
||||||
|
str(missing_path),
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert excinfo.value.code == 2
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "--slide-css-file path does not exist" in err
|
||||||
|
assert str(missing_path) in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_slide_css_file_non_utf8_exits(tmp_path, monkeypatch, capsys):
|
||||||
|
"""--slide-css-file with non-UTF-8 bytes → sys.exit(2)."""
|
||||||
|
_redirect_overrides_root(tmp_path, monkeypatch)
|
||||||
|
bad_path = tmp_path / "latin1.css"
|
||||||
|
# 0xff is a stand-alone invalid UTF-8 start byte; strict decode raises.
|
||||||
|
bad_path.write_bytes(b".slide { color: \xff; }\n")
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
_exec_main_block(
|
||||||
|
captured,
|
||||||
|
[
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
"03.mdx",
|
||||||
|
"--slide-css-file",
|
||||||
|
str(bad_path),
|
||||||
|
],
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert excinfo.value.code == 2
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "--slide-css-file must be UTF-8 encoded" in err
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ def _exec_main_block(
|
|||||||
override_zone_geometries=None,
|
override_zone_geometries=None,
|
||||||
override_section_assignments=None,
|
override_section_assignments=None,
|
||||||
override_image_overrides=None,
|
override_image_overrides=None,
|
||||||
|
override_slide_css=None,
|
||||||
reuse_from=None,
|
reuse_from=None,
|
||||||
):
|
):
|
||||||
captured["called"] = True
|
captured["called"] = True
|
||||||
@@ -72,6 +73,7 @@ def _exec_main_block(
|
|||||||
captured["override_zone_geometries"] = override_zone_geometries
|
captured["override_zone_geometries"] = override_zone_geometries
|
||||||
captured["override_section_assignments"] = override_section_assignments
|
captured["override_section_assignments"] = override_section_assignments
|
||||||
captured["override_image_overrides"] = override_image_overrides
|
captured["override_image_overrides"] = override_image_overrides
|
||||||
|
captured["override_slide_css"] = override_slide_css
|
||||||
captured["reuse_from"] = reuse_from
|
captured["reuse_from"] = reuse_from
|
||||||
|
|
||||||
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
|
||||||
|
|||||||
101
tests/test_phase_z2_slide_css_smoke.py
Normal file
101
tests/test_phase_z2_slide_css_smoke.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""IMP-45 (#74) u6 — subprocess smoke for the slide-level CSS override axis.
|
||||||
|
|
||||||
|
End-to-end guard that the ``slide_overrides.css`` frontmatter axis added
|
||||||
|
in u2 propagates through the pipeline (u4) and lands in ``final.html``
|
||||||
|
via :func:`src.slide_css_injector.inject_slide_css` (u3).
|
||||||
|
|
||||||
|
The fixture is the new ``slide_overrides.css`` frontmatter block in
|
||||||
|
``samples/mdx_batch/04.mdx`` (u6 migration of the legacy frontend-only
|
||||||
|
``MDX04_DEFAULT_OVERRIDE_CSS`` constant). The pipeline is spawned via
|
||||||
|
``python -m src.phase_z2_pipeline 04.mdx <run_id>`` so the assertion
|
||||||
|
applies to the on-disk artifact CI / CLI / regression all observe, not
|
||||||
|
to a live iframe view.
|
||||||
|
|
||||||
|
The subprocess returncode is intentionally NOT asserted: mdx04 has known
|
||||||
|
downstream issues (see ``test_pipeline_smoke_imp85.py`` —
|
||||||
|
``test_mdx04_no_longer_emits_imp85_crash_signature``) that are tracked
|
||||||
|
on a separate axis. Step 13 runs before the downstream failure surface,
|
||||||
|
so ``final.html`` is written with the injected slide CSS marker
|
||||||
|
regardless. The test asserts ``final.html`` exists and contains both
|
||||||
|
the IMP-45 sentinel marker and a distinctive CSS substring from the
|
||||||
|
migrated frontmatter block.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SAMPLES_DIR = REPO_ROOT / "samples" / "mdx_batch"
|
||||||
|
RUNS_DIR = REPO_ROOT / "data" / "runs"
|
||||||
|
|
||||||
|
# IMP-45 (#74) u3 marker sentinel emitted by
|
||||||
|
# :func:`src.slide_css_injector.inject_slide_css` around the injected
|
||||||
|
# ``<style>`` block. Match the ``_IMP45_STYLE_MARKER_OPEN`` constant in
|
||||||
|
# ``src/slide_css_injector.py`` byte-for-byte.
|
||||||
|
IMP45_OPEN_MARKER = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||||
|
IMP45_CLOSE_MARKER = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||||
|
|
||||||
|
# Distinctive substring from the migrated frontmatter block in
|
||||||
|
# ``samples/mdx_batch/04.mdx``. ``f29b__cell:nth-child(n+3)`` is unique
|
||||||
|
# to the MDX04 slide-level override CSS and does not appear elsewhere in
|
||||||
|
# the slide_base / partial templates, so its presence in ``final.html``
|
||||||
|
# is sufficient evidence that the frontmatter axis fed the injector.
|
||||||
|
MDX04_DISTINCTIVE_CSS_SUBSTRING = ".f29b__cell:nth-child(n+3)"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pipeline(mdx_name: str, run_id: str, timeout: int = 240) -> subprocess.CompletedProcess:
|
||||||
|
"""Spawn ``python -m src.phase_z2_pipeline <mdx> <run_id>``."""
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"src.phase_z2_pipeline",
|
||||||
|
str(SAMPLES_DIR / mdx_name),
|
||||||
|
run_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_run_id(prefix: str) -> str:
|
||||||
|
return f"{prefix}_imp45_slide_css_smoke_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mdx04_slide_overrides_css_lands_in_final_html() -> None:
|
||||||
|
"""mdx04 ``slide_overrides.css`` frontmatter must reach ``final.html``.
|
||||||
|
|
||||||
|
Contract pins both the IMP-45 marker sentinel and a distinctive CSS
|
||||||
|
substring from the migrated frontmatter block so a regression in
|
||||||
|
either the frontmatter extractor (u2), the kwarg forwarding (u4),
|
||||||
|
or the injector (u3) is caught by this single smoke.
|
||||||
|
"""
|
||||||
|
run_id = _unique_run_id("mdx04")
|
||||||
|
cp = _run_pipeline("04.mdx", run_id)
|
||||||
|
|
||||||
|
final_html_path = RUNS_DIR / run_id / "phase_z2" / "final.html"
|
||||||
|
assert final_html_path.is_file(), (
|
||||||
|
f"final.html not found at {final_html_path}\n"
|
||||||
|
f"--- stderr tail ---\n{cp.stderr[-1500:]}\n"
|
||||||
|
f"--- stdout tail ---\n{cp.stdout[-1500:]}"
|
||||||
|
)
|
||||||
|
html = final_html_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert IMP45_OPEN_MARKER in html, (
|
||||||
|
f"IMP-45 open marker missing from mdx04 final.html ({final_html_path}).\n"
|
||||||
|
f"slide_overrides.css frontmatter axis did not reach the injector."
|
||||||
|
)
|
||||||
|
assert IMP45_CLOSE_MARKER in html, (
|
||||||
|
f"IMP-45 close marker missing from mdx04 final.html ({final_html_path}).\n"
|
||||||
|
f"Marker wrap appears unbalanced — check inject_slide_css() output."
|
||||||
|
)
|
||||||
|
assert MDX04_DISTINCTIVE_CSS_SUBSTRING in html, (
|
||||||
|
f"Migrated frontmatter CSS substring "
|
||||||
|
f"{MDX04_DISTINCTIVE_CSS_SUBSTRING!r} missing from mdx04 final.html "
|
||||||
|
f"({final_html_path}). The slide_overrides.css block did not propagate."
|
||||||
|
)
|
||||||
100
tests/test_slide_css_injector.py
Normal file
100
tests/test_slide_css_injector.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""IMP-45 (#74) u3 — tests for :mod:`src.slide_css_injector`.
|
||||||
|
|
||||||
|
Mirrors the inject_image_overrides_style test pattern in
|
||||||
|
``tests/test_image_id_stamper.py`` (lines 306-377) for the new
|
||||||
|
``slide_css`` axis. Eight cases :
|
||||||
|
|
||||||
|
1. empty css returns html unchanged
|
||||||
|
2. None css returns html unchanged (mirror of empty-css guard for the
|
||||||
|
typed ``str | None`` signature)
|
||||||
|
3. inserts before </head> (precedence path 1)
|
||||||
|
4. case-insensitive head close (precedence path 1, uppercase variant)
|
||||||
|
5. body-open fallback when no </head> (precedence path 2)
|
||||||
|
6. document-start fallback when neither head nor body present (path 3)
|
||||||
|
7. idempotent on second call with identical css
|
||||||
|
8. replaces existing block when re-called with different css (latest-wins)
|
||||||
|
|
||||||
|
Plus one additional guard :
|
||||||
|
|
||||||
|
9. marker comments wrap the injected <style> block (open precedes close,
|
||||||
|
<style> sits between them)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.slide_css_injector import inject_slide_css
|
||||||
|
|
||||||
|
|
||||||
|
_OPEN_MARKER = "<!--IMP45-SLIDE-CSS:OPEN-->"
|
||||||
|
_CLOSE_MARKER = "<!--IMP45-SLIDE-CSS:CLOSE-->"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_empty_string_returns_html_unchanged():
|
||||||
|
html = "<html><head></head><body>x</body></html>"
|
||||||
|
assert inject_slide_css(html, "") == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_none_returns_html_unchanged():
|
||||||
|
html = "<html><head></head><body>x</body></html>"
|
||||||
|
assert inject_slide_css(html, None) == html
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_inserts_before_head_close():
|
||||||
|
html = "<html><head><title>t</title></head><body>x</body></html>"
|
||||||
|
out = inject_slide_css(html, ".x { color: red; }")
|
||||||
|
assert "<style>" in out
|
||||||
|
head_idx = out.lower().index("</head>")
|
||||||
|
body_idx = out.lower().index("<body")
|
||||||
|
style_idx = out.index("<style>")
|
||||||
|
assert style_idx < head_idx < body_idx
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_case_insensitive_head_close():
|
||||||
|
html = "<HTML><HEAD></HEAD><BODY>x</BODY></HTML>"
|
||||||
|
out = inject_slide_css(html, ".x { color: red; }")
|
||||||
|
assert "<style>" in out
|
||||||
|
assert out.index("<style>") < out.upper().index("</HEAD>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_falls_back_to_body_open_when_no_head():
|
||||||
|
html = "<body>x</body>"
|
||||||
|
out = inject_slide_css(html, ".x { color: red; }")
|
||||||
|
assert "<style>" in out
|
||||||
|
body_open_end = out.index(">", out.index("<body")) + 1
|
||||||
|
assert out[body_open_end:].lstrip().startswith(_OPEN_MARKER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_falls_back_to_document_start_when_no_head_or_body():
|
||||||
|
html = "<div>fragment</div>"
|
||||||
|
out = inject_slide_css(html, ".x { color: red; }")
|
||||||
|
assert out.startswith(_OPEN_MARKER)
|
||||||
|
assert "<style>" in out
|
||||||
|
assert out.rstrip().endswith("</div>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_is_idempotent_on_second_call():
|
||||||
|
html = "<html><head></head><body>x</body></html>"
|
||||||
|
css = ".x { color: red; }"
|
||||||
|
once = inject_slide_css(html, css)
|
||||||
|
twice = inject_slide_css(once, css)
|
||||||
|
assert twice == once
|
||||||
|
assert once.count(_OPEN_MARKER) == 1
|
||||||
|
assert twice.count(_OPEN_MARKER) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_replaces_existing_block_with_new_css():
|
||||||
|
html = "<html><head></head><body>x</body></html>"
|
||||||
|
first = inject_slide_css(html, ".old { color: red; }")
|
||||||
|
second = inject_slide_css(first, ".new { color: blue; }")
|
||||||
|
assert ".old" not in second
|
||||||
|
assert ".new" in second
|
||||||
|
assert second.count(_OPEN_MARKER) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_slide_css_wraps_block_with_marker_comments():
|
||||||
|
html = "<html><head></head><body>x</body></html>"
|
||||||
|
out = inject_slide_css(html, ".x { color: red; }")
|
||||||
|
assert _OPEN_MARKER in out
|
||||||
|
assert _CLOSE_MARKER in out
|
||||||
|
s = out.index(_OPEN_MARKER)
|
||||||
|
e = out.index(_CLOSE_MARKER)
|
||||||
|
assert s < out.index("<style>") < out.index("</style>") < e
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
|
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
|
||||||
|
|
||||||
Covers the persisted axes called out in the Stage 2 plan
|
Covers the persisted axes called out in the Stage 2 plan
|
||||||
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``):
|
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``;
|
||||||
|
IMP-45 #74 u1 extended to 6 axes by adding ``slide_css``):
|
||||||
|
|
||||||
1. Round-trip ``save`` → ``load`` (5 KNOWN_AXES + foreign top-level keys).
|
1. Round-trip ``save`` → ``load`` (6 KNOWN_AXES + foreign top-level keys).
|
||||||
2. Unknown-key passthrough (foreign axes preserved across partial merges).
|
2. Unknown-key passthrough (foreign axes preserved across partial merges).
|
||||||
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
|
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
|
||||||
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
|
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
|
||||||
@@ -119,13 +120,20 @@ def _full_payload() -> dict:
|
|||||||
"image_overrides": {
|
"image_overrides": {
|
||||||
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
|
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
|
||||||
},
|
},
|
||||||
|
"slide_css": "<style>.slide .frame-process-product .label { font-size: 14px; }</style>",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_known_axes_includes_image_overrides():
|
def test_known_axes_includes_image_overrides():
|
||||||
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (5 total)."""
|
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (now 6 total)."""
|
||||||
assert "image_overrides" in KNOWN_AXES
|
assert "image_overrides" in KNOWN_AXES
|
||||||
assert len(KNOWN_AXES) == 5
|
assert len(KNOWN_AXES) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_axes_includes_slide_css():
|
||||||
|
"""IMP-45 #74 u1 — ``slide_css`` is a known axis (6 total)."""
|
||||||
|
assert "slide_css" in KNOWN_AXES
|
||||||
|
assert len(KNOWN_AXES) == 6
|
||||||
|
|
||||||
|
|
||||||
def test_save_then_load_round_trip(tmp_path):
|
def test_save_then_load_round_trip(tmp_path):
|
||||||
@@ -152,6 +160,7 @@ def test_save_partial_payload_preserves_other_axes(tmp_path):
|
|||||||
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
||||||
assert loaded["frames"] == _full_payload()["frames"]
|
assert loaded["frames"] == _full_payload()["frames"]
|
||||||
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
|
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
|
||||||
|
assert loaded["slide_css"] == _full_payload()["slide_css"]
|
||||||
|
|
||||||
|
|
||||||
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
||||||
@@ -173,6 +182,7 @@ def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
|
|||||||
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
|
||||||
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
|
||||||
assert loaded["frames"] == _full_payload()["frames"]
|
assert loaded["frames"] == _full_payload()["frames"]
|
||||||
|
assert loaded["slide_css"] == _full_payload()["slide_css"]
|
||||||
|
|
||||||
|
|
||||||
def test_save_axis_replaces_not_deep_merges(tmp_path):
|
def test_save_axis_replaces_not_deep_merges(tmp_path):
|
||||||
@@ -231,9 +241,17 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
|
|||||||
pos_frames = raw.index('"frames"')
|
pos_frames = raw.index('"frames"')
|
||||||
pos_image_overrides = raw.index('"image_overrides"')
|
pos_image_overrides = raw.index('"image_overrides"')
|
||||||
pos_layout = raw.index('"layout"')
|
pos_layout = raw.index('"layout"')
|
||||||
|
pos_slide_css = raw.index('"slide_css"')
|
||||||
pos_zg = raw.index('"zone_geometries"')
|
pos_zg = raw.index('"zone_geometries"')
|
||||||
pos_zs = raw.index('"zone_sections"')
|
pos_zs = raw.index('"zone_sections"')
|
||||||
assert pos_frames < pos_image_overrides < pos_layout < pos_zg < pos_zs
|
assert (
|
||||||
|
pos_frames
|
||||||
|
< pos_image_overrides
|
||||||
|
< pos_layout
|
||||||
|
< pos_slide_css
|
||||||
|
< pos_zg
|
||||||
|
< pos_zs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_save_leaves_no_tmp_file_on_success(tmp_path):
|
def test_save_leaves_no_tmp_file_on_success(tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user