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. +

+

+ PyPI + Python + License + Docs +

+

+ +

한국어 | 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 ![](images/...) 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 정수를 직접 다루게 되므로, 위 환산표를 곁에 두고 작업하면 됩니다.