Files
C.E.L_Slide_test2/src/mdx_normalizer.py
T
KyeongminandClaude Opus 4.7 9062931863
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 22s
feat(#74): IMP-45 u1~u8 slide-level CSS override (frontmatter slide_overrides.css + --override-slide-css/--slide-css-file + idempotent Step 13 injector)
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>
2026-05-25 03:26:03 +09:00

530 lines
20 KiB
Python

"""Phase T-1: MDX 4-Layer 파서.
Stage 0에서 호출. 원본 MDX를 정규화하여 이후 모든 Stage에 깨끗한 입력 제공.
Layer 1: python-frontmatter — YAML frontmatter 분리, title 추출
Layer 2: regex — 코드블록 보호 + MDX 전용 패턴 (details, :::, JSX, import)
Layer 3: markdown-it-py — AST 파싱 → 이미지/표/헤딩 구조 추출
Layer 4: regex — 텍스트 정리, 빈 줄 정리, clean_text
조사 결과 (T-1):
- python-frontmatter: parse() → (dict, str). frontmatter 없으면 안전하게 {}
- markdown-it-py: js-default 프리셋에 table 기본 포함. 한국어 정상
- 코드블록 보호: backtick 10→3 순서 매칭. 중첩/inline 검증됨
"""
from __future__ import annotations
import re
import logging
from typing import Any
import frontmatter
from markdown_it import MarkdownIt
logger = logging.getLogger(__name__)
# ══════════════════════════════════════
# 코드블록 보호 (Layer 2 선행)
# ══════════════════════════════════════
class _CodeBlockProtector:
"""코드블록을 placeholder로 보호하고 복원.
backtick 개수가 많은 순서(10→3)로 매칭하여 중첩 코드블록 안전 처리.
"""
def __init__(self):
self._store: dict[str, str] = {}
self._counter = 0
def _make_key(self) -> str:
self._counter += 1
return f"__CODEBLOCK_{self._counter}__"
def protect(self, text: str) -> str:
# fenced code blocks (큰 backtick부터)
for n in range(10, 2, -1):
pattern = rf"^(`{{{n}}})([^\n]*)\n(.*?)\n\1\s*$"
def _replacer(m, _n=n):
key = self._make_key()
self._store[key] = m.group(0)
return key
text = re.sub(pattern, _replacer, text, flags=re.MULTILINE | re.DOTALL)
# inline code
def _inline_replacer(m):
key = self._make_key()
self._store[key] = m.group(0)
return key
text = re.sub(r"`[^`\n]+`", _inline_replacer, text)
return text
def restore(self, text: str) -> str:
for key, original in self._store.items():
text = text.replace(key, original)
return text
# ══════════════════════════════════════
# Layer 2: MDX 전용 패턴 처리
# ══════════════════════════════════════
def _convert_md_list_to_html(text: str) -> str:
"""마크다운 리스트(* item, - item)를 HTML <ul><li>로 변환.
들여쓰기 수준(2-4칸)을 감지하여 중첩 <ul>을 생성한다.
"""
lines = text.split("\n")
result = []
list_stack: list[int] = [] # 현재 열린 리스트의 들여쓰기 레벨들
for line in lines:
m = re.match(r"^(\s*)[*\-]\s+(.+)$", line)
if m:
indent = len(m.group(1))
content = m.group(2)
if not list_stack:
result.append("<ul>")
list_stack.append(indent)
elif indent > list_stack[-1]:
result.append("<ul>")
list_stack.append(indent)
else:
while len(list_stack) > 1 and indent < list_stack[-1]:
result.append("</ul></li>")
list_stack.pop()
result.append(f"<li>{content}")
else:
while list_stack:
result.append("</li></ul>")
list_stack.pop()
result.append(line)
while list_stack:
result.append("</li></ul>")
list_stack.pop()
return "\n".join(result)
def _convert_md_table_to_html(text: str) -> str:
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환.
어떤 마크다운 테이블이든 동작. 하드코딩 없음.
"""
lines = text.split("\n")
result = []
table_lines = []
in_table = False
for line in lines:
stripped = line.strip()
if stripped.startswith("|") and stripped.endswith("|"):
table_lines.append(stripped)
in_table = True
else:
if in_table and table_lines:
result.append(_render_md_table(table_lines))
table_lines = []
in_table = False
result.append(line)
if table_lines:
result.append(_render_md_table(table_lines))
return "\n".join(result)
def _render_md_table(table_lines: list[str]) -> str:
"""마크다운 테이블 라인들을 HTML 테이블로."""
if len(table_lines) < 2:
return "\n".join(table_lines)
def _parse_row(line):
cells = [c.strip() for c in line.split("|")]
# 앞뒤 빈 셀 제거 (| col1 | col2 | → ['', 'col1', 'col2', ''])
return [c for c in cells if c or c == ""].__getitem__(slice(1, -1)) if cells[0] == "" else cells
headers = _parse_row(table_lines[0])
# 구분선(|---|---|) 스킵
data_start = 1
if len(table_lines) > 1 and all(c.strip().replace("-", "").replace(":", "") == "" for c in table_lines[1].split("|") if c.strip()):
data_start = 2
rows = [_parse_row(line) for line in table_lines[data_start:]]
# HTML 생성 — 셀 내 <br/> → <br> 유지 (줄바꿈 역할)
header_html = "".join(f"<th>{h}</th>" for h in headers)
rows_html = ""
for row in rows:
cells = ""
for c in row:
c = re.sub(r"<br\s*/?>", "<br>", c)
cells += f"<td>{c}</td>"
rows_html += f"<tr>{cells}</tr>\n"
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
"""MDX 전용 패턴 처리. popups를 추출하고 텍스트에서 마커로 교체.
Returns:
(처리된 텍스트, popups 리스트)
"""
popups = []
# <details><summary>제목</summary>내용</details> → 팝업 추출
def _extract_popup(m):
title = m.group(1).strip()
content = m.group(2).strip()
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
content = content.replace("</div>", "")
# 마크다운 테이블 → HTML 테이블 (br 치환보다 먼저 — 셀 내 <br/>로 행이 쪼개지는 것 방지)
content = _convert_md_table_to_html(content)
# 테이블 밖 <br/> → \n (테이블 안은 이미 <br>로 변환 완료)
content = re.sub(r"<br\s*/>", "\n", content)
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
# 마크다운 리스트(* item) → HTML <ul><li>
content = _convert_md_list_to_html(content)
popups.append({"title": title, "content": content})
return f"[팝업: {title}]"
text = re.sub(
r"<details>\s*<summary[^>]*>(.+?)</summary>(.*?)</details>",
_extract_popup,
text,
flags=re.DOTALL,
)
# import 문 제거
text = re.sub(r"^import\s+.+$", "", text, flags=re.MULTILINE)
text = re.sub(r"^export\s+.+$", "", text, flags=re.MULTILINE)
# <br/> 제거
text = re.sub(r"<br\s*/?>", "", text)
# JSX div style → 태그만 제거 (내용 유지)
text = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", text)
text = text.replace("</div>", "")
# 커스텀 컴포넌트 (<Component />, <Component>...</Component>)
text = re.sub(r"<[A-Z]\w+\s*/>", "", text)
text = re.sub(r"<[A-Z]\w+[^>]*>.*?</[A-Z]\w+>", "", text, flags=re.DOTALL)
# :::directive[제목] → ## 승격 + 핵심요약 마킹
def _process_directive(m):
directive = m.group(1)
title = m.group(2)
if directive in ("note", "tip", "caution", "danger"):
return f"[핵심요약: {title}]"
return f"## {title}"
text = re.sub(r":::(\w+)\[(.+?)\]", _process_directive, text)
text = re.sub(r"^:::\s*$", "", text, flags=re.MULTILINE)
# ## N. 제목 → ## 제목 (번호 제거, 공백 1개 이상 필수 — T-1 조사 버그 수정)
text = re.sub(r"^## \d+\.\s+", "## ", text, flags=re.MULTILINE)
# ### N.N 제목 → ### 제목
text = re.sub(r"^### \d+\.\d+\s+", "### ", text, flags=re.MULTILINE)
# * **제목** → ## 승격 (## 전 도입부에서만)
first_hash = text.find("\n## ")
if first_hash == -1:
first_hash = len(text)
intro = text[:first_hash]
rest = text[first_hash:]
intro = re.sub(r"^\* \*\*(.+?)\*\*\s*$", r"## \1", intro, flags=re.MULTILINE)
text = intro + rest
# 이탤릭 출처 (단독 줄)
text = re.sub(r"^\s*\*([^*\n]+)\*\s*$", r"출처: \1", text, flags=re.MULTILINE)
# 장식용 --- 제거
text = re.sub(r"^---\s*$", "", text, flags=re.MULTILINE)
return text, popups
# ══════════════════════════════════════
# Layer 3: AST 파싱
# ══════════════════════════════════════
def _extract_structure(text: str) -> dict[str, Any]:
"""markdown-it-py AST 파싱으로 구조 추출.
Returns:
{"images": [...], "tables": [...], "sections": [...]}
"""
md = MarkdownIt("js-default")
tokens = md.parse(text)
images = []
tables = []
sections = []
current_section_title = ""
current_section_lines = []
current_section_level = 2
bullet_depth = 0 # 불릿 중첩 깊이 추적 (bullet_list_open/close)
def _flush_section():
nonlocal current_section_title, current_section_lines, current_section_level, bullet_depth
if current_section_title:
sections.append({
"level": current_section_level,
"title": current_section_title,
"content": "\n".join(current_section_lines).strip(),
})
current_section_lines = []
current_section_level = 2
bullet_depth = 0
for i, token in enumerate(tokens):
# 이미지 추출 (inline children)
if token.type == "inline" and token.children:
for child in token.children:
if child.type == "image":
attrs = child.attrs or {}
images.append({
"alt": child.content or attrs.get("alt", ""),
"path": attrs.get("src", ""),
})
# 표 추출
if token.type == "table_open":
table = {"headers": [], "rows": []}
# 이후 토큰에서 thead/tbody 파싱
j = i + 1
in_thead = False
in_tbody = False
current_row = []
while j < len(tokens) and tokens[j].type != "table_close":
t = tokens[j]
if t.type == "thead_open":
in_thead = True
elif t.type == "thead_close":
in_thead = False
if current_row:
table["headers"] = current_row
current_row = []
elif t.type == "tbody_open":
in_tbody = True
elif t.type == "tbody_close":
in_tbody = False
elif t.type == "tr_close":
if in_tbody and current_row:
table["rows"].append(current_row)
elif in_thead and current_row:
table["headers"] = current_row
current_row = []
elif t.type == "inline" and (in_thead or in_tbody):
current_row.append(t.content)
j += 1
if table["headers"] or table["rows"]:
tables.append(table)
# 불릿 depth 추적 (섹션 내용 수집 시 계층 보존)
if current_section_title:
if token.type == "bullet_list_open":
bullet_depth += 1
elif token.type == "bullet_list_close":
bullet_depth = max(0, bullet_depth - 1)
# 섹션 추출 (## 및 ### 기준 — 대목차/소목차 모두)
if token.type == "heading_open" and token.tag in ("h2", "h3"):
# 다음 토큰이 inline (제목 텍스트) — 무의미한 제목(<br/> 등)은 건너뜀
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
heading_text = tokens[i + 1].content.strip()
# <br/>, 빈 문자열, 숫자만 등은 section 제목으로 부적합
clean_heading = re.sub(r'<br\s*/?>', '', heading_text).strip()
if clean_heading and len(clean_heading) > 1:
_flush_section()
current_section_title = clean_heading
current_section_level = 2 if token.tag == "h2" else 3
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
"ordered_list_open", "fence"):
# 섹션 내용 수집 — inline 토큰의 content만
pass
if current_section_title and token.type == "inline" and token.tag == "":
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
parent_type = tokens[i - 1].type if i > 0 else ""
if parent_type != "heading_open":
# depth prefix 추가: D1=1단 불릿, D2=2단 불릿, D3=3단 불릿
depth = max(1, bullet_depth) if bullet_depth > 0 else 0
if depth > 0:
current_section_lines.append(f"D{depth}: {token.content}")
else:
current_section_lines.append(token.content)
_flush_section()
return {"images": images, "tables": tables, "sections": sections}
# ══════════════════════════════════════
# Layer 4: 텍스트 정리
# ══════════════════════════════════════
def _clean_text(text: str) -> str:
"""최종 텍스트 정리: 남은 HTML 태그 제거, 빈 줄 정리."""
# 이미지 참조 보존 (markdown 형식 → 마커)
text = re.sub(r"!\[(.+?)\]\((.+?)\)", r"[이미지: \1]", text)
# 남은 HTML 태그 제거 (self-closing)
text = re.sub(r"<[^>]+/?>", "", text)
# 연속 빈 줄 정리
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ══════════════════════════════════════
# 메인 함수
# ══════════════════════════════════════
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 파서로 정규화.
Stage 0에서 호출. 결과는 PipelineContext.normalized에 저장.
Returns:
{
"clean_text": str,
"title": str,
"images": [{"alt": str, "path": str}],
"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 패턴 처리 ──
protector = _CodeBlockProtector()
protected = protector.protect(body)
processed, popups = _process_mdx_patterns(protected)
restored = protector.restore(processed)
logger.info(f"[Layer 2] popups={len(popups)}개, 코드블록={protector._counter}개 보호/복원")
# ── Layer 3: AST 파싱 → 구조 추출 ──
structure = _extract_structure(restored)
images = structure["images"]
tables = structure["tables"]
sections = structure["sections"]
logger.info(f"[Layer 3] images={len(images)}, tables={len(tables)}, sections={len(sections)}")
# ── Layer 4: 텍스트 정리 ──
clean_text = _clean_text(restored)
logger.info(f"[Layer 4] clean_text={len(clean_text)}자")
return {
"clean_text": clean_text,
"title": title,
"images": images,
"popups": popups,
"tables": tables,
"sections": sections,
"slide_overrides": slide_overrides,
}
# ══════════════════════════════════════
# Stage 0 검증
# ══════════════════════════════════════
def validate_stage0(result: dict, raw_mdx: str) -> list[dict]:
"""Stage 0 출력 검증.
Returns:
에러 리스트 (빈 리스트 = 통과)
"""
errors = []
clean_text = result.get("clean_text", "")
if not clean_text.strip():
errors.append({
"severity": "FATAL",
"field": "clean_text",
"localization": "clean_text가 비어있음",
"instruction": "원본 MDX를 확인하세요",
})
return errors
# 원본 대비 텍스트 보존율 (30% 이상)
raw_text_len = len(re.sub(r"<[^>]+>|\{[^}]+\}|---\n.*?\n---", "", raw_mdx, flags=re.DOTALL).strip())
if raw_text_len > 0:
preservation = len(clean_text) / raw_text_len
if preservation < 0.3:
errors.append({
"severity": "FATAL",
"field": "clean_text",
"localization": f"텍스트 보존율 {preservation:.0%} < 30%",
"evidence": f"원본 {raw_text_len}자 → clean {len(clean_text)}자",
"instruction": "파서가 너무 많은 텍스트를 제거함",
})
# 이미지 수 대조
raw_img_count = len(re.findall(r"!\[", raw_mdx))
result_img_count = len(result.get("images", []))
if raw_img_count > 0 and result_img_count == 0:
errors.append({
"severity": "ADJUSTABLE",
"field": "images",
"localization": f"원본 이미지 {raw_img_count}개, 추출 0개",
"instruction": "이미지 추출 패턴 확인",
})
# 팝업 수 대조
raw_details_count = raw_mdx.count("<details>")
result_popup_count = len(result.get("popups", []))
if raw_details_count > 0 and result_popup_count == 0:
errors.append({
"severity": "ADJUSTABLE",
"field": "popups",
"localization": f"원본 details {raw_details_count}개, 추출 0개",
"instruction": "details 추출 패턴 확인",
})
return errors