diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx index dff1f14..2f3b1d8 100644 --- a/Front/client/src/components/SlideCanvas.tsx +++ b/Front/client/src/components/SlideCanvas.tsx @@ -36,10 +36,6 @@ interface SlideCanvasProps { userSelection: UserSelection; /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */ 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). */ isPipelineRunning?: boolean; /** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */ @@ -86,7 +82,6 @@ export default function SlideCanvas({ slidePlan, userSelection, finalHtmlUrl, - slideOverrideCss, isPipelineRunning, isPendingLayout, pendingLayoutId, @@ -391,15 +386,6 @@ export default function SlideCanvas({ const doc = (e.currentTarget as HTMLIFrameElement).contentDocument; 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 측정 ── // backend final.html 의 .zone[data-zone-position="..."] 요소를 // 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화. diff --git a/Front/client/src/pages/Home.tsx b/Front/client/src/pages/Home.tsx index 8854e93..5b564ae 100644 --- a/Front/client/src/pages/Home.tsx +++ b/Front/client/src/pages/Home.tsx @@ -242,22 +242,6 @@ export default function Home() { // 호출되는 단일 callback. handleFileUpload 가 자동 분석 trigger. 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") => { try { const res = await fetch(`/api/sample-mdx?mdx=${encodeURIComponent(which)}`); @@ -634,17 +618,6 @@ export default function Home() { return state.slidePlan; }, [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(() => { - 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 선택 ── const handleFrameSelect = useCallback((frameId: string) => { const zone = getSelectedZone(effectiveSlidePlan, state.userSelection); @@ -805,7 +778,6 @@ export default function Home() { normalizedContent={state.normalizedContent} userSelection={state.userSelection} finalHtmlUrl={runMeta?.final_html_url} - slideOverrideCss={slideOverrideCss} isPipelineRunning={state.isLoading} isPendingLayout={!!pendingLayout} pendingLayoutId={pendingLayout} diff --git a/samples/mdx_batch/04.mdx b/samples/mdx_batch/04.mdx index 2104520..bbb6183 100644 --- a/samples/mdx_batch/04.mdx +++ b/samples/mdx_batch/04.mdx @@ -2,6 +2,17 @@ title: DX 지연 요인 sidebar: 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에 대한 인식 diff --git a/src/mdx_normalizer.py b/src/mdx_normalizer.py index 05e7834..b027581 100644 --- a/src/mdx_normalizer.py +++ b/src/mdx_normalizer.py @@ -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, } diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index d5f0251..4264a6c 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -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 \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 diff --git a/src/user_overrides_io.py b/src/user_overrides_io.py index df8ca6b..2795fa8 100644 --- a/src/user_overrides_io.py +++ b/src/user_overrides_io.py @@ -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//`` 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": , "zone_geometries": {: {"x": float, "y": float, "w": float, "h": float}}, "zone_sections": {: [, ...]}, "frames": {: }, - "image_overrides": {: {"x": float, "y": float, "w": float, "h": float}} + "image_overrides": {: {"x": float, "y": float, "w": float, "h": float}}, + "slide_css": } ``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 diff --git a/tests/regression/fixtures/89a_pre_baseline_sha.json b/tests/regression/fixtures/89a_pre_baseline_sha.json index b7473f9..4aaf4cb 100644 --- a/tests/regression/fixtures/89a_pre_baseline_sha.json +++ b/tests/regression/fixtures/89a_pre_baseline_sha.json @@ -40,8 +40,8 @@ "04.mdx": { "mdx_file": "04.mdx", "run_id": "89a_baseline_04", - "final_html_size_bytes": 27707, - "sha256": "2bce45041cdcca6518cd92586c1be9e051a5c98f5a0ad61fdde02604618a1d80", + "final_html_size_bytes": 28042, + "sha256": "ddb6bf2f8d76ca1f56588a50dd4af5aeb5f45e0a83d5241b83b5932d0c66d41c", "pipeline_exit_code": null }, "05.mdx": { diff --git a/tests/test_mdx_normalizer.py b/tests/test_mdx_normalizer.py new file mode 100644 index 0000000..ed1529d --- /dev/null +++ b/tests/test_mdx_normalizer.py @@ -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 = "" + + +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)", + } diff --git a/tests/test_phase_z2_cli_overrides.py b/tests/test_phase_z2_cli_overrides.py index 0653005..138d924 100644 --- a/tests/test_phase_z2_cli_overrides.py +++ b/tests/test_phase_z2_cli_overrides.py @@ -56,6 +56,8 @@ def _exec_main_block( override_zone_geometries=None, override_section_assignments=None, override_image_overrides=None, + override_slide_css=None, + reuse_from=None, ): captured["mdx_path"] = mdx_path captured["run_id"] = run_id @@ -64,6 +66,8 @@ def _exec_main_block( captured["override_zone_geometries"] = override_zone_geometries captured["override_section_assignments"] = override_section_assignments 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(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_zone_geometries"] 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 `") < e diff --git a/tests/test_user_overrides_io.py b/tests/test_user_overrides_io.py index 6e30adc..c82edea 100644 --- a/tests/test_user_overrides_io.py +++ b/tests/test_user_overrides_io.py @@ -1,9 +1,10 @@ """IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``. 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). 3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning). 4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators / @@ -119,13 +120,20 @@ def _full_payload() -> dict: "image_overrides": { "img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0}, }, + "slide_css": "", } 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 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): @@ -152,6 +160,7 @@ def test_save_partial_payload_preserves_other_axes(tmp_path): assert loaded["zone_sections"] == _full_payload()["zone_sections"] assert loaded["frames"] == _full_payload()["frames"] 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): @@ -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_sections"] == _full_payload()["zone_sections"] assert loaded["frames"] == _full_payload()["frames"] + assert loaded["slide_css"] == _full_payload()["slide_css"] 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_image_overrides = raw.index('"image_overrides"') pos_layout = raw.index('"layout"') + pos_slide_css = raw.index('"slide_css"') pos_zg = raw.index('"zone_geometries"') 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):