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 ์ ์๋ฅผ ์ง์ ๋ค๋ฃจ๊ฒ ๋๋ฏ๋ก, ์ ํ์ฐํ๋ฅผ ๊ณ์ ๋๊ณ ์์
ํ๋ฉด ๋ฉ๋๋ค.