From e3b87371dbd0cc4d4ffdbc7045558ef13cff8b83 Mon Sep 17 00:00:00 2001
From: airmang <38392618+airmang@users.noreply.github.com>
Date: Mon, 20 Jul 2026 22:48:25 +0900
Subject: [PATCH] docs: add HWPX/OWPML internals field guide + English README
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
실제 한/글 동작에서 확인된, 코드만으로는 알 수 없는 HWPX/OWPML 실전 지식을
docs/internals/ 아래에 정리하고, 영문 README를 추가한다.
- docs/internals/: 6개 주제(HWPUNIT 좌표계, hp:linesegarray 조판 캐시,
TABLEOFCONTENTS dirty 재계산, OPC 재패킹, 메모 구조, 오라클 한계) + 색인.
모든 주장은 저장소 코드·테스트(src/hwpx/..., tests/...)나 공개 표준으로
검증했고, 실측 한/글 동작은 "실제 한/글에서 확인된 동작"으로 명시.
- Sphinx toctree에 배선(내부 가이드 색인 → 6개 주제), gh-pages 빌드 무손상.
- README_EN.md: 현재 origin/main README.md의 충실한 영문 번역.
- README.md/README_EN.md: 상단 언어 토글 + "더 보기"에 내부 가이드 링크.
Co-Authored-By: Claude Fable 5
---
README.md | 3 +
README_EN.md | 217 +++++++++++++++++++++++++++++++
docs/index.md | 1 +
docs/internals/README.md | 40 ++++++
docs/internals/lineseg.md | 83 ++++++++++++
docs/internals/memo-structure.md | 85 ++++++++++++
docs/internals/opc-packaging.md | 84 ++++++++++++
docs/internals/oracle-limits.md | 50 +++++++
docs/internals/toc-dirty.md | 58 +++++++++
docs/internals/units.md | 52 ++++++++
10 files changed, 673 insertions(+)
create mode 100644 README_EN.md
create mode 100644 docs/internals/README.md
create mode 100644 docs/internals/lineseg.md
create mode 100644 docs/internals/memo-structure.md
create mode 100644 docs/internals/opc-packaging.md
create mode 100644 docs/internals/oracle-limits.md
create mode 100644 docs/internals/toc-dirty.md
create mode 100644 docs/internals/units.md
diff --git a/README.md b/README.md
index 96c44b9..1f6d400 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,8 @@
+한국어 | English
+
---
## 🧩 HWPX Stack (3종)
@@ -174,6 +176,7 @@ hwpx-analyze-template 보고서.hwpx
- **[🚀 빠른 시작](docs/quickstart.md)** · **[📚 사용 가이드](docs/usage.md)** — 첫 파일 열기부터 문단·표·메모·섹션 편집, 텍스트 추출·검증까지
- **[💡 예제 모음](docs/examples.md)** · [`examples/`](examples/) — `build_release_checklist.py`(메모·스타일 편집 HWPX 생성), `extract_text.py`(CLI 텍스트 추출), `find_objects.py`(OWPML 노드 추적) 등
- **[📐 스키마 개요](docs/schema-overview.md)** · **[🔧 설치 검증](docs/installation.md)**
+- **[🔬 HWPX 내부 실전 가이드](docs/internals/)** — 실제 한/글 동작에서 확인된 HWPUNIT·조판 캐시·목차 필드·OPC 재패킹·메모·오라클 한계
- **[📖 전체 문서 (Sphinx)](https://airmang.github.io/python-hwpx/)** — API 레퍼런스·50+ 실전 패턴·FAQ
- **[📝 CHANGELOG](CHANGELOG.md)** · **[🤝 CONTRIBUTING](CONTRIBUTING.md)** · **[👥 CONTRIBUTORS](CONTRIBUTORS.md)**
diff --git a/README_EN.md b/README_EN.md
new file mode 100644
index 0000000..e86d254
--- /dev/null
+++ b/README_EN.md
@@ -0,0 +1,217 @@
+
+
python-hwpx
+
+ Read, edit, generate, and structurally validate HWPX documents in Python — without Hancom Office.
+
+
+
+
+
+
+
+
+
+한국어 | English
+
+---
+
+## 🧩 HWPX Stack (3 components)
+
+| Layer | Repo | Role |
+|---|---|---|
+| 📦 Library | **[`python-hwpx`](https://github.com/airmang/python-hwpx)** | Pure-Python HWPX parsing / editing / generation core |
+| 🔌 MCP server | [`hwpx-mcp-server`](https://github.com/airmang/hwpx-mcp-server) | Manipulate HWPX from MCP clients (Claude Desktop, VS Code, etc.) |
+| 🎯 Agent skill | [`hwpx-plugin`](https://github.com/airmang/hwpx-plugins) | First-party plugin / skill bundle that lets agents use HWPX directly |
+
+`python-hwpx` is the core library providing HWPX parsing, editing, and generation,
+while `hwpx-mcp-server` and `hwpx-plugin` are first-party integration components
+maintained directly by the same project.
+"First-party" refers to the project's maintenance relationship; it does not imply
+official certification by Hancom or any third party.
+
+The current public PyPI release is `python-hwpx 3.6.0`. A plain
+`pip install python-hwpx` installs this release.
+The current package classifier is `Development Status :: 3 - Alpha`. This classifier
+reflects the maturity of the API and product; it does not stand in for the public
+version or the minimum compatible version of the plugin.
+
+---
+
+## We speak in measurements — Published Corpus
+
+The outputs of this stack are verified by **exhaustive measurement against real
+Hancom Office**, not by claims (frozen corpus N=497, 2026-07-19; details and caveats
+in the
+[measured corpus metrics](https://airmang.github.io/python-hwpx/corpus-metrics.html)):
+
+- **Hancom open-acceptance rate 100%** (476/476 all-pass, lower bound ≥99.4%) · parsing 96.2%
+- **Byte preservation of untouched regions 100%** (497/497, patch path) · **personal-info 0-leak**
+- 416 render-verified cases + an honesty bucket (PDF export of tracked-change documents is refused by Hancom itself — published as a measured limitation)
+- Form-fill differential is 49.2% on wild public forms — **we publish the low number as-is** and record it as remaining work
+
+> These numbers are on the *output acceptance* axis (does real Hancom accept the files we produce).
+> This is a different axis from document *parsing recall*, so do not compare it side by side with parser-project figures.
+
+---
+
+## Why python-hwpx
+
+- **No Hancom Office required for core editing** — HWPX is a ZIP+XML (OWPML/OPC) structure, so pure Python reads and writes it anywhere: Windows, macOS, Linux, CI.
+- **From reading to generation in one core** — text/format extraction, paragraph/table/form editing, new-document generation, and XSD schema validation are all handled by one API.
+- **Agent- and automation-friendly** — `hwpx-mcp-server` and `hwpx-plugin`, maintained by the same project, connect to the core.
+
+Document parsing, editing, and generation can be done in pure Python. However, to
+assert final visual quality — page breaks, table overflow, font substitution, and so
+on — you separately use a real Hancom render oracle as needed.
+
+## Quick start
+
+```bash
+pip install python-hwpx # Python 3.10+ · lxml ≥ 4.9
+```
+
+```python
+from hwpx import HwpxDocument
+
+# Open an existing document → edit → save
+doc = HwpxDocument.open("보고서.hwpx")
+doc.add_paragraph("자동화로 추가한 문단입니다.")
+doc.save_to_path("보고서-수정.hwpx")
+
+# Create a new document
+new = HwpxDocument.new()
+new.add_paragraph("python-hwpx로 만든 새 문서")
+new.save_to_path("새문서.hwpx")
+```
+
+> 💡 A context manager is also supported — resources are cleaned up automatically on leaving the `with` block:
+> ```python
+> with HwpxDocument.open("보고서.hwpx") as doc:
+> doc.add_paragraph("자동으로 리소스가 정리됩니다.")
+> doc.save_to_path("결과물.hwpx")
+> ```
+
+Once you have the `open`/`new` → `edit`/`extract` → `save_to_path` flow, you can expand into the rest as needed.
+
+## What it does
+
+### 🔍 Read · Extract
+- Text/HTML/Markdown export — `export_text()` · `export_html()` · `export_markdown()`
+- **Rich Markdown** — `export_rich_markdown()` preserves inline formatting (`**bold**` · `*italic*` · `~~strikethrough~~`), nested tables (colspan/rowspan safe), shape text, images, footnotes/endnotes, hyperlinks, and heading auto-detection (`#`/`##`)
+- **Document ingest gateway** — `hwpx.ingest.DocumentIngestor` detects HWPX and normalizes it into rich Markdown plus section/table metadata
+- `TextExtractor` / `ObjectFinder` — iterate sections/paragraphs, find objects by tag/attribute/XPath (`hp:tab` is preserved as `\t`, roundtrip-safe)
+
+```python
+doc = HwpxDocument.open("보고서.hwpx")
+md = doc.export_rich_markdown(
+ image_dir="out/images", # extract BinData images to disk
+ image_ref_prefix="images/", # prefix for  paths in the markdown
+ detect_headings=True, # auto #/## based on Ⅰ./1. patterns
+)
+```
+
+### ✏️ Edit
+- Add/remove/format paragraphs, run-level bold/italic/underline/color
+- Add/remove sections (`add_section(after=)` · `remove_section()`, manifest managed automatically)
+- Create tables, cell text, merge/split, nested tables, image embedding, headers/footers, memos (anchor-based), footnotes/endnotes, bookmarks/hyperlinks, multi-column editing
+- **Edit formatting of existing documents** — alignment · line spacing · indentation · paragraph spacing, paper · margins · orientation, page numbers, bullets/numbering
+- **Style-based replacement** — filter runs by color · underline · `charPrIDRef` and replace selectively (`replace_text_in_runs` · `find_runs_by_style`)
+
+```python
+# Find and replace only red text
+doc.replace_text_in_runs("임시", "확정", text_color="#FF0000")
+```
+
+### 🖊️ Form filling (byte-preserving)
+- Query and format-preserving fill of click-here (누름틀) fields, label-based cell lookup (`find_cell_by_label`) · path-based fill (`fill_by_path`)
+- **Byte-preserving structural editing** — cell filling / row·column·table delete·insert / column-width autofit / shrink-to-fit fonts performed without reassembling the document, preserving the form's formatting exactly. Untouched regions are left byte-for-byte intact by `hwpx.patch`, which splices section XML bytes
+
+```python
+doc = HwpxDocument.open("신청서.hwpx")
+result = doc.fill_by_path({
+ "성명 > right": "홍길동",
+ "소속 > right": "플랫폼팀",
+})
+doc.save_to_path("신청서-작성완료.hwpx")
+print(result["applied_count"], result["failed_count"])
+```
+
+### 🏗️ Generation · Official-document tools
+- `hwpx.builder` — assembly-style generation of Section/Heading/Table/Image/Header + a hard-gated save report
+- Official-document tools — `official_lint` (item-marker hierarchy · "끝." marker · attachments · date lint), approval-block presets
+- `advanced_generators` — photo boards (image_grid) · meeting nameplates · table-based org charts
+- `mail_merge` — bulk generation of N copies from template + data, table sum/average computation
+- `doc_diff` — paragraph LCS diff · old/new comparison tables · reference-consistency lint
+- `style_profile` — extract and apply reference-document profiles, template registry
+
+### ✅ Validation · Safety · Low-level
+- XSD schema + package structure validation — CLI `hwpx-validate` · `hwpx-validate-package`, `hwpx-analyze-template`
+- `validate_editor_open_safety` — gate for save/pack/repair/builder output, returns `openSafety` evidence
+- `hwpx.tools.fuzz` (seeded deterministic scenarios · triple oracle) · `hwpx.tools.layout_preview` (page-box approximation HTML/PNG, self-verifying) · `opc.security` (XML entity · ZIP compression-bomb guards)
+- Directly manipulate OWPML schema ↔ Python objects via `hwpx.oxml` dataclasses, with automatic HWPML 2016→2011 namespace normalization
+
+```bash
+hwpx-validate-package 보고서.hwpx
+hwpx-analyze-template 보고서.hwpx
+```
+
+> For the full list of features, classes, and methods, see the [usage guide](docs/usage.md) and the [API reference](https://airmang.github.io/python-hwpx/api_reference.html).
+
+## Comparison with competing libraries
+
+| | python-hwpx | pyhwpx | pyhwp |
+|---|---|---|---|
+| **Target format** | `.hwpx` (OWPML/OPC) | `.hwpx` | `.hwp` (v5 binary) |
+| **Hancom install** | Not required | Required (Windows COM) | Not required |
+| **Cross-platform** | ✅ Linux / macOS / Windows / CI | ❌ Windows only | ✅ |
+| **Edit/generate API** | ✅ | ✅ (COM) | ❌ mostly read-only |
+| **Schema validation** | ✅ | ❌ | ❌ |
+| **AI agent integration (MCP)** | ✅ `hwpx-mcp-server` | ❌ | ❌ |
+
+> HWP (v5 binary) files are not supported. Convert to HWPX in Hancom Office first.
+
+## Known limitations
+
+- `add_shape()` / `add_control()` do not generate every child element Hancom requires. When adding complex objects, verify by opening in Hancom.
+- Image binary embedding is supported, but full automatic generation of the `` element is not provided.
+- Encryption/decryption of encrypted HWPX files is not supported.
+
+## More
+
+- **[🚀 Quick start](docs/quickstart.md)** · **[📚 Usage guide](docs/usage.md)** — from opening your first file to editing paragraphs/tables/memos/sections and text extraction/validation
+- **[💡 Examples](docs/examples.md)** · [`examples/`](examples/) — `build_release_checklist.py` (generate HWPX with memo/style edits), `extract_text.py` (CLI text extraction), `find_objects.py` (trace OWPML nodes), and more
+- **[📐 Schema overview](docs/schema-overview.md)** · **[🔧 Install verification](docs/installation.md)**
+- **[🔬 HWPX internals field guide](docs/internals/)** — HWPUNIT · layout cache · TOC fields · OPC repacking · memos · oracle limits, verified against real Hancom behavior
+- **[📖 Full docs (Sphinx)](https://airmang.github.io/python-hwpx/)** — API reference · 50+ practical patterns · FAQ
+- **[📝 CHANGELOG](CHANGELOG.md)** · **[🤝 CONTRIBUTING](CONTRIBUTING.md)** · **[👥 CONTRIBUTORS](CONTRIBUTORS.md)**
+
+## Contributing
+
+Bug reports, feature proposals, and PRs are all welcome.
+
+```bash
+git clone https://github.com/airmang/python-hwpx.git
+cd python-hwpx
+pip install -e ".[dev]"
+pytest
+```
+
+## Acknowledgements
+
+This project is indebted to the following open standards and projects.
+
+- **[OWPML — Open Word-processor Markup Language (KS X 6101)](https://www.kssn.net/search/stddetail.do?itemNo=K001010119985)** — the Korean industrial standard HWPX is based on
+- **[hancom-io/hwpx-owpml-model](https://github.com/hancom-io/hwpx-owpml-model)** — reference model for OWPML element structure · **[neolord0/hwpxlib](https://github.com/neolord0/hwpxlib)** — oracle sample corpus
+- **[edwardkim/rhwp](https://github.com/edwardkim/rhwp)** — inspiration for idempotence / verification-gate design
+- **범정부오피스 (Whole-of-government Office)** — ideas for official-document editing workflows
+
+## License
+
+Apache License 2.0. See LICENSE and NOTICE.
+
+## Maintainer
+
+Primary maintainer/contact: **Kohkyuhyun** ([@airmang](https://github.com/airmang))
+
+- ✉️ [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
+- 🐙 [@airmang](https://github.com/airmang)
diff --git a/docs/index.md b/docs/index.md
index 5c21c73..fcbd6cb 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -20,6 +20,7 @@ examples
corpus-metrics
schema-overview
+internals/README
faq
changelog
```
diff --git a/docs/internals/README.md b/docs/internals/README.md
new file mode 100644
index 0000000..1b9b7c7
--- /dev/null
+++ b/docs/internals/README.md
@@ -0,0 +1,40 @@
+# HWPX/OWPML 내부 실전 가이드
+
+```{toctree}
+:maxdepth: 1
+:hidden:
+
+units
+lineseg
+toc-dirty
+opc-packaging
+memo-structure
+oracle-limits
+```
+
+코드만으로는 알 수 없는, **실제 한/글 동작에서 확인된** HWPX/OWPML 실전 지식을 모은 문서입니다.
+
+HWPX는 [KS X 6101 OWPML](https://www.kssn.net/search/stddetail.do?itemNo=K001010119985) 표준 위에 세워진 ZIP+XML 포맷이지만, 표준 문서에도 한컴 공식 문서에도 적혀 있지 않은 동작이 많습니다. 파일을 만들어 실제 한/글에서 열어 보면서만 알 수 있는 것들 — 저장 시점에 캐시되는 조판 결과, 필드 재계산 트리거, OPC 재패킹 규칙, 메모의 참조 구조, 렌더 기반 검증의 한계 같은 것들이 그렇습니다.
+
+이 가이드는 그런 지식을 정리해, 기여자가 "왜 이렇게 처리해야 하는가"를 코드와 함께 이해하도록 돕는 것을 목표로 합니다. 각 문서의 모든 주장은 이 저장소의 코드·테스트(`src/hwpx/...`, `tests/...`)나 공개 표준으로 검증할 수 있는 것만 담았고, 실측으로 관찰한 한/글 동작은 "실제 한/글에서 확인된 동작"으로 명시했습니다.
+
+## 문서 목록
+
+| 주제 | 문서 | 한 줄 요약 |
+|---|---|---|
+| 좌표 단위 | [units.md](units.md) | HWPUNIT 좌표계(1 inch = 7,200)와 라이브러리가 pt/mm/%를 내부에서 변환하는 이유 |
+| 조판 캐시 | [lineseg.md](lineseg.md) | `hp:linesegarray`는 한/글이 저장 시점에 캐시한 줄나눔 결과 — 편집 후 stale로 남기면 글자 겹침 |
+| 목차 필드 | [toc-dirty.md](toc-dirty.md) | `TABLEOFCONTENTS` 필드의 `dirty="1"`이 여는 시점 재계산을 트리거하는 메커니즘 |
+| OPC 패키징 | [opc-packaging.md](opc-packaging.md) | `mimetype` 첫 엔트리·STORED 규칙, version.xml/manifest, 2016→2011 네임스페이스 정규화 |
+| 메모 구조 | [memo-structure.md](memo-structure.md) | 메모 본문·MEMO 필드·`MemoShapeIDRef` 참조가 맞아야 한/글이 메모를 표시하는 이유 |
+| 오라클 한계 | [oracle-limits.md](oracle-limits.md) | 한/글 export 기반 검증이 침묵 실패할 수 있는 경우와 픽셀 검증이 필요한 이유 |
+
+## 읽는 순서
+
+처음이라면 [units.md](units.md) → [opc-packaging.md](opc-packaging.md) → [lineseg.md](lineseg.md) 순서를 권합니다. 좌표 단위와 컨테이너 구조를 먼저 잡으면 나머지 주제의 코드가 훨씬 잘 읽힙니다.
+
+## 전제
+
+- 이 가이드는 HWPX(OWPML/OPC) 포맷을 다룹니다. HWP v5 바이너리 포맷은 대상이 아닙니다.
+- 인용된 코드 경로는 이 저장소 기준입니다(`src/hwpx/...`). 버전에 따라 줄 번호는 달라질 수 있으니 함수·클래스 이름으로 찾으세요.
+- "실제 한/글에서 확인된 동작"은 실제 한컴오피스로 파일을 열거나 저장해 관찰한 결과를 뜻합니다. 표준에 명문화되어 있지 않을 수 있습니다.
diff --git a/docs/internals/lineseg.md b/docs/internals/lineseg.md
new file mode 100644
index 0000000..9c1562a
--- /dev/null
+++ b/docs/internals/lineseg.md
@@ -0,0 +1,83 @@
+# hp:linesegarray — 조판(줄나눔) 캐시
+
+`hp:linesegarray`는 HWPX를 편집할 때 가장 조용히, 그러나 확실하게 문서를 깨뜨릴 수 있는 요소입니다. 텍스트를 고친 뒤 이 캐시를 방치하면 한/글 렌더에서 **글자가 겹쳐** 보입니다. 이 문서는 그것이 무엇이고, 언제 stale이 되며, 이 라이브러리가 어떻게 처리하는지 설명합니다.
+
+## 무엇인가
+
+``는 문단(``) 안에 들어가는 **조판 결과 캐시**입니다. 자식으로 `` 요소들을 가지며, 각 `lineseg`는 한 줄의 세로 위치·높이·베이스라인·시작 텍스트 위치(`textpos`)·가로 위치/크기 등 한/글이 **저장 시점에 계산해 둔 줄나눔 기하**를 담습니다. 저수준 모델이 이 필드를 전부 표현합니다 — `src/hwpx/oxml/body.py`의 `class LineSeg`(text_pos, vert_pos, vert_size, text_height, baseline, ... 필드)와 `class LineSegArray`(linesegs 리스트).
+
+핵심은 이것이 **의미 있는 콘텐츠가 아니라 파생된 레이아웃 메타데이터**라는 점입니다. `src/hwpx/oxml/section_story.py`의 주석이 이를 명확히 합니다:
+
+> A cached `lineSegArray` is layout metadata and may be discarded after a text edit; every other child is semantic content and therefore fails closed before mutation.
+
+즉 텍스트·서식은 지우면 안 되지만, 조판 캐시는 지워도 됩니다. 지우면 한/글이 문서를 열 때 스스로 다시 계산합니다.
+
+## 언제 stale이 되나
+
+`lineseg`의 `textpos`는 "이 줄이 문단 텍스트의 몇 번째 글자에서 시작하는가"를 가리킵니다. 문단의 텍스트 길이를 편집으로 바꾸면, 이전에 캐시된 `textpos` 값들이 새 텍스트와 어긋납니다. 특히 `textpos`가 새 문단의 텍스트 길이를 넘어서면 그 캐시는 명백히 **stale**입니다.
+
+라이브러리는 이 정의를 그대로 검사합니다 — `src/hwpx/tools/package_validator.py`:
+
+```python
+if textpos > text_length:
+ _error(
+ issues,
+ part_name,
+ f"paragraph {paragraph_index} has stale lineseg textpos={textpos} "
+ f"beyond text length {text_length}",
+ )
+```
+
+`src/hwpx/layout/lint.py`도 같은 조건을 `STALE_LINESEG_DETECTED`로 잡아냅니다(렌더러 없이도 탐지 가능한 하드 에러).
+
+**stale을 남기면 무슨 일이 생기나.** 캐시된 줄 기하는 *옛* 텍스트를 기준으로 하므로, 한/글은 새(더 긴) 텍스트를 옛 줄 슬롯에 밀어 넣어 렌더합니다. 그 결과가 글자 겹침입니다. `src/hwpx/patch.py`의 주석이 이 인과를 못 박아 둡니다:
+
+> Splicing new text into a paragraph invalidates its cached line geometry, so the byte path must drop the cache; otherwise Hangul renders the new text into the stale line slots and overlapping glyphs result.
+
+이 동작은 실제 한컴 렌더 오라클로 확인된 것입니다: 텍스트를 교체한 문단은 줄이 겹치고, 캐시를 제거한 동일 문단은 정상 렌더됩니다.
+
+## 두 가지 전략
+
+편집 후 캐시를 다룰 방법은 두 가지입니다.
+
+1. **지운다 → 한/글이 재계산.** 편집한 문단의 `linesegarray`를 제거하면 한/글이 열 때 그 문단만 다시 조판합니다. 안전하지만, 제거된 바이트만큼 파일이 달라집니다.
+2. **보존한다 → 바이트 보존에 유리, 그러나 stale 위험.** 캐시를 그대로 두면 미편집 영역의 바이트가 원본과 동일하게 유지됩니다. 편집한 문단의 캐시까지 남기면 위험합니다.
+
+이 라이브러리의 설계 원칙은 **둘의 조합 — "손댄 문단만" 무효화**입니다. 편집한 문단의 캐시는 지우고(전략 1), 손대지 않은 문단의 캐시는 보존합니다(전략 2).
+
+## 라이브러리의 실제 처리: 문단 단위 스코프 무효화
+
+문서 전체의 캐시를 몽땅 지우는 것이 아니라, **편집한 문단(들)의 캐시만** 지웁니다. 이것이 이 라이브러리에서 가장 중요한 불변식입니다.
+
+편집 진입점마다 스코프 무효화가 걸려 있습니다.
+
+- **바이트 스플라이스 경로** (`src/hwpx/patch.py`): `_strip_paragraph_layout_cache()`가 정규식으로 한 문단의 ``만 제거하고, 스플라이스한 문단에만 적용합니다. 미편집 span은 캐시까지 원본 바이트 그대로 round-trip됩니다.
+- **본문 텍스트 교체** (`src/hwpx/body_patch.py`): `replace_text`가 편집된 문단을 **가장 안쪽 `` 단위**로 묶어 그 블록의 캐시만 제거합니다.
+- **표 셀 채움** (`src/hwpx/table_patch.py`): `fill_cells`·`_blank_cell_text`가 채운(또는 비운) 셀 문단의 캐시만 제거하고, 손대지 않은 표는 바이트 동일하게 유지합니다.
+- **머리글/바닥글 스토리** (`src/hwpx/oxml/section_story.py`): 텍스트 세터가 대상 문단의 캐시를 `_clear_paragraph_layout_cache()`로 지웁니다(주석: `Clear cached lineseg so Hangul recalculates layout.`).
+- **오브젝트 모델 세터** (`src/hwpx/oxml/run.py`, `paragraph.py`, `table.py`): run/문단/셀 텍스트·스타일을 바꾸는 API가 자신이 건드린 문단의 캐시를 그 자리에서 제거합니다.
+
+### 왜 "전부 지우기"가 아니라 "스코프"인가
+
+모델 전체를 재직렬화할 때조차 캐시를 전부 지우지 않고, **명백히 stale한 것만** 정리합니다(안전망). `src/hwpx/oxml/document_parts.py`의 주석이 그 이유를 설명합니다:
+
+> The mutating APIs clear the caches of exactly the paragraphs they touch, so even a dirty section only needs the stale sweep as a safety net. Nuking every cache here forced Hancom to re-lay-out untouched pages of multi-page forms, which is what stacked glyphs and shifted page counts.
+
+즉 **전부 지우기가 오히려 버그**였습니다. 여러 페이지짜리 양식에서 캐시를 통째로 날리면, 한/글이 손대지 않은 페이지까지 재조판하면서 페이지 수가 바뀌고 글자가 겹쳤습니다. 그래서 저장 경계의 sweep은 stale한 것만 제거합니다 — `src/hwpx/opc/package.py`의 `_strip_section_layout_caches`는 `textpos > text length`인 캐시만 지웁니다.
+
+`src/hwpx/oxml/section.py`에는 전량 제거 메서드 `remove_layout_caches()`가 존재하지만, 저장 파이프라인에 연결되어 있지 않습니다. 실제로 배선된 것은 stale-only인 `remove_stale_layout_caches()`뿐입니다.
+
+## 테스트로 고정된 계약
+
+이 동작은 여러 테스트로 못 박혀 있습니다.
+
+- `tests/test_layout_cache_scope.py` — 단일 셀 채움이 "그 셀 문단의 캐시만" 무효화하고 나머지는 보존함을 검증. no-op 저장은 모든 캐시를 보존(`test_noop_save_preserves_every_layout_cache`).
+- `tests/test_kordoc_absorption.py::test_byte_preserving_patch_strips_only_patched_paragraph_layout_cache` — 스플라이스가 대상 문단만 제거하고 이웃 문단 캐시는 살아 있음을 검증.
+- `tests/test_layout_lint.py`, `tests/test_gap_closure_tools.py` — seeded stale 캐시가 렌더러 없이도 하드 에러/`ValueError`로 잡히는지 검증.
+- `tests/test_coverage_promotion.py` — 미편집 문단의 `LineSeg`/`LineSegArray`가 저장·재파싱을 거쳐 손실 없이 round-trip됨을 검증.
+
+## 실전 요약
+
+- HWPX 텍스트를 직접 편집한다면(문자열 치환, XML 조작), **그 문단의 ``를 반드시 제거**하세요. 안 그러면 한/글에서 글자가 겹칩니다.
+- 단, **손대지 않은 문단의 캐시는 남겨 두세요.** 전량 삭제는 미편집 페이지의 재조판을 유발해 페이지 수/레이아웃을 흐트러뜨립니다.
+- 이 라이브러리의 고수준·바이트 보존 API를 쓰면 이 처리는 자동입니다. 저수준으로 내려갈 때만 직접 신경 쓰면 됩니다.
diff --git a/docs/internals/memo-structure.md b/docs/internals/memo-structure.md
new file mode 100644
index 0000000..9c87c7d
--- /dev/null
+++ b/docs/internals/memo-structure.md
@@ -0,0 +1,85 @@
+# 메모(hp:memo) 구조
+
+한/글의 메모(주석)를 프로그램으로 붙일 때 흔히 겪는 함정은 "요소는 만들었는데 한/글에서 메모가 안 보인다"입니다. 메모가 표시되려면 **세 조각 — 메모 본체, 본문의 MEMO 필드, 그리고 둘을 잇는 참조 — 가 모두 맞아야** 하기 때문입니다. 이 문서는 실제로 표시되는 메모의 구조를 코드로 짚습니다.
+
+## 세 조각
+
+### 1. 메모 본체: `` 안의 ``
+
+메모 본체는 섹션의 `` 컨테이너 안에 ``로 들어갑니다(`src/hwpx/oxml/memo.py`의 `HwpxOxmlMemoGroup`, `HwpxOxmlMemo`). 각 ``는 `id`와 `memoShapeIDRef` 속성을 가집니다.
+
+메모의 **본문 텍스트**는 `` 안의 `` → `` → `` → ``에 놓입니다(`HwpxOxmlMemo.set_text`). 즉 메모 본체가 만들어내는 구조는:
+
+```
+
+
+
+ 메모 내용
+
+
+
+```
+
+> 주의: 여기서 본문을 담는 컨테이너는 `paraList`입니다. 아래에 나오는 MEMO **필드**의 `subList`와 혼동하기 쉬운데, 둘은 서로 다른 위치입니다. (참고로 각주/미주 ``/``는 또 별개로 `subList`를 씁니다.)
+
+### 2. 본문의 MEMO 필드: ``
+
+메모가 한/글 편집기의 여백에 **풍선으로 표시되려면**, 본문 문단에 대응하는 MEMO 필드 컨트롤이 있어야 합니다. `docs/usage.md`가 이를 명시합니다:
+
+> 한글 편집기에서 메모 풍선을 표시하려면 본문 문단에 대응되는 MEMO 필드 컨트롤(`hp:fieldBegin`/`hp:fieldEnd`)이 있어야 합니다.
+
+이 필드는 `src/hwpx/_document/memos.py`의 `attach_memo_field`가 만듭니다. 문단의 앞뒤에 ``와 `` run을 삽입해, 필드가 문단 내용을 감싸게 합니다. `fieldBegin`의 `id`와 `fieldEnd`의 `beginIDRef`가 짝을 이룹니다.
+
+**중요한 실측 함정**: 여백에 실제로 보이는 코멘트 텍스트는 이 MEMO **필드의 `subList`** 에 담깁니다. `memos.py`의 주석이 과거 회귀를 기록합니다:
+
+> The MEMO field's subList holds the comment TEXT — this is what Hancom shows in the margin memo box. (Previously this emitted `memo.id`, so Hancom rendered the numeric id instead of the comment.)
+
+즉 이 subList에 텍스트가 아니라 id를 넣으면, 한/글은 코멘트 대신 숫자를 그립니다. 표시는 되지만 내용이 틀린 셈입니다.
+
+### 3. 잇는 참조: MemoShapeIDRef
+
+메모 본체와 그 시각적 속성(메모 상자의 색·테두리 등)을 잇는 것이 **MemoShapeIDRef**입니다. MEMO 필드의 파라미터로 들어갑니다(`attach_memo_field`):
+
+```python
+parameters = _append_element(field_begin, f"{_HP}parameters", {"count": "5", "name": ""})
+_append_element(parameters, f"{_HP}stringParam", {"name": "ID"}).text = memo.id or ""
+...
+# Hancom's own files use MemoShapeIDRef (65535 = the built-in default memo
+# shape) — an empty/absent ref leaves the memo box unlinked.
+_append_element(parameters, f"{_HP}stringParam", {"name": "MemoShapeIDRef"}).text = (
+ memo_shape_id or "65535"
+)
+```
+
+여기서 두 가지를 알 수 있습니다.
+
+- **`65535`는 기본 메모 상자를 뜻하는 센티넬**입니다. 메모에 고유한 `memoShapeIDRef`가 없으면 이 값을 씁니다.
+- **참조가 비어 있거나 없으면 메모 상자가 연결되지 않습니다**("leaves the memo box unlinked"). 즉 유효한 참조가 있어야 한/글이 메모를 제대로 표시합니다.
+
+한편 메모 본체의 `memoShapeIDRef`는 헤더에 정의된 메모 도형 속성(``, 모델상 `MemoShape`; `src/hwpx/oxml/header.py`)을 가리킵니다. `document.memo_shape(id)`로 조회할 수 있습니다.
+
+> 표기 주의: 속성 이름은 위치에 따라 다릅니다. 메모 본체 요소의 속성은 소문자 `memoShapeIDRef`이고, MEMO 필드 파라미터의 이름은 대문자 `MemoShapeIDRef`입니다. 코드가 둘을 구분해 씁니다.
+
+## 고수준 API
+
+이 세 조각을 손으로 맞추지 않도록, 라이브러리는 앵커 기반 API를 제공합니다.
+
+- `document.add_memo(text, memo_shape_id_ref=...)` — 메모 본체만 추가.
+- `document.add_memo_with_anchor(text, paragraph=...)` — 메모 본체 생성 + 대상 문단(없으면 새로 생성)에 MEMO 필드를 붙여 **표시까지** 보장(`src/hwpx/_document/memos.py`). 문단을 지정하지 않으면 `anchor_char_pr_id_ref`/`char_pr_id_ref`로 새 문단을 만듭니다.
+
+필드 run의 `charPrIDRef`는 인자 → 문단의 `char_pr_id_ref` → 메모가 추론한 값 → `"0"` 순으로 해결됩니다(`attach_memo_field`).
+
+> 참고: 이 라이브러리의 앵커 API 이름은 `add_memo_with_anchor`입니다. (MCP 서버 쪽에는 `add_memo_by_anchor`라는 도구 이름이 있지만, 그것은 이 라이브러리 함수를 감싼 래퍼입니다.)
+
+## 테스트로 고정된 계약
+
+- `tests/test_memo_and_style_editing.py::test_attach_memo_field_inserts_control_runs` — 파라미터 개수, `MemoShapeIDRef`가 메모의 참조와 일치, 그리고 (회귀 방지) 필드 subList가 **id가 아니라 코멘트 텍스트**를 담는지 검증.
+- 같은 파일의 `test_section_memo_parsing_exposes_text_and_shape`, `test_document_add_edit_and_remove_memos`, `test_add_memo_with_anchor_roundtrips_on_real_document` — 파싱·추가·편집·삭제·왕복.
+- `tests/test_oxml_parsing.py` — 헤더의 `` 파싱과 id 정규화 조회(`memo_shape("07") == shapes["7"]`).
+
+## 실전 요약
+
+- 메모를 "보이게" 하려면 세 조각이 다 필요합니다: 본체(`` + `paraList` 본문), 본문의 MEMO 필드(`fieldBegin`/`fieldEnd`), 그리고 둘을 잇는 유효한 `MemoShapeIDRef`.
+- 여백 풍선에 뜨는 텍스트는 **MEMO 필드의 `subList`** 에서 옵니다. 여기에 텍스트가 아닌 값을 넣으면 엉뚱한 내용이 표시됩니다.
+- 참조가 비면 상자가 연결되지 않습니다. 고유 도형이 없으면 `65535`(기본 도형)를 쓰세요.
+- 직접 XML을 조립하지 말고 `add_memo_with_anchor`를 쓰면 이 배선이 자동입니다.
diff --git a/docs/internals/opc-packaging.md b/docs/internals/opc-packaging.md
new file mode 100644
index 0000000..e30d8d0
--- /dev/null
+++ b/docs/internals/opc-packaging.md
@@ -0,0 +1,84 @@
+# OPC/ZIP 컨테이너 재패킹
+
+HWPX는 OPC(Open Packaging Conventions) 규약을 따르는 ZIP 컨테이너입니다 — 안에 여러 XML 파트(본문 섹션, 헤더, 매니페스트, 버전 정보)와 바이너리(이미지 등)가 들어갑니다. 겉보기엔 평범한 ZIP이지만, **한/글이 파일을 인식하려면 지켜야 하는 재패킹 규칙**이 몇 가지 있습니다. 이 규칙을 어기면 한/글이 파일을 열지 못하거나 손상으로 판단합니다.
+
+## mimetype는 첫 엔트리, 그리고 STORED
+
+가장 중요한 규칙입니다. **`mimetype` 파트는 ZIP의 첫 번째 엔트리여야 하고, 압축하지 않고(STORED) 저장해야 합니다.** OPC 계열 포맷(ODF, EPUB 등)이 공유하는 관례로, ZIP 헤더만 읽어도 파일 종류를 판별할 수 있게 하기 위함입니다.
+
+라이브러리의 상수와 쓰기 경로가 이를 강제합니다 — `src/hwpx/opc/package.py`:
+
+```python
+MIMETYPE_PATH = "mimetype"
+DEFAULT_MIMETYPE = "application/hwp+zip"
+```
+
+`_write_archive`가 mimetype을 항상 먼저 쓰고, 나머지는 원래 순서를 보존한 뒤 새 파트를 뒤에 붙입니다. mimetype만 `ZIP_STORED`, 나머지는 `ZIP_DEFLATED`로 씁니다:
+
+```python
+def _write_archive(self, zf: ZipFile) -> None:
+ self._write_mimetype(zf) # 항상 첫 엔트리
+ ...
+ for name in [*ordered_names, *new_names]:
+ self._write_zip_entry(zf, name, self._files[name], ZIP_DEFLATED)
+```
+
+원본이 mimetype을 압축해서 저장했더라도, 저장 시 무조건 STORED로 다시 씁니다. 검증기 `src/hwpx/tools/package_validator.py`는 세 조건을 하드 에러로 잡습니다: mimetype 값이 `application/hwp+zip`인가, mimetype이 첫 엔트리인가, `ZIP_STORED`인가.
+
+수리 경로도 같습니다 — `src/hwpx/tools/repair.py`의 `_ordered_entries`가 mimetype을 맨 앞으로 옮기고, 없거나 중복이면 거부합니다. `repair_repack`은 mimetype이 STORED가 아니었으면 "재정렬됨"으로 리포트합니다.
+
+이 계약은 `tests/test_opc_package.py`, `tests/test_repair_repack.py`가 검증합니다(예: `test_save_rewrites_mimetype_as_stored_even_when_source_was_compressed`).
+
+## 미편집 바이트를 지키는 부분 패치
+
+한/글이 파일을 "변조되지 않았다"고 인식하게 하려면, 손대지 않은 파트는 원본 그대로 두는 것이 안전합니다. 저장 시 라이브러리는 **원래 ZIP 엔트리 순서와 메타데이터(압축 방식, create_system, external_attr 등)를 보존**하고, 편집한 파트만 다시 씁니다(`test_save_preserves_existing_archive_order_and_entry_metadata`). 바이트 보존 편집 경로는 여기에 더해, 섹션 XML의 바이트를 부분 스플라이스해 미편집 영역을 한 바이트도 건드리지 않습니다.
+
+## version.xml 관리
+
+`version.xml`은 한/글 버전 정보를 담는 선택적이지만 중요한 파트입니다(`src/hwpx/opc/package.py`의 `VersionInfo`). 실전에서 알아 둘 점:
+
+- **없어도 열린다.** 파싱 시 version.xml이 없으면 에러가 아니라 경고를 내고 기본값을 씁니다(`_parse_version`). 단, 한번 열린 문서에서는 `mimetype`·`container.xml`·`version.xml`을 필수·삭제 불가 파트로 취급합니다.
+- **dirty일 때만 다시 쓴다.** 저장 시 version 정보가 변경(dirty)된 경우에만 재직렬화하고, XML 선언(``)은 원본을 보존합니다.
+- **한컴의 오타까지 보존.** 기본 version.xml의 루트 속성 이름은 `tagetApplication`입니다(`targetApplication`의 오타). 한/글이 실제로 그렇게 쓰므로 라이브러리도 그대로 재현합니다.
+
+## manifest와 container 관계
+
+HWPX의 매니페스트는 ODF식 `META-INF/manifest.xml`이 아니라 **OPF 형식의 `Contents/content.hpf`** 입니다(`src/hwpx/opc/relationships.py`의 `MAIN_ROOTFILE_MEDIA_TYPE = "application/hwpml-package+xml"`).
+
+- `META-INF/container.xml`이 rootfile을 선언합니다. 없으면 하드 에러(`_parse_container`), rootfile이 하나도 없어도 에러입니다.
+- 메인 rootfile(`content.hpf`)의 `/`들이 각 파트를 id로 매핑하고, `/`가 본문 파트 순서를 정합니다. `parse_manifest_relationships`가 헤더/마스터페이지/히스토리/버전 파트를 여기서 분류합니다.
+
+## 네임스페이스 정규화: 2016 → 2011
+
+HWPML 네임스페이스는 연도별 변종이 있습니다(2011, 2016, 2024). 라이브러리는 다양한 연도로 저작된 문서를 하나의 상수 집합으로 다루기 위해, **파싱 직전에 2016 네임스페이스 URI를 2011 등가물로 치환**합니다 — `src/hwpx/opc/xml_utils.py`:
+
+```python
+_HWPML_2016_TO_2011 = (
+ (b".../hwpml/2016/paragraph", b".../hwpml/2011/paragraph"),
+ (b".../hwpml/2016/head", b".../hwpml/2011/head"),
+ (b".../hwpml/2016/section", b".../hwpml/2011/section"),
+ (b".../hwpml/2016/core", b".../hwpml/2011/core"),
+ (b".../hwpml/2016/master-page", b".../hwpml/2011/master-page"),
+ (b".../hwpml/2016/history", b".../hwpml/2011/history"),
+ (b".../hwpml/2016/app", b".../hwpml/2011/app"),
+)
+
+def normalize_hwpml_namespaces(data: bytes) -> bytes:
+ for old, new in _HWPML_2016_TO_2011:
+ if old in data:
+ data = data.replace(old, new)
+ return data
+```
+
+7개 계열(paragraph, head, section, core, master-page, history, app)이 대상입니다. 덕분에 하위 코드는 2011 URI 하나로만 요소를 찾으면 됩니다.
+
+**주의할 정직한 한계**: 이 정규화는 *파싱한 트리*에만 영향을 줍니다. 편집하지 않은 파트는 원본 저장 바이트 그대로 저장되므로, 예컨대 2024 네임스페이스 문서는 편집 후에도 2024 URI가 보존됩니다(`tests/test_namespace_handling.py::test_open_to_bytes_preserves_source_namespace_after_edit`). 열기는 2011/2016/2024를 모두 수용합니다.
+
+수리 경로는 섹션/헤더 루트를 한/글이 쓰는 것과 같은 폭넓은 네임스페이스 선언(`hp10`이 2016 paragraph URI를 가리키는 것 포함)으로 다시 감싸고, `standalone="yes"` 선언을 재발행합니다(`src/hwpx/tools/repair.py`의 `_serialize_hwpml_compat_root`). 이는 read-modify-save 왕복이 "변조된 것처럼 보이지 않게" 하려는 것입니다.
+
+## 실전 요약
+
+- HWPX ZIP을 직접 재패킹한다면 **mimetype을 첫 엔트리·STORED**로 넣으세요. 이 하나만 어겨도 한/글이 인식하지 못할 수 있습니다.
+- 미편집 파트는 순서·메타데이터·바이트를 그대로 보존하는 편이 안전합니다.
+- 매니페스트는 `Contents/content.hpf`(OPF)이고, `META-INF/container.xml`이 그것을 rootfile로 선언합니다.
+- 손상된 파일은 `hwpx.tools.repair`(CLI `hwpx-validate-package`와 함께)로 재정렬·정규화할 수 있습니다.
diff --git a/docs/internals/oracle-limits.md b/docs/internals/oracle-limits.md
new file mode 100644
index 0000000..a09c33f
--- /dev/null
+++ b/docs/internals/oracle-limits.md
@@ -0,0 +1,50 @@
+# 한/글 export 기반 검증의 한계
+
+이 프로젝트가 산출물을 "한/글이 실제로 받아들이는가"로 검증하는 방식은 강력하지만, 만능은 아닙니다. 특히 **한/글이 export한 PDF에서 텍스트를 추출해 검사하는 방식**에는 조용히 무너질 수 있는 사각지대가 있습니다. 이 문서는 그 한계와, 왜 픽셀(시각) 검증이 필요한지를 설명합니다.
+
+## 두 종류의 오라클: 텍스트 기반과 픽셀 기반
+
+한/글로 문서를 렌더해 검증하는 방법은 크게 둘입니다.
+
+1. **텍스트 기반**: 한/글이 PDF로 export → PDF에서 단어/좌표를 추출(예: fitz `get_text`/words) → 기대값과 비교. 예컨대 목차 쪽번호 검증(`src/hwpx/tools/toc_fidelity.py`)이 "한/글 render → fitz words"로 캐시된 쪽번호와 실제 렌더 쪽번호를 대조합니다.
+2. **픽셀 기반**: 한/글이 PDF로 export → PDF를 이미지로 래스터화 → 픽셀(잉크) 수준에서 검사. `src/hwpx/visual/`의 렌더 게이트가 이 방식입니다.
+
+두 방식은 잡아내는 것이 다르고, 텍스트 기반에는 다음과 같은 한계가 있습니다.
+
+## 텍스트 추출이 침묵 실패할 수 있다
+
+**실제 한/글에서 확인된 동작**: 특정 문서에서는 한/글이 본문 텍스트를 **문자(글리프)가 아니라 벡터 커브로** PDF에 export합니다. 이렇게 되면 PDF에는 시각적으로 글자가 그려져 있지만, **텍스트 추출은 아무 단어도 돌려주지 않습니다**(추출 결과가 비어 있음).
+
+이것이 위험한 이유는 실패가 **조용하기** 때문입니다. 텍스트 기반 게이트는 "추출된 텍스트 vs 기대 텍스트"를 비교하는데, 추출 결과가 비면 "비교할 것이 없음"이 되어 — 게이트를 어떻게 설계했느냐에 따라 — **아무 결함도 없는 것처럼 통과**해 버릴 수 있습니다. 실제로는 렌더가 정상인지조차 확인하지 못했는데 말입니다. 이것이 "침묵 실패(silent pass)"입니다.
+
+## 그래서 픽셀 검증이 필요하다
+
+벡터 커브로 그려졌든 글리프로 그려졌든, **래스터화한 이미지에는 잉크가 남습니다**. 픽셀 기반 검증은 텍스트 추출에 의존하지 않으므로 이 사각지대를 피합니다. `src/hwpx/visual/page_qa.py`가 잉크(픽셀) 수준에서 검사합니다:
+
+- **빈 페이지 탐지**: 잉크 비율이 너무 낮으면 "의미 있는 렌더 잉크 없음"으로 실패 — 즉 텍스트가 있어야 할 자리에 아무것도 안 그려졌으면 잡습니다.
+- **겹침 탐지**: 비정상적으로 높고 빽빽한 잉크 띠를 "붕괴되거나 겹친 텍스트"로 판정합니다(주석: `Abnormally tall dense ink band suggests collapsed or overlapping text`). 글자 겹침(→ [lineseg.md](lineseg.md) 참고)은 텍스트 추출로는 안 보여도 픽셀로는 드러납니다.
+- **클리핑 탐지**: 잉크가 페이지 가장자리에 닿으면 잘렸을 가능성으로 경고.
+
+`src/hwpx/visual/detectors.py`는 before/after 두 렌더의 잉크 마스크 차이(`diff_ratio`)를 픽셀 단위로 계산해, 마스크 밖에서 잉크가 늘어난 영역을 잡습니다. 전부 텍스트 내용이 아니라 **픽셀의 유무**로 판정하므로 커브 export에 흔들리지 않습니다.
+
+## 정직 규율: 검증하지 못한 것을 통과로 세지 않는다
+
+이 프로젝트의 렌더 게이트는 "무엇을 검사했는가"와 "판정이 무엇인가"를 의도적으로 분리합니다. `src/hwpx/visual/report.py`의 `VisualReport`:
+
+> `render_checked` is `True` only when a Hancom render diff actually ran. Off-oracle (no Hancom / missing imaging deps) it is `False` and the report is a *structural degrade*, never a silent visual pass.
+>
+> When `render_checked` is `False` it means "nothing could be verified" (optimistic-but-labelled), not "verified clean".
+
+즉 한/글 오라클이 없거나 이미지 처리 의존성이 없으면, 결과는 "구조만 봤음(`render_checked=False`)"으로 **낮춰 라벨링**되지, "시각적으로 깨끗함"으로 위장되지 않습니다. 텍스트 기반 목차 검증도 마찬가지로, 오라클이 없으면 구조 검증으로 degrade합니다(`toc_fidelity.py`).
+
+이 규율은 실측 코퍼스 발행에도 그대로 적용됩니다(`docs/corpus-metrics.md`). 예컨대 변경추적 문서의 PDF export는 **한/글 자체가 거부**하는데, 이런 건은 `render_unavailable` 버킷으로 분리해 발행하고 **절대 pass로 집계하지 않습니다**. "낮은 숫자도 그대로 발행"하는 것이 이 프로젝트의 원칙입니다.
+
+## 함정 하나 더: refresh와 render의 세션 분리
+
+관련해서 실측된 함정: 필드(목차 등)를 재생성 중인 한/글 세션에서 곧바로 PDF export를 시도하면 이 한/글 빌드가 크래시합니다(잘린 PDF 후 프로세스 사망). 그래서 "필드 재계산"과 "렌더"는 별개 세션으로 분리됩니다(→ [toc-dirty.md](toc-dirty.md) 참고). 오라클을 자동화할 때 알아 두면 좋은 제약입니다.
+
+## 실전 요약
+
+- 한/글 export의 텍스트 추출 결과가 비어 있다고 해서 "문서에 글자가 없다"는 뜻이 아닙니다 — 벡터 커브로 그려졌을 수 있습니다. 텍스트 기반 검사만 믿으면 침묵 실패에 빠질 수 있습니다.
+- 렌더 정상 여부는 **픽셀(잉크) 수준**으로 검증하세요(빈 페이지·겹침·클리핑). 이 방식은 글리프/커브 여부와 무관합니다.
+- 검증하지 못한 것은 통과가 아니라 "미검증(`render_checked=False`)"으로 라벨링하는 것이 정직합니다. 이 프로젝트의 게이트와 코퍼스 리포트가 그 규율을 따릅니다.
diff --git a/docs/internals/toc-dirty.md b/docs/internals/toc-dirty.md
new file mode 100644
index 0000000..95f0812
--- /dev/null
+++ b/docs/internals/toc-dirty.md
@@ -0,0 +1,58 @@
+# 목차 필드(TABLEOFCONTENTS)와 dirty="1"
+
+한/글의 자동 목차는 `` 필드로 표현됩니다. 이 필드에 대해 "언제 페이지 번호가 다시 계산되는가"는 자동화 환경에서 특히 중요합니다 — 메뉴 없이 목차를 갱신할 방법이 딱 하나뿐이기 때문입니다. 이 문서는 `dirty="1"` 플래그의 실제 동작과 그 한계를 설명합니다.
+
+## 목차 필드의 구조
+
+이 라이브러리는 한/글이 직접 저장한 문서를 리버스 엔지니어링해 목차 필드 계약을 재현합니다(`src/hwpx/tools/toc_author.py`). 골자는:
+
+- ``가 `TableOfContents:set:...` Command 문자열과 함께 목차 영역을 엽니다.
+- 각 목차 항목은 HYPERLINK 필드로, 하나의 `` 안에 "제목텍스트 + 점선(dot-leader) `` + 쪽번호" 형태를 담습니다. 쪽번호는 중첩된 `hp:tab`의 tail에 들어갑니다.
+- 항목의 앵커는 대상 문단의 `id` 속성입니다. 따라서 목차가 가리키는 제목 문단은 **문서 전체에서 유일한 id**를 가져야 합니다(`ensure_paragraph_anchor_id`가 이를 보장). 한/글이 새로 생성한 문단은 상수 id `2147483648`을 공유하는 경우가 있어 앵커로는 쓸 수 없습니다(`src/hwpx/tools/toc_fidelity.py`의 `NON_UNIQUE_PARA_ID`).
+
+## dirty="1"이 하는 일
+
+라이브러리가 목차를 삽입할 때(`add_native_toc`), 항목의 쪽번호는 **naive한 추정치**로 채워집니다. 라이브러리는 한/글이 아니므로 실제 페이지네이션을 알 수 없습니다. 대신 필드에 `dirty="1"`을 세팅합니다.
+
+**실제 한/글에서 확인된 동작**: `dirty="1"`인 TABLEOFCONTENTS 필드는 한/글이 **문서를 여는 시점에** 통째로 재생성됩니다 — 항목, 스타일, 쪽번호 전부를 한/글이 직접 계산합니다. `src/hwpx/visual/oracle.py`의 `refresh_document` 주석이 이 트리거를 기록합니다:
+
+> The measured native-TOC re-number trigger: a `dirty="1"` TABLEOFCONTENTS is rebuilt on open — Hancom itself computes entries and page numbers — and CROSSREF caches recompute automatically.
+
+`src/hwpx/tools/toc_author.py`의 `mark_toc_dirty()`가 바로 이 용도입니다: 모든 TABLEOFCONTENTS 필드에 `dirty="1"`을 세팅해, 다음 열기 때 한/글이 재계산하도록 만듭니다. 페이지네이션을 바꾸는 편집 후 호출합니다.
+
+```python
+def mark_toc_dirty(doc: HwpxDocument) -> int:
+ """Set dirty="1" on every TABLEOFCONTENTS field — the measured
+ re-number trigger: Hancom regenerates a dirty TOC (entries, styles, page
+ numbers) when it next opens the document."""
+```
+
+결과적으로, 사용자가 파일을 처음 열었을 때 보는 목차는 **한/글이 스스로 계산한** 목차입니다. 라이브러리의 추정 쪽번호가 아니라.
+
+## 왜 이것이 유일하게 신뢰할 수 있는 갱신 경로인가
+
+한/글 데스크톱에서는 목차를 우클릭해 "차례 새로 고침"으로 갱신할 수 있습니다. 하지만 **메뉴가 없는 자동화 환경**(서버, CI, 헤드리스 파이프라인)에서는 그 UI 조작이 불가능합니다. 남는 것은 XML에 `dirty="1"`을 심어 두고, 한/글이 문서를 여는 순간 재계산에 맡기는 방법뿐입니다.
+
+이 프로젝트의 렌더 오라클은 정확히 이 경로를 활용합니다 — `refresh_document`는 문서를 열어 dirty 필드가 재생성되게 하고, 제자리 저장 후 닫습니다. 한 가지 실측된 함정: **재생성 중인 세션에서 곧바로 PDF export를 시도하면 이 한/글 빌드가 크래시**합니다(잘린 PDF 후 프로세스 사망). 그래서 refresh와 render는 의도적으로 별개의 세션으로 분리됩니다.
+
+## 중요한 한계: dirty는 "신선도 표시"가 아니다
+
+`dirty="1"`은 **재계산 트리거**이지, 캐시된 쪽번호가 맞는지 틀린지를 알려주는 **신선도(staleness) 표시가 아닙니다**. `src/hwpx/tools/toc_fidelity.py`가 이 구분을 명확히 합니다:
+
+> the TOC block only recomputes after an explicit 차례 새로 고침 — so `dirty` is NOT a reliable staleness marker and the only honest verdict comes from comparing cached page numbers against rendered ones (Hancom render -> fitz words).
+
+즉:
+
+- 파일 안의 목차 쪽번호를 보고 "dirty가 꺼져 있으니 맞다"고 단정할 수 없습니다. 한/글이 실제로 다시 열어 재계산하기 전까지는, 그 숫자는 그저 캐시일 뿐입니다.
+- 목차 쪽번호가 정말 맞는지 **정직하게 검증**하려면, 한/글로 렌더한 뒤 실제 렌더된 쪽번호와 캐시 값을 비교해야 합니다(`toc_fidelity`가 하는 일). 오라클이 없으면 이 검사는 구조 검증(`render_checked=False`)으로 낮춰지며, 통과로 위장하지 않습니다.
+
+## CROSSREF는 다르다
+
+같은 필드 계열이지만 페이지 상호참조(``)는 동작이 다릅니다. **실제 한/글에서 확인된 동작**: CROSSREF 캐시는 편집/저장 시 **자동으로** 재계산됩니다(`src/hwpx/tools/toc_fidelity.py`·`toc_author.py`에 measured로 기록). 목차 블록처럼 명시적 재생성 트리거가 필요하지 않습니다.
+
+## 실전 요약
+
+- 목차를 삽입/편집한 뒤에는 `mark_toc_dirty()`(또는 `dirty=True`)로 TABLEOFCONTENTS 필드를 dirty 상태로 남기세요. 한/글이 다음 열기 때 쪽번호까지 재계산합니다.
+- 라이브러리가 채운 쪽번호는 추정치입니다. 최종 값은 한/글이 만듭니다.
+- 목차 정합을 **검증**하려면 한/글 렌더와 비교하세요. `dirty` 값만으로 신선도를 판단하지 마세요.
+- 목차가 가리키는 제목 문단은 유일한 `id`를 가져야 합니다(상수 id `2147483648`은 앵커로 부적합).
diff --git a/docs/internals/units.md b/docs/internals/units.md
new file mode 100644
index 0000000..b40440c
--- /dev/null
+++ b/docs/internals/units.md
@@ -0,0 +1,52 @@
+# HWPUNIT 좌표계
+
+HWPX 문서의 거의 모든 기하 값 — 용지 크기, 여백, 표 셀 너비, 글자 크기, 개체 위치 — 은 **HWPUNIT**이라는 단일 정수 단위로 저장됩니다. 이 단위를 이해하면 XML을 직접 들여다볼 때 숫자가 무엇을 뜻하는지 바로 읽을 수 있습니다.
+
+## 기본 환산
+
+HWPUNIT은 인치를 7,200등분한 단위입니다. 라이브러리의 변환 상수가 이를 그대로 정의합니다 (`src/hwpx/_document/_units.py`):
+
+```python
+_HWP_UNITS_PER_MM = 7200 / 25.4
+_HWP_UNITS_PER_PT = 100
+```
+
+여기서 세 가지 환산이 도출됩니다.
+
+| 단위 | HWPUNIT | 유래 |
+|---|---|---|
+| 1 inch | 7,200 | 정의 |
+| 1 pt | 100 | 7,200 / 72 (1 inch = 72 pt) |
+| 1 mm | 약 283.46 | 7,200 / 25.4 |
+
+`src/hwpx/form_fit/measure.py`, `src/hwpx/form_fit/seal.py`의 헤더 주석도 같은 관계를 못 박아 둡니다: `1 pt = 100 HWPUNIT, 1 inch = 7200 HWPUNIT`, `1 PDF point = 7200/72 = 100 HWPUNIT`. PDF 좌표(1 pt)와 HWPUNIT이 정확히 100:1로 대응하므로, 한/글이 export한 PDF의 좌표를 HWPUNIT으로 되돌릴 때 실수 오차 없이 정수배로 환산됩니다.
+
+## HWPUNIT이 나타나는 곳
+
+같은 단위가 여러 요소에 걸쳐 재사용됩니다.
+
+- **용지·여백**: 섹션 정의의 ``, 여백 값. `src/hwpx/_document/layout.py`가 mm 입력을 `_mm_to_hwp_units()`로 변환해 채웁니다.
+- **표 셀 너비/높이**: ``, ``. 기본 셀 너비 상수도 HWPUNIT입니다 — `src/hwpx/oxml/_document_primitives.py`의 `_DEFAULT_CELL_WIDTH = 7200`(= 1 inch).
+- **글자 크기**: 문자 속성 ``. 글자 높이도 같은 스케일이라 **100 HWPUNIT = 1 pt**입니다. 실제 한컴이 저장한 문서를 보면 10 pt 글자는 `height="1000"`, 9 pt는 `height="900"`, 11 pt는 `height="1100"`으로 나옵니다. `src/hwpx/form_fit/measure.py` 헤더가 지적하듯 "글자 높이와 셀 너비가 같은 단위를 공유"하기 때문에, 글자의 진행폭(advance)을 글자 높이의 분수(em)로 바로 계산할 수 있고 별도의 DPI/포인트 변환이 필요 없습니다.
+- **개체 위치·크기**: 그림·도형의 ``, `` 좌표. `src/hwpx/document.py`, `src/hwpx/_document/shapes.py`의 시그니처가 `height: int = 7200`처럼 HWPUNIT 기본값을 그대로 노출합니다(주석: `Coordinates are in HWPUNIT (7200 per inch)`).
+- **이미지 크기**: `src/hwpx/_document/media.py`가 mm 입력을 `_mm_to_hwp_units()`로 변환하고, 기본값은 `14400`(= 2 inch) 같은 HWPUNIT 상수입니다.
+
+## 예외: 줄 간격은 퍼센트
+
+모든 것이 HWPUNIT은 아닙니다. **줄 간격(line spacing)** 은 퍼센트 값으로 저장됩니다. `src/hwpx/_document/layout.py`의 문단 서식 함수 주석이 이를 명시합니다:
+
+> Millimetre inputs are converted to HWP units; paragraph spacing uses points; line spacing is stored as a percent value.
+
+즉 문단 위/아래 간격(`spacing_before_pt`, `spacing_after_pt`)은 pt → HWPUNIT으로 변환되지만, `line_spacing_percent`는 ``로 퍼센트 그대로 들어갑니다. 단위를 하나로 뭉뚱그리면 틀리는 지점입니다.
+
+## 공개 API가 사람 단위를 쓰는 이유
+
+HWPUNIT은 XML 저장 형식으로는 정확하지만, 사람이 직접 다루기엔 불편합니다("10 pt 글자"를 매번 1000으로 환산해야 함). 그래서 이 라이브러리의 고수준 API는 **사람 단위(pt/mm/%)를 입력받아 내부에서 HWPUNIT으로 변환**합니다.
+
+- 문단 서식: `indent_left_mm`, `first_line_indent_mm`, `spacing_before_pt`, `line_spacing_percent` (`src/hwpx/_document/layout.py`)
+- 이미지: `width_mm`, `height_mm` (`src/hwpx/_document/media.py`)
+- 페이지·표: `pageWidthMm`, `heightMm` 등 (`src/hwpx/agent/commands.py`)
+
+변환은 항상 `round()`로 정수화됩니다(HWPUNIT은 정수). 반대로 문서를 분석해 사람에게 보여줄 때는 HWPUNIT → mm로 되돌립니다 — `src/hwpx/tools/style_profile.py`, `src/hwpx/tools/layout_preview.py`가 `value / _HWP_UNITS_PER_MM`으로 역변환해 리포트합니다.
+
+정리하면: **저장 형식은 HWPUNIT, 사용자 표면은 pt/mm/%.** 저수준 `hwpx.oxml` 데이터클래스를 직접 조작할 때는 HWPUNIT 정수를 직접 다루게 되므로, 위 환산표를 곁에 두고 작업하면 됩니다.