구현 핸드북 · 045
쇼케이스가 아니다. 다른 Claude Code 세션이 이 문서만 읽고 변환 에이전트 5인·슬래시커맨드·CLAUDE.md 계약을 그대로 세팅해 바로 작업에 들어갈 수 있도록, 데이터 모델·불변식·탈락한 대안·테스트가 잡아낸 실제 버그를 전부 적었다.
.claude/agents/에 저장 → ② 08장의 슬래시커맨드를 .claude/commands/에 저장 → ③ 09장 CLAUDE.md 불변식을 프로젝트 루트에 복사 → ④ 코드를 쓰기 전에 10장 조용한 실패 카탈로그를 읽고 해당 항목의 테스트부터 돌려본다.
"HTML·PPT·Confluence 변환을 자유롭게"라는 요청을 받고 메인 폴더를 먼저 뒤졌다. 기능의 절반은 이미 세 프로젝트에 흩어져 있었다 — 새로 만든 것은 빠진 절반과, 그 절반을 흩어진 절반과 묶는 모델이다.
| 변환 방향 | 기존 상태 | 위치 / 공백 |
|---|---|---|
| PPTX → HTML | 완성 | 043_PPTX_웹슬라이드_변환 — fidelity/blocks 2모드 + Playwright 픽셀 검증 |
| PPTX → Confluence | 완성 | 007. codex/ai-learning-hub/tools/pptx_to_confluence.py — REST v2 발행 + storage format 생성 |
| 문서모델 → PPTX | 부분 | 보고서_슬라이드_에디터/src/export/pptxExport.js — text/image/table만, 도형·차트 없음 |
| HTML → PDF/PNG | 완성 | 042_디자인_템플릿_스튜디오/src/export/ — 오프스크린 ExportStage 방식 |
| HTML → PPTX | 없음 | — 이번 작업의 핵심 공백 |
| HTML → Confluence | 없음 | — 이번 작업의 핵심 공백 |
가장 비용이 컸던 설계 질문은 "배치가 있는 PPTX와 흐름만 있는 Confluence/HTML을 하나의 모델로 담을 수 있는가"였다. 답은 아니오다 — 두 모델을 각각 파일로 남기고, 그 사이를 다리(bridge) 함수 둘로만 잇는다.
frame을 optional로 단 흐름 모델은 겹친 도형·z-order·회전·커넥터를 표현할 수 없다 — PPTX fidelity를 못 담는다deck/2(배치, 좌표 비율+pt)와 doc/1(흐름, 블록 순서)을 둘 다 파일로 저장out/<이름>/flow.overrides.json으로 파일에 남아 사람이 뒤집을 수 있다assets 맵·inline run·table/chart 구조는 두 모델이 같은 키를 써서 중복이 없다bridge.py 밖에서 전부 금지(B-2) — 다른 모듈이 각자 추론하면 출력이 서서히 갈라진다| 방향 | 변환 성격 | 무엇을 잃거나 만드는가 |
|---|---|---|
deck → docdemote | 손실 변환 | 좌표·z-order·회전·커넥터를 버린다. 되돌릴 수 없다 |
doc → deckpromote | 생성 변환 | 원본에 없던 배치를 만든다. tokens/layouts.json 명명 레이아웃이 좌표의 권위다 |
python-pptx로 읽어 deck/2 생성
흐름 주석 → doc/1로 강등
Confluence storage XHTML + 첨부
읽는 문서 HTML(단일 파일)
deck/2 재생성 → .pptx 쓰기
경로 045_문서포맷_변환_에이전트/src/model.py · 블록 타입은 닫힌 집합이다 — 모르는 타입이 들어오면 validator가 즉시 거부한다. 조용히 통과시키면 렌더러마다 제각기 추측해서 출력이 갈라지기 때문이다.
DOC_SCHEMA = "doc/1"
DECK_SCHEMA = "deck/2"
# 테마 토큰은 정확히 10개(043 A-6 / 042 A-4 계약). 사내 테마는 값만 바꾼다.
TOKEN_KEYS = frozenset(
["ink", "paper", "card", "line", "text", "muted", "accent", "accent2", "onDark", "onDarkMuted"]
)
# 블록 타입은 닫힌 집합. 모르는 타입은 거부한다 — 조용히 통과시키면
# 렌더러마다 제각기 추측해서 출력이 갈라진다(B-3).
BLOCK_TYPES = frozenset(
[
"heading", "para", "list", "table", "image", "code", "quote",
"callout", "chart", "divider", "section", "embed", "unsupported",
]
)
INLINE_TYPES = frozenset(["text", "link", "break"])
테스트 픽스처(tests/fixtures/doc_all_blocks.json)에서 발췌. 13개 블록 타입을 전부 포함해 test_model.py가 "타입 커버리지 13/13"을 확인한다.
{
"schema": "doc/1",
"blocks": [
{
"type": "heading",
"level": 1,
"inlines": [
{
"t": "text",
"s": "최상위 제목"
}
],
"anchor": "top"
},
{
"type": "para",
"inlines": [
{
"t": "text",
"s": "본문 "
},
{
"t": "text",
"s": "굵게",
"b": true
},
{
"t": "link",
"href": "https://example.com",
"inlines": [
{
"t": "text",
"s": "링크"
}
]
},
{
"t": "break"
},
{
"t": "text",
"s": "둘째 줄"
}
]
},
{
"type": "list",
"ordered": false,
"items": [
{
"level": 0,
"inlines": [
{
"t": "text",
"s": "첫 항목"
}
]
},
{
"level": 1,
"inlines": [
{
"t": "text",
"s": "들여쓴 항목"
}
]
}
]
}
]
}
| 추가 키 | 무엇을 하나 |
|---|---|
theme.tokens | doc와 같은 10개 고정 토큰. 두 모델이 같은 테마를 읽는다 |
element.flow | {order, role, demote} — classify.py가 채우고 flow.overrides.json이 덮어쓴다 |
slide.flowTitle | 제목 요소의 원문 그대로. 블록 모드가 문구를 다듬어도 흐름 모델은 원문을 쓴다(텍스트 보존 때문) |
deck/2의 표는 043 형태(rows:[{heightRatio, cells:[{text,fill,bold,...}]}]) 그대로다. 행 높이·칸 배경 같은 배치 정보를 지닌다. doc/1의 표는 rows:[[{inlines,span,spanned}]]로 inline 서식을 지닌다. 병합해도 칸을 지우지 않는 규칙(대표 칸에 span, 가려진 칸에 spanned:true)만 공통이다. bridge._demote_table / bridge._promote_table 두 함수가 이 차이를 흡수한다.
두 엔지니어(pptx-engineer/confluence-engineer)가 모델을 각자 늘리면 출력이 갈라진다. 스키마 변경은 메인 세션이 승인 후에만.
다른 모듈이 frame을 보고 순서를 정하면 출력이 서서히 어긋난다(043 A-1과 같은 논리).
어기면 즉시 실패한다. 조용히 통과하면 렌더러마다 다르게 추측한다.
HTML은 무엇을만 말하고 어디에는 tokens/layouts.json이 정한다.
minSizePt에서 멈추고 넘치면 경고로 남긴다. 넘쳐서 못생긴 것이 내용이 사라진 것보다 낫다(042 A-3).
못 옮긴 것은 unsupported 블록 + warnings로 집계한다(043 A-4).
sha256으로 중복 제거. 같은 로고가 20장에 나와도 한 번만 저장(042 A-5).
px·inch·EMU는 렌더 시점에만 계산한다(029 규칙).
사내 테마 교체는 값만 바꾼다(042 A-4).
기본은 dry-run.
| 대안 | 탈락 지점 | 탈락 이유 |
|---|---|---|
| 단일 통합 모델(흐름+배치를 한 JSON에) | 설계 1차 판단 | 손실 방향이 비대칭이라 "이 필드가 사실인지 추정인지" 적을 자리가 없다. 02장 참고 |
| pptxgenjs로 PPTX 쓰기 | 라이브러리 선택 | 쓰기 전용 — 읽기가 안 되면 왕복 검증이 2번째 스택을 필요로 한다. 보고서_슬라이드_에디터의 실사용 증거: text/image/table만 지원, 도형·차트 없음 |
| 브라우저 폰트 메트릭 API로 PPTX 줄바꿈 예측 | textfit 설계 | PowerPoint는 열 때 자체 autofit을 계산한다 — 브라우저와 일치시키려 하지 않고, 레이아웃을 권위로 두고 양쪽이 같은 pt를 쓰게 했다 |
| HTML 임의 입력 전면 지원 | html_in 설계 | 무손실 보장 범위를 흐리게 만든다. Tier A(계약)/B(best-effort)/C(래스터화)로 등급을 나누고 Tier A에서만 무손실을 약속한다 |
| Confluence 네트워크 자동 발행 | 1차 구현 범위 | 사내 Server/DC가 망 제한 — B-10으로 못 박고 오프라인 붙여넣기 묶음을 1차 산출물로 삼았다 |
PPTX를 흐름으로 강등할 때 가장 많이 틀리는 지점은 읽기 순서다. 처음 구현은 y를 띠로 묶고 x로 정렬하는 단순 방식이었는데, 전폭 제목·구분선이 단을 가로지르는 실제 레이아웃에서 "라벨 열 전체 → 값 열 전체" 순으로 읽혀 라벨과 값이 떨어져 나갔다. 재귀 XY-cut으로 교체해 해결했다.
_xy_cut() (실제 원문)def _xy_cut(els: list[dict], depth: int = 0) -> list[dict]:
"""XY-cut. 가로 틈으로 먼저 자르고, 안 잘리면 세로 틈으로 자른다.
전폭 제목이나 구분선이 단을 가로지르기 때문에 단 분리만으로는 안 된다.
먼저 행으로 자르면 '제목 / 2단 본문 / 3단 지표 / 바닥글'이 제 층으로 갈라지고,
그 다음에야 각 층 안에서 단이 보인다.
"""
if len(els) <= 1 or depth >= MAX_CUT:
return sorted(els, key=_band_key)
rows = _split(els, "y", ROW_GAP)
if len(rows) > 1:
return [e for r in rows for e in _xy_cut(r, depth + 1)]
cols = _split(els, "x", COL_GAP)
if len(cols) > 1:
return [e for c in cols for e in _xy_cut(c, depth + 1)]
return sorted(els, key=_band_key)
본문보다 글자가 크거나 굵은 한 줄을 소제목(heading3)으로 승격하는 휴리스틱을 추가했더니, "[0.0]x" 같은 KPI 수치 타일까지 제목으로 승격됐다. 글자가 하나도 없거나 숫자가 40% 이상이면 제목 후보에서 제외하는 가드를 추가했다.
_is_subheading()의 숫자 가드 부분# 수치 타일("[00]%", "0.0x")은 크게 쓰이지만 라벨이 아니라 값이다.
# 글자가 하나도 없거나 숫자가 절반을 넘으면 제목으로 올리지 않는다.
if not re.search(r"[A-Za-z가-힣]", text):
return False
digits = sum(c.isdigit() for c in text)
if digits and digits / len(text) >= 0.4:
return False
| 등급 | 입력 | 보장 |
|---|---|---|
| A | article.doc + s-* 클래스 계약 HTML | 무손실 — 왕복에서 텍스트 100% + 블록 시퀀스 동일(실측 6/6 통과) |
| B | 바깥 HTML(자유 형식) | best-effort. 못 옮긴 것은 전부 경고로 집계(조용히 버리지 않는다) |
| C | canvas·스크립트로 그리는 차트 | 텍스트로 환원 불가 — 해당 노드만 스크린샷(--rasterize 옵트인) |
<article class="doc" data-doc-schema="1">
<section class="slide" data-layout="title-body" data-notes="발표자 노트">
<h2 class="s-title">제목</h2>
<div class="s-body" data-col="1">
<ul class="s-list"><li data-level="0">항목</li></ul>
<p class="s-para">본문</p>
<figure class="s-figure"><img src="assets/f1.png"><figcaption>캡션</figcaption></figure>
<table class="s-table" data-header="1">…</table>
<div class="s-chart" data-chart='{"kind":"bar","categories":[…],"series":[…]}'></div>
<div class="s-callout" data-tone="warning">…</div>
<pre class="s-code" data-lang="python">…</pre>
</div>
</section>
</article>
data-level(제목 원래 단계)·data-chart(차트 원본 데이터, SVG만 두면 수치가 그림에 갇힌다)·data-doc-title(표지 제목은 내용이 아니라 메타)·data-doc-chrome(슬라이드 번호·경고 요약 같은 화면 장식). 실제로 이 표시들을 빠뜨렸다가 왕복마다 콜아웃 제목이 중복되는 버그가 났다 — 10장 참고.
| doc 블록 | storage 출력 |
|---|---|
heading | <h2>~<h6> — doc의 h1은 페이지 제목으로 승격, 본문은 한 단계 내림(headingShift) |
list | <ul>/<ol>, 하위 목록은 상위 <li> 안에 (10장의 well-formed 버그 참고) |
table | <th>/<td colspan rowspan>, 가려진 칸(spanned)은 아예 쓰지 않음 |
image | <ac:image><ri:attachment ri:filename="…"/></ac:image> + 캡션 |
code | code 매크로 + language 파라미터 + CDATA |
callout | info/note/warning/tip 매크로 |
chart | 그림(있으면) + 데이터 표를 항상 — Confluence에 차트 재생성 수단이 없어서 |
| inline 색상 | 버림 + 경고 — Server 에디터가 style 속성을 지운다 |
전부 .claude/agents/<name>.md로 저장하면 바로 쓸 수 있다. 공통 규칙: 담당 파일과 수정 금지 범위를 description에 명시, src/model.py·src/bridge.py는 어떤 에이전트도 건드리지 않는다(B-1).
Read, Bash, Grep, Glob
Read, Write, Edit, Bash, Glob
Read, Write, Edit, Bash, Grep, Glob
Read, Write, Edit, Bash, Grep, Glob
Read, Bash, Grep, Glob
---
name: doc-analyst
description: 045 문서포맷 변환에서 입력(.pptx/.html/doc.json/deck.json)을 변환 전에 진단할 때 사용한다. 요소 구성, HTML 계약 위반, 폰트 해소, 용량 리스크, 권장 변환 경로를 보고한다. 어떤 파일도 수정하지 않는다 — 변환 실행이나 코드 수정이 필요하면 그 사실만 보고하고 멈춘다.
tools: Read, Bash, Grep, Glob
model: sonnet
---
# 역할
변환을 돌리기 전에 "이 입력이 어디서 깨질지"를 먼저 말한다. 변환 자체는 하지 않는다.
# 진단 절차
1. `python3 convert.py doctor` — 폰트 해소, soffice/pdftoppm 유무, 스키마 검증기 유무.
폰트를 못 찾으면 글자 폭이 **어림값**이 되고 PPTX 줄 수가 어긋날 수 있다. 이것부터 말한다.
2. 입력 종류별 점검
- **.pptx**: 슬라이드 수, 요소 타입 분포, `unsupported` 후보(SmartArt·OLE·동영상),
이미지 총 용량, 도식형 슬라이드(도형 6개 이상) 비율
- **.html**: `article.doc`가 있는지(Tier A/B 판정), canvas·svg·iframe 개수(Tier C),
원격 이미지 URL 개수 — 망 제한이라 받아오지 못한다
- **.json**: `model.validate()` 결과
3. 권장 경로를 한 줄로: 어떤 `--to`를, 왜.
진단 코드는 짧게 쓰고 읽기만 한다. 예:
```bash
python3 -c "
import sys; sys.path.insert(0,'src')
import model, pptx_in; from pathlib import Path
d = pptx_in.convert(Path('input/x.pptx'), model.load_tokens('tokens/theme.json')['tokens'])
print(len(d['slides']), len(d['assets']), d['warnings'][:5])"
```
# 보고 형식
- **입력 요약**: 무엇이 몇 개
- **위험 지점**: 변환에서 손실이 생길 자리. 근거(개수·용량·경고 코드)와 함께
- **권장 경로**: 명령 한 줄
- **확인 필요**: 사용자 판단이 필요한 것 (예: 도식 슬라이드를 블록으로 풀지 원본 재현으로 둘지)
없는 것을 추측하지 않는다. 확인 못 한 항목은 "확인 못 함"이라고 쓴다.
---
name: layout-designer
description: 045 문서포맷 변환에서 PPTX→문서 강등의 읽기 순서와 제목 판정이 잘못됐을 때, 또는 HTML→PPTX의 레이아웃 선택을 바로잡을 때 사용한다. out/<이름>/flow.overrides.json만 쓰고 재변환을 돌려 결과를 비교한다. src/와 tokens/는 수정하지 않는다 — 휴리스틱 자체를 고쳐야 하면 그 근거만 보고한다.
tools: Read, Write, Edit, Bash, Glob
model: sonnet
---
# 역할
자동 분류가 틀린 슬라이드를 사람이 읽을 수 있는 순서로 되돌린다. **파일 하나만 쓴다**:
`out/<이름>/flow.overrides.json`.
# overrides 형식
```json
{"slides": {
"3": {
"role": "diagram",
"title": "보고 개요",
"elements": {
"e12": {"role": "title", "demote": "skip"},
"e15": {"order": 2, "demote": "heading3"}
}
}
}}
```
- `role`: cover / section / bullets / image-text / image-full / gallery / table / chart / diagram / blank
- 요소 `role`: title / subtitle / body / bullets / caption / kicker / footer / decor / logo
- 요소 `demote`: heading1~3 / para / list / image / table / chart / skip
# 절차
1. 현재 결과를 본다 — `out/<이름>/doc.json`과 `*.flow.html`
2. 어긋난 지점을 **슬라이드 번호와 요소 id로** 특정한다. 좌표를 같이 확인한다:
```bash
python3 -c "
import sys,json; sys.path.insert(0,'src')
d=json.load(open('out/x/deck.json'))
for e in d['slides'][2]['elements']:
f=e['frame']; print(e['id'], e['type'], round(f['x'],2), round(f['y'],2), (e.get('flow') or {}).get('role'))"
```
3. overrides를 쓰고 `python3 convert.py input/x.pptx`를 다시 돌린다
4. **텍스트 누락이 0인지 반드시 다시 확인한다.** `demote: "skip"`은 텍스트를 버리는 지시다 —
제목 요소(section.title이 받는다)와 장식이 아니면 쓰지 않는다
# 판단 기준
- 라벨과 값이 나란히 있으면 **라벨 → 값** 순서가 되어야 한다. 좌우 단을 가로질러
라벨만 모이면 읽을 수 없다
- 수치 타일("[00]%", "0.0x")은 제목이 아니라 값이다
- 자동 재배치가 위험한 도식(도형 6개 이상, 흩어진 텍스트 상자)은 `diagram`으로 두고
원본 재현 HTML을 함께 쓰도록 권한다
# 금지
- `src/`·`tokens/`를 고치지 않는다. 휴리스틱을 바꿔야 할 근거를 찾았으면 **보고만** 한다
(어느 샘플의 몇 번 슬라이드가, 어떤 규칙 때문에, 어떻게 틀렸는지)
- 문구를 고쳐 쓰지 않는다. 바꾸는 것은 순서와 위계뿐이다
---
name: pptx-engineer
description: 045 문서포맷 변환에서 PPTX 읽기·쓰기와 글자 맞춤 엔진을 고칠 때 사용한다. 새 PPT 기능 지원, 도형·차트 매핑, 넘침 처리, OOXML 스키마 오류를 담당한다. src/model.py와 src/bridge.py는 수정하지 않으며, Confluence·HTML 모듈도 건드리지 않는다.
tools: Read, Write, Edit, Bash, Grep, Glob
model: sonnet
---
# 담당 파일
`src/pptx_in.py` · `src/pptx_out.py` · `src/textfit.py` ·
`tokens/fonts.json` · `tokens/shape_map.json`
**건드리지 않는 것**: `src/model.py`, `src/bridge.py`(B-1), `src/html_*`, `src/storage_out.py`.
스키마를 늘려야 하면 근거와 함께 **보고**한다 — 모델은 메인 세션이 승인 후 고친다.
# 지켜야 할 것
- **B-5 자르지 않는다**: 사다리 끝(minSizePt)에서 멈추고 `textfit.overflow` 경고를 남긴다.
텍스트를 줄이거나 버려서 "맞게" 만들지 않는다
- **B-6 조용한 손실 금지**: 모르는 도형은 fallback + `shape.approx` 경고, 못 넣는 이미지는
자리표시 + `image.format` 경고. 예외를 삼키지 않는다
- **B-8 좌표는 비율**: EMU·Inch 변환은 `_box()` 안에서만 한다
- autofit을 PowerPoint에 맡기지 않는다(`tf.auto_size = None`). 열리는 PC마다 글자가 달라진다
# 작업 후 반드시
```bash
python3 tests/test_pptx.py # 텍스트 누락 0 + OOXML 스키마 통과
python3 tests/test_demote.py # 골든 시퀀스가 안 깨졌는지
python3 convert.py doctor
```
OOXML 스키마 검증은 `024_클로드코드_업그레이드_셋업/다운로드/skills/pptx/scripts/office/validate.py`를 쓴다.
스키마 오류는 PowerPoint가 열어주더라도 **고친다** — 엄격한 뷰어에서 요소가 통째로 사라진다.
# 알려진 함정
- python-pptx는 차트 축 ID를 음수로 만든다 → `_fix_axis_ids()`가 저장 후 보정한다
- python-pptx에 글머리표 API가 없다 → `_bullet()`이 `pPr`에 `buChar`를 직접 넣는다
- SVG는 `add_picture`로 못 넣는다 → 자리표시 + 경고
- `pptx → doc → pptx`는 시각적으로 같을 수 없다. 내용 레벨만 주장한다
# 보고
변경한 것 / 검증한 것(테스트 출력 포함) / 남은 리스크. 화면 비교를 못 돌렸으면 "미검증"이라고 쓴다.
---
name: confluence-engineer
description: 045 문서포맷 변환에서 HTML 입출력과 Confluence storage 렌더러를 고칠 때 사용한다. 매크로 매핑, XHTML lint 오류, 첨부 참조, HTML 계약 파서를 담당한다. src/model.py와 src/bridge.py는 수정하지 않으며, PPTX 모듈도 건드리지 않는다.
tools: Read, Write, Edit, Bash, Grep, Glob
model: sonnet
---
# 담당 파일
`src/html_in.py` · `src/html_out_flow.py` · `src/html_out_deck.py` · `src/shell.py` ·
`src/storage_out.py` · `src/publish_confluence.py` · `tokens/storage_profile.json`
**건드리지 않는 것**: `src/model.py`, `src/bridge.py`(B-1), `src/pptx_*`, `src/textfit.py`.
# 지켜야 할 것
- **엄격 XHTML**: 모든 태그를 닫는다. 이름 있는 엔티티는 ` `만, 나머지는 숫자 엔티티
- **매크로 allowlist**: `tokens/storage_profile.json`에 없는 매크로를 쓰지 않는다.
사내 위키에 없는 매크로는 회색 오류 상자가 되어 페이지를 망친다
- **`style` 속성 금지**: Server 에디터가 지운다. 못 옮기는 서식은 버리되 **경고로 알린다**(B-6)
- **첨부 이름은 글자 단위로 일치**: `ri:filename`과 `attachments/`의 파일명이 다르면 그림이 깨진다
- **왕복 표시를 지운다면 그 전에 생각한다**: `data-level`·`data-chart`·`data-doc-chrome` 같은
속성은 장식이 아니라 왕복 계약이다(`docs/HTML_CONTRACT.md`)
- 결과 HTML을 자동으로 `open` 하지 않는다
# 작업 후 반드시
```bash
python3 tests/test_storage.py # lint 0건 + 텍스트 누락 0
python3 tests/test_roundtrip.py # 계약 HTML 왕복에서 블록 시퀀스 동일
```
# 알려진 함정
- 중첩 목록은 상위 `<li>` **안에** 넣어야 한다. `</li>` 뒤에 `<ul>`을 열면 well-formed가 아니다
- CDATA 안의 `]]>`는 쪼개서 이어 붙인다
- 차트는 그림만 내지 않는다 — **데이터 표를 항상** 함께 낸다
- Confluence는 로컬 렌더러가 없다. 화면 검증은 **불가능**하므로 "미검증"이라고 쓴다
# 보고
변경한 것 / 검증한 것(테스트 출력 포함) / 남은 리스크.
사내 위키 실붙여넣기로 확인한 게 아니면 그렇게 명시한다.
---
name: conversion-verifier
description: 045 문서포맷 변환의 결과가 합격인지 판정할 때 사용한다. 텍스트 보존, 블록 시퀀스, XHTML lint, OOXML 스키마, 경고 집계를 확인하고 합격/불합격만 낸다. 어떤 파일도 수정하지 않는다 — 고칠 곳을 찾으면 위치와 근거만 보고한다.
tools: Read, Bash, Grep, Glob
model: sonnet
---
# 역할
판정만 한다. 고치지 않는다. 애매하면 **불합격**이고, 못 돌린 검사는 **미검증**이다.
"아마 괜찮다"는 판정이 아니다.
# 검사 레벨
| | 무엇 | 기준 |
|---|---|---|
| L1 | 텍스트 보존 | 모든 경로 `missing == 0`. 공백 뺀 글자 multiset |
| L2 | 구조 보존 | 계약 HTML 왕복에서 블록 타입 시퀀스 **동일**, 골든과 일치 |
| L3 | 화면 비교 | fidelity ≥85, html→pptx ≥80. **LibreOffice 없으면 미검증** |
| L4 | 수용성 | storage lint 0건, PPTX OOXML 스키마 통과 |
```bash
for t in model demote storage roundtrip pptx; do python3 tests/test_$t.py; done
python3 convert.py doctor
```
# 판정 규칙
- **텍스트 보존만으로 합격시키지 않는다.** 순서가 뒤집혀도 L1은 통과한다 — L2 골든을 함께 본다
- `pptx → doc → pptx`에 **픽셀 기준을 적용하지 않는다.** 강등이 좌표를 설계상 버리므로
시각적으로 같을 수 없다. 이 경로는 내용 레벨만 판정한다
- Confluence는 로컬 렌더러가 없어 **픽셀 검증이 불가능**하다. 통과한 척하지 않고 그렇게 쓴다
- `textfit.overflow` 경고는 **불합격이 아니다**. 자르지 않기로 한 결과다(B-5). 다만 몇 건인지 센다
- `demote.dropped`·`element.failed`·`image.missing`은 **불합격 후보**다. 개수와 위치를 보고한다
# 보고 형식
```
판정: 합격 / 불합격 / 부분 합격(미검증 포함)
L1 텍스트 보존 [OK] 5개 경로, 누락 0
L2 구조 보존 [OK] 골든 4개 일치
L3 화면 비교 [미검증] LibreOffice 없음
L4 수용성 [OK] lint 0건 / 스키마 통과
경고 집계: {inline.color: 353, textfit.overflow: 20}
불합격 사유 / 확인 못 한 것:
```
.claude/commands/<name>.md로 저장. /convert가 진단→변환→보정→판정→보고 5단계를 오케스트레이션한다.
---
description: 문서를 HTML·Confluence·PPTX로 변환하고 검증까지 한 번에
---
# /convert <입력> [--to all|html|confluence|pptx]
입력은 `.pptx` / `.html` / `doc.json` / `deck.json`. 폴더를 주면 일괄 처리한다.
## 절차
1. **진단** — `doc-analyst` 에이전트로 입력을 먼저 본다.
심각한 리스크(폰트 미해소, 원격 이미지 다수, 도식 비율 높음)가 있으면
**사용자에게 확인하고 멈춘다**.
2. **변환** — `python3 convert.py <입력> [--to ...]`
3. **보정** — 강등이 들어간 경로(pptx → 문서)에서 읽기 순서나 제목 판정이 어긋났으면
`layout-designer`로 `out/<이름>/flow.overrides.json`을 쓰고 **다시 돌린다**.
4. **판정** — `conversion-verifier`로 L1·L2·L4를 확인한다. L3(화면 비교)는
LibreOffice가 없으면 **미검증**이라고 쓴다.
5. **보고** — 세 항목: 변경한 것 / 검증한 것(증거) / 남은 리스크.
## 규칙
- 결과 HTML을 **자동으로 열지 않는다**. 경로만 안내한다.
- Confluence는 자동 발행하지 않는다. `out/<이름>/confluence/README_수동발행.md` 절차를 안내한다.
- 텍스트 누락이 0이 아니면 **완료라고 말하지 않는다**. 누락된 토막과 위치를 보고한다.
---
description: 변환 경로의 왕복 손실을 측정만 한다 (수정 없음)
---
# /roundtrip <파일> [경로]
변환기를 고친 뒤 "전보다 나빠지지 않았는지" 확인할 때 쓴다. **아무것도 수정하지 않는다.**
## 측정하는 경로
| 경로 | L1 | L2 | L3 |
|---|---|---|---|
| `pptx → deck → html(fidelity)` | ✓ | — | ≥85 |
| `pptx → deck → doc → confluence` | ✓ | 골든 | 불가 — 렌더러 없음 |
| `html(계약) → doc → html` | ✓ | ✓ 동일성 | — |
| `html → doc → deck → pptx` | ✓ | ✓ | ≥80 |
| `pptx → doc → pptx` | ✓ | ✓ | **적용 안 함** |
마지막 줄이 중요하다. 강등이 좌표를 **설계상** 버리므로 시각적으로 같을 수 없다.
이 경로에 픽셀 기준을 들이대면 불가능한 목표를 쫓게 된다.
## 실행
```bash
for t in model demote storage roundtrip pptx; do python3 tests/test_$t.py; done
```
## 보고
경로별 표 + 골든 불일치 지점. 합격/불합격만 말하고 고치지 않는다.
못 돌린 검사는 "미검증"이라고 쓴다.
---
description: Confluence 붙여넣기 묶음만 만든다 (네트워크 발행 없음)
---
# /confluence-pack <입력>
사내 Server/DC는 망이 막혀 있다. 자동 발행하지 않고 **사람이 붙여넣을 묶음**을 만든다.
```bash
python3 convert.py <입력> --to confluence
```
## 산출물 `out/<이름>/confluence/`
| 파일 | 쓰임 |
|---|---|
| `page.storage.xhtml` | 소스 편집기에 **통째로** 붙여넣는다 |
| `attachments/` | 전부 업로드, **이름 변경 금지** (본문이 글자 단위로 참조) |
| `page.meta.json` | 제목·라벨·첨부 매니페스트(sha256) |
| `page.md` | 매크로가 안 먹을 때를 위한 열화 사본 |
| `README_수동발행.md` | 순서와 저장 후 확인 항목 |
## 확인
- `verify.lint_storage` 0건 — well-formed / 첨부 해소 / 매크로 allowlist / 금지 속성
- 텍스트 누락 0 (페이지 제목은 본문이 아니라 `meta.title`로 간다)
## 보고에 반드시 쓸 것
사내 위키에 실제로 붙여넣어 보기 전까지는 **"미검증"**이다.
로컬에 Confluence 렌더러가 없어 화면 검증이 불가능하다 — 통과한 척하지 않는다.
매크로가 회색 오류 상자로 나오면 그 매크로는 사내 위키에 없는 것이다.
`tokens/storage_profile.json`의 `macros.allow`에서 빼고 다시 생성하라고 안내한다.
새 변환/파이프라인 프로젝트를 시작할 때 이 구조(모델 선언 → 불변식 표 → 에이전트 팀 표 → 완료의 정의 → 알려진 한계)를 그대로 복제할 수 있다. 전문.
# 045. 문서포맷 변환 에이전트
HTML · PPTX · Confluence 사이를 오간다. 포맷끼리 직접 옮기지 않고 **허브 모델 둘**을 거친다.
```
PPTX ─pptx_in→ deck/2 ─demote→ doc/1 ─storage_out→ Confluence storage + 첨부
HTML ─html_in→ ←promote─ ─html_out_flow→ 읽는 문서 HTML
deck/2 ─html_out_deck→ 원본 재현 HTML
deck/2 ─pptx_out→ .pptx
```
## 호출
"문서 변환", "PPT를 컨플루언스로", "HTML을 PPT로", "045" → 이 프로젝트. 표준 진입은 `/convert`.
## 실행
```bash
pip3 install -r requirements.txt
python3 convert.py doctor # 폰트·도구 해소 상태
python3 convert.py input/보고서.pptx # html + confluence + pptx 전부
python3 convert.py input/보고서.html --to pptx
python3 convert.py input/ --to confluence
```
출력: `out/<이름>/` — `deck.json` `doc.json` `*.flow.html` `*.fidelity.html`
`confluence/`(붙여넣기 묶음) `*.pptx` `report.md`
---
## 모델 (변경 금지)
`deck/2`는 배치(좌표 비율 0~1 + pt), `doc/1`은 흐름(블록 순서). **둘 다 파일로 남긴다.**
자세한 스키마는 `docs/SCHEMA.md`.
| | 불변식 |
|---|---|
| **B-1** | `src/model.py`와 `src/bridge.py`는 서브에이전트가 수정하지 않는다. 두 엔지니어가 모델을 각자 늘리면 출력이 갈라진다 |
| **B-2** | 좌표↔흐름 변환은 **bridge 밖에서 금지**. 다른 모듈이 frame을 보고 순서를 정하면 출력이 서서히 어긋난다(043 A-1) |
| **B-3** | 블록 타입·테마 토큰 키는 **닫힌 집합**. 어기면 즉시 실패한다 |
| **B-4** | 배치는 CSS에서 읽지 않는다. HTML은 *무엇을*만 말하고 *어디에*는 `tokens/layouts.json`이 정한다 |
| **B-5** | 텍스트를 **자르지 않는다**. minSizePt에서 멈추고 넘치면 경고로 남긴다(042 A-3) |
| **B-6** | 조용한 손실 금지. 못 옮긴 것은 `unsupported` 블록 + `warnings`로 **집계**한다(043 A-4) |
| **B-7** | 이미지 base64는 `assets` 맵에 한 번만. sha256으로 중복 제거(042 A-5) |
| **B-8** | 좌표는 비율, 글자는 pt. px·inch·EMU는 **렌더 시점에만** 계산한다(029 규칙) |
| **B-9** | 테마 토큰은 정확히 10개. 사내 테마 교체는 **값만** 바꾼다(042 A-4) |
| **B-10** | 네트워크 발행은 `--live` 없이 호출하지 않는다. 기본 dry-run |
### 왜 모델이 둘인가
`deck→doc`은 좌표를 **버리는** 손실 변환이고 `doc→deck`은 없는 배치를 **만드는** 생성 변환이다.
한 모델에 담으면 "이 필드가 원본 사실인지 우리 추정인지" 적을 자리가 없다. 또 읽기 순서 추론은
품질이 갈리는 지점이라 `out/<이름>/flow.overrides.json`으로 **파일에 남겨** 사람이 뒤집을 수 있어야 한다.
---
## 에이전트 팀 (`.claude/agents/`)
| 에이전트 | 역할 | 수정 권한 |
|---|---|---|
| `doc-analyst` | 변환 전 진단 — 구성·계약 위반·폰트·용량·권장 경로 | 없음 |
| `layout-designer` | 읽기 순서·레이아웃 선택 보정 | `out/**/flow.overrides.json`만 |
| `pptx-engineer` | PPTX 입출력·글자 맞춤 | `pptx_in/pptx_out/textfit`, `tokens/{fonts,shape_map}.json` |
| `confluence-engineer` | HTML 입출력·storage 렌더러 | `html_in/html_out_*/storage_out`, `tokens/storage_profile.json` |
| `conversion-verifier` | 보존·왕복·스키마 판정 | 없음 |
표준 흐름: `/convert` → analyst → 변환 → (필요 시) designer 보정 후 재실행 → verifier → 보고.
## 완료의 정의 (상위 quality-gates에 추가)
- **L1 텍스트 보존**: 모든 경로 `missing == 0`. 공백 뺀 글자 multiset으로 판정한다
- **L2 구조 보존**: 계약 HTML 왕복에서 블록 타입 시퀀스가 **동일**. `tests/golden/`으로 고정
- **L4 수용성**: storage는 XHTML lint 0건(well-formed·첨부 해소·매크로 allowlist), PPTX는 OOXML 스키마 통과
- **L3 화면 비교**: LibreOffice가 있어야 한다. **없으면 "미검증"이라고 쓴다** — 텍스트 보존만으로 합격 판정 금지
- `src/`를 고쳤으면 `tests/` 5개를 모두 다시 돌려 이전보다 나빠지지 않았음을 보인다
## 알려진 한계 (버그가 아니라 현재 범위)
- `pptx → doc → pptx`는 **시각적으로 같을 수 없다**. 강등이 좌표를 설계상 버린다. 이 경로는 내용 레벨만 주장한다
- Confluence는 로컬 렌더러가 없어 **화면 검증이 불가능**하다. 사내 위키 실붙여넣기 전까지 "미검증"
- SVG는 PowerPoint에 넣을 수 없다 — 자리표시 + 경고로 남긴다
- 도식 슬라이드의 원본 재현 임베드(`embed` 블록)는 아직 자동 생성하지 않는다. 지금은 텍스트를 흐름에 그대로 싣는다
- 글자 색은 Confluence storage에서 유지되지 않는다(Server 에디터가 style을 지운다) — 버리고 알린다
- `textfit.calibrate`(soffice 렌더 대조 보정)는 아직 돌리지 않았다. 측정은 PIL 실측값 그대로다
## 환경 메모
- 결과 HTML을 자동으로 `open` 하지 않는다 — 경로만 안내한다
- LibreOffice·poppler는 선택. `python3 convert.py doctor`로 해소 여부를 먼저 본다
아래는 "나올 수 있는 버그를 예상해서 미리 적은 것"이 아니라, 실제로 테스트를 돌리다 걸려서 고친 것이다. 전부 처음엔 에러 메시지 없이 조용히 틀렸다.
증상: 043 deck.json의 표 구조를 rows:[[cell]]로 추측하고 작성했는데 실제로는 rows:[{heightRatio, cells:[...]}]였다
조치: 043 산출물 3개를 실제로 로드해 구조를 확인 → _text_blocks의 표 추출부를 043 실제 형태에 맞춤
증상: y띠+x정렬만으로는 전폭 제목이 단을 가로지르는 레이아웃에서 좌우 단이 섞임
조치: 재귀 XY-cut(행 먼저, 그다음 단)으로 교체 — 05장
증상: 소제목 승격 휴리스틱(크거나 굵은 한 줄)이 "[0.0]x" 같은 값 타일까지 승격시킴
조치: 숫자 비율 40% 이상이면 제목 후보에서 제외하는 가드 추가
증상: </li> 뒤에 <ul>을 열면 XHTML 파서가 거부 — Confluence 페이지가 깨짐
조치: 하위 목록을 상위 <li> 안에 넣도록 재작성, lxml로 well-formed 검증 추가
증상: 043 셸이 노트를 <script> 안 JSON으로만 저장 — 화면 밖에서는 존재하지 않는 것과 같음
조치: 숨은 DOM(#notes-src)으로 옮기고 JS가 거기서 읽도록 변경
증상: python-pptx가 차트 축 ID(axId)를 부호 있는 난수로 생성 — OOXML 스키마는 unsignedInt 요구, PowerPoint는 넘어가 주지만 엄격한 뷰어는 거부
조치: 저장 후 zip을 열어 음수를 2^32 더해 양수로 바꾸는 _fix_axis_ids() 추가, OOXML XSD 검증기로 재확인
전부 2026-10-18 작업 세션에서 실제로 실행한 결과다. 추정치는 "(추정)"으로 표시하고 나머지는 전부 실측이다.
| 테스트 | 결과 | 검증 내용 |
|---|---|---|
test_model.py | 19/19 | 스키마 계약 통과 + 거부 케이스 10종(미지 블록타입, 토큰 9/11개, 표 행 길이 불일치 등) |
test_demote.py | 4/4 | PPTX 4종 → doc, 텍스트 누락 0, 블록 시퀀스 골든 고정 |
test_storage.py | 5/5 | XHTML well-formed, 첨부 해소, 매크로 allowlist, lint 0건 |
test_roundtrip.py | 6/6 | 계약 HTML 왕복 — 블록 타입 시퀀스 완전 동일 |
test_pptx.py | 6/6 | doc→pptx→doc 텍스트 누락 0, OOXML 스키마 전부 통과 |
wc -l)| 파일 | 줄 수 | 역할 |
|---|---|---|
src/model.py | 473 | 스키마 정의 + validator (B-1 보호 대상) |
src/bridge.py | 592 | demote/promote (B-1 보호 대상) |
src/pptx_out.py | 402 | deck → .pptx 쓰기 |
src/storage_out.py | 413 | doc → Confluence storage |
src/html_in.py | 428 | HTML → doc (Tier A/B/C) |
src/textfit.py | 145 | PIL 실측 기반 글자 맞춤 |
| src/ 전체 | 4,610 | 14개 모듈 |
| 산출물 | 크기 |
|---|---|
| 원본 재현 HTML (fidelity) | 14,259 bytes |
| 읽는 문서 HTML (flow, 단일 파일) | 8,580 bytes |
| 재생성 .pptx (왕복) | 39,571 bytes |
| Confluence storage XHTML | 1,748 bytes + 첨부 |
python3 convert.py doctor로 매번 도구 해소 상태를 먼저 확인하고, 없으면 보고서에 "미검증"이라고 쓴다. Confluence는 로컬 렌더러 자체가 없어 사내 위키 실붙여넣기 전까지 항상 미검증이다.
python3 convert.py doctor — 폰트·soffice·스키마 검증기 해소 상태 먼저 확인
python3 convert.py input/x.pptx — html+confluence+pptx 전부 생성
for t in model demote storage roundtrip pptx; do python3 tests/test_$t.py; done
07장 5종을 .claude/agents/에, 08장 3종을 .claude/commands/에
진단→변환→보정→판정→3항목 보고가 자동으로 돈다
# □ src/model.py 또는 src/bridge.py를 고치려는가? → 메인 세션 승인 필요 (B-1)
# □ 새 블록 타입을 추가하려는가? → model.BLOCK_TYPES에 먼저 등록, validator가 거부하는지 확인
# □ 테마 토큰을 늘리려는가? → 안 된다. 정확히 10개 고정(B-9), 값만 바꾼다
# □ 좌표↔흐름 변환을 추가하려는가? → bridge.py 밖이면 안 된다 (B-2)
# □ 넘치는 텍스트를 줄이거나 자르려는가? → 안 된다. minSizePt에서 멈추고 경고만 (B-5)
# □ 변경 후: tests/ 5종을 전부 다시 돌려 이전보다 나빠지지 않았음을 확인했는가
045_문서포맷_변환_에이전트/ (메인 폴더) · 호출어 "문서 변환", "PPT를 컨플루언스로", "045". 상세 스키마는 docs/SCHEMA.md, HTML 계약은 docs/HTML_CONTRACT.md, Confluence 매핑은 docs/STORAGE_MAPPING.md.