diff --git a/.claude/hooks/docs-lint.py b/.claude/hooks/docs-lint.py new file mode 100755 index 0000000..24b7723 --- /dev/null +++ b/.claude/hooks/docs-lint.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""docs/*.md 편집 후 자동 검증 — CONVENTIONS.md 정책 enforcement. + +PostToolUse hook 으로 Edit / Write / MultiEdit 후 실행. stdin 으로 받은 hook +event 의 ``tool_input.file_path`` 가 ``docs/*.md`` 면 검증, 그 외는 즉시 종료. + +검증 항목 (CONVENTIONS.md 의 hard rule 4 종): + +1. **Status 헤더** — Living 외 모든 spec 은 ``**Status**: ...`` 메타 라인 보유 +2. **업스트림 monorepo 잔재 키워드** — 분사 리포 컨벤션 위배 (``사용자 Fork`` / + ``rhwp 본체`` / ``pyo3-sandbox`` 등). v0.1.0 historical Frozen 본문은 예외 +3. **같은 vX.Y.Z 디렉토리 내 spec ↔ spec 직접 link** — pair 페어 + (``.md`` ↔ ``-research.md``) 만 예외 +4. **깨진 .md 링크** — relative path 가 실제 파일을 가리키는지 + +위반 발견 시 exit 2 + stderr — Claude Code 가 stderr 를 LLM 컨텍스트에 +주입하여 모델이 위반 사항을 인지하고 후속 조치 결정. exit 1 은 non-blocking +이라 LLM 에 노출되지 않으므로 사용 금지 (hooks 명세). +""" + +import json +import re +import sys +from pathlib import Path + +# * stdin 에서 hook event 파싱 +try: + event = json.loads(sys.stdin.read() or "{}") +except json.JSONDecodeError: + sys.exit(0) + +tool_input = event.get("tool_input") or {} +file_path = tool_input.get("file_path") or "" +if not file_path: + sys.exit(0) + +repo = Path(__file__).resolve().parents[2] +try: + rel = Path(file_path).resolve().relative_to(repo) +except ValueError: + sys.exit(0) + +rel_str = str(rel).replace("\\", "/") +if not (rel_str.startswith("docs/") and rel.suffix == ".md"): + sys.exit(0) + +target = repo / rel +if not target.is_file(): + sys.exit(0) + +text = target.read_text(encoding="utf-8") +errors: list[str] = [] + + +# * 1. Status header (required outside Living docs) +LIVING_FILES = {"docs/CONVENTIONS.md", "docs/roadmap/README.md"} +if rel_str not in LIVING_FILES: + if not re.search(r"^\*\*Status\*\*:", text, re.MULTILINE): + errors.append( + "missing Status header — add '**Status**: " + " · " + "**GA|Target**: vX.Y.Z · **Last updated**: YYYY-MM-DD' " + "(CONVENTIONS § Status header format)" + ) + + +# * 2. Upstream monorepo residue keywords (v0.1.0 Frozen historical exempted) +HISTORICAL_FROZEN = ("docs/implementation/v0.1.0/",) +if not any(rel_str.startswith(p) for p in HISTORICAL_FROZEN): + forbidden = [ + "사용자 Fork", + "rhwp 본체", + "pyo3-sandbox", + "/Cargo.toml (루트)", + "pyo3-bindings.md", + ] + for kw in forbidden: + if kw in text: + errors.append( + f"upstream monorepo residue keyword {kw!r} — " + "this is a spinoff binding repo, not the source-of-truth repo" + ) + + +# * 3. Same-version spec ↔ spec direct link (pair files exempted) +# ^ SemVer 정확 매칭 (vMAJOR.MINOR.PATCH) — 이전의 [\d.]+ 기반은 catastrophic +# backtracking 위험 (CodeQL py/redos). v0.3.0 / v0.3.1 등 모두 cover. +m = re.match(r"docs/(roadmap|design)/(v\d+\.\d+\.\d+)/(.+)\.md$", rel_str) +if m: + base = m.group(3) + pair_topic = base.removesuffix("-research") + # ^ pair: .md ↔ -research.md (the only allowed direct link) + if base.endswith("-research"): + allowed_link = f"{pair_topic}.md" + else: + allowed_link = f"{base}-research.md" + self_link = f"{base}.md" + for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text): + link_target = link.split("#")[0] + # only same-directory .md candidates qualify + if "/" in link_target: + continue + if link_target in (allowed_link, self_link): + continue + errors.append( + f"same-version spec direct link {link!r} — " + "route through phase-N.md or roadmap/README.md " + "(CONVENTIONS § Cross-link direction rule)" + ) + + +# * 4. Broken .md link +dir_path = target.parent +for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text): + link_target = link.split("#")[0].split("?")[0] + if not link_target or link_target.startswith("http"): + continue + resolved = (dir_path / link_target).resolve() + if not resolved.exists(): + errors.append(f"broken .md link {link!r} (resolved: {resolved})") + + +if errors: + sys.stderr.write(f"\ndocs-lint: {rel_str} — {len(errors)} violation(s)\n") + for i, e in enumerate(errors, 1): + sys.stderr.write(f" {i}. {e}\n") + sys.stderr.write("policy: docs/CONVENTIONS.md\n") + sys.exit(2) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e6115dc --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/docs-lint.py" + } + ] + } + ] + } +} diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml index ad18715..be34da9 100644 --- a/.github/codeql-config.yml +++ b/.github/codeql-config.yml @@ -1,2 +1,5 @@ paths-ignore: - tests + - external + - examples + - benches diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f238ff1..9d5d0b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,22 +27,44 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # * 메인 테스트 + 린트 + 타입체크 - # abi3-py310 wheel 로 빌드는 한 번이지만 런타임 동작은 버전별 검증 필요 → Linux × 전 버전. - # macOS / Windows 는 OS 레이어 스모크이므로 py3.12 하나만. + # * Linux abi3 wheel 1회 빌드 → 모든 Linux 잡(test×4 / slow / core-only)이 공유 + # abi3-py310 이라 py3.10/3.11/3.12/3.13 가 동일 wheel 재사용 가능. + # macOS/Windows 는 단일 잡이라 빌드/테스트 분리 이득이 없어 그대로 매번 빌드. + build-linux-wheel: + name: Build Linux abi3 wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + - run: uv sync --no-install-project --group all + - run: uv run maturin build --release --out dist + - uses: actions/upload-artifact@v7 + with: + name: rhwp-python-linux-wheel + path: dist/*.whl + retention-days: 1 + + # * 메인 테스트 + 린트 + 타입체크 (Linux × 전 Python 버전 — wheel 공유) test: - name: Test (${{ matrix.os }} / py${{ matrix.python }}) - runs-on: ${{ matrix.os }} + name: Test (Linux / py${{ matrix.python }}) + needs: build-linux-wheel + runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - - { os: ubuntu-latest, python: "3.10", lint: true } - - { os: ubuntu-latest, python: "3.11" } - - { os: ubuntu-latest, python: "3.12" } - - { os: ubuntu-latest, python: "3.13" } - - { os: macos-latest, python: "3.12" } - - { os: windows-latest, python: "3.12" } + - { python: "3.10", lint: true } + - { python: "3.11" } + - { python: "3.12" } + - { python: "3.13" } defaults: run: shell: bash @@ -50,13 +72,15 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python }} - run: uv sync --no-install-project --group all - - run: uv run maturin develop --release + - uses: actions/download-artifact@v8 + with: + name: rhwp-python-linux-wheel + path: dist/ + - run: uv pip install --reinstall dist/*.whl - name: Run pytest (not slow) with coverage run: uv run pytest tests/ -m "not slow" --cov=rhwp --cov-report=term-missing -v - name: Run pyright (normal) @@ -68,6 +92,11 @@ jobs: tests/test_langchain_loader.py tests/test_langchain_loader_ir.py \ tests/test_ir_schema.py tests/test_ir_roundtrip.py tests/test_ir_tables.py \ tests/test_ir_iter_blocks.py tests/test_ir_schema_export.py \ + tests/test_ir_picture.py tests/test_ir_furniture.py \ + tests/test_ir_formula.py tests/test_ir_footnote.py \ + tests/test_ir_list.py tests/test_ir_caption.py \ + tests/test_ir_toc.py tests/test_ir_field.py \ + tests/test_cli.py \ tests/conftest.py tests/type_check_samples.py - name: Run pyright (intentional errors — expect 4) if: matrix.lint @@ -81,9 +110,36 @@ jobs: exit 1 fi - # * PDF 렌더링 — 느려서 별도 잡 + # * macOS / Windows 스모크 — 단일 잡이라 wheel 분리 이득 없음 → 직접 maturin develop + test-other-os: + name: Test (${{ matrix.os }} / py3.12) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + - run: uv sync --no-install-project --group all + - run: uv run maturin develop --release + - run: uv run pytest tests/ -m "not slow" -v + + # * PDF 렌더링 — 느려서 별도 잡, Linux wheel 재사용 test-slow: name: Test slow (Linux / py3.12 — PDF) + needs: build-linux-wheel runs-on: ubuntu-latest defaults: run: @@ -92,18 +148,21 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.12" - run: uv sync --no-install-project --group testing - - run: uv run maturin develop --release + - uses: actions/download-artifact@v8 + with: + name: rhwp-python-linux-wheel + path: dist/ + - run: uv pip install --reinstall dist/*.whl - run: uv run pytest tests/ -m slow -v # * extras 미설치 시 langchain 테스트가 importorskip 로 auto-skip 되는지 검증 test-core-only: name: Test without extras (importorskip auto-skip) + needs: build-linux-wheel runs-on: ubuntu-latest defaults: run: @@ -112,8 +171,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - uses: astral-sh/setup-uv@v8.1.0 with: python-version: "3.12" @@ -121,15 +178,29 @@ jobs: run: | uv venv uv pip install pytest - - run: uv run maturin develop --release + - uses: actions/download-artifact@v8 + with: + name: rhwp-python-linux-wheel + path: dist/ + - run: uv pip install dist/*.whl - name: Run pytest — extras-gated tests must auto-skip via importorskip # ^ 파일-레벨 importorskip 은 해당 파일 전체를 skip 1개로 카운트. - # 현재 gated 파일: test_langchain_loader.py + test_langchain_loader_ir.py - # (langchain-core), test_ir_schema_export.py (jsonschema), - # test_async.py (aiofiles) → 총 4 파일 + # v0.3.0 기준 gated 파일: test_langchain_loader.py + test_langchain_loader_ir.py + # (langchain-core), test_ir_schema_export.py (jsonschema), test_cli.py (typer) + # → 총 4 파일. test_async.py 는 v0.3.0 부터 stdlib 만 사용 (aiofiles 의존성 제거) run: | uv run pytest tests/ -m "not slow" -v | tee pytest-output.txt if ! grep -qE '(^|[^0-9])4 skipped([^0-9]|$)' pytest-output.txt; then - echo "::error::expected 4 extras-gated files to auto-skip via importorskip (langchain×2, jsonschema, aiofiles)" + echo "::error::expected 4 extras-gated files to auto-skip via importorskip (langchain×2, jsonschema, typer)" exit 1 fi + + all-tests-passed: + name: All tests passed + if: always() + runs-on: ubuntu-latest + needs: [build-linux-wheel, test, test-other-os, test-slow, test-core-only] + steps: + - uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/publish-schema.yml b/.github/workflows/publish-schema.yml index ea65bbd..b9fb658 100644 --- a/.github/workflows/publish-schema.yml +++ b/.github/workflows/publish-schema.yml @@ -8,7 +8,8 @@ on: push: branches: [main] paths: - - 'python/rhwp/ir/schema/hwp_ir_v1.json' + # ^ glob — v2 도입 시 hwp_ir_v2.json 만 변경되어도 자동 트리거 + - 'python/rhwp/ir/schema/hwp_ir_v*.json' - 'python/rhwp/ir/nodes.py' - 'python/rhwp/ir/schema.py' - '.github/workflows/publish-schema.yml' @@ -54,11 +55,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: Prepare pages directory — copy every versioned schema + - name: Prepare pages directory — copy versioned schema + content-addressed alias # ^ 불변 경로 정책: repo 의 hwp_ir_v*.json 을 모두 각 버전 URL 로 배포. # v2 도입 시 python/rhwp/ir/schema/hwp_ir_v2.json 을 추가하기만 하면 # 이 루프가 자동으로 v1/v2 양쪽 모두를 pages 아티팩트에 포함한다. # `actions/deploy-pages@v4` 의 replace-all 동작으로 v1 이 누락되는 것을 원천 차단. + # + # Content-addressed alias (v0.3.0 S4 추가, ir-expansion.md § 스키마 버저닝): + # 같은 v1 URL 안에서 minor bump (1.0 → 1.1) 가 발생할 때마다 hash-tagged + # immutable alias 를 alongside 발행. 구 hash 는 영구 보존되어 SchemaStore / + # 외부 도구가 정확한 스냅샷을 reproducible 하게 참조 가능. run: | set -euo pipefail mkdir -p pages/schema/hwp_ir @@ -69,7 +75,10 @@ jobs: ver="${name#hwp_ir_}" # v1, v2, ... mkdir -p "pages/schema/hwp_ir/$ver" cp "$f" "pages/schema/hwp_ir/$ver/schema.json" - echo "Published $f -> pages/schema/hwp_ir/$ver/schema.json" + sha=$(shasum -a 256 "$f" | awk '{print $1}') + alias="pages/schema/hwp_ir/${name}-sha256-${sha}.json" + cp "$f" "$alias" + echo "Published $f -> $ver/schema.json + alias ${name}-sha256-${sha}.json" copied=$((copied + 1)) done if [ "$copied" -eq 0 ]; then diff --git a/.gitignore b/.gitignore index e034722..c189367 100644 --- a/.gitignore +++ b/.gitignore @@ -38,5 +38,8 @@ coverage.xml # * MCP .serena/ +# * Claude Code — settings.json 은 팀 공유 (hooks 등록), local override 만 ignore +.claude/settings.local.json + # * Examples 산출물 render_output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cadf8..f535b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] — 2026-04-28 + +### Changed — async API 의존성 정리 + +- `rhwp.aparse` 가 `aiofiles` 대신 stdlib `asyncio.to_thread` 사용 — 외부 의존성 제거. `[async]` extras 키는 빈 배열로 보존 (`pip install rhwp[async]` 명령 호환 유지, v0.4.0 에서 키 자체 제거 검토). 의미·성능 동등 — Python `asyncio` 가 native async file I/O 를 미지원하는 한 모든 async file lib (aiofiles 포함) 도 결국 thread pool wrapping 이라 둘은 같은 메커니즘. CI `test-without-extras` skip count 5 → 4 (`test_async.py` 가 더 이상 gated 아님). + +MINOR release — Phase 2 두 축 동시 GA. v0.2.0 의 Document IR v1.0 위에 HWP 고유 의미 요소 8 종을 추가하고 (SchemaVersion 1.1), 동시에 Python 고유 가치 (IR/LangChain 청크/스키마 export) 를 shell 에서 직접 소비할 수 있는 `rhwp-py` CLI 를 재도입한다. 모든 v0.2.0 공개 API 보존 — 추가만 있고 기존 코드는 그대로 동작. + +상류 `edwardkim/rhwp` 커밋 핀을 `bea635b` → `033617e` 로 bump (upstream v0.6.x → v0.7.7 흡수, 380 commits). IR 확장이 사용하는 first-class struct/enum (Picture, Equation, Footnote/Endnote, Caption, Field/FieldType, Header/Footer, ParaShape) 자체는 핀 변경 전부터 노출돼 있어 IR 매핑 작업은 상류 변경에 의존하지 않는다 — bump 의 효과는 직교 영역에 한정: 렌더 경로 정정 (TypesetEngine pagination drift, TAC 표/그림 좌표 통합 수정), export text/markdown 추가 (Task #237), v0.7.6/v0.7.7 릴리즈 흡수. + +### Added — Document IR v1.1 (8 신규 블록 + Furniture 채움) + +- `PictureBlock` (S1) — 이미지. `image: ImageRef | None` (URI 스킴 `bin://` 기본), `caption: CaptionBlock | None` (S3 부터), `description: str | None` (HWP alt-text). `Document.bytes_for_image(picture)` 헬퍼로 raw bytes 해석. +- `FormulaBlock` (S2) — 수식. `script: str` (HWP equation script raw) + `script_kind: Literal["hwp_eq", "latex", "mathml"]` + `text_alt: str | None` (RAG 폴백 평문 근사). LaTeX/MathML 자동 변환은 v0.3.0 미제공 (공개 변환기 부재). +- `FootnoteBlock` / `EndnoteBlock` (S2) — 각주 / 미주. `blocks: list[Block]` 재귀, `marker_prov` (본문 인용 위치) 와 `prov` (각주 본문 위치) 분리. 각주/미주 본문은 `furniture.footnotes` / `endnotes` 로 라우팅 — body 검색 오염 회피. +- `ListItemBlock` (S3) — 목록 항목. `level + marker + enumerated` 평면 모델 (group container 미도입, HWP 상류가 list group 미지원). `marker` 는 v0.3.0 placeholder (`"•"` / `"1."` / `"-"`) — 정확 marker (`"가."`, `"(a)"`) 는 v0.4.0+. +- `CaptionBlock` (S3) — 캡션. `blocks: list[Block]` 재귀 + `direction: Literal["top", "bottom", "left", "right"]`. 부모 컨테인먼트 — `PictureBlock.caption` / `TableBlock.caption_block` 으로 1:1 부착 (ref-id 미도입). v0.2.0 `TableBlock.caption: str` 보존 + `caption_block` 옵셔널 추가. +- `TocBlock` + `TocEntryBlock` (S3) — 목차. `entries: list[TocEntryBlock]` (TocEntryBlock 은 leaf type, Block 유니온 멤버 아님). v0.3.0 entries 는 빈 placeholder — 항목 추출은 v0.4.0+ (heading hierarchy + bookmark resolver 필요). +- `FieldBlock` + `FieldKind` (S3) — 필드. 닫힌 `Literal` 14 종 (`date`/`crossref`/`hyperlink`/...) + `"unknown"` 안전판 + `field_type_code: int | None` (forward-compat). `FieldType::Formula` 는 `"calc"` (Equation 의 `"formula"` 와 이름 충돌 회피). +- `Furniture` 채움 (S1+S2) — `page_headers` / `page_footers` (master_pages + Control::Header/Footer 매핑) / `footnotes` / `endnotes` 모두 실제 본문 출고. `iter_blocks(scope="furniture")` 순서: page_headers → page_footers → footnotes → endnotes (v0.2.0 furniture 순서 계약 확장). + +### Added — `rhwp-py` CLI 재도입 + +v0.2.0 에서 폐기됐던 CLI 를 별도 이름 (`rhwp-py`) 으로 재도입. 상류 Rust `rhwp` 바이너리와 PATH 충돌 회피 + Python 고유 가치 (IR / LangChain) 에 집중 — 기능 중복 0. + +- 신규 entry point: `rhwp-py = "rhwp.cli:app"` (typer 지연 로드, 미설치 시 친절 에러 + exit 2). +- 서브커맨드: `parse` (요약 정보) / `version` / `schema` (in-package JSON Schema) / `ir` (전체 IR JSON) / `blocks` (블록 스트림 NDJSON / JSON / text) / `chunks` (LangChain RecursiveCharacterTextSplitter 결과). +- `blocks` 의 `--kind` enum 11 종 (`paragraph` / `table` + 8 신규 + `all`) — IR 확장 GA 와 동기. +- `chunks --include-furniture` — `HwpLoader(mode="ir-blocks", include_furniture=True)` 와 동일 정책. +- 글로벌 옵션 `--quiet/-q` — stderr progress 메시지 가드. +- 새 extras: `[cli]` (typer 만), `[cli-chunks]` (typer + langchain-text-splitters). +- 업스트림 바이너리와의 역할 분담: 구조 추출은 `rhwp-py`, 시각 출력 (SVG/PDF) / 메타데이터 덤프 (`info`/`dump`) / 라운드트립 진단 (`diag`/`ir-diff`) 은 상류 `rhwp` — overlap 0. + +### Added — LangChain `HwpLoader.include_furniture` + +- `HwpLoader(mode="ir-blocks", include_furniture=True)` — body 다음에 furniture (page_headers / page_footers / footnotes / endnotes) 도 LangChain Document 로 yield. 각 furniture Document 는 `metadata.scope="furniture"` 로 표시되어 RAG 가 body / furniture 분리 색인 가능. +- 기본 `include_furniture=False` — v0.2.0 시절 동작 (body 만) 보존. +- `mode="single"` / `"paragraph"` 에서는 `include_furniture` 무시 — text 추출은 항상 body 만. + +### Added — Schema GA + content-addressed alias + +- SchemaVersion `"1.0"` → `"1.1"` (in-place v1 URL — major 안의 minor 추가). +- `_KNOWN_KINDS` 11 known kinds (10 known + UnknownBlock) — `Block` Annotated Union 11 멤버. callable Discriminator 는 O(1) lookup. +- JSON Schema in-place 갱신 — `python/rhwp/ir/schema/hwp_ir_v1.json` (1,261 lines, 20 `$defs`). +- Content-addressed alias `hwp_ir_v1-sha256-.json` — `publish-schema.yml` 가 매 deploy 시 hash-tagged immutable copy 를 alongside 발행. 구 hash 는 영구 보존 (SchemaStore / 외부 도구 reproducibility). +- `_harden_unknown_variant` 가 `_KNOWN_KINDS` SSOT 를 사용 — `TocEntryBlock.kind="toc_entry"` 같은 leaf-only kind 가 not.enum 에 포함되어 라운드트립 깨지는 케이스 회피. + +### Documentation + +- `docs/roadmap/v0.3.0/ir-expansion.md` — IR 확장 spec (8 결정 사항 + research 인용). +- `docs/roadmap/v0.3.0/cli.md` — `rhwp-py` 재도입 spec (이름 선정 + overlap=0 + extras 정책). +- `docs/design/v0.3.0/ir-expansion-research.md` / `cli-design-research.md` — 결정 증거. +- `docs/implementation/v0.3.0/stages/stage-{1..4}.md` — 단계별 구현 로그 (S1: Picture+Furniture, S2: Formula+Footnote/Endnote, S3: ListItem+Caption+Toc+Field, S4: Schema GA + CLI + LangChain include_furniture + 문서). +- `README.md` — v0.3.0 신규 블록 + `rhwp-py` CLI 섹션 추가, content-addressed alias 안내. + +### Tests + +- 5 신규 IR 테스트 파일 (S1 picture+furniture, S2 formula+footnote, S3 list+caption+toc+field) + 1 CLI 테스트 파일 → 405 (S3) → S4 추가. +- `tests/test_cli.py` — typer.testing.CliRunner 기반 smoke + 통합 (parse/version/schema/ir/blocks/chunks 전 서브커맨드 + exit code 1/2 검증 + langchain-text-splitters 미설치 monkeypatch). +- `tests/test_langchain_loader_ir.py` 확장 — `include_furniture` 옵션 4 테스트. +- CI `test-without-extras` skip count 4 → 5 (typer 추가). + +### Deferred to v0.4.0+ + +- `ListItemBlock` 정확 marker (`"가."`, `"(a)"`) — `Numbering.level_formats` lookup. +- `TocBlock.entries` 채움 + `target_section_idx` resolver + `is_stale` 검출. +- `FieldBlock.cached_value` 추출 (paragraph text inline `field_ranges` 매핑 필요). +- `InlineRun.href` 자동 채움 (Hyperlink/Bookmark Field 와 cross-link). +- `PictureBlock` `embedded` / `external_dir` 임베딩 모드 (`Document.to_ir(image_mode=...)` 옵션). +- `RevisionMark` (변경 이력) — 상류 미지원 (영구 비목표 후보). + ## [0.2.0] — 2026-04-25 MINOR release — Phase 2 착수. RAG / LLM 파이프라인이 직접 소비하는 구조화 Document IR v1 (Pydantic V2 + JSON Schema Draft 2020-12) 을 도입. 기존 `Document` / `HwpLoader` API 는 변경 없음 (backward-compatible). 상류 `edwardkim/rhwp` 커밋 핀을 `bea635b` (main HEAD) 로 갱신 — v0.1.0 의 `1636213` 이후 upstream 변경은 docs (매뉴얼 현행화 / README 동기화 / 자기검열) 만으로 코드 동작 변화 없음. BMP→PNG 재인코딩 fix (#240) 는 여전히 upstream `origin/devel` 에만 있으며 본 release pin 에 미포함 — BMP 임베딩 HWP 의 SVG/PDF 렌더링 이슈는 upstream main 머지를 대기. @@ -134,7 +205,8 @@ The `rhwp` Rust core is consumed via git submodule pinned to upstream commit `16 - Local `maturin build --release` wheel (3.0 MB) verified end-to-end in a clean venv: install → import → `rhwp.parse` → `HwpLoader` load. (Note: the v0.1.0 sdist exceeded PyPI's 100 MB limit and did not upload; fixed in [0.1.1](#011--2026-04-23).) - GitHub Actions workflow (`publish.yml`) builds Linux (x86_64 + aarch64) / macOS (x86_64 + aarch64) / Windows wheels + sdist on release publish, then uploads via PyPI Trusted Publisher (OIDC). -[Unreleased]: https://github.com/DanMeon/rhwp-python/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/DanMeon/rhwp-python/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/DanMeon/rhwp-python/releases/tag/v0.3.0 [0.2.0]: https://github.com/DanMeon/rhwp-python/releases/tag/v0.2.0 [0.1.1]: https://github.com/DanMeon/rhwp-python/releases/tag/v0.1.1 [0.1.0]: https://github.com/DanMeon/rhwp-python/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 5ff471a..787afd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,27 +10,6 @@ Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (gl - **License**: MIT — dual copyright (Edward Kim for rhwp core, DanMeon for bindings). Both LICENSE files are bundled in the wheel (`license-files = ["LICENSE", "external/rhwp/LICENSE"]`) - **Status**: unofficial community package. The `rhwp` name on PyPI is intentionally left for the upstream maintainer -## Quick start - -```bash -git clone --recurse-submodules https://github.com/DanMeon/rhwp-python -cd rhwp-python -uv sync --no-install-project --group all -uv run maturin develop --release -uv run pytest -m "not slow" -``` - -If the repo is already cloned without submodules: `git submodule update --init --recursive`. - -## Quality checks - -- `uv run ruff format python/ tests/ benches/` — format -- `uv run ruff check python/ tests/ benches/` — lint -- `uv run pyright python/ tests/` — type check -- `cargo clippy --all-targets -- -D warnings` — Rust lint (run after any `src/*.rs` change) - -Autolint hook (`~/.claude/hooks/autolint.js`) runs ruff/pyright on edited files automatically; the commands above are for cross-file / cold checks. - ## Global rules inherited All rules from `~/.claude/CLAUDE.md` apply. This file adds only project-specific details — do not restate global rules here. @@ -38,15 +17,19 @@ All rules from `~/.claude/CLAUDE.md` apply. This file adds only project-specific ## Project-specific rules ### Rust + Python hybrid build -- After any Rust change (`src/*.rs`): `uv run maturin develop --release` before `pytest`. Without it, tests run against the stale binary +- After any Rust change (`src/*.rs`): `uv run maturin develop --release` before `pytest` (without it, tests run against the stale binary), and `cargo clippy --all-targets -- -D warnings` for lint +- `external/rhwp/` is upstream-owned. Never edit it locally — file an issue / PR against [edwardkim/rhwp](https://github.com/edwardkim/rhwp) instead - PyO3 `#[pyclass(unsendable)]`: `_Document` is bound to its creation thread (upstream `DocumentCore` holds `RefCell` fields — `!Sync`). Same-thread worker pattern (`parse + consume + return primitives` inside one thread) works; `asyncio.to_thread(rhwp.parse, path)` does NOT — the Future resolves on the main thread and first attribute access panics with `_rhwp::document::PyDocument is unsendable, but sent to another thread` -- GIL release via `py.detach` in `_Document::from_bytes` / `render_pdf()` / `export_pdf()` — keep this pattern when adding new CPU/IO-bound methods +- GIL release via `py.detach` — apply selectively, not blanket: + - **Release** for ≥1 ms CPU/IO-bound work that touches only Rust-side data (parse, render, decode, compress, file read). Current sites: `_Document::from_bytes` / `render_pdf()` / `export_pdf()`. When adding new methods of this shape, follow the same pattern + - **Don't release** for trivial getters, short attribute access, or hot paths that frequently call back into Python — the `detach`/`attach` round-trip cost exceeds the gain, and may slow things down + - **When unsure**, measure with the `benches/bench_gil.py` pattern (with vs without `py.detach` wall-clock comparison) before committing - `abi3-py310` feature: **one wheel covers 3.10–3.13+**. Don't bind to Python version-specific C API ### Async direction - Python-surface APIs for I/O and integrations are **async-first**: when adding LangChain / LlamaIndex / Haystack loaders, implement `aload` / `alazy_load` / async counterparts alongside sync versions - **Forbidden pattern**: `asyncio.to_thread(rhwp.parse, path)` — `_Document` is unsendable (see Rust+Python hybrid build note above), the returned Document panics on main-thread access. `async fn` in `#[pymethods]` is also incompatible (PyO3 requires `Send + 'static` futures) -- **Supported async pattern**: `aparse(path)` uses `aiofiles.open()` for the file read on the event-loop thread, then calls `Document.from_bytes(data)` on the same thread. Document never crosses a thread boundary. Optional dep: `pip install rhwp[async]` — missing `aiofiles` raises `ImportError` (no silent fallback) +- **Supported async pattern**: `aparse(path)` uses stdlib `asyncio.to_thread` to offload the file read to a thread pool, then calls `Document.from_bytes(data)` on the event-loop thread. Document never crosses a thread boundary. No external dependency — Python `asyncio` lacks native async file I/O so all async file libs (aiofiles etc.) wrap thread pools anyway; stdlib achieves the same effect with zero install footprint - **Document instance-level async methods (`doc.ato_ir()` etc.) are NOT provided** — they would require thread offload which unsendable forbids. For async code, `await rhwp.aparse(path)` once, then call sync methods on the Document directly (these are fast, in-memory, GIL-holding operations) - If upstream rhwp ever replaces its `RefCell` caches with thread-safe synchronization, revisit this — `unsendable` could then be dropped, enabling true `async fn pymethods` @@ -54,21 +37,23 @@ All rules from `~/.claude/CLAUDE.md` apply. This file adds only project-specific - Real HWP fixtures live in the submodule: `external/rhwp/samples/aift.hwp` (HWP5), `table-vpos-01.hwpx` (HWPX). `tests/conftest.py` + `benches/bench_gil.py` reference this path - When changing one path, change both - Markers: `slow` (PDF render), `langchain` (extras required). Default run: `pytest -m "not slow"` -- Extras-gated test files use module-level `pytest.importorskip` so the whole file counts as **1 skip** when the extra is missing. Current gated files: `test_langchain_loader.py` + `test_langchain_loader_ir.py` (langchain-core), `test_ir_schema_export.py` (jsonschema), `test_async.py` (aiofiles) → CI's `test-without-extras` job validates **exactly 4 skipped** (see `.github/workflows/ci.yml`). When adding a new extras-gated file, bump the count in both CLAUDE.md and ci.yml +- Extras-gated test files use module-level `pytest.importorskip` so the whole file counts as **1 skip** when the extra is missing. Current gated files: `test_langchain_loader.py` + `test_langchain_loader_ir.py` (langchain-core), `test_ir_schema_export.py` (jsonschema), `test_cli.py` (typer) → CI's `test-without-extras` job validates **exactly 4 skipped** (see `.github/workflows/ci.yml`). When adding a new extras-gated file, bump the count in both CLAUDE.md and ci.yml - `tests/type_check_errors.py` holds **exactly 4 intentional pyright errors** — CI validates that too. When editing, preserve count; don't fix them ### Git workflow - Single-branch trunk model: feature branches off `main` → PR to `main`. No `develop` / `staging` +- Branch naming: **MINOR** = `feature/vX.Y.0` (long-lived, isolates external contract changes across stages). **PATCH** = `/` (short-lived, merges directly to main, tag only `vX.Y.Z`) where `` follows [Conventional Commits](https://www.conventionalcommits.org/) (`fix` / `chore` / `refactor` / `docs` / `build` / `ci` / `perf` / `test` / `revert`) - Commit subject: lowercase `type: description` (seed commit: `init: 프로젝트 초기화`) - PR body follows [.github/pull_request_template.md](.github/pull_request_template.md) — Summary / Why / Related Issues - Full contributor flow (fork, pre-submit checks, rhwp-core changes): [CONTRIBUTING.md](CONTRIBUTING.md) ### Versioning / release - Git tags `vX.Y.Z`, SemVer, MINOR-sized increments +- **Cargo.toml is the version source of truth** via `dynamic = ["version"]` in pyproject.toml. Always bump Cargo.toml before tagging — `publish.yml`'s `verify-version` aborts on mismatch - **No breaking changes across Phase boundaries** (Phase 1 → 2 must keep existing APIs) - Release trigger: GitHub Release `published` event fires `publish.yml`. Draft releases don't trigger -- `publish.yml` runs `verify-version` — Cargo.toml `version` must match the tag or publish aborts. Always bump Cargo.toml before tagging - Every release records the `external/rhwp` submodule commit hash in CHANGELOG +- Integration-only runtime deps (LangChain, typer, jsonschema) belong in `[project.optional-dependencies]`, never `[project] dependencies` — keeps the core wheel dependency-free ### Documentation Authoritative policy is `docs/CONVENTIONS.md` — read it before any docs work. Active spec index SSOT is `docs/roadmap/README.md`. @@ -85,27 +70,3 @@ Hard rules (auto-applied without further instruction): - `secrets.GITHUB_TOKEN` is injected automatically; don't try to "register" it - Workflow permissions stay minimal. `publish.yml` declares `id-token: write` at the job level only -## Directory layout - -``` -. -├── src/ Rust bindings (lib.rs + document/errors/version.rs) -├── python/rhwp/ Python package -│ ├── __init__.py(.pyi) -│ ├── py.typed -│ └── integrations/langchain.py(.pyi) -├── tests/ pytest — conftest reads external/rhwp/samples -├── benches/bench_gil.py GIL-release benchmark -├── examples/ typer-based usage samples (extras: [examples]) -├── external/rhwp/ git submodule — pinned upstream commit -└── docs/ 4-axis documentation -``` - -## Common mistakes to avoid - -- Forgetting `--recurse-submodules` on clone → samples missing. Fix: `git submodule update --init --recursive` -- Forgetting `maturin develop --release` after Rust changes → tests run against stale binary -- Changing `tests/conftest.py` sample path without updating `benches/bench_gil.py` -- Adding a runtime dependency to `[project] dependencies` when it belongs in `[project.optional-dependencies]` (LangChain, typer currently gated as extras) -- Bumping the version only in `pyproject.toml` — **Cargo.toml is the source of truth** via `dynamic = ["version"]` -- Modifying `external/rhwp/` directly — it's upstream-owned. Upstream PRs only diff --git a/Cargo.toml b/Cargo.toml index 1598320..5127917 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rhwp-python" -version = "0.2.0" +version = "0.3.0" edition = "2021" # ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수. # PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내 diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 0000000..b9aa8b7 --- /dev/null +++ b/KNOWN_ISSUES.md @@ -0,0 +1,65 @@ +# Known issues + +이 패키지를 사용할 때 알아두면 좋은 운영상 제약. 미구현 기능 / 작업 중 항목은 +[`roadmap/`](roadmap/README.md) 의 활성 spec 인덱스 참조. + +## Document 객체는 단일 스레드 객체 + +`rhwp.Document` 는 PyO3 `#[pyclass(unsendable)]` 로 바인딩되어 있어 생성 스레드를 +벗어난 접근은 `RuntimeError` 또는 panic 으로 이어진다. 상류 `rhwp` 코어가 내부에 +`RefCell` 캐시를 가지고 있어 `Sync` 가 아니기 때문이다. + +### 멀티스레드 처리 패턴 + +worker 내에서 `parse + consume` 까지 완결한 뒤 원시 타입 (`int`, `str`, `bytes`) 만 +반환한다. Document 인스턴스가 worker 경계를 넘지 않는 한 안전하다. + +```python +from concurrent.futures import ThreadPoolExecutor +import rhwp + +def parse_and_extract(path: str) -> str: + doc = rhwp.parse(path) # ^ worker 내에서 생성 + return doc.extract_text() # ^ str 만 main thread 로 반환 + +with ThreadPoolExecutor(max_workers=4) as ex: + texts = list(ex.map(parse_and_extract, paths)) +``` + +벤치마크 / 재현은 `benches/bench_gil.py`. + +### Async 진입점 + +`asyncio.to_thread(rhwp.parse, path)` 는 사용 금지 — Future 가 main thread 에서 +resolve 되는 순간 첫 attribute 접근이 unsendable 위반으로 panic 한다. + +대신 `rhwp.aparse(path)` 를 사용한다. 파일 I/O 만 thread pool 로 offload 하고 +Document 는 event-loop thread 에서 생성되므로 thread 경계를 절대 넘지 않는다. + +```python +import asyncio, rhwp + +async def main(): + doc = await rhwp.aparse("report.hwp") + text = doc.extract_text() +``` + +상류 `rhwp` 가 `RefCell` 캐시를 thread-safe 동기화로 전환하면 `unsendable` 제거 +및 true `async fn pymethods` 가 가능해진다 — 현재는 미정. + +## PDF 렌더 stdout 노이즈 + +상류 `rhwp` 코어가 PDF 렌더 경로에서 `[DEBUG_TAB_POS]` / `LAYOUT_OVERFLOW` 진단 +로그를 stdout 으로 직접 출력한다. 본 바인딩은 이를 가로채지 않는다. + +```bash +python script.py 2>&1 | grep -v -E "(DEBUG_TAB_POS|LAYOUT_OVERFLOW)" +``` + +상류 이슈로 추적 — 보고 채널은 [edwardkim/rhwp/issues](https://github.com/edwardkim/rhwp/issues). + +## 읽기 / 렌더링 전용 + +HWP / HWPX **저장 (serialization)** 은 미지원이다. 본 패키지는 읽기 / 추출 / +렌더링 (SVG, PDF) 만 제공한다. 저장은 상류 코어가 export 경로를 GA 한 뒤 +바인딩에 노출하는 구조이며, 현재 로드맵에 없다. diff --git a/README.md b/README.md index 26f33f3..bc8abed 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - **PyPI 패키지명**: `rhwp-python` - **Python import**: `import rhwp` -- **Rust 코어**: [`external/rhwp`](external/) 에 git submodule 로 고정 +- **Rust 코어**: [`edwardkim/rhwp`](https://github.com/edwardkim/rhwp) ## 왜 rhwp-python 인가 @@ -89,10 +89,11 @@ chunks = RecursiveCharacterTextSplitter(chunk_size=500).split_documents(docs) 모든 Document 메타데이터: `source`, `section_count`, `paragraph_count`, `page_count`, `rhwp_version`. `paragraph` 모드에서는 `paragraph_index` 추가. -## Document IR (v0.2.0+) +## Document IR RAG / LLM 파이프라인이 직접 소비하는 구조화 문서 모델. Pydantic V2 모델 + JSON -Schema (Draft 2020-12) — HWP 의 구역 / 단락 / 표 / 서식 런을 손실 없이 노출한다. +Schema (Draft 2020-12) — HWP 의 구역 / 단락 / 표 / 그림 / 수식 / 각주 / 목록 / +캡션 / 목차 / 필드를 손실 없이 노출한다. ```python from rhwp.ir.nodes import ParagraphBlock, TableBlock @@ -122,14 +123,23 @@ docs = HwpLoader("report.hwp", mode="ir-blocks").load() # para_idx / (표의 경우) rows / cols / text / caption 포함 ``` -**JSON Schema** — `rhwp.ir.schema.export_schema()` 로 Draft 2020-12 스키마 생성, -`load_schema()` 로 in-package 동봉 JSON 로드 (네트워크 불필요). `$id` 공개 URL: -`https://danmeon.github.io/rhwp-python/schema/hwp_ir/v1/schema.json` — 불변 경로 -정책 (v1 영구). 외부 도구는 이 URL 또는 `*.hwp_ir.json` 파일명 convention 사용 가능. +**JSON Schema** — `rhwp.ir.schema.export_schema()` / `load_schema()`. 공개 `$id`: +`https://danmeon.github.io/rhwp-python/schema/hwp_ir/v1/schema.json` (불변 경로). -미구현 블록 타입 (Picture / Formula / Footnote / ListItem / Caption / TocEntry / -Field) 은 `UnknownBlock` catch-all 로 forward-compat — v0.3.0 에서 추가되어도 -v0.2.0 소비자가 깨지지 않는다. +## rhwp-py CLI + +```bash +pip install "rhwp-python[cli]" # parse / version / schema / ir / blocks +pip install "rhwp-python[cli-chunks]" # + chunks (langchain text splitter) + +rhwp-py parse report.hwp +rhwp-py blocks report.hwp --kind table --format ndjson | jq '.rows' +rhwp-py chunks report.hwp --size 1000 --format ndjson +``` + +`rhwp-py` 는 구조 추출 (IR / 블록 / 청크 / 스키마) 전담 — 시각 출력 (SVG/PDF) / +메타데이터 덤프는 상류 `rhwp` Rust 바이너리. 자세한 사용은 `rhwp-py --help` +또는 [cli.md](docs/roadmap/v0.3.0/cli.md) 참조. ## 성능 @@ -147,56 +157,21 @@ Apple M2 (8 코어) release 빌드. Parse = 파일 읽기 + 전체 파싱 + Docu 코어 수에 비례해 스케일. PDF 렌더링 자체는 `usvg` + `pdf-writer` 내부에서 CPU/allocator 바운드라 2 ~ 3 워커에서 약 1.1× 정도만 향상됨 — 재현은 `benches/bench_gil.py` 참고. -## 알려진 제약 (Phase 1) +## 알려진 제약 / 운영 노트 -- `Document` 객체는 `#[pyclass(unsendable)]` — 단일 스레드 접근만 허용. - 교차 스레드 접근 시 `RuntimeError`. 멀티스레드에선 `benches/bench_gil.py` 패턴 사용 — - 워커 내에서 `parse + consume` 까지 완결한 뒤 원시 타입(`int`, `str`, `bytes`) 만 반환. -- 폰트 임베딩 / 디버그 오버레이 / 페이지 메타데이터 API 없음 (Phase 2+). -- HWP/HWPX **저장(serialization)** 미지원 — 읽기/렌더링 전용. -- 표 / 이미지 / 수식 구조화 접근 없음 — 텍스트 추출만 지원. -- PDF 렌더 경로가 rhwp 코어의 `[DEBUG_TAB_POS]` / `LAYOUT_OVERFLOW` 로그를 - stdout 으로 출력. 필요 시 `grep -v -E "(DEBUG_TAB_POS|LAYOUT_OVERFLOW)"` 로 필터링. +운영상 제약 (`Document` 의 단일 스레드 모델, async 진입점, PDF stdout 노이즈) +및 미구현 영역 요약은 [KNOWN_ISSUES.md](KNOWN_ISSUES.md). 작업 중 / 계획 항목은 +[docs/roadmap/](docs/roadmap/README.md) 의 활성 spec 인덱스. ## 개발 -이 리포는 rhwp Rust 코어를 `external/rhwp` git submodule 로 소비합니다. - -```bash -git clone --recurse-submodules https://github.com/DanMeon/rhwp-python -cd rhwp-python - -# dev + testing + linting 툴 일괄 설치 -uv sync --no-install-project --group all -uv run maturin develop --release - -# 테스트 (core + LangChain, slow PDF 제외) -uv run pytest tests/ -m "not slow" - -# PDF 렌더링 테스트 -uv run pytest tests/ -m slow - -# 타입 체크 -uv run pyright python/ tests/ - -# GIL 해제 벤치마크 -uv run python benches/bench_gil.py 2>&1 | grep -v -E "(DEBUG_TAB_POS|LAYOUT_OVERFLOW)" -``` - -clone 시 `--recurse-submodules` 를 빠뜨렸다면: - -```bash -git submodule update --init --recursive -``` - -테스트 fixture 는 submodule 내부 `external/rhwp/samples/` 에 있으며, -`tests/conftest.py` 가 이 경로를 참조합니다. +소스에서 빌드·테스트·기여하는 절차는 [CONTRIBUTING.md](CONTRIBUTING.md) 참조. ## 버전 관리 이 Python 패키지와 `rhwp` Rust 코어는 **독립적으로** 버저닝됩니다. `rhwp.version()` 은 이 패키지 버전을, `rhwp.rhwp_core_version()` 은 -고정된 submodule 에 포함된 Rust 코어의 버전을 반환합니다. +번들된 Rust 코어 버전을 반환합니다. ## 라이선스 diff --git a/README_EN.md b/README_EN.md index f3c022d..ca1cc64 100644 --- a/README_EN.md +++ b/README_EN.md @@ -108,16 +108,12 @@ Workload: 9 files (`aift.hwp` 5.5 MB + `table-vpos-01.hwpx` 359 KB + `tac-img-02 CPU/allocator-bound inside `usvg` + `pdf-writer`, so parallelization yields only 약 1.1× on 2–3 workers — see `benches/bench_gil.py` for reproducible measurement. -## Known limitations (Phase 1) - -- `Document` objects are `#[pyclass(unsendable)]` — single-threaded access only. - Cross-thread access raises `RuntimeError`. Use the pattern in `benches/bench_gil.py`: - run `parse + consume` inside the worker, return primitives (`int`, `str`, `bytes`). -- No font embedding / debug overlay / page metadata APIs (Phase 2+). -- No HWP/HWPX **serialization** (save) — read/render only. -- No structured access to tables, images, formulas — text extraction only. -- PDF render path outputs Rust-side `[DEBUG_TAB_POS]` / `LAYOUT_OVERFLOW` log lines - from the rhwp core. Filter with `grep -v -E "(DEBUG_TAB_POS|LAYOUT_OVERFLOW)"` for now. +## Known limitations / operational notes + +Operational constraints (`Document` single-threaded model, async entry point, +PDF stdout noise) and unimplemented areas are summarized in +[KNOWN_ISSUES.md](KNOWN_ISSUES.md) (Korean). In-progress / planned items live +in the active spec index at [docs/roadmap/](docs/roadmap/README.md). ## Development diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 208368c..ecd77c3 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -7,7 +7,7 @@ | 분류 | 의미 | 갱신 정책 | 예시 | |---|---|---|---| | **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `README.md` | -| **Active** | 현재 진행 중 — 의도/스코프 수준의 진화하는 문서 | 큰 변경만, in-place 갱신 OK | `docs/roadmap/phase-N.md`, `docs/design/pyo3-bindings.md` (cross-version reference) | +| **Active** | 현재 진행 중 — 의도/스코프 수준의 진화하는 문서 | 큰 변경만, in-place 갱신 OK | `docs/roadmap/phase-N.md` | | **Draft** | 작성 중인 spec — 해당 버전 GA 전까지 활발 갱신 | 버전 GA 전까지 자유 갱신, GA 후 Frozen 으로 전환 | `docs/roadmap/v0.3.0/*.md` (현재 v0.3.0 GA 전) | | **Frozen** | GA 완료된 spec / 완료된 stage / 완료된 검증 | **변경 금지** — 오타·링크 수정만 in-place 허용. 큰 변경은 새 spec + supersede | `docs/roadmap/v0.2.0/ir.md` (v0.2.0 GA 완료), `docs/implementation/v0.2.0/stages/*.md` | @@ -26,7 +26,7 @@ ``` - **Status**: 현재 분류 -- **GA** (Frozen 일 때): 어느 버전에서 GA 됐는지. **Target** (Draft 일 때): 어느 버전을 향한 작업인지. Active 면 둘 다 생략 가능 +- **GA** (Frozen, 부모 버전 이미 GA): 어느 버전에서 GA 됐는지. **Target** (Draft, 또는 implementation stage 가 부모 GA 전에 Frozen 처리된 경우): 어느 버전을 향한 작업인지. Active 면 둘 다 생략 가능 - **Last updated**: 본문에 의미 있는 변경이 있었던 날짜 (오타·링크 수정 제외) - 모든 spec 변경 시 `Last updated` 만큼은 갱신 @@ -42,10 +42,11 @@ docs/ │ ├── phase-{2,3,4}.md Active — Phase 의도/스코프 (구체 결정 미포함) │ └── v/.md Draft → Frozen on GA — per-version spec ├── design/ -│ ├── pyo3-bindings.md Active — 버전 무관 cross-version reference │ └── v/-research.md Draft → Frozen on GA — ADR-style 결정 증거 ├── implementation/ │ └── v/... Frozen — 완료된 stage 작업 로그 +├── upstream/ +│ └── .md Active — 외부 (rhwp Rust 코어) 이슈 초안. 업스트림 머지 시 archive └── verification/ └── v/... Frozen — 완료된 검증 리포트 ``` @@ -58,13 +59,18 @@ docs/ ### design/ -- `pyo3-bindings.md` (Active) — 버전 무관 reference. PyO3 / abi3 / unsendable 같은 cross-version 제약을 모은 참조 문서 - `vX.Y.Z/-research.md` (Draft → Frozen) — ADR-style 결정 증거. 결정 매트릭스 + 항목별 (팩트/검증자 반박/최종 결정/출처). 짝이 되는 roadmap spec 과 1:1 페어 ### implementation/ - `vX.Y.Z/migration.md` 또는 `vX.Y.Z/stages/stage-N.md` (Frozen) — 작업 로그. 완료 즉시 Frozen. 산출물 / 검증 결과 / 이월 사항 기록 - 작은 작업 (단일 세션·수일 규모) 은 단일 `migration.md`. 큰 작업 (여러 주, 의존성 추적 필요) 은 `stages/stage-N.md` 분할 +- **stage 작성 시점이 부모 버전 GA 전이면** Status 는 `Frozen + Target: vX.Y.Z` 로 표기 (작성 즉시 immutable, GA 라벨은 미부여). 부모 버전 GA 시 `Target` → `GA` 로 일괄 전환 + +### upstream/ + +- `.md` (Active) — 업스트림 (`edwardkim/rhwp` 등) 에 제출 검토 중인 이슈/제안 초안. 머지·해결 시 archive 또는 삭제. per-version 매핑 없음 +- 본 디렉토리는 외부 시스템 (GitHub Issues) 으로 흘러가기 전 단계의 staging — 정식 spec 의 일부가 아님 ### verification/ @@ -119,6 +125,28 @@ Living ───→ Active ───→ Draft ───→ Frozen 오타·깨진 링크·외부 URL 변경 같은 비-의미 변경은 in-place 가능 (Last updated 갱신). +## Implementation log 구조 + +`docs/implementation/vX.Y.Z/` 안의 두 종류 분류: + +- `stages/stage-N.md` — 해당 release 의 spec § 구현 스테이지 분할 에 명시된 작업 (대규모, 다단계). spec 의 stage 표와 1:1 mapping +- `.md` (직속 평면) — spec 없는 작은 작업 (refactor / chore / perf / dep bump 등). 결정 비교 (a/b/c 옵션) 가치 있어 CHANGELOG 한 줄로 부족할 때만 작성. branch prefix `/` 와 1:1 mapping (예: branch `refactor/aparse-stdlib` → log `aparse-cleanup.md`) + +CHANGELOG 한 줄로 충분한 변경 (typo 정리, 단순 dep bump, 작은 docstring 갱신 등) 은 파일 작성 안 함 — git log + CHANGELOG 가 SSOT. + +미래 ad-hoc note 가 5개 이상 모이면 그때 `chores/` 디렉토리화 검토 (YAGNI). 본 패턴 = spec-driven AI coding 진영의 "결정 보존 — 미래 세션 reconstruct 회피" trend (MADR / Cline `decisions.md` / OpenSpec) 와 정합. + +## Archive 정책 (v1.0+) + +Major release GA 시점에 직전 major 의 frozen spec 들을 archive 디렉토리로 이동한다 — README 인덱스 비대화 / 검색 노이즈 완화. 파일 이동만, 본문 변경 없음. + +- v1.0 GA → 모든 v0.X.Y frozen spec 을 `docs/archive/v0/` 로 이동 (동일 구조 유지: `archive/v0/roadmap/v0.3.0/...`) +- 이후 v2.0 GA → `docs/archive/v1/`, ... 로 누적 +- `roadmap/README.md` 인덱스는 active major 만 노출, archive 는 별도 페이지 (`docs/archive/README.md`) 에 한 줄 link +- git size 자체는 무관 (markdown delta compression 효율적) — 본 정책은 인덱스/검색 가독성 목적 + +v1.0 GA 전까지 아무것도 안 함 — 본 정책은 v1.0 GA 직전 작업으로 잡아둠. + ## 명명 규칙 - 파일명: kebab-case (`ir-expansion.md`, not `ir_expansion.md`) diff --git a/docs/design/pyo3-bindings.md b/docs/design/pyo3-bindings.md deleted file mode 100644 index 04f4543..0000000 --- a/docs/design/pyo3-bindings.md +++ /dev/null @@ -1,1184 +0,0 @@ -# PyO3 Python 바인딩 구현 가이드 (Phase 1) - -**Status**: Active · **Last updated**: 2026-04-23 - -> **문서 목적**: rhwp를 PyPI 배포 가능한 Python 패키지로 만드는 작업의 기술 참조. 다른 Claude Code 세션이 이 문서를 기반으로 구현한다. -> -> **배경**: GitHub Issue [#227](https://github.com/edwardkim/rhwp/issues/227) 에서 시작. **사용자 Fork(`DanMeon/rhwp`)에서 독립 진행**하는 개인 패키지화 작업이다. 상류 리포(`edwardkim/rhwp`)에 PR 제출 여부는 별도 판단. -> -> **전제**: 이 문서 작성 전에 `pyo3-sandbox/`에서 기술 검증 완료 ([검증 문서 5종](../../pyo3-sandbox/docs/) 참조). 본 가이드는 그 결과를 현업 패턴과 대조해 정식 구현 청사진으로 정제한 것. - ---- - -## 0. 불변 원칙 (CRITICAL CONSTRAINTS) - -다음은 구현 전 과정에서 **절대 위반하면 안 되는** 제약이다. 위반 시 작업 중단 후 작업지시자 확인 필수. - -### 0.1 rhwp 코어 무수정 - -| 경로 | 수정 가능? | -|------|----------| -| `/src/**/*.rs` (rhwp 본체) | ❌ **절대 수정 금지** | -| `/Cargo.toml` (루트) | ❌ **수정 금지** | -| `/pyproject.toml` (존재 시) | ❌ **수정 금지** | -| 새 디렉토리 (예: `/rhwp-python/` 또는 `/pyo3-bindings/`) | ✓ 자유 생성 | -| `/pyo3-sandbox/` | ✓ 참조만 (직접 복사 금지) | - -**이유**: rhwp는 WASM 단일 스레드 타겟을 위한 설계 철학(예: `RefCell` 기반 interior mutability)을 가진다. 이를 변경하면 WASM 빌드·기존 소비자(Studio·Chrome·Safari 확장)가 회귀할 수 있음. 메인테이너 승인 없이 코어 설계 변경 금지. - -**대응**: 새 크레이트를 만들어 rhwp를 **경로 의존성**(`rhwp = { path = ".." }`)으로 참조. PyO3 래퍼 레이어만 새 크레이트에 작성. - -### 0.2 하이퍼-워터폴 프로세스 준수 - -프로젝트 `CLAUDE.md`의 워크플로우 준수: - -1. 수행 계획서 (`mydocs/plans/task_m{마일스톤}_{이슈}.md`) 작성 → 승인 -2. 구현 계획서 (`task_m{마일스톤}_{이슈}_impl.md`, 3~6단계) 작성 → 승인 -3. 단계별 구현 → 단계별 완료 보고서 → 승인 -4. 최종 보고서 → 승인 - -각 단계 완료 시 승인 없이 다음 단계 진행 금지. - -### 0.3 기타 원칙 - -- 한글 문서 작성 (코드·LLM-facing 텍스트는 영어) -- Pydantic V2, Python 3.10+ 타입 유니언, `uv` 패키지 관리자 -- PostToolUse 린트 훅(`ruff format`, `ruff check --fix`, `pyright`)이 자동 실행되므로 결과 확인만 - ---- - -## 1. 현업 패턴 조사 결과 (근거) - -### 1.1 조사 대상 - -| 프로젝트 | 구조 | 참고 가치 | -|---------|-----|----------| -| [pydantic-core](https://github.com/pydantic/pydantic-core) | subdirectory + `python-source` | **최고 참조** (rhwp와 유사한 단일 리포 + 네이티브 코어) | -| [polars (py-polars)](https://github.com/pola-rs/polars/tree/main/py-polars) | subdirectory in workspace | 대규모 프로젝트 매뉴얼 빌드 관리 | -| [ruff](https://github.com/astral-sh/ruff) | workspace + crates/ | CLI·LSP 특수 형태, 부분 참고 | -| [tiktoken](https://github.com/openai/tiktoken) | flat + `tiktoken/` | 간단한 케이스 참조 | - -### 1.2 공통 패턴 - -모든 성숙 프로젝트가 수렴하는 현업 관행: - -1. **`python-source = "python"` + 명시적 Python 디렉토리** — 루트 네임스페이스 충돌 방지 -2. **`module-name = "pkg._pkg"` (내부 submodule 감춤)** — 사용자는 `pkg.foo()`만 보고, Rust 네이티브 모듈은 `_pkg`로 숨김 -3. **`crate-type = ["cdylib", "rlib"]`** — cdylib(Python 연결) + rlib(stub 생성 바이너리용) -4. **`abi3-py39` 또는 `abi3-py310`** — 단일 휠이 여러 Python 버전 커버 -5. **`[profile.release] lto = "fat" + codegen-units = 1 + strip = true`** — 릴리즈 바이너리 최소화·최대 최적화 -6. **`maturin-action`으로 멀티플랫폼 CI** — Linux(manylinux+musllinux)·macOS(x86·ARM)·Windows 매트릭스 -7. **PyPI Trusted Publishing (OIDC)** — API 토큰 관리 탈피 -8. **`py.typed` 마커 + `.pyi` 스텁 번들링** — PEP 561 준수 -9. **`[dependency-groups]` (PEP 735)** — dev/test/lint 분리 -10. **PGO(Profile-Guided Optimization)** — 성숙 단계에서 추가 (Phase 1 범위 외) - -### 1.3 타입 스텁 생성 도구 (선택지) - -| 도구 | 방식 | 권장도 | -|------|------|-------| -| 수동 작성 `.pyi` | 현재 sandbox 방식 | **Phase 1 권장** (API 작아서) | -| [pyo3-stub-gen](https://github.com/Jij-Inc/pyo3-stub-gen) | Rust 매크로 기반 자동 생성 | Phase 2+ (API 커지면) | -| [pyo3-stubgen](https://pypi.org/project/pyo3-stubgen/) | 런타임 introspection | 비권장 (정확도 낮음) | -| mypy `stubgen` | 범용 | 비권장 (PyO3 특화 아님) | - -**결정**: Phase 1은 수동 `.pyi`. 이유 — API 표면 작음(10개 미만), 사용자 문서에 들어갈 docstring을 세밀하게 제어 필요, 의존성 최소화. - ---- - -## 2. 프로젝트 구조 결정 - -### 2.1 선택지 비교 - -| 옵션 | 설명 | rhwp 적용 | -|------|-----|----------| -| A. Subdirectory crate | 리포 하위 `/rhwp-python/` 신설, 경로 의존 | **권장** | -| B. Cargo workspace + Python crate | 루트 `[workspace]` 선언, 멤버 등록 | 불가 (루트 Cargo.toml 수정 필요) | -| C. 별도 리포 | `edwardkim/rhwp-python` 신설 | 불가 (배포·버전 동기화 부담) | - -### 2.2 결정: 옵션 A - -**이유**: -- 원칙 0.1(코어 무수정)과 양립 가능 -- pydantic-core의 검증된 패턴 (subdirectory에 Python 크레이트) -- 단일 리포 유지 → 버전·이슈·PR 관리 간결 -- 배포 시점에 `cd rhwp-python && maturin build`만 하면 됨 - -### 2.3 최종 디렉토리 구조 - -``` -rhwp/ # 루트 (기존, 무수정) -├── src/ # rhwp 코어 (무수정) -├── Cargo.toml # rhwp 코어 (무수정) -├── pyo3-sandbox/ # 기존 실증 코드 (참고용) -└── rhwp-python/ # 🆕 신설 — Phase 1 구현 위치 - ├── Cargo.toml # 바인딩 크레이트 manifest - ├── pyproject.toml # Python 패키지 manifest - ├── build.rs # (선택) 빌드 스크립트 - ├── README.md # 사용자용 문서 - ├── LICENSE # MIT (rhwp와 동일) - ├── src/ - │ ├── lib.rs # PyO3 모듈 엔트리 - │ ├── document.rs # Document 클래스 바인딩 - │ ├── errors.rs # 예외 매핑 - │ └── version.rs # 버전 함수 - ├── python/ - │ └── rhwp/ - │ ├── __init__.py # 재노출 + 고수준 Python API - │ ├── __init__.pyi # 타입 스텁 - │ └── py.typed # PEP 561 마커 (빈 파일) - ├── tests/ - │ ├── test_parse.py - │ ├── test_render.py - │ ├── test_errors.py - │ └── conftest.py - ├── benches/ # (선택) pytest-benchmark - └── .github/workflows/ - └── release.yml # PyPI 배포 워크플로우 -``` - -### 2.4 네이밍 규약 - -| 항목 | 값 | 근거 | -|------|----|------| -| PyPI 패키지명 | `rhwp` | 가장 기억하기 쉬움, 네임 선점 | -| import 이름 | `rhwp` | `import rhwp` | -| 내부 Rust 모듈 | `rhwp._rhwp` | pydantic-core 패턴 (`pydantic_core._pydantic_core`) | -| Cargo 크레이트 이름 | `rhwp-python` | PyPI와 구분 | -| cdylib 출력 이름 | `_rhwp` | `#[pymodule]` 함수명과 일치 | - -**사용자 경험**: -```python -import rhwp # 공개 API -doc = rhwp.parse("a.hwp") # 최상위 함수 -# rhwp._rhwp는 접근 가능하지만 사용자 대상 아님 (밑줄 규약) -``` - ---- - -## 3. Cargo.toml 템플릿 - -**파일**: `rhwp-python/Cargo.toml` - -```toml -[package] -name = "rhwp-python" -version = "0.1.0" # rhwp 코어와 별도 버전 (3.3 참조) -edition = "2021" -# * rust-version 미명시 — 루트 rhwp 정책 (stable Rust, MSRV unclaimed) 준수. -# PyO3 0.28 이 Rust 1.83+ 요구하지만 이는 README 문서로 안내 -license = "MIT" -description = "Python bindings for rhwp — HWP/HWPX parser and renderer" -repository = "https://github.com/edwardkim/rhwp" -readme = "README.md" -publish = false # crates.io 배포 대상 아님 - -# * sdist 포함 파일 제어 (pydantic-core 패턴) -include = [ - "/pyproject.toml", - "/README.md", - "/LICENSE", - "/build.rs", - "/src", - "/python/rhwp", - "/tests", - "!__pycache__", - "!*.so", - "!*.pyd", -] - -[lib] -name = "_rhwp" -crate-type = ["cdylib", "rlib"] # cdylib: Python ext / rlib: 향후 stub_gen 바이너리용 - -[dependencies] -pyo3 = { version = "0.28", features = ["extension-module", "abi3-py39"] } -# ^ abi3-py39: Python 3.9+ 단일 휠로 전부 커버 -rhwp = { path = ".." } # 코어 경로 의존성 (무수정 원칙) - -[profile.release] -lto = "fat" # Link-Time Optimization 최대치 -codegen-units = 1 # 단일 코드 생성 유닛 → 더 공격적인 인라이닝 -strip = true # 디버그 심볼 제거 (바이너리 크기 ↓) - -[profile.bench] -# 릴리즈와 동일 최적화 수준 권장 -``` - -**주의**: -- `[workspace]` 섹션 추가 금지 (루트 Cargo.toml과 충돌) -- `publish = false` — 실수로 `cargo publish` 방지 (PyPI 배포는 maturin 담당) -- `rhwp = { path = ".." }` 경로는 rhwp-python 디렉토리 기준 상대 경로 - ---- - -## 4. pyproject.toml 템플릿 - -**파일**: `rhwp-python/pyproject.toml` - -pydantic-core 스타일을 rhwp에 맞춰 이식. - -```toml -[build-system] -requires = ["maturin>=1.13,<2"] -build-backend = "maturin" - -[project] -name = "rhwp" -description = "Parser and renderer for HWP/HWPX documents (Korean word processor format)" -requires-python = ">=3.9" -license = "MIT" -license-files = ["LICENSE"] -authors = [ - { name = "edwardkim", email = "..." }, - # Phase 1 기여자 추가 가능 -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Rust", - "Operating System :: POSIX :: Linux", - "Operating System :: Microsoft :: Windows", - "Operating System :: MacOS", - "Typing :: Typed", - "Natural Language :: Korean", - "Topic :: Office/Business :: Office Suites", - "Topic :: Text Processing", -] -keywords = ["hwp", "hwpx", "hancom", "korean", "document", "parser"] -dynamic = ["version"] # Cargo.toml에서 가져옴 - -[project.urls] -Homepage = "https://github.com/edwardkim/rhwp" -Repository = "https://github.com/edwardkim/rhwp" -Issues = "https://github.com/edwardkim/rhwp/issues" - -# * 옵셔널 익스트라 (선택적 통합, 필요 시 Phase 1.5) -[project.optional-dependencies] -langchain = ["langchain-core>=0.2"] - -[dependency-groups] # PEP 735 (maturin 1.7+ 지원, PyO3 0.28은 maturin 1.13+ 필요) -dev = ["maturin>=1.13"] -testing = [ - {include-group = "dev"}, - "pytest>=8", - "pytest-cov", -] -linting = [ - {include-group = "dev"}, - "ruff", - "pyright", -] -all = [ - {include-group = "dev"}, - {include-group = "testing"}, - {include-group = "linting"}, -] - -[tool.maturin] -python-source = "python" -module-name = "rhwp._rhwp" -bindings = "pyo3" -features = ["pyo3/extension-module"] -include = [ - { path = "python/rhwp/py.typed", format = "wheel" }, - { path = "python/rhwp/*.pyi", format = "wheel" }, -] - -[tool.pytest.ini_options] -testpaths = ["tests"] -minversion = "8.0" -addopts = ["-ra", "--strict-markers"] -markers = [ - "slow: 느린 테스트 (PDF 렌더링 등)", -] - -[tool.pyright] -venvPath = "." -venv = ".venv" -include = ["python", "tests"] -reportMissingImports = true -reportMissingTypeStubs = false - -[tool.ruff] -line-length = 100 -target-version = "py39" - -[tool.ruff.lint] -select = ["E", "F", "W", "I", "UP", "B"] -``` - -**핵심 포인트**: -- `dynamic = ["version"]` + `Cargo.toml`의 version이 자동으로 반영됨 (maturin 기능) -- `module-name = "rhwp._rhwp"` — 내부 네이티브 모듈 숨김 -- `python-source = "python"` — Python 소스 위치 명시 -- `include`에 `py.typed`와 `*.pyi` 반드시 포함 → 휠에 타입 정보 번들 - ---- - -## 5. Rust 바인딩 코드 패턴 - -### 5.1 모듈 엔트리 (`src/lib.rs`) - -```rust -use pyo3::prelude::*; - -mod document; -mod errors; -mod version; - -use document::PyDocument; - -// ^ gil_used = true: free-threaded Python 비활성 (DocumentCore 내부 RefCell 캐시가 !Sync) -#[pymodule(gil_used = true)] -fn _rhwp(m: &Bound<'_, PyModule>) -> PyResult<()> { - // * 버전 함수 - m.add_function(wrap_pyfunction!(version::version, m)?)?; - m.add_function(wrap_pyfunction!(version::rhwp_core_version, m)?)?; - - // * 최상위 함수 - m.add_function(wrap_pyfunction!(document::parse, m)?)?; - - // * 클래스 - m.add_class::()?; - - Ok(()) -} -``` - -**패턴**: 모듈 엔트리는 **등록만** 담당. 로직은 개별 모듈에 분산. - -### 5.2 Document 클래스 (`src/document.rs`) - -pyo3-sandbox의 `PyDocument`에서 이식. GIL 해제 적용분 포함. - -```rust -use pyo3::exceptions::{PyIOError, PyValueError}; -use pyo3::prelude::*; -use pyo3::types::PyBytes; - -use crate::errors::{parse_error_to_py, ParseError}; - -// ^ unsendable: DocumentCore 내부 RefCell 필드로 !Sync — 다른 스레드 접근 시 런타임 패닉 방어 -#[pyclass(name = "Document", module = "rhwp", unsendable)] -pub struct PyDocument { - inner: rhwp::document_core::DocumentCore, -} - -fn load_document(path: String) -> Result { - let bytes = std::fs::read(&path).map_err(ParseError::Io)?; - rhwp::document_core::DocumentCore::from_bytes(&bytes) - .map_err(|e| ParseError::Parse(format!("{e:?}"))) -} - -#[pymethods] -impl PyDocument { - #[new] - fn new(py: Python<'_>, path: &str) -> PyResult { - let path = path.to_owned(); - let doc = py - .detach(move || load_document(path)) - .map_err(parse_error_to_py)?; - Ok(PyDocument { inner: doc }) - } - - #[getter] - fn section_count(&self) -> usize { - self.inner.document().sections.len() - } - - #[getter] - fn paragraph_count(&self) -> usize { - self.inner - .document() - .sections - .iter() - .map(|s| s.paragraphs.len()) - .sum() - } - - #[getter] - fn page_count(&self) -> u32 { - self.inner.page_count() - } - - fn extract_text(&self) -> String { - self.inner - .document() - .sections - .iter() - .flat_map(|s| s.paragraphs.iter()) - .map(|p| p.text.as_str()) - .filter(|t| !t.is_empty()) - .collect::>() - .join("\n") - } - - fn paragraphs(&self) -> Vec { - self.inner - .document() - .sections - .iter() - .flat_map(|s| s.paragraphs.iter()) - .map(|p| p.text.clone()) - .collect() - } - - fn render_svg(&self, page: u32) -> PyResult { - self.inner - .render_page_svg_native(page) - .map_err(|e| PyValueError::new_err(format!("render page {page} failed: {e:?}"))) - } - - fn render_all_svg(&self) -> PyResult> { - self.render_all_svg_internal() - } - - #[pyo3(signature = (output_dir, prefix=None))] - fn export_svg(&self, output_dir: &str, prefix: Option<&str>) -> PyResult> { - let out_dir = std::path::Path::new(output_dir); - std::fs::create_dir_all(out_dir).map_err(|e| PyIOError::new_err(e.to_string()))?; - - let page_count = self.inner.page_count(); - let stem = prefix.unwrap_or("page"); - let mut written = Vec::with_capacity(page_count as usize); - for page in 0..page_count { - let svg = self.inner.render_page_svg_native(page).map_err(|e| { - PyValueError::new_err(format!("render page {page} failed: {e:?}")) - })?; - let filename = if page_count == 1 { - format!("{stem}.svg") - } else { - format!("{stem}_{:03}.svg", page + 1) - }; - let path = out_dir.join(&filename); - std::fs::write(&path, &svg).map_err(|e| PyIOError::new_err(e.to_string()))?; - written.push(path.to_string_lossy().into_owned()); - } - Ok(written) - } - - fn render_pdf<'py>(&self, py: Python<'py>) -> PyResult> { - let svgs = self.render_all_svg_internal()?; - let bytes = py - .detach(move || rhwp::renderer::pdf::svgs_to_pdf(&svgs)) - .map_err(|e| PyValueError::new_err(format!("PDF conversion failed: {e}")))?; - Ok(PyBytes::new(py, &bytes)) - } - - fn export_pdf(&self, py: Python<'_>, output_path: &str) -> PyResult { - let svgs = self.render_all_svg_internal()?; - let output_path = output_path.to_owned(); - py.detach(move || -> PyResult { - let bytes = rhwp::renderer::pdf::svgs_to_pdf(&svgs) - .map_err(|e| PyValueError::new_err(format!("PDF conversion failed: {e}")))?; - std::fs::write(&output_path, &bytes) - .map_err(|e| PyIOError::new_err(e.to_string()))?; - Ok(bytes.len()) - }) - } - - fn __repr__(&self) -> String { - format!( - "Document(sections={}, paragraphs={}, pages={})", - self.section_count(), - self.paragraph_count(), - self.page_count() - ) - } -} - -// * 내부 헬퍼 (not exposed to Python) -impl PyDocument { - fn render_all_svg_internal(&self) -> PyResult> { - let page_count = self.inner.page_count(); - (0..page_count) - .map(|p| { - self.inner - .render_page_svg_native(p) - .map_err(|e| PyValueError::new_err(format!("render page {p} failed: {e:?}"))) - }) - .collect() - } -} - -#[pyfunction] -pub fn parse(py: Python<'_>, path: &str) -> PyResult { - PyDocument::new(py, path) -} -``` - -### 5.3 에러 매핑 (`src/errors.rs`) - -```rust -use pyo3::exceptions::{PyIOError, PyValueError}; -use pyo3::PyErr; - -pub enum ParseError { - Io(std::io::Error), - Parse(String), -} - -pub fn parse_error_to_py(e: ParseError) -> PyErr { - match e { - ParseError::Io(err) => PyIOError::new_err(err.to_string()), - ParseError::Parse(msg) => PyValueError::new_err(format!("parse failed: {msg}")), - } -} -``` - -### 5.4 버전 함수 (`src/version.rs`) - -```rust -use pyo3::prelude::*; - -#[pyfunction] -pub fn version() -> String { - env!("CARGO_PKG_VERSION").to_string() -} - -#[pyfunction] -pub fn rhwp_core_version() -> String { - rhwp::version() -} -``` - ---- - -## 6. Python 모듈 레이아웃 - -### 6.1 `python/rhwp/__init__.py` - -pydantic-core 패턴 + 고수준 래퍼 추가. - -```python -"""rhwp — HWP/HWPX parser and renderer.""" -from __future__ import annotations - -from ._rhwp import ( - Document, - parse, - rhwp_core_version, - version, -) - -__all__ = [ - "Document", - "parse", - "version", - "rhwp_core_version", -] -``` - -### 6.2 `python/rhwp/__init__.pyi` - -pyo3-sandbox의 스텁 이식 + docstring 강화. - -```python -"""rhwp — HWP/HWPX parser and renderer (Korean word processor format).""" - -__all__ = [ - "Document", - "parse", - "version", - "rhwp_core_version", -] - - -def version() -> str: - """rhwp Python 패키지 버전 (예: "0.1.0").""" - ... - - -def rhwp_core_version() -> str: - """rhwp Rust 코어 버전 (예: "0.7.3").""" - ... - - -def parse(path: str) -> Document: - """HWP5 또는 HWPX 파일을 파싱하여 Document 반환. - - Args: - path: HWP 또는 HWPX 파일 경로. - - Returns: - 파싱된 Document. - - Raises: - OSError: 파일을 열 수 없을 때. - ValueError: 파일 포맷이 유효하지 않을 때. - """ - ... - - -class Document: - """파싱된 HWP/HWPX 문서. - - 직접 생성자를 호출하거나 :func:`parse` 를 사용할 수 있다. - """ - - section_count: int - """섹션 수.""" - - paragraph_count: int - """전체 섹션에 걸친 총 문단 수.""" - - page_count: int - """페이지네이션 후 총 페이지 수.""" - - def __init__(self, path: str) -> None: - """HWP/HWPX 파일 경로로부터 파싱. - - Raises: - OSError: 파일을 열 수 없을 때. - ValueError: 파일 포맷이 유효하지 않을 때. - """ - ... - - def extract_text(self) -> str: - """전체 문서의 텍스트를 `\\n`으로 연결해 반환 (빈 문단 제외).""" - ... - - def paragraphs(self) -> list[str]: - """모든 문단의 텍스트 리스트 (빈 문단 포함, len == paragraph_count).""" - ... - - def render_svg(self, page: int) -> str: - """특정 페이지를 SVG 문자열로 렌더링. - - Args: - page: 0-based 페이지 인덱스. - - Raises: - ValueError: 페이지 인덱스가 범위를 벗어났거나 렌더링 실패 시. - """ - ... - - def render_all_svg(self) -> list[str]: - """모든 페이지를 SVG 문자열 리스트로 렌더링.""" - ... - - def export_svg(self, output_dir: str, prefix: str | None = None) -> list[str]: - """모든 페이지를 SVG 파일로 저장. - - Args: - output_dir: 출력 디렉토리 (자동 생성). - prefix: 파일명 접두사 (기본 "page"). 다중 페이지 시 `{prefix}_{NNN}.svg`. - - Returns: - 생성된 파일 경로 리스트. - """ - ... - - def render_pdf(self) -> bytes: - """전체 문서를 PDF 바이트로 렌더링.""" - ... - - def export_pdf(self, output_path: str) -> int: - """문서를 PDF 파일로 저장. - - Returns: - 저장된 바이트 수. - """ - ... - - def __repr__(self) -> str: ... -``` - -### 6.3 `python/rhwp/py.typed` - -빈 파일. PEP 561 마커. - -```bash -touch python/rhwp/py.typed -``` - ---- - -## 7. 테스트 전략 - -### 7.1 pytest 구조 - -``` -tests/ -├── conftest.py # 공용 fixture -├── test_parse.py # 파싱 기본 -├── test_text_extraction.py # 텍스트 API -├── test_svg_rendering.py # SVG API -├── test_pdf_rendering.py # PDF API (@pytest.mark.slow) -├── test_errors.py # 에러 전파 -├── test_types.py # pyright 스텁 검증 -└── samples/ # 테스트용 HWP 파일 (소용량만) - ├── simple.hwp - └── simple.hwpx -``` - -**중요**: 기존 `/samples/`의 대용량 파일은 테스트에 사용하지 말 것. 작은 fixture 파일만 `tests/samples/`에 새로 만들거나 선택 (CI 시간 최소화). - -### 7.2 주요 테스트 케이스 - -pyo3-sandbox의 테스트를 이식하되: -- `import rhwp_sandbox` → `import rhwp` -- `rhwp_sandbox.parse` → `rhwp.parse` -- 모듈 네임 변경만으로 대부분 그대로 작동 - -### 7.3 pyright 검증 - -`tests/test_types.py`에 의도된 타입 오류 케이스 포함. `pyright --outputjson | jq '.summary.errorCount'` 로 검증. - ---- - -## 8. CI/CD 전략 - -### 8.1 워크플로우 구성 (`.github/workflows/release.yml`) - -pydantic-core의 구조를 간략화한 버전: - -```yaml -name: Release - -on: - push: - tags: ["python-v*"] # 태그 트리거 (rhwp 코어와 구분) - pull_request: - paths: - - "rhwp-python/**" - - ".github/workflows/release.yml" - -permissions: - contents: read - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python: ["3.9", "3.12"] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - uses: dtolnay/rust-toolchain@stable - - uses: PyO3/maturin-action@v1 - with: - command: develop - args: --release - working-directory: rhwp-python - - run: pip install pytest pyright - - run: cd rhwp-python && pytest - - run: cd rhwp-python && pyright tests/ - - build-linux: - runs-on: ubuntu-latest - strategy: - matrix: - target: [x86_64, aarch64] - steps: - - uses: actions/checkout@v4 - - uses: PyO3/maturin-action@v1 - with: - target: ${{ matrix.target }} - manylinux: auto - args: --release --out dist - working-directory: rhwp-python - - uses: actions/upload-artifact@v4 - with: - name: wheels-linux-${{ matrix.target }} - path: rhwp-python/dist/*.whl - - build-macos: - runs-on: macos-latest - strategy: - matrix: - target: [x86_64, aarch64] - steps: - - uses: actions/checkout@v4 - - uses: PyO3/maturin-action@v1 - with: - target: ${{ matrix.target }} - args: --release --out dist - working-directory: rhwp-python - - uses: actions/upload-artifact@v4 - with: - name: wheels-macos-${{ matrix.target }} - path: rhwp-python/dist/*.whl - - build-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: PyO3/maturin-action@v1 - with: - args: --release --out dist - working-directory: rhwp-python - - uses: actions/upload-artifact@v4 - with: - name: wheels-windows - path: rhwp-python/dist/*.whl - - sdist: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: PyO3/maturin-action@v1 - with: - command: sdist - args: --out dist - working-directory: rhwp-python - - uses: actions/upload-artifact@v4 - with: - name: sdist - path: rhwp-python/dist/*.tar.gz - - # NOTE: 외부 저장소 publish job 은 Phase 1 범위 외. - # 빌드 산출물은 Actions artifact 로만 업로드하여 로컬 검증에 활용. - # 외부 배포(PyPI 등)는 상류 메인테이너 합의 후 별도 단계로 진행. - - verify-wheel: - # ^ 각 빌드된 wheel 을 클린 venv 에서 설치해 end-to-end 검증 - needs: [build-linux, build-macos, build-windows] - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - strategy: - matrix: - include: - - os: ubuntu-latest - artifact: wheels-linux-x86_64 - - os: macos-latest - artifact: wheels-macos-aarch64 - - os: windows-latest - artifact: wheels-windows - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: { python-version: "3.12" } - - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.artifact }} - path: dist - - run: pip install dist/*.whl - - run: python -c "import rhwp; print(rhwp.version())" -``` - -### 8.2 외부 저장소 publish 범위 외 - -Phase 1 은 빌드 산출물을 **Actions artifact 로만** 업로드한다. PyPI·TestPyPI 포함 어떠한 외부 저장소에도 자동 publish 하지 않음. 이유: - -- 이름 선점 / 브랜드 혼동 방지 — 상류 메인테이너 합의 전 외부 업로드 금지 -- OIDC Trusted Publisher 설정도 Phase 1 에서는 하지 않음 (publish job 자체가 없으므로 불필요) -- 배포가 필요한 시점 (Phase 2+) 에 별도 workflow 파일로 분리하여 관리 - ---- - -## 9. 버전 관리 - -### 9.1 버전 정책 - -| 항목 | 값 | -|------|-----| -| 초기 버전 | `0.1.0` (rhwp 0.7.x와 **독립**) | -| Semver 준수 | ✓ | -| Phase 1 목표 버전 | `0.1.x` (alpha) | -| Stable 목표 | `1.0.0` (Phase 3 이후) | - -**독립 버전을 선택한 이유**: rhwp Python 바인딩은 독자적 제품 수명 주기를 가짐. 코어가 0.8.0 → 0.9.0으로 가도 바인딩 API가 그대로면 0.1.5 → 0.2.0 으로 독립 증가 가능. - -### 9.2 태그 규약 - -- Python 패키지: `python-v0.1.0` -- rhwp 코어 릴리즈: 기존 `v0.7.3` 유지 -- 워크플로우가 `python-v*`만 트리거 - -### 9.3 CHANGELOG - -`rhwp-python/CHANGELOG.md` 별도 유지. Keep a Changelog 포맷. - ---- - -## 10. 단계별 구현 계획 (Phase 1) - -아래 단계를 **순차** 진행. 각 단계 완료 시 작업지시자(사용자) 승인 후 다음 단계로. 좁은 범위부터 시작해서 한 단계씩 확장한다. - -### Stage 0: 최소 증명 (半日) - -**목적**: 프로젝트 구조가 현업 패턴에 부합하는지 빠르게 검증. 전체 API를 만들기 전에 **뼈대만** 세워서 빌드/임포트 경로가 올바른지 확인. - -**범위**: **`rhwp.version()`만 동작**. 그 외 API 전부 제외. - -**산출물**: -- `rhwp-python/Cargo.toml` (최소) -- `rhwp-python/pyproject.toml` (최소) -- `rhwp-python/src/lib.rs` — `version()` 하나만 노출 -- `rhwp-python/python/rhwp/__init__.py` — 재노출 -- `rhwp-python/python/rhwp/__init__.pyi` — 타입 스텁 (version만) -- `rhwp-python/python/rhwp/py.typed` -- `rhwp-python/README.md` (짧게) -- `mydocs/working/task_m{M}_{이슈}_stage0.md` - -**완료 기준**: -```bash -cd rhwp-python && maturin develop -python -c "import rhwp; print(rhwp.version())" -# → 0.1.0 -``` - -### Stage 1: 프로젝트 구조 + 최소 빌드 성공 (1~2일) - -**목표**: `maturin develop` 성공 + `import rhwp; rhwp.version()` 작동 - -**산출물**: -- `rhwp-python/` 디렉토리 신설 -- `Cargo.toml`, `pyproject.toml`, `README.md`, `LICENSE` -- `src/lib.rs`, `src/version.rs` (최소) -- `python/rhwp/__init__.py`, `__init__.pyi`, `py.typed` -- `tests/test_smoke.py` (`rhwp.version()` 검증만) -- `mydocs/working/task_m{M}_{이슈}_stage1.md` - -**완료 기준**: -```bash -cd rhwp-python && maturin develop && python -c "import rhwp; print(rhwp.version())" -# → 0.1.0 -``` - -### Stage 2: 파싱 + 텍스트 API (1~2일) - -**목표**: `parse(path) → Document`, `extract_text`, `paragraphs`, getters 동작 - -**산출물**: -- `src/document.rs`, `src/errors.rs` -- `python/rhwp/__init__.pyi` 업데이트 -- `tests/test_parse.py`, `test_text_extraction.py`, `test_errors.py` -- `mydocs/working/task_m{M}_{이슈}_stage2.md` - -**완료 기준**: pytest 전부 통과, pyright 0 errors - -### Stage 3: SVG/PDF 렌더링 API (1~2일) - -**목표**: `render_svg`, `render_all_svg`, `export_svg`, `render_pdf`, `export_pdf` - -**산출물**: -- `src/document.rs` 렌더링 메서드 추가 -- `tests/test_svg_rendering.py`, `test_pdf_rendering.py` -- `mydocs/working/task_m{M}_{이슈}_stage3.md` - -### Stage 4: GIL 해제 + 성능 벤치 (1일) - -**목표**: `parse`/`render_pdf`에 `py.detach` 적용, 벤치로 검증 - -**산출물**: -- `src/document.rs`, `src/errors.rs` GIL 해제 리팩터 -- `benches/` 또는 `scripts/bench.py` (ThreadPoolExecutor 비교) -- `mydocs/working/task_m{M}_{이슈}_stage4.md` - -**완료 기준**: 단일 vs 4스레드 parse 실측 1.5배 이상 - -### Stage 5: CI/CD 파이프라인 (2~3일) - -**목표**: GitHub Actions 워크플로우 구성, wheel 빌드 + clean venv install 검증 - -**산출물**: -- `.github/workflows/rhwp-python-release.yml` (test + build + verify-wheel, 외부 publish job 없음) -- `rhwp-python/README.md` 사용자용 완성 (source install 안내) -- CI 에서 빌드된 wheel 을 clean venv 에 install + import 검증 -- `mydocs/working/task_m{M}_{이슈}_stage5.md` - -**완료 기준**: -```bash -# CI 에서 (verify-wheel job) -pip install dist/*.whl -python -c "import rhwp; print(rhwp.version())" -# → 정상 동작. 외부 저장소 publish 는 Phase 1 범위 외 -``` - -### Stage 6: 문서 + 최종 보고서 (1일) - -**목표**: 사용자 문서 정비, 최종 보고서 작성 - -**산출물**: -- `rhwp-python/README.md` 완성 -- `rhwp-python/docs/quickstart.md` (선택) -- `mydocs/report/task_m{M}_{이슈}_report.md` - -### 총 예상 기간 - -**6~11일** (단독 작업 기준, 각 승인 대기 시간 별도). - ---- - -## 11. 검증 체크리스트 (Phase 1 완료 기준) - -``` -[ ] rhwp 코어(`/src/`, 루트 `Cargo.toml`) 수정 이력 없음 (git log 확인) -[ ] `cd rhwp-python && maturin develop --release` 성공 -[ ] `python -c "import rhwp"` 성공 -[ ] pytest 전체 통과 (core + langchain extras 없이) -[ ] pyright 0 errors on 정상 케이스 -[ ] pyright 의도된 오류 케이스 전부 검출 -[ ] `maturin build --release` 성공, wheel 생성 -[ ] 새 venv에서 `pip install ` + import + parse 성공 -[ ] CI: Linux/macOS/Windows 3개 플랫폼 녹색 (test + wheel build + verify-wheel) -[ ] `verify-wheel` job — 각 OS clean venv 에서 wheel install + import 성공 -[ ] 외부 저장소 publish 이력 0건 (workflow grep 확인) -[ ] README.md 사용자 기준 실행 가능한 예제 포함 -[ ] samples/aift.hwp 파싱 → 텍스트 추출 → SVG 렌더 → PDF 내보내기 전체 동작 -[ ] 각 Stage 완료 보고서 (`mydocs/working/`) 존재 -[ ] 최종 보고서 (`mydocs/report/`) 작성 완료 -``` - ---- - -## 12. 흔한 함정 및 해결 - -### 12.1 wasm-bindgen 공존 - -**현상**: rhwp 코어가 `wasm_bindgen::prelude::*`를 top-level에서 사용. 네이티브 빌드에서 혼란 가능. - -**해결**: 이미 sandbox에서 검증됨 — 네이티브 타겟(`cfg(not(target_arch = "wasm32"))`)은 wasm-bindgen 영향 없음. **그대로 두고 추가 작업 불필요**. - -### 12.2 `Vec` vs `bytes` - -**현상**: PyO3 기본 변환에서 `Vec`이 Python `list[int]`로 노출됨. `bytes`로 하려면 명시 필요. - -**해결**: `PyBytes::new(py, &vec)` 사용 (5.2절 render_pdf 참고). - -### 12.3 `RefCell` 기반 !Sync + `#[pyclass(unsendable)]` - -**현상 1 — 컴파일 에러 (`#[pyclass]` Sync 요구)**: -PyO3 0.23+ 에서 `#[pyclass]` 기본값은 `T: Sync` 요구. `DocumentCore` 는 `RefCell>>` 등 `!Sync` 필드를 가지므로 컴파일 실패. - -**해결 1**: `#[pyclass(..., unsendable)]` 지정. 타 스레드 접근 시 **런타임 패닉**으로 보호 (컴파일은 통과). - -```rust -#[pyclass(name = "Document", module = "rhwp", unsendable)] -pub struct PyDocument { ... } -``` - -**현상 2 — `py.detach` 클로저 캡처 에러**: `&DocumentCore`를 `py.detach` 클로저에 넘기면 `!Send` 로 컴파일 에러. - -**해결 2**: 코어 무수정 원칙하에서: -- `parse()` — 새 `DocumentCore`를 클로저 내부에서 생성만 → OK -- `render_pdf()` PDF 변환 단계 — 소유권 있는 `Vec` 전달 → OK -- 기타 메서드 — GIL 유지 (sandbox 결과와 동일) - -**현상 3 — free-threaded Python 기본 허용 (PyO3 0.27+)**: `#[pymodule]` 기본값이 free-threaded 허용. `RefCell` 기반 단일 스레드 설계와 충돌 가능. - -**해결 3**: `#[pymodule(gil_used = true)]` 로 명시 GIL 요구. - -상세: [gil_release.md](../../pyo3-sandbox/docs/gil_release.md). - -### 12.4 GIL 반환값 타입 실수 - -**현상**: `render_pdf`가 `list[int]` 반환 (bytes 아님). 사용자 실망. - -**해결**: 반환 타입을 `Bound<'py, PyBytes>`로 명시. 테스트에서 `isinstance(result, bytes)` 확인. - -### 12.5 Python/Rust 버전 불일치 - -**현상**: `rhwp.version()`과 `rhwp.rhwp_core_version()` 헷갈림. - -**해결**: 함수명 명확화 + docstring 명시. `.pyi` 파일에 각 함수 예시 포함. - -### 12.6 maturin develop 시 venv 미감지 - -**현상**: CI에서 `maturin develop` 실행 시 venv 미감지로 시스템 Python에 설치 시도. - -**해결**: `VIRTUAL_ENV` 환경변수 명시 또는 `--python` 플래그 사용. - -### 12.7 abi3 휠 태그 오류 - -**현상**: `cp39-abi3-*` 휠이 특정 Python 버전에서 거부됨. - -**해결**: `abi3-py39` feature가 정확히 `Python 3.9 이상`만 지원함을 보장. CI 매트릭스에 3.9 포함. - ---- - -## 13. 참고 자료 - -### 13.1 이 리포 내 참고 - -- [pyo3-sandbox/](../../pyo3-sandbox/) — 이미 검증된 실증 코드 -- [pyo3-sandbox/docs/benchmark.md](../../pyo3-sandbox/docs/benchmark.md) — 성능 기준선 -- [pyo3-sandbox/docs/gil_release.md](../../pyo3-sandbox/docs/gil_release.md) — GIL 해제 상세 -- [pyo3-sandbox/docs/pyhwp_comparison.md](../../pyo3-sandbox/docs/pyhwp_comparison.md) — 경쟁 분석 -- GitHub Issue [#227](https://github.com/edwardkim/rhwp/issues/227) - -### 13.2 외부 참고 - -| 주제 | URL | -|------|-----| -| PyO3 공식 가이드 | https://pyo3.rs/ | -| maturin 사용자 가이드 | https://www.maturin.rs/ | -| maturin-action | https://github.com/PyO3/maturin-action | -| pydantic-core (최고 참조) | https://github.com/pydantic/pydantic-core | -| py-polars | https://github.com/pola-rs/polars/tree/main/py-polars | -| pyo3-stub-gen (Phase 2+ 고려) | https://github.com/Jij-Inc/pyo3-stub-gen | -| PEP 561 (py.typed) | https://peps.python.org/pep-0561/ | -| PEP 735 (dependency-groups) | https://peps.python.org/pep-0735/ | -| PyPI Trusted Publishing | https://docs.pypi.org/trusted-publishers/ | - ---- - -## 14. 문서 변경 이력 - -| 날짜 | 변경 | 작성 | -|------|------|------| -| 2026-04-21 | 초안 작성 (현업 패턴 조사 + Phase 1 청사진) | Claude (sandbox 세션) | - ---- - -## 다음 Claude 세션에 전하는 메모 - -이 문서 기반으로 작업 시작 시 다음 순서 권장: - -### 1. **작업 시작 전 선결** - -- 본 문서 전체 정독 -- [pyo3-sandbox/docs/](../../pyo3-sandbox/docs/) 의 5개 문서 훑어보기 -- `pyo3-sandbox/src/lib.rs`, `pyo3-sandbox/pyproject.toml`, `pyo3-sandbox/Cargo.toml` 읽고 현재 검증된 바인딩 파악 -- 현재 Fork 상태 (`git remote -v`, `git log`)로 브랜치·원격 확인 - -### 2. **수행 계획서 작성** - -- `mydocs/plans/task_m{마일스톤}_{이슈번호}.md` -- 본 문서의 10절(단계별 구현 계획)을 기반으로 일정·담당·산출물 구체화 -- 작업지시자 승인 요청 - -### 3. **구현 계획서 작성** - -- `mydocs/plans/task_m{마일스톤}_{이슈번호}_impl.md` -- 최소 3단계, 최대 6단계 (본 문서 10절의 Stage 0~6을 기반으로 실제 프로젝트 상황에 맞춰 재구성) -- 각 단계의 파일 변경 목록·검증 방법 상세화 - -### 4. **구현은 Stage 0부터 순차 진행** - -- Stage 0 (최소 증명)이 **가장 중요** — 이게 구조 검증의 첫 지점 -- 각 Stage 완료마다 보고서 작성·작업지시자 승인 -- `pyo3-sandbox/` 소스를 **복사하지 말 것**. 본 문서의 5~6절 템플릿을 기반으로 직접 작성 -- 모든 변경은 `rhwp-python/` 내부에서만 (원칙 0.1) - -### 5. **좁게, 천천히** - -- 본 문서의 검증 체크리스트(11절)는 Phase 1 **최종** 목표. 중간 단계에서는 훨씬 작은 단위로 쪼개서 진행 -- 의심스러울 때는 "한 스테이지 더 잘게 쪼개기" 쪽으로 결정 -- 하이퍼-워터폴 방법론 → 속도보다 **작업지시자와의 합의·검증 우선** - -### 6. **막힐 경우** - -- 먼저 [pyo3-sandbox/](../../pyo3-sandbox/)에서 유사 사례 찾기 -- 그 다음 pydantic-core 실제 코드 참조 -- 그래도 안 풀리면 작업지시자에게 질문 - -### 7. **다음 세션은 이 문서를 읽고 쓸 수 있다** - -본 문서는 버전 관리되는 가이드. 구현 중 발견한 이슈·결정사항은 **본 문서의 14절 변경 이력에 추가**하고, 해당 섹션 본문도 업데이트해 다음 사람에게 전달. diff --git a/docs/implementation/v0.3.0/aparse-cleanup.md b/docs/implementation/v0.3.0/aparse-cleanup.md new file mode 100644 index 0000000..a1db12a --- /dev/null +++ b/docs/implementation/v0.3.0/aparse-cleanup.md @@ -0,0 +1,138 @@ +# v0.3.0 — `aparse` aiofiles 제거 (stdlib `asyncio.to_thread` 전환) + +**Status**: Frozen · **GA**: v0.3.0 · **Last updated**: 2026-04-28 + +v0.2.0 에서 도입한 `rhwp.aparse` 의 `aiofiles` 기반 우회를 stdlib `asyncio.to_thread` 로 정리. v0.3.0 GA 전 cleanup — 별도 patch (v0.3.1) 로 분리하지 않고 v0.3.0 에 합침. 본 implementation note 는 결정 근거 (거부된 대안 비교) 를 보존 — CHANGELOG 한 줄로는 표현 부족한 정보. + +## 배경 + +v0.2.0 부터 `rhwp.aparse(path)` 는 Rust `_Document` 의 `#[pyclass(unsendable)]` 제약 (상류 `RefCell` 기반 캐시 → `!Sync`) 을 우회하기 위해 `aiofiles.open()` 사용: + +```python +async with aiofiles.open(path, "rb") as f: + data = await f.read() +return Document.from_bytes(data, source_uri=path) +``` + +`aiofiles` 는 `[async]` extras 로 격리 — `pip install rhwp[async]` opt-in. 이 정책의 문제: + +- **우회 비용을 사용자에게 위임한 일관성 깨짐** — `unsendable` 우회는 우리 측 책임인데 그 비용 (extras install) 만 사용자에게 떠넘김 +- **stdlib 대안 존재** — Python 3.9+ `asyncio.to_thread` 가 동일 메커니즘을 stdlib 만으로 제공 +- **의존성 그래프 불필요한 노드** — security audit, version bump, extras 매트릭스 (`test-without-extras` skip count 관리) 비용 추가 + +## 1. aiofiles vs stdlib `asyncio.to_thread` — 메커니즘 비교 + +| 항목 | aiofiles | `asyncio.to_thread` (Python 3.9+) | +|---|---|---| +| Backend | 자체 dedicated thread (default 1 worker) | `asyncio` default `ThreadPoolExecutor` (`min(32, cpu+4)` workers) | +| Syscall | blocking `read(2)` on thread | blocking `read(2)` on thread | +| Async wrapper | `async with`, chunked read 지원 | callable wrapping, 단발 호출 | +| Cancellation | async-friendly (await 시점 cancel) | thread 시작 후 cancel 불가 — 단발 read 라 무관 | +| 의존성 | external (`pip install aiofiles`) | stdlib (Python 3.9+) | + +**핵심 등가성**: Python `asyncio` 자체는 native async file I/O 미지원 — Linux `io_uring` / Windows IOCP 같은 OS-level async file I/O 를 stdlib 가 노출 안 함. 따라서 모든 async file lib (aiofiles 포함) 가 결국 thread pool 에 sync read 를 offload 하는 우회. aiofiles 의 추가 가치는 **`async with` 표현 문법 + chunked read 옵션** 둘뿐. + +**HWP 시나리오 영향**: + +| 요소 | aiofiles 가 유리 | 실제 영향 | +|---|---|---| +| Chunked read (large file) | streaming gather | `Document.from_bytes(전체)` 가 어차피 bytes 전부 메모리 보유 — chunked 의 이득 zero | +| Per-call overhead | 자체 thread 재사용 | `to_thread` 도 default pool 재사용 — 차이 μs 단위 | +| Cancellation | async-friendly | `aparse` 는 단발 read — cancel 시나리오 거의 없음 | +| Thread pool 크기 | dedicated 1 | default `min(32, cpu+4)` — 다중 파일 동시 처리 시 stdlib 가 살짝 유리 | + +**dominant cost**: HWP 파싱 (`Document.from_bytes`) = 수십~수백 ms vs File read = 수 ms. I/O 방식 차이는 파싱 시간의 noise (1% 미만). + +**결론**: 의미·성능 등가. stdlib 채택 시 사용자 인지 가능한 회귀 없음. + +## 2. 채택 옵션 비교 — 왜 (c) stdlib 인가 + +| 옵션 | 의존성 | aparse 동작 | 채택 | +|---|---|---|---| +| (a) v0.3.0 이전 — `[async]` extras | aiofiles (optional) | 미설치 시 ImportError | ✗ — 우회 비용을 사용자에게 위임, 일관성 깨짐 | +| (b) aiofiles 기본 의존성 | aiofiles (required) | 항상 동작 | ✗ — stdlib 대안 있는데 외부 의존성 강제는 weak | +| (c) **stdlib 만 사용** | 없음 | 항상 동작 | **✓** — 가장 깔끔, 외부 의존성 zero, 의미·성능 동등 | + +**(c) 채택 이유**: + +1. **본질적 정답** — stdlib 으로 동등 효과 가능한데 외부 의존성 강제는 weak justification +2. **의존성 graph 비용 절감** — security audit, version bump, extras 매트릭스, CI 분기 제거 +3. **사용자 install UX** — `pip install rhwp` 만으로 async API 동작 → 문서·온보딩 단순화 +4. **(b) 와의 차이** — (b) 도 install 부담 zero 이지만 외부 의존성을 굳이 끼고 있을 명분 없음 + +## 3. extras 키 처리 — 빈 배열 유지 + +`pip install rhwp[async]` 를 적은 기존 사용자에게 어떤 영향? + +| 처리 | 결과 | 채택 | +|---|---|---| +| `async` 키 완전 제거 | pip unknown extra 경고 (warning + skip), install 성공 — noisy | 보류 (v0.4.0 검토) | +| `async = []` 빈 배열 유지 | 사용자 명령 그대로 동작, no warning | **✓ v0.3.0** | +| `async = ["aiofiles>=23"]` 유지 | aiofiles 가 unused dependency 로 install — 의존성 그래프 오염 | ✗ | + +빈 배열 유지가 가장 무해 — 사용자 install 명령이 그대로 동작하면서 실제 aiofiles 는 끌어오지 않음. v0.4.0 에서 키 자체 제거 검토 (CHANGELOG 충분히 알린 후). + +## 4. `unsendable` 자체 해결 — 왜 v0.3.0 범위 밖 + +본 cleanup 의 결정 범위 밖이지만, 미래 reference 차원 옵션: + +| 옵션 | WASM 영향 | 변경 면적 | 평가 | +|---|---|---|---| +| A. `RefCell` → `Mutex` 통일 | atomic op 1-2 추가 — 측정 가능 회귀 거의 없음 | small | 가장 빠른 PR. 메인테이너 승인 필요 | +| B. `cfg(target_arch)` 분기 | zero | medium (코드 두 갈래) | 두 코드패스 유지 부담 — 메인테이너 보통 거부 | +| C. Cache 구조 분리 refactor | zero (read-only `Arc` + `OnceLock`) | large | 가장 우아. cache 사용 패턴 전수 분석 필요 | + +**현 시점 우선순위 낮음** — 우리 측 wrapping (stdlib `asyncio.to_thread`) 비용이 낮고 위험도 작음. [find-control-text-positions](../../../upstream/issue-find-control-text-positions.md) 쪽이 IR Provenance 정확도에 직접 영향이라 상류 push 우선순위가 더 높음. 본 항목은 issue 등록 후보로만 추적. + +## 5. 산출물 (코드 / CI / 문서) + +| 파일 | 변경 | +|---|---| +| `python/rhwp/document.py` | `aparse` 본문 — `aiofiles.open()` → `asyncio.to_thread(_read_bytes, path)`. 새 헬퍼 `_read_bytes(path) -> bytes`. Module docstring 의 aiofiles 언급 제거 | +| `python/rhwp/integrations/langchain.py` | docstring + 클래스 docstring 의 "aiofiles 기반" / "[async] 필요" 언급 제거. ImportError raises 항목 제거 | +| `pyproject.toml` | `[project.optional-dependencies]` 의 `async = ["aiofiles>=23"]` → `async = []` (빈 배열). `[dependency-groups] testing` 에서 `aiofiles>=23` 제거 | +| `tests/test_async.py` | module-level `pytest.importorskip("aiofiles")` 제거. `test_aparse_raises_import_error_without_aiofiles` 삭제 (의미 없어짐). `test_aparse_no_external_dependency` + `test_aparse_raises_file_not_found_for_missing_path` 신규 | +| `.github/workflows/ci.yml` | `test-without-extras` expected skip count `5 → 4` (`test_async.py` 가 더 이상 gated 아님) | +| `CLAUDE.md` | "Async direction" 섹션 + "Tests" 섹션의 aiofiles 언급 제거 + stdlib 패턴으로 교체. "Forbidden pattern" 항목은 유지 (`asyncio.to_thread(rhwp.parse, path)` 는 여전히 panic — Document 자체를 thread 에서 send 못함) | +| `CHANGELOG.md` | `[0.3.0]` 의 `Changed` 섹션 신설 — `aparse` 의 aiofiles 의존성 제거 한 줄 | + +## 6. 호환성 + +| 시나리오 | 결과 | +|---|---| +| 사용자가 `pip install rhwp` (extras 없이) 설치 → `aparse` 호출 | v0.2.0 까지 ImportError, **v0.3.0 부터 정상 동작** | +| 사용자가 `pip install rhwp[async]` 명령 사용 | 빈 배열 유지로 그대로 동작, 추가 패키지 install 없음 | +| 사용자 코드에서 `import aiofiles` 직접 사용 | rhwp 가 aiofiles 를 transitive 로 끌고 오지 않으므로 사용자가 직접 install 필요. v0.2.0 이전과 동일 | +| `aload` / `alazy_load` API 시그니처 | 변경 없음 — backward-compat | + +**API surface diff**: 없음. 내부 구현만 교체. semver PATCH 의미이지만 v0.3.0 GA 전이라 별도 PATCH release 분리 안 하고 합침. + +## 7. 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest tests/test_async.py -v` | 8 passed (기존 6 + 신규 2: `no_external_dependency` / `raises_file_not_found_for_missing_path`) | +| `uv run pytest -m "not slow"` | 전체 회귀 없음 | +| `uv run pyright python/ tests/` | 0 errors | +| `cargo clippy --all-targets -- -D warnings` | clean (Rust 변경 없음) | +| Benchmark | 별도 측정 안 함 — § 1 의 메커니즘 등가성으로 충분 | + +## 비목표 + +- **`unsendable` 제약 자체 해결** — § 4 옵션 A/B/C, 별도 추적 +- **진짜 native async file I/O** (Linux `io_uring`, Windows IOCP 등) — Python stdlib 미지원, HWP 사용 케이스에서 over-engineering. 영구 비목표 후보 +- **`aload` / `alazy_load` API surface 변경** — 시그니처 동일, 내부 구현만 교체. v0.4.0+ 에서도 변경 계획 없음 + +## 참조 + +### 1차 소스 + +- Python `asyncio.to_thread` (3.9+ stdlib): +- aiofiles (thread pool wrapping 확인): +- PEP 508 (unknown extras 처리): + +### 상류 컨텍스트 + +- `external/rhwp/src/document_core/` — `RefCell` 기반 cache (unsendable 의 근본 원인) +- `src/document.rs` (rhwp-python) — `#[pyclass(unsendable)]` 선언 위치 +- [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) — 상류 visibility 변경 요청 선례 (참고) diff --git a/docs/implementation/v0.3.0/stages/stage-1.md b/docs/implementation/v0.3.0/stages/stage-1.md new file mode 100644 index 0000000..0655513 --- /dev/null +++ b/docs/implementation/v0.3.0/stages/stage-1.md @@ -0,0 +1,117 @@ +# Stage S1 — PictureBlock + Furniture 채움 (완료) + +**Status**: Frozen · **Target**: v0.3.0 · **Last updated**: 2026-04-26 + +**작업일**: 2026-04-26 +**계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 +**설계 근거**: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md) + +## 스코프 + +ir-expansion.md §S1 row 정확 매핑: + +- `PictureBlock` / `ImageRef` Pydantic 모델 도입 +- Rust `ir.rs` 의 picture walker (`Control::Picture` → `RawPicture`) +- `bin_data_content` 노출 — `Document.bytes_for_image(picture)` 헬퍼 +- master_pages + `Control::Header` / `Control::Footer` 매핑하여 + `furniture.page_headers` / `page_footers` 채움 +- SchemaVersion `1.0` → `1.1` (첫 신규 블록 타입 도입 시점) + +S2 (Formula + Footnote/Endnote), S3 (ListItem + Caption + Toc + Field), +S4 (Schema 1.1 GA + CLI/LangChain) 스코프는 본 스테이지 범위 밖. + +## 산출물 + +| 파일 | 변동 | 내용 | +|---|---|---| +| `python/rhwp/ir/nodes.py` | +29 / -8 | `ImageRef` / `PictureBlock` 추가, `_KNOWN_KINDS` `{paragraph, table}` → `{paragraph, table, picture}`, `Block` 유니온 picture 멤버, `CURRENT_SCHEMA_VERSION` 1.0 → 1.1 | +| `python/rhwp/ir/__init__.pyi` | +6 | `ImageRef` / `PictureBlock` re-export | +| `python/rhwp/ir/_raw_types.py` | +25 | `RawImageRef`, `RawPicture` TypedDict; `RawParagraph.pictures`, `RawDocument.headers`/`footers` 필드 추가 | +| `python/rhwp/ir/_mapper.py` | +60 | `_build_picture_block`, `_image_uri`, `_mime_for_extension` (12 종 mime 테이블), `_flatten_paragraph` 가 pictures 도 emit, `build_hwp_document` 가 furniture 채움 | +| `src/ir.rs` | +85 | `RawImageRef` / `RawPicture` 구조체, `build_raw_picture`, `collect_headers_footers_from_paragraph`, `lookup_bin_data_bytes`, `RawDocument.headers`/`footers`. `build_raw_paragraph` 가 `&Document` 를 받도록 시그니처 확장 (bin_data_list 접근용) | +| `src/document.rs` | +13 | `bytes_for_image_id(bin_data_id: u16) -> PyResult>>` | +| `python/rhwp/_rhwp.pyi` | +1 | `bytes_for_image_id` 스텁 | +| `python/rhwp/document.py` | +52 | `Document.bytes_for_image(picture)` — `bin://` URI 파싱 + 5 단계 fail-fast 검증 | +| `python/rhwp/integrations/langchain.py` | +13 | `_block_to_content_and_meta` 에 `PictureBlock` 분기 추가 (description → content, image_uri/image_mime → metadata) | +| `python/rhwp/ir/schema/hwp_ir_v1.json` | +71 / -2 | 재생성 (`ImageRef`, `PictureBlock` `$defs` 추가, schema_version default `1.1`) | +| `tests/test_ir_picture.py` | +257 (신규) | 36 테스트 (ImageRef/PictureBlock 모델 + mime 매핑 + mapper + bytes_for_image 5 에러 케이스 + 실제 샘플 lookup) | +| `tests/test_ir_furniture.py` | +166 (신규) | 15 테스트 (Furniture 모델 + mapper 헤더/푸터 라우팅 + 순서 + 실제 샘플 + iter_blocks recurse) | +| `tests/test_ir_schema.py` | -2 / +5 | "picture" → "footnote" 로 unknown-kind 픽스처 교체, parametrize fixture 갱신 | +| `tests/test_ir_roundtrip.py` | -7 / +21 | schema_version 1.0 → 1.1 (2 곳), `test_furniture_is_empty` → `test_furniture_lists_have_correct_types`, `test_body_contains_only_known_block_kinds` 에 PictureBlock 추가 | +| `tests/test_ir_iter_blocks.py` | -7 / +18 | `test_iter_blocks_furniture_is_empty_in_v0_2_0` → `test_iter_blocks_furniture_yields_consistent_with_lists`, known-kinds 테스트에 PictureBlock 추가 | +| `tests/test_ir_schema_export.py` | +2 | `expected_nodes` set 에 `ImageRef`, `PictureBlock` 추가 (10 → 12) | +| `tests/test_ir_tables.py` | +6 / -2 | TableCell.blocks 검사에 PictureBlock 허용 (셀 내부 그림 가능) | +| `tests/test_ir_mapper.py` | +1 | `_paragraph` helper 가 `pictures=[]` 채움 (TypedDict 필드 추가 반영) | +| `.github/workflows/ci.yml` | +1 | scoped pyright 목록에 `test_ir_picture.py` + `test_ir_furniture.py` 추가 | + +## S1 확정 결정 사항 + +| 결정 | 선택 | 근거 | +|---|---|---| +| **Schema 버전 bump 시점** | S1 에서 즉시 1.0 → 1.1 | 첫 신규 블록 타입 (`picture`) 이 출고되는 시점부터 자기-기술 일관성 보장. S4 GA 는 schema JSON 발행 시점일 뿐, 버전은 콘텐츠 진화에 즉시 반영 | +| **PictureBlock.caption 필드 부재** | S3 까지 보류 | spec § 5 `CaptionBlock` 도입이 S3 — S1 에서 placeholder 추가 시 forward-incompat 변경 위험. 현 시점은 `description: str \| None` 로 alt-text 만 노출 | +| **Furniture endnotes 필드 부재** | S2 까지 보류 | EndnoteBlock 도입이 S2. v0.2.0 ↔ v0.3.0 호환 충돌 (spec § 호환성) 은 endnotes 가 추가될 때만 트리거 — S1 시점은 v0.2.0 Furniture 와 같은 형태 (page_headers / page_footers / footnotes) 유지 | +| **`bin://` URI** | 1-based, 상류 그대로 | spec § 1 명시. `Document.bytes_for_image` 가 `(bin_data_id - 1)` 인덱스로 `bin_data_content` lookup — 상류 `renderer/layout/utils.rs::find_bin_data` 와 동일 패턴 | +| **MasterPage paragraphs 처리** | Header/Footer 컨트롤만 추출 | spec § 8 매퍼 정책 reading. MasterPage paragraph 자체는 furniture 미포함 (페이지 배경 템플릿이지 머리글 아님) | +| **MasterPage flat 인덱스** | `flat_map().enumerate()` | 한 섹션의 여러 MasterPage paragraph 가 고유 idx 받도록 enumerate 를 flat_map 바깥에 배치 — 같은 섹션 내 (section_idx, para_idx) 충돌 회피 | +| **Block 출고 순서** | ParagraphBlock → tables → pictures | S1 단순 정책. HWP control 시각 순서 보존은 v0.4.0+ `order: int` 필드 검토 | +| **mime 매핑 위치** | Python mapper | Rust 는 raw extension (`Option`) 만 출고, Python 이 12 종 mime 테이블 + `application/octet-stream` 폴백. IR 진화 시 maturin rebuild 회피 | +| **bytes_for_image 에러 정책** | 5 단계 fail-fast | image=None / non-bin scheme / parse fail / u16 range / lookup miss 각각 명시적 ValueError. 글로벌 CLAUDE.md "Error Philosophy — No Silent Fallback" 준수 | + +## 비타협 제약 준수 + +- 모든 신규 IR 모델 (`ImageRef`, `PictureBlock`) `ConfigDict(extra="forbid", frozen=True)` +- `Field(ge=/le=/gt=/lt=)` 사용 **없음** — `width`/`height`/`dpi` 는 plain `int | None` +- Python 3.10+ 유니온 표기 (`T | None`) — v0.2.0 의 `Optional[T]` import 정리 +- mapper 도메인 분기는 모두 Python (Rust 는 평탄 raw 만 출고) — IR 진화 시 maturin rebuild 회피 패턴 보존 + +## 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest -m "not slow"` | **255 passed** (v0.2.0 의 204 + S1 신규 51) | +| `uv run pyright python/ tests/` | **0 errors** | +| `uv run pyright tests/type_check_errors.py` | **4 intentional errors** (CI 검증 통과) | +| `uv run ruff check ` | clean (pre-existing 4 issues 무관) | +| `cargo clippy --all-targets -- -D warnings` | clean | +| Schema JSON conformance (`test_load_schema_matches_export_schema`) | pass — `hwp_ir_v1.json` 12 `$defs` 포함 | +| `code-reviewer` fresh-context 검증 | Critical 0 / Major 0 / Minor 3 (모두 반영 또는 알려진 한계로 명시) | + +### 테스트 커버리지 (ir-expansion.md §S1 → 실제 케이스) + +| ir-expansion.md 요구 | 테스트 | +|---|---| +| PictureBlock + ImageRef 직렬화 왕복 | `test_image_ref_roundtrip`, `test_picture_block_roundtrip_with_image`, `test_picture_block_broken_reference` | +| `bin://` URI 파싱 / mime 매핑 | `test_mime_mapping_known_extensions[12]`, `test_mime_mapping_unknown_falls_back`, `test_build_picture_block_with_known_extension` | +| caption 컨테인먼트 | (S3 — `caption: CaptionBlock` 도입 시) | +| `page_headers/page_footers/footnotes/endnotes` 순서 보장 | `test_build_hwp_document_preserves_header_footer_order`, `test_iter_blocks_furniture_yields_consistent_with_lists`, `test_real_sample_iter_blocks_furniture_matches_lists` | +| `endnotes` 가 v0.2.0 schema 와 충돌 | `test_furniture_rejects_v0_3_0_endnotes_field_in_s1` (S2 endnotes 도입 시점에 trigger 검증 활성화) | +| `bytes_for_image` 5 단계 에러 | `test_bytes_for_image_raises_on_broken_reference`, `..._unsupported_scheme`, `..._invalid_uri`, `..._out_of_range`, `..._lookup_miss` | + +LLM strict-mode 스키마 conformance / `jsonschema` meta-validation 은 v0.2.0 S4 패턴 그대로 통과 (신규 12 `$defs` 모두 `additionalProperties: false`, 수치 range 키워드 부재). + +## 알려진 한계 (S2/S3 또는 후속 MINOR 에서 처리) + +- **`bin_data_content` 와 `bin_data_list` 인덱스 정합성** — 모든 BinData 가 Embedding 타입일 때만 정확. Link/Storage 혼합 문서에서는 잘못된 entry 반환 가능 — 상류 renderer 도 같은 가정 공유 (상류 패리티). `lookup_bin_data_bytes` 의 doc-comment 에 명시. +- **`PictureBlock.caption: CaptionBlock | None` 부재** — S3 추가 예정. 현재는 `description: str | None` 으로 alt-text 만. +- **`PictureBlock` `width`/`height`/`dpi` 항상 None** — 상류 Picture 가 픽셀 dimension 을 직접 노출하지 않으며 (border 좌표만), HWPUNIT 계산은 v0.4.0+ 검토. +- **`has_content=false` 케이스의 mapper 처리** — 현재는 `bin_data_id` 를 보존한 채 `bin://N` URI 출고 (forensics 위해), `Document.bytes_for_image` 호출 시점에 ValueError. 더 엄격한 정책 (image=None 으로 라우팅) 은 v0.4.0+ 검토. +- **HWP control 시각 순서 보존** — ParagraphBlock → tables → pictures 평탄화. 표/그림 혼재 문단의 원본 순서 보존은 v0.4.0+ `order: int` 필드 검토. +- **Furniture footnotes/endnotes** — S2 (`FootnoteBlock` / `EndnoteBlock` 도입) 에서 채움. + +## S2 진입 조건 (인수인계) + +S2 는 spec § 2 (FormulaBlock) + § 3 (FootnoteBlock / EndnoteBlock) 도입. +S1 에서 고정한 계약: + +1. **`Block` 유니온 + `_KNOWN_KINDS` 확장 패턴** — S2 는 `formula`, `footnote`, `endnote` 추가 시 동일 패턴 (Annotated[T, Tag("...")] + frozenset 추가 + `_block_discriminator` 라우팅 자동) 적용. +2. **`Furniture.footnotes` 타입 강화** — 현재 `list[Block]` → S2 에서 `list[FootnoteBlock]` 로. v0.2.0 → v0.3.0 호환 메모 (spec § 호환성) 가 endnotes 신설과 함께 활성화. +3. **mapper Furniture 채움 패턴** — 현 `headers`/`footers` flatten 패턴을 footnote 컨트롤에도 동일 적용. 본문 마커 위치는 InlineRun 텍스트 그대로 보존, 본문 자체만 furniture.footnotes 로 라우팅 (spec § 3 "body vs furniture 배치"). +4. **Provenance.marker_prov 추가** — Footnote/Endnote 의 본문 마커 위치 (몇번째 단락의 몇번째 글자) 별도 보존. S2 에서 `Provenance` 모델 확장 또는 `marker_prov: Provenance` 필드 추가 검토. + +## 참조 + +- 상위 설계: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) +- 결정 사항 증거: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md) +- 상류 타입 (S1 매핑): `external/rhwp/src/model/{image,bin_data,header_footer,document}.rs` +- v0.2.0 선례 (스테이지 분할 패턴): [implementation/v0.2.0/stages/stage-1.md](../../v0.2.0/stages/stage-1.md) diff --git a/docs/implementation/v0.3.0/stages/stage-2.md b/docs/implementation/v0.3.0/stages/stage-2.md new file mode 100644 index 0000000..e44bfce --- /dev/null +++ b/docs/implementation/v0.3.0/stages/stage-2.md @@ -0,0 +1,117 @@ +# Stage S2 — FormulaBlock + Footnote/Endnote (완료) + +**Status**: Frozen · **Target**: v0.3.0 · **Last updated**: 2026-04-26 + +**작업일**: 2026-04-26 +**계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 +**선행 stage**: [stage-1.md](stage-1.md) (PictureBlock + Furniture page_headers/footers) + +## 스코프 + +ir-expansion.md §S2 row 정확 매핑: + +- `FormulaBlock` Pydantic 모델 + Rust `Control::Equation` walker +- `FootnoteBlock` / `EndnoteBlock` Pydantic 모델 + Rust `Control::Footnote`/`Endnote` walker +- 본문 마커 위치는 그대로 `InlineRun.text` (수정 없음). 각주/미주 본문은 `furniture.footnotes` / `furniture.endnotes` 로 라우팅 +- `Furniture.footnotes` 타입 강화 (`list[Block]` → `list[FootnoteBlock]`) +- `Furniture.endnotes` 신규 필드 (`list[EndnoteBlock]`) +- `Provenance.marker_prov` 패턴 — 본문 인용 마커 위치를 각주 본문 자체 위치 (`prov`) 와 별도 필드로 보존 + +S3 (ListItem + Caption + Toc + Field) 와 S4 (Schema GA + CLI/LangChain 정식 매핑) 는 본 스테이지 범위 밖. + +## 산출물 + +| 파일 | 변동 | 내용 | +|---|---|---| +| `python/rhwp/ir/nodes.py` | +75 / -8 | `FormulaBlock`, `FootnoteBlock`, `EndnoteBlock` 추가, `_KNOWN_KINDS` `{paragraph, table, picture}` → 6 종 확장, `Block` 유니온 3 변형 추가, `Furniture.footnotes` 타입 `list[FootnoteBlock]` 강화 + `endnotes: list[EndnoteBlock]` 신규, `iter_blocks(scope="furniture")` 가 endnotes 포함 끝에 yield, `_walk_blocks` 가 `FootnoteBlock.blocks` / `EndnoteBlock.blocks` 재귀 진입, `Sequence[Block]` 시그니처로 invariant list 호환 | +| `python/rhwp/ir/__init__.pyi` | +9 | `EndnoteBlock` / `FootnoteBlock` / `FormulaBlock` re-export | +| `python/rhwp/ir/_raw_types.py` | +35 | `RawFormula`, `RawFootnote`, `RawEndnote` TypedDict; `RawParagraph.formulas`, `RawDocument.footnotes`/`endnotes` 신규 | +| `python/rhwp/ir/_mapper.py` | +75 | `_build_formula_block`, `_build_footnote_block`, `_build_endnote_block`. `build_hwp_document` 가 furniture footnotes/endnotes 채움. `_flatten_paragraph` 가 formulas 도 emit | +| `src/ir.rs` | +130 / -12 | `RawFormula`/`RawFootnote`/`RawEndnote` struct, `RawParagraph.formulas`, `RawDocument.footnotes`/`endnotes`. `build_raw_paragraph` 가 `Control::Equation` 도 추출. `build_raw_formula` + `simple_eq_text_alt` (over → /, sqrt → √, `{}` → `()`). `build_raw_footnote` / `build_raw_endnote`. `collect_furniture_from_paragraph` 확장 (Footnote/Endnote arm 추가). `FurnitureAcc` 누적 struct 도입 (clippy::too_many_arguments 회피) | +| `python/rhwp/integrations/langchain.py` | +27 | `_block_to_content_and_meta` 가 `FormulaBlock` (text_alt or script as content; script_kind/inline meta) 분기 + `FootnoteBlock`/`EndnoteBlock` 통합 분기 (paragraph 본문 평문 합쳐 content; number/marker 메타) | +| `python/rhwp/ir/schema/hwp_ir_v1.json` | +197 / -6 | 재생성 — 15 `$defs` (S1 12 + Formula/Footnote/Endnote 3), Furniture 가 endnotes 포함, footnotes type narrowing | +| `tests/test_ir_formula.py` | +280 (신규) | 19 테스트 — 모델 왕복/frozen/extra=forbid + script_kind 닫힌 Literal + model_copy 패턴 + mapper coverage + Formula in TableCell + Formula in FootnoteBlock + naive limit fix + 실제 샘플 lookup | +| `tests/test_ir_footnote.py` | +332 (신규) | 25 테스트 — Footnote/Endnote 분리된 두 타입 + frozen/extra=forbid + 재귀 (셀 안 표) + mapper preserve number/marker + furniture 라우팅 + iter_blocks 순서 (page_headers→footers→footnotes→endnotes) + recurse=True 진입 + body 분리 계약 | +| `tests/test_ir_schema_export.py` | +3 | `expected_nodes` 12 → 15 (FormulaBlock/FootnoteBlock/EndnoteBlock 추가) | +| `tests/test_ir_schema.py` | -3 / +5 | unknown-kind 픽스처 "footnote" → "list_item" (S2 에서 known 승격) / parametrize fixture 갱신 | +| `tests/test_ir_iter_blocks.py` | -7 / +25 | furniture order 테스트가 FootnoteBlock/EndnoteBlock 인스턴스 사용, known-kinds 검사 확장 | +| `tests/test_ir_roundtrip.py` | -7 / +21 | body kinds 검사 FormulaBlock 추가, furniture lists 검사 footnotes/endnotes 별도 타입 검증 | +| `tests/test_ir_furniture.py` | -10 / +33 | endnotes 수용 테스트로 전환 (S1 의 reject 테스트 flip), `_empty_raw_doc` 헬퍼 footnotes/endnotes 추가, raw paragraph 가 formulas 채움 | +| `tests/test_ir_tables.py` | +4 / -2 | TableCell.blocks 검사에 FormulaBlock 허용 | +| `tests/test_ir_mapper.py` | +6 / -1 | `_paragraph` helper 가 formulas=[] 채움 | +| `.github/workflows/ci.yml` | +1 | scoped pyright 목록에 `test_ir_formula.py` + `test_ir_footnote.py` 추가 | + +## S2 확정 결정 사항 + +| 결정 | 선택 | 근거 | +|---|---|---| +| **`script_kind` 닫힌 Literal** | `Literal["hwp_eq", "latex", "mathml"]` | spec § 2. `model_copy(update={"script": tex, "script_kind": "latex"})` 패턴이 Pydantic frozen 모델과 자연스럽게 호환 | +| **HWP equation script → LaTeX 자동 변환 미제공** | raw `"hwp_eq"` 출고 + 사용자 책임 | spec § 비목표. 공개 변환기 부재 (조사 결과). `text_alt` 는 단순 정규화만 (RAG 폴백) — 정확 변환 사용자가 외부 도구로 | +| **각주/미주 분리된 두 타입** | `FootnoteBlock` ≠ `EndnoteBlock` | 상류 rhwp 가 분리, HWP 사용자 의도 다름 (페이지 하단 vs 문서 끝). Pandoc 통합 패턴 거부 | +| **각주 본문 위치** | `furniture.footnotes` / `endnotes` (body 와 분리) | spec § 3. RAG body 검색 오염 회피. 본문 마커 텍스트는 InlineRun.text 그대로 보존 — 마커만 보고 싶으면 body 만, 본문이 필요하면 furniture 명시 요청 | +| **`marker_prov` 별도 필드** | spec § 3 그대로 | 본문 인용 위치 ↔ 각주 본문 위치 분리. 현 시점은 둘 다 parent paragraph (section_idx, para_idx) 공유 — char_offset 까지의 marker precision 은 v0.4.0+ | +| **`marker_prov.char_start/char_end` = None** | parent paragraph 단위만 | 상류 rhwp 가 paragraph 안 control 의 character 위치를 직접 노출하지 않음 (field_ranges 매핑 필요). v0.4.0+ 검토. nodes.py FootnoteBlock docstring 에 명시 | +| **`Furniture.footnotes` 타입 강화** | `list[Block]` → `list[FootnoteBlock]` | spec § 8. v0.3.0 개발 중 단계 변경 — S1 의 `list[Block]` 은 placeholder 였고 실제 채움 시점에 강화 | +| **`Furniture.endnotes` 신규** | spec § 8 그대로 | v0.2.0 ↔ v0.3.0 호환 충돌 (extra="forbid") 가 endnotes 추가로 의도적 trigger — schema_version 1.0 ≠ 1.1 분기를 강제 | +| **`_walk_blocks` Sequence 시그니처** | `list[Block]` → `Sequence[Block]` | `furniture.footnotes: list[FootnoteBlock]` 같은 협소 타입 list 가 invariant 충돌 없이 수용. pyright Sequence 공변성 활용 | +| **`FurnitureAcc` 누적 struct** | 인자 4개 → struct 1개 | clippy::too_many_arguments (8/7 한계) 회피. 누적 vec 4개 묶음이 의미상 응집 — `#[allow]` 보다 cleaner | +| **`simple_eq_text_alt` naive replace** | 토큰 경계 무시 (e.g. `"sqrtish"` → `"√ish"`) | spec § 2 "단순 정규화" 약속의 의도적 한계. `text_alt` 는 None 폴백 가능, 정확 변환은 사용자 책임. 한계는 docstring + naive_limit_fix 테스트 로 픽스 | + +## 비타협 제약 준수 + +- 모든 신규 IR 모델 (`FormulaBlock`, `FootnoteBlock`, `EndnoteBlock`) `ConfigDict(extra="forbid", frozen=True)` +- `script_kind: Literal[...]` — closed enum 으로 strict mode 토큰 마스킹 호환 +- `Field(ge=/le=/gt=/lt=)` 사용 **없음** — `number: int`, `inline: bool` 모두 plain 타입 +- mapper 도메인 분기 (text_alt 정규화는 Rust, 폴백 정책은 Python) — IR 진화 시 maturin rebuild 회피 패턴 보존 +- `__init__.pyi` 만 변경 — `__init__.py` 는 docstring 만 (순환 import 방지) + +## 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest -m "not slow"` | **291 passed** (S1 의 255 + S2 신규 36; 2 skipped — 샘플에 수식·미주 부재) | +| `uv run pyright python/ tests/` | **0 errors** | +| `uv run pyright tests/type_check_errors.py` | **4 intentional errors** (CI 검증 통과) | +| `cargo clippy --all-targets -- -D warnings` | clean | +| Schema JSON conformance (`test_load_schema_matches_export_schema`) | pass — 15 `$defs` 모두 동기화 | +| `code-reviewer` fresh-context 검증 | Critical 0 / Major 0 / Minor 5 (M1 naive_limit fix 픽스, M4 cross-container coverage 추가, M5 redundant import 제거, N6 mapper site comment 추가, M2/M3 알려진 한계로 명시) | +| 실제 샘플 e2e | aift.hwp 에서 footnote 1개 정상 추출 (`marker_prov=(2, 472)`, blocks=1) | + +### 테스트 커버리지 (ir-expansion.md §S2 → 실제 케이스) + +| ir-expansion.md 요구 | 테스트 | +|---|---| +| FormulaBlock script_kind 분기 / `text_alt` 폴백 | `test_formula_block_accepts_known_script_kinds[3]`, `test_formula_block_rejects_unknown_script_kind`, `test_build_formula_block_text_alt_can_be_none`, `test_simple_eq_text_alt_known_naive_limits` | +| FootnoteBlock / EndnoteBlock 의 marker_prov ↔ prov 분리 | `test_footnote_block_marker_and_prov_separately_assignable`, `test_build_footnote_block_preserves_number_and_marker` | +| 각주 안의 표 재귀 | `test_footnote_block_blocks_supports_recursion_with_table` | +| `recurse=True` 가 FootnoteBlock.blocks 진입 | `test_iter_blocks_recurse_enters_footnote_blocks`, `test_iter_blocks_recurse_enters_endnote_blocks` | +| body vs furniture 분리 | `test_footnotes_endnotes_never_appear_in_body`, `test_real_sample_body_excludes_header_footer_text` | +| furniture 순서 (page_headers→footers→footnotes→endnotes) | `test_iter_blocks_furniture_order_includes_footnotes_endnotes`, `test_build_hwp_document_preserves_header_footer_order` | +| Formula in TableCell / FootnoteBlock | `test_formula_inside_table_cell_is_flattened`, `test_formula_inside_footnote_body_is_flattened` | +| `endnotes` 신규 필드 수용 (S1 거부 → S2 수용) | `test_furniture_accepts_endnotes_field_in_s2`, `test_build_hwp_document_routes_endnotes_to_furniture` | + +## 알려진 한계 (S3/S4 또는 후속 MINOR 에서 처리) + +- **`marker_prov.char_start/char_end` = None** — 정확 위치 계산 알고리즘은 상류 `document_core::find_control_text_positions` 에 이미 존재하나 `pub(crate)` 라 외부 crate 에서 호출 불가. 상류 visibility 변경 요청 등록 (참조: [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md)). 머지 + submodule pin 갱신 시점에 paragraph 단위 → character 단위로 격상 (v0.3.x patch 또는 v0.4.0 검토). +- **`simple_eq_text_alt` 토큰 경계 미인식** — `replace("sqrt", "√")` 가 `"sqrtish"` 같은 식별자 안 부분문자열도 치환. spec § 2 "단순 정규화" 의도적 한계, RAG 폴백용. 정확 LaTeX 가 필요하면 사용자가 외부 변환 + `model_copy` 사용. +- **Equation `inline` flag 항상 False** — 상류 `Equation.common.inline_object` 등에서 추론 가능하지만 v0.3.0 RAG 1차 사용처에서 차이 무의미. 디스플레이/인라인 구분이 필요해질 때 v0.4.0+ 활성화. +- **Footnote/Endnote 자체 안의 footnote 컨트롤은 silently dropped** — `build_raw_paragraph` 가 본문에서만 control 추출. 중첩 각주가 HWP 에서 실재 가능해질 때 walker 확장. +- **Furniture body separation 의 부정 검증은 spec 차원** — `test_furniture_accepts_endnotes_field_in_s2` 가 v0.2.0 reader 의 ValidationError 를 직접 시뮬하지 않음 (v0.2.0 schema 보존 안 함). spec § 호환성에 명시. + +## S3 진입 조건 (인수인계) + +S3 는 spec § 4-7 (ListItem + Caption + Toc + Field 4 종 일괄). S2 에서 고정한 계약: + +1. **`Block` 유니온 + `_KNOWN_KINDS` 확장 패턴** — S3 는 `list_item`, `caption`, `toc`, `field` 추가 시 동일 패턴. +2. **`Furniture` 외 본문 컨테이너 — `CaptionBlock`** — `PictureBlock.caption: CaptionBlock | None` 필드 추가 (v0.3.0 S1 미배치). S3 에서 `_build_picture_block` 매핑 확장 + `TableBlock.caption_block: CaptionBlock | None` 추가 (기존 `caption: str | None` 호환 보존). +3. **mapper 평탄화 패턴** — `_flatten_paragraph` 가 ParagraphBlock → tables → pictures → formulas → list_items 순으로 emit. S3 에서 ListItemBlock 도 같은 위치에 추가. +4. **`Provenance.marker_prov` 패턴** — Footnote/Endnote 외에는 적용 대상 없음. ListItem/Caption/Toc/Field 는 단일 `prov` 만. +5. **`FieldKind` 닫힌 Literal 14 종 + `"unknown"`** — spec § 7. 상류 `FieldType` 이 추가될 때 `field_type_code: int | None` 으로 forward-compat. + +## 참조 + +- 상위 설계: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) +- 결정 사항 증거: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md) +- 상류 타입 (S2 매핑): `external/rhwp/src/model/{footnote,control}.rs` +- 선행 stage: [stage-1.md](stage-1.md) +- v0.2.0 선례: [implementation/v0.2.0/stages/](../../v0.2.0/stages/) (S1~S5) diff --git a/docs/implementation/v0.3.0/stages/stage-3.md b/docs/implementation/v0.3.0/stages/stage-3.md new file mode 100644 index 0000000..de8f1ff --- /dev/null +++ b/docs/implementation/v0.3.0/stages/stage-3.md @@ -0,0 +1,138 @@ +# Stage S3 — ListItem + Caption + Toc + Field (완료) + +**Status**: Frozen · **Target**: v0.3.0 · **Last updated**: 2026-04-27 + +**작업일**: 2026-04-27 +**계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 +**선행 stage**: [stage-2.md](stage-2.md) (FormulaBlock + Footnote/Endnote) + +## 스코프 + +ir-expansion.md §S3 row 정확 매핑 — 작은 4 종 일괄 도입: + +- `ListItemBlock` Pydantic 모델 + Rust `ParaShape.head_type` walker (Number/Bullet/Outline → list item, None → 일반 paragraph) +- `CaptionBlock` Pydantic 모델 + Rust `shape::Caption` walker. `PictureBlock.caption: CaptionBlock | None` / `TableBlock.caption_block: CaptionBlock | None` 부착 (v0.2.0 `caption: str` 필드 호환 보존) +- `TocBlock` + `TocEntryBlock` Pydantic 모델 + Rust `FieldType::TableOfContents` 라우팅. v0.3.0 entries 는 빈 placeholder +- `FieldBlock` + `FieldKind` 닫힌 Literal 14 종 + `"unknown"` 안전판. `FieldType::TableOfContents` 외 모든 Field control 매핑 +- `Block` 유니온 10 known + UnknownBlock = 11 멤버. `_KNOWN_KINDS` 동기 갱신 +- `iter_blocks(recurse=True)` 가 `CaptionBlock.blocks` 진입 — `PictureBlock.caption` / `TableBlock.caption_block` 은 부모 metadata 로 간주되어 진입 안 함 +- `HwpLoader(mode="ir-blocks")` 신규 4 블록 매핑 — RAG-friendly content + meta + +S4 (Schema GA + CLI/LangChain 정식 매핑 + 문서) 는 본 스테이지 범위 밖. + +## 산출물 + +| 파일 | 변동 | 내용 | +|---|---|---| +| `python/rhwp/ir/nodes.py` | +254 / -19 | `FieldKind` Literal (14+unknown), `ListItemBlock` / `CaptionBlock` / `TocBlock` / `TocEntryBlock` / `FieldBlock` 추가, `_KNOWN_KINDS` 6→10 종 확장, `Block` 유니온 4 변형 추가, `PictureBlock.caption: CaptionBlock | None` 신규, `TableBlock.caption_block: CaptionBlock | None` 신규 (기존 `caption: str` 보존), `_walk_blocks` 에 `CaptionBlock` 재귀 진입 추가, `model_rebuild()` 에 `CaptionBlock` / `PictureBlock` / `TableBlock` 추가 | +| `python/rhwp/ir/__init__.pyi` | +18 | `CaptionBlock` / `FieldBlock` / `FieldKind` / `ListItemBlock` / `TocBlock` / `TocEntryBlock` re-export | +| `python/rhwp/ir/_raw_types.py` | +85 | `RawListInfo` (`head_type` lowercase string) / `RawCaption` / `RawToc` / `RawTocEntry` / `RawField` TypedDict 추가, `RawParagraph.tocs` / `.fields` / `.list_info` / `RawPicture.caption` / `RawTable.caption_block` 신규 필드 | +| `python/rhwp/ir/_mapper.py` | +145 / -6 | `_build_list_item_block` (head_type → marker placeholder + enumerated 결정) / `_build_caption_block` / `_build_toc_block` / `_build_toc_entry_block` / `_build_field_block` 추가. `_VALID_FIELD_KINDS` / `_VALID_CAPTION_DIRECTIONS` / `_LIST_MARKER_BY_HEAD` 어휘 set 도입. `_flatten_paragraph` 가 list_info 분기 + tocs/fields emit. `_build_picture_block` 가 caption 채움. `_build_table_block` 가 caption_block 채움 | +| `src/ir.rs` | +185 / -12 | `RawListInfo` (head_type 만 출고 — marker 결정은 Python) / `RawCaption` / `RawToc` / `RawTocEntry` / `RawField` struct 추가. `RawParagraph` / `RawPicture` / `RawTable` 신규 필드. `build_raw_list_info` / `build_raw_caption` / `build_raw_field` / `build_raw_toc` / `caption_direction_to_str` / `field_type_to_str` 추가. `build_raw_paragraph` 의 control match 가 `Control::Field` 도 처리 (TableOfContents → tocs, 그 외 → fields). `build_raw_picture` / `build_raw_table` 가 caption 구조화. `tests` 에 `field_type_to_str_all_variants_lowercase` / `caption_direction_lowercase` 추가 | +| `python/rhwp/integrations/langchain.py` | +70 | `_block_to_content_and_meta` 가 `ListItemBlock` (marker+text content; level/enumerated meta) / `CaptionBlock` (blocks 평문 content; direction meta) / `TocBlock` (entries text 개행 결합; entry_count meta) / `FieldBlock` (cached_value content; field_kind/raw_instruction meta) 분기. `PictureBlock` / `TableBlock` 분기가 caption_block 텍스트 폴백 (대칭). `_caption_plain_text` 헬퍼 — Paragraph + Formula(text_alt 또는 script) + Field(cached_value) 평문 추출 (수식·필드 캡션 색인 누락 방지) | +| `python/rhwp/ir/schema/hwp_ir_v1.json` | +407 / -0 | 재생성 — 20 `$defs` (S2 15 + ListItemBlock/CaptionBlock/TocBlock/TocEntryBlock/FieldBlock 5), Block oneOf 10 변형 + UnknownBlock, PictureBlock.caption / TableBlock.caption_block 필드, UnknownBlock.kind not.enum 10 known kinds 갱신 | +| `tests/test_ir_list.py` | +147 (신규) | 14 테스트 — 모델 왕복/frozen/extra=forbid + Block discriminator 라우팅 + mapper list_info=None ↔ ListItemBlock 분기 + enumerated/marker/level 보존 + 셀 안 ListItemBlock + marker placeholder 한계 명시 | +| `tests/test_ir_caption.py` | +268 (신규) | 29 테스트 — CaptionBlock 모델 + direction Literal 닫힌 어휘 + PictureBlock.caption 부착 왕복 + TableBlock.caption_block + caption: str 호환 공존 + mapper RawCaption → CaptionBlock + unknown direction → bottom 폴백 + iter_blocks recurse 정책 (caption 진입 안 함, standalone caption.blocks 진입함) | +| `tests/test_ir_toc.py` | +199 (신규) | 18 테스트 — TocBlock 왕복 + TocEntryBlock leaf type (Block 유니온 멤버 아님, dict literal 은 UnknownBlock 라우팅) + 빈 entries v0.3.0 정책 + target_section_idx/is_stale 항상 None/False + iter_blocks 가 TocEntryBlock 안 yield | +| `tests/test_ir_field.py` | +217 (신규) | 46 테스트 — FieldBlock 모델 + FieldKind 14+unknown Literal + 모든 known kind parametrize + invalid kind ValidationError + mapper unknown 폴백 + field_type_code 보존 + raw_instruction round-trip + calc vs formula 이름 충돌 회피 + _flatten_paragraph 가 FieldBlock emit | +| `tests/test_ir_furniture.py` | +6 | `_empty_raw_para` 가 신규 필드 (tocs/fields/list_info) 채움 + table dict literal 에 caption_block 추가 | +| `tests/test_ir_footnote.py` | +3 | `_empty_raw_para` 가 신규 필드 채움 | +| `tests/test_ir_formula.py` | +14 / -0 | inline raw dict 에 신규 필드 추가, RawParagraph 명시 어노테이션 + RawFootnote 생성자 사용 (pyright TypedDict 추론 보강) | +| `tests/test_ir_mapper.py` | +6 | `_paragraph` 헬퍼가 신규 필드 채움 + `_table` 가 caption_block=None 채움 | +| `tests/test_ir_picture.py` | +1 | `_raw_picture` 헬퍼가 caption=None 채움 | +| `tests/test_ir_iter_blocks.py` | +9 / -2 | known-kinds 검사 4 종 추가 (ListItemBlock/CaptionBlock/TocBlock/FieldBlock) | +| `tests/test_ir_roundtrip.py` | +37 / -10 | body/furniture known-kinds 검사 4 종 추가, paragraph_count 검사가 ParagraphBlock + ListItemBlock 합계, provenance/inline 테스트가 ListItemBlock 도 포함 | +| `tests/test_ir_tables.py` | +13 / -3 | TableCell.blocks 검사가 ListItemBlock/TocBlock/FieldBlock 도 허용 | +| `tests/test_ir_schema.py` | +5 / -5 | unknown-kind 픽스처가 "list_item" → "revision_mark" (S3 에서 known 승격), parametrize fixture 가 가설적 미래 변형 사용 | +| `tests/test_ir_schema_export.py` | +6 | `expected_nodes` set 15 → 20 (ListItemBlock/CaptionBlock/TocBlock/TocEntryBlock/FieldBlock 추가) | +| `.github/workflows/ci.yml` | +2 | scoped pyright 목록에 `test_ir_list.py` / `test_ir_caption.py` / `test_ir_toc.py` / `test_ir_field.py` 추가 | + +## S3 확정 결정 사항 + +| 결정 | 선택 | 근거 | +|---|---|---| +| **ListItem 분류 source** | `ParaShape.head_type` (None/Number/Bullet/Outline) | spec § 4. 상류 list group 미존재 — paragraph 자체가 list item. `numbering_id` 기반은 정확 marker lookup 필요 (v0.4.0+) | +| **ListItemBlock marker placeholder** | Number/Outline → `"1."`, Bullet → `"•"`, 미지 head_type → `"-"` 폴백 | spec § 4 "v0.3.0 단순 정책". 정확 marker (`"가."`, `"(a)"` 등) 는 `Numbering.level_formats` lookup 필요 — v0.4.0+ 검토. **마커 결정은 Python `_mapper.py::_LIST_MARKER_BY_HEAD`** (Rust 는 head_type lowercase string 만 출고) — IR 진화 시 maturin rebuild 회피 | +| **ListItem level 매핑** | `ParaShape.para_level` (0~6) 그대로 | 1-indexed 변환 안 함 — Pydantic 도메인은 0-indexed 일관 (다른 블록 인덱스와 동일) | +| **CaptionBlock 컨테인먼트 방식** | `PictureBlock.caption` / `TableBlock.caption_block` 직접 부착 | spec § 5. HWP 가 1:1 (`Picture.caption: Option`), ref-id 패턴 도입 시 JSON-Pointer resolver 부담. v0.2.0 `TableBlock.caption: str` 보존 + `caption_block` 추가 | +| **CaptionBlock 도 Block 유니온 멤버** | `kind="caption"` 등록 (10 known 중 1) | 일반 파싱 경로에서는 body 단독 등장 안 함 (Picture/Table 자식). 사용자 직접 구성 경로 + JSON 직렬화 일관성 위해 union 멤버로. 단, iter_blocks 가 부모 caption 에 진입 안 함 (RAG 노이즈 회피) | +| **iter_blocks 재귀 정책** | `CaptionBlock.blocks` 는 진입, `PictureBlock.caption` 은 진입 안 함 | spec 본문 § iter_blocks. 부모 metadata 로 간주된 caption 은 LangChain loader 가 별도 Document 로 중복 로드하는 noise 회피. standalone CaptionBlock.blocks (사용자 직접 구성) 는 일반 컨테이너처럼 재귀 진입 | +| **caption direction Literal** | `Literal["top", "bottom", "left", "right"]` + `"bottom"` 기본값 | 상류 `CaptionDirection` enum 4 종 → lowercase string. `"bottom"` 기본값은 HWP 기본 + Docling 관례 일치 | +| **Caption mapper unknown direction 폴백** | `"bottom"` 폴백 | spec 정신 — Rust 가 새 CaptionDirection variant 추가 시 forward-compat. silent fallback 이지만 graceful 진화 가능 | +| **TocBlock + TocEntryBlock 분리** | TocEntryBlock 은 union 멤버 아님 | spec § 6. TableCell 과 같은 패턴 — entry 는 leaf type, iter_blocks 는 TocBlock 만 yield. `_KNOWN_KINDS` 미포함 → dict literal "toc_entry" 는 UnknownBlock 라우팅 | +| **TocBlock entries v0.3.0 정책** | 빈 placeholder | spec § 6 결정 7. 정확 entry 추출 + `target_section_idx` resolver + `is_stale` 검출은 v0.4.0+ — heading hierarchy + bookmark resolver 필요 | +| **FieldKind 닫힌 Literal 어휘** | 14 known + `"unknown"` (총 15) | spec § 7. 상류 `FieldType` 14 variant 1:1 매핑. Rust `field_type_to_str` 와 Python `_VALID_FIELD_KINDS` 양방향 동기 (Rust unit test `field_type_to_str_all_variants_lowercase` 로 픽스) | +| **FieldType::Formula → "calc"** | `"formula"` 가 아닌 `"calc"` | spec § 7. Equation ("formula" kind, 수식) 와 이름 충돌 회피 — HWP `FieldType::Formula` 는 표 합계 등 "계산 필드" 의미라 별도 어휘 | +| **FieldBlock unknown 강제 폴백** | mapper 가 미지 field_kind 를 `"unknown"` 으로 강제 + `field_type_code` 보존 | silent fallback 이지만 forensics 신호 (`field_type_code`) 가 남으므로 "원인 추적 가능" 조건 충족. forward-compat 친화 | +| **TableOfContents 라우팅** | `FieldBlock` 가 아닌 별도 `TocBlock` | spec § 7. ToC 는 의미적으로 다른 블록 (entries 컬렉션). 매퍼가 `Control::Field` match 에서 분기 | +| **HWP Field cached_value 추출** | v0.3.0 미구현 (None 출고) | HWP Field 는 cached_value 를 직접 노출 안 함 — paragraph text 안 inline. `field_ranges` 매핑 필요 (v0.4.0+ 검토) | +| **HWP Field raw_instruction** | `field.command` 그대로 (빈 문자열은 None) | round-trip 보존용 — Word `` 대응. v0.3.0 소비자는 보통 미사용 | +| **InlineRun.href 와 FieldBlock 중복** | 모든 Field control 을 FieldBlock 으로 emit | spec § 7 권장 정책 (side-effecting cross-ref 만 FieldBlock) 은 v0.4.0+ 검토. v0.3.0 은 Hyperlink/Bookmark 도 FieldBlock 으로 — InlineRun.href 자동 채움 path 미구현이라 중복 없음 | +| **LangChain mapping 추가 정책** | mode="ir-blocks" default body 만 | spec 본문 § HwpLoader 변경 의 `include_furniture: bool` 옵션은 v0.4.0+ 검토. 본 stage 는 신규 4 블록의 `_block_to_content_and_meta` 분기만 추가 | +| **schema_version 유지** | `"1.1"` 그대로 (S2 와 동일) | spec § 스키마 버저닝. MINOR 안에서 새 블록 추가는 1.1 유지 — S1 에서 1.0 → 1.1 bump 한 후 S2/S3 모두 1.1 안에서 누적 | + +## 비타협 제약 준수 + +- 모든 신규 IR 모델 (`ListItemBlock` / `CaptionBlock` / `TocBlock` / `TocEntryBlock` / `FieldBlock`) `ConfigDict(extra="forbid", frozen=True)` +- `FieldKind` / `script_kind` / `direction` / `role` 모두 닫힌 `Literal` — strict mode 토큰 마스킹 호환 +- `Field(ge=/le=/gt=/lt=)` 사용 **없음** — `level: int`, `cached_page: int | None`, `field_type_code: int | None` 모두 plain 타입 +- mapper 도메인 분기 (caption direction 폴백, FieldKind 어휘 검증, list marker placeholder) 는 모두 Python — IR 진화 시 maturin rebuild 회피 패턴 보존 +- `__init__.pyi` 만 변경 — `__init__.py` 는 docstring 만 (순환 import 방지) +- 외부 LLM-facing 어휘 (FieldKind value, caption direction value) 는 Rust `field_type_to_str` / `caption_direction_to_str` 가 SSOT — Python 어휘는 pure consumer + +## 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest -m "not slow"` | **405 passed** (S2 의 291 + S3 신규 110 + 기존 보강 4; 2 skipped — 샘플에 수식·미주 부재) | +| `uv run pyright python/ tests/` | **0 errors** | +| `uv run pyright tests/type_check_errors.py` | **4 intentional errors** (CI 검증 통과) | +| `cargo clippy --all-targets -- -D warnings` | clean | +| Schema JSON conformance (`test_load_schema_matches_export_schema`) | pass — 20 `$defs` 모두 동기화, oneOf 10 변형 | +| Schema ↔ Pydantic round-trip (`test_unknown_kind_routing_pydantic_matches_schema`) | pass — UnknownBlock not.enum 가 `_KNOWN_KINDS` SSOT 사용 (TocEntryBlock 등 leaf-only kind 제외) | +| 실제 샘플 e2e | aift.hwp 에서 정상 파싱 — list/caption/toc/field 컨트롤 등장 시 자동 매핑 | +| `code-reviewer` fresh-context 검증 | Critical 1 (C1: schema not.enum 이 leaf-only `toc_entry` 포함 → 라운드트립 깨짐), Major 2 (M1: marker 가 Rust 에 박힘 — Python 으로 이동, M2: TocEntryBlock prov 공유 한계 알려진 한계 추가), Minor 5 (m3: TableBlock LangChain caption_block 폴백 추가, m4: CaptionBlock 평문 추출이 Formula/Field 도 포함, m5: dead code 주석, m6/m7 cast() 정리 보류). C1/M1/M2/m3/m4/m5/n3 모두 본 stage 내 fix | + +### 테스트 커버리지 (ir-expansion.md §S3 → 실제 케이스) + +| ir-expansion.md 요구 | 테스트 | +|---|---| +| ListItemBlock level/marker/enumerated 조합 | `test_build_list_item_preserves_marker_enum_level[3]`, `test_build_list_item_preserves_provenance` | +| Picture/Table 양쪽에 caption 부착 | `test_picture_block_caption_roundtrip`, `test_table_block_caption_str_and_caption_block_coexist` | +| `TableBlock.caption_block ↔ caption` 일관성 | `test_table_block_caption_str_only_v0_2_0_pattern`, `test_build_hwp_document_table_with_caption_block_routed` | +| TocBlock 컨테이너 + TocEntryBlock leaf type | `test_toc_block_with_entries_roundtrip`, `test_toc_entry_block_is_not_in_block_union`, `test_iter_blocks_yields_toc_block_only` | +| `is_stale` 미구현 디폴트 | `test_build_toc_entry_is_stale_always_false_v0_3_0`, `test_build_toc_entry_target_section_idx_always_none_v0_3_0` | +| FieldKind 14 종 + unknown 라우팅 | `test_field_block_accepts_all_known_kinds[15]`, `test_build_field_block_unknown_kind_falls_back_to_unknown` | +| `cached_value` vs `raw_instruction` | `test_build_field_block_preserves_raw_instruction`, `test_field_block_full_roundtrip` | +| Caption iter_blocks 정책 | `test_iter_blocks_recurse_does_not_enter_picture_caption`, `test_iter_blocks_recurse_enters_standalone_caption_in_body` | +| `calc` vs `formula` 이름 충돌 회피 | `test_field_block_calc_distinguishes_from_formula_block` | +| Rust ↔ Python 어휘 동기화 | `src/ir.rs::tests::field_type_to_str_all_variants_lowercase`, `caption_direction_lowercase` | + +## 알려진 한계 (S4 또는 후속 MINOR 에서 처리) + +- **ListItemBlock marker placeholder** — Number/Outline 은 항상 `"1."`, Bullet 은 항상 `"•"`. 정확 marker (`"가."`, `"(a)"` 등) 추출은 `Numbering.level_formats` + `level_start_numbers` lookup 필요. v0.4.0+ 검토. spec § 4 "v0.3.0 단순 정책" 의도적 한계 +- **TocBlock entries 빈 placeholder** — TOC field 검출만 수행, 항목 추출은 v0.4.0+. heading hierarchy walk + bookmark resolver 필요. spec § 6 결정 7 +- **FieldBlock cached_value 항상 None** — HWP Field 가 cached_value 를 직접 노출 안 함. paragraph text 안 inline 위치를 `field_ranges` 매핑으로 추출해야 정확 — v0.4.0+ 검토. 현 시점은 None 출고 +- **InlineRun.href 자동 채움 path 미구현** — Hyperlink/Bookmark Field 는 FieldBlock 으로만 노출, InlineRun.href 는 v0.2.0 시점부터 빈 채로. spec § 7 권장 정책 (side-effecting cross-ref 만 FieldBlock) 은 v0.4.0+ +- **`HwpLoader(mode="ir-blocks")` `include_furniture` 옵션 부재** — spec 본문 § HwpLoader 변경 의 옵션은 v0.4.0+. 본 stage 는 신규 블록의 content/meta 매핑만 추가 +- **CaptionBlock direction unknown 폴백 silent** — Rust 가 새 CaptionDirection variant 추가 시 mapper 가 silent 하게 `"bottom"` 으로 폴백. forensics 신호 없음 (FieldBlock 의 `field_type_code` 같은 raw 보존 필드 부재). spec § 5 의도적 단순화 — direction 은 부수적 metadata 라 fallback 비용 낮음 +- **TocEntryBlock 모든 entries 가 동일 Provenance 공유** — `_build_toc_entry_block` 가 부모 TOC field 의 (section_idx, para_idx) 를 모든 entry 에 복사. v0.3.0 entries 는 빈 placeholder 라 영향 없지만, v0.4.0+ 에서 entries 가 채워질 때 entry 별 위치 추출이 필요. `RawTocEntry` 에 자체 위치 필드 (`section_idx`/`para_idx` 또는 `entry_idx`) 추가 시점에 격상 + +## S4 진입 조건 (인수인계) + +S4 는 ir-expansion.md §S4 row — Schema v1.1 GA + CLI/LangChain 정식 매핑 + 문서. S3 에서 고정한 계약: + +1. **`Block` 유니온 + `_KNOWN_KINDS` 10 known** — S4 는 이 set 변경 없이 schema/CLI/문서 마무리. +2. **schema_version `"1.1"` GA** — S1/S2/S3 모두 1.1 안에서 누적 진화. S4 는 in-package JSON in-place 갱신 + content-addressed alias `hwp_ir_v1-sha256-.json` 발행 + `publish-schema.yml` 트리거. +3. **`HwpLoader` 신규 매핑 패턴** — S3 가 7 신규 블록 (S1 3 + S2 3 + S3 4) 의 `_block_to_content_and_meta` 분기 모두 추가했으므로 S4 는 `include_furniture: bool` 옵션 + CLI `rhwp-py blocks --kind` 만 추가. +4. **CaptionBlock 부착 패턴** — `PictureBlock.caption` / `TableBlock.caption_block` 의 forward ref + model_rebuild 패턴이 S3 에서 정착 — S4 에서 새 부모 블록 (예: ChartBlock) 에 caption 부착 시 동일 패턴 적용 가능. +5. **상류 visibility 의존성 추적** — `marker_prov.char_start/char_end` (S2 한계) 와 `cached_value` (S3 한계) 둘 다 상류 `find_control_text_positions` / `field_ranges` 매핑 필요. [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) 머지 시점에 v0.3.x patch 또는 v0.4.0 에서 격상 검토. + +## 참조 + +- 상위 설계: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) +- 결정 사항 증거: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md) +- 상류 타입 (S3 매핑): `external/rhwp/src/model/{control,style,shape}.rs` (FieldType / ParaShape.head_type / Caption / CaptionDirection) +- 선행 stage: [stage-1.md](stage-1.md), [stage-2.md](stage-2.md) +- 상류 제안 이슈 (S2 시점 정리): [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) +- v0.2.0 선례: [implementation/v0.2.0/stages/](../../v0.2.0/stages/) (S1~S5) diff --git a/docs/implementation/v0.3.0/stages/stage-4.md b/docs/implementation/v0.3.0/stages/stage-4.md new file mode 100644 index 0000000..e45c83e --- /dev/null +++ b/docs/implementation/v0.3.0/stages/stage-4.md @@ -0,0 +1,145 @@ +# Stage S4 — Schema v1.1 GA + rhwp-py CLI + LangChain include_furniture (완료) + +**Status**: Frozen · **Target**: v0.3.0 · **Last updated**: 2026-04-28 + +**작업일**: 2026-04-28 +**계획 문서**: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md) §구현 스테이지 분할 + [roadmap/v0.3.0/cli.md](../../../roadmap/v0.3.0/cli.md) +**선행 stage**: [stage-3.md](stage-3.md) (ListItem + Caption + Toc + Field) +**Phase 위치**: [roadmap/phase-2.md](../../../roadmap/phase-2.md) § v0.3.0 두 축의 연동 — IR 확장과 CLI 두 spec 을 한 릴리스에 동시 GA. + +## 스코프 + +ir-expansion.md §S4 row + cli.md §S1~S4 row 를 한 stage 에 통합 — phase-2.md § 두 축의 연동 정책에 따라 IR 확장 GA 와 CLI 재도입을 같은 시점에 발행한다. + +**ir-expansion.md §S4 row 매핑**: + +- SchemaVersion `1.1` GA — in-package JSON in-place 갱신 (S3 에서 1.1 정착, S4 는 publish path 정비) +- Content-addressed alias `hwp_ir_v1-sha256-.json` 발행 — `publish-schema.yml` 의 page artifact 준비 단계에 추가 +- `rhwp-py blocks --kind` 확장 — IR 확장 8 신규 kind + `paragraph`/`table` + `all` = 11 종 enum +- `HwpLoader(mode="ir-blocks")` 신규 매핑 — `include_furniture: bool` 옵션 추가 (S3 알려진 한계 격상) +- README/examples 업데이트 + +**cli.md §S1~S4 row 통합 매핑**: + +- S1 (CLI 스켈레톤): `python/rhwp/cli/{__init__.py, __main__.py, app.py}` + `parse` / `version` / `schema` 서브커맨드 + `[cli]` extras + `[project.scripts] rhwp-py` entry point +- S2 (IR 커맨드): `python/rhwp/cli/ir.py` 의 `ir` / `blocks` 서브커맨드 + `--format json|ndjson|text` +- S3 (LangChain chunks): `python/rhwp/cli/chunks.py` + `[cli-chunks]` extras gate +- S4 (문서화·검증): README / CHANGELOG / 실제 HWP 검증 + +## 산출물 + +| 파일 | 변동 | 내용 | +|---|---|---| +| `python/rhwp/cli/__init__.py` | +27 (신규) | `app()` entry point — typer ImportError 가드 (typer/click 부재 시 친절 메시지 + exit 2). 패키지 import 자체는 typer 없이도 성공 (지연 로드) | +| `python/rhwp/cli/__main__.py` | +6 (신규) | `python -m rhwp.cli` 진입점 — entry point 와 동일 동작 (`rhwp-py` 명령 미등록 시 폴백) | +| `python/rhwp/cli/_state.py` | +16 (신규) | `--quiet/-q` 글로벌 플래그 모듈 변수 — callback (app.py) ↔ 서브커맨드 (ir.py) 중립 모듈로 순환 import 회피 | +| `python/rhwp/cli/app.py` | +117 (신규) | Typer 앱 + `_global_options` callback (`--quiet`) + `parse_cmd` / `version_cmd` / `schema_cmd` 본 모듈 + `register_ir_commands` / `register_chunks_command` 호출 | +| `python/rhwp/cli/ir.py` | +222 (신규) | `ir_cmd` (전체 IR JSON) + `blocks_cmd` (NDJSON 기본). `BlockKindOpt` (11 종) / `BlockScopeOpt` (3) / `BlocksFormatOpt` (3) str Enum. `_filter_blocks` (kind/limit) + `_emit_blocks` (포맷 분기) + `_block_to_text` (10 블록 평문 추출, LangChain `_block_to_content_and_meta` 와 같은 정책) | +| `python/rhwp/cli/chunks.py` | +104 (신규) | `chunks_cmd` — `langchain_text_splitters` 미설치 시 exit 2 + 친절 메시지. `ChunksMode` (3) / `ChunksFormat` (3) str Enum. `--include-furniture` 플래그 → `HwpLoader(include_furniture=...)` 전달 | +| `python/rhwp/integrations/langchain.py` | +27 / -1 | `HwpLoader.__init__` 에 `include_furniture: bool = False` 파라미터. `_yield_documents` 가 ir-blocks 모드에서 body 다음 furniture 블록도 yield (`metadata.scope="furniture"`). single/paragraph 모드는 옵션 무시 | +| `python/rhwp/integrations/langchain.pyi` | +8 / -1 | stub 에 `include_furniture: bool` attribute + `__init__` 키워드 인자 추가 | +| `pyproject.toml` | +24 | `[cli]` (typer 만) / `[cli-chunks]` (typer + langchain-core + langchain-text-splitters) extras 추가, `[project.scripts] rhwp-py = "rhwp.cli:app"` entry point 등록, `[dependency-groups] testing` 에 typer 추가, `[langchain]` extras 에 `langchain-text-splitters>=0.2` 추가 (M3 fix — `[cli,langchain]` 조합으로도 chunks 작동), `[tool.ruff.lint.per-file-ignores]` 로 `cli/*.py` + `examples/*.py` 의 B008 ignore (typer 관용 패턴) | +| `tests/test_cli.py` | +285 (신규) | 20 테스트 — 파일 레벨 `importorskip("typer")` (CI 5 skip 카운트 중 1). `--help` smoke / `version` / `parse` (포맷 + exit 1) / `schema` (stdout vs `export_schema()` 동등 + `--out`) / `ir` (default 한 줄 vs `--indent` 다중 줄 + `--out`) / `blocks` (NDJSON / JSON array / text + `--kind table` 필터 + `--scope furniture` + `--no-recurse` 차이) / `chunks` (`--mode`, `--include-furniture`, monkeypatch 로 langchain-text-splitters 부재 시 exit 2) | +| `tests/test_langchain_loader_ir.py` | +33 | `include_furniture` 4 테스트 — default False / True 면 body_only ≤ with_furn (일반 invariant) / `metadata.scope` in {None, "furniture"} / paragraph 모드는 옵션 무시 (a/b 동등) | +| `.github/workflows/ci.yml` | +3 / -2 | `pyright` scoped 목록에 `tests/test_cli.py` 추가. `test-without-extras` 잡의 skip count 4 → 5 (typer 추가) + 에러 메시지 갱신 | +| `.github/workflows/publish-schema.yml` | +13 / -2 | `Prepare pages directory` 단계에 content-addressed alias 발행 추가 — `shasum -a 256 $f` 로 해시 추출, `pages/schema/hwp_ir/${name}-sha256-${sha}.json` 파일 alongside 복사. 이름 패턴은 ir-expansion.md § 스키마 버저닝 정확 매핑. paths 트리거를 `hwp_ir_v1.json` → `hwp_ir_v*.json` glob 으로 일반화 (m5 fix — v2 도입 시 자동 트리거) | +| `README.md` | +13 / -3 | "v0.3.0 신규" 단락 + "rhwp-py CLI" 섹션 (압축형 — 핵심 명령 3 개 + 상류 분담 한 줄 + cli.md spec 링크). content-addressed alias 안내 한 줄 추가. LangChain 섹션에 `include_furniture=True` 한 줄 | +| `CHANGELOG.md` | +68 / -1 | `[0.3.0] — 2026-04-28` 신규 항목 — 8 신규 IR 블록 / `rhwp-py` CLI / `include_furniture` / Schema GA + alias / 문서 / 테스트 / Deferred to v0.4.0+ 7 개 카테고리. footer anchor link `[0.3.0]` 추가 + `[Unreleased]` compare URL 갱신 (m3 fix) | +| `docs/implementation/v0.3.0/stages/stage-4.md` | (본 문서) | S4 구현 로그 — v0.2.0 패턴 재사용 | + +## S4 확정 결정 사항 + +| 결정 | 선택 | 근거 | +|---|---|---| +| **두 spec (ir-expansion + cli) 동시 GA** | 한 stage 에 통합 | phase-2.md § 두 축의 연동 — "IR 확장 매퍼/Pydantic 모델이 먼저 GA 가능 상태에 도달해야 CLI enum 도 의미 있게 노출 — 따라서 구현 순서는 IR 확장 stage S1~S3 → IR 확장 S4 (스키마 1.1) + CLI S2 (`blocks` enum 확장) 동시 진행이 자연" | +| **`[langchain]` extras 에 langchain-text-splitters 포함 (M3 fix)** | 포함 | cli.md spec 의 `[cli,langchain]` 조합 약속 위반 회피. RAG 사용처에서 text-splitters 거의 항상 동반 — v0.2.0 사용자 업그레이드 시 자동 설치 비용 ≪ spec 충실성. 단일 source of truth | +| **`rhwp.cli.app` import chain 의 모든 ImportError 를 친절 처리 (M2 fix)** | `rhwp.*` 자체 모듈만 raise, 그 외는 모두 typer 설치 결함 메시지 + exit 2 | `e.name in ("typer", "click")` 화이트리스트는 transitive (rich/shellingham/annotated-doc) 누락 시 raw traceback 노출 — 사용자에게 cli.md spec § typer 미설치 시 동작 약속 위반 | +| **`blocks --format ndjson|json` 도 빈 블록 skip (m1 fix)** | LangChain loader 와 일관 — `_filter_blocks` 에서 모든 포맷 공통 skip | RAG 1차 사용처 일관성 — `rhwp-py blocks --format ndjson` 결과를 vector DB 에 흘리는 사용자가 LangChain loader 의 결과와 동일 청크 수를 기대. 원시 IR 모두 보려면 `rhwp-py ir` 사용 | +| **CLI 패키지 분리 구조** | spec stage 표 그대로 (`app.py` / `ir.py` / `chunks.py`) | cli.md § 구현 스테이지 분할 표 충실. 단일 모듈로 합치면 chunks 의 langchain extras 가드와 IR 확장 매핑이 한 파일에 섞여 향후 진화 비용 | +| **`_state.py` 16 줄 모듈 분리** | 별도 모듈 유지 | `--quiet` 는 cli.md § 글로벌 옵션 표 명시. callback (app.py) ↔ 서브커맨드 (ir.py) 중립 import 가 필요 — app.py 인라인하면 ir.py 가 app.py import 시 순환. 작은 모듈이지만 의도 명확 | +| **CLI 평문 추출 (`_block_to_text`) 정책** | LangChain loader 와 의도적으로 동일 우선순위 | RAG 일관성 — Picture caption→description 폴백, Formula text_alt→script 등 같은 정책. CLI 가 langchain-core 의존을 가질 수 없어 헬퍼 공유 불가 → 양쪽에 작성하되 docstring 상호 참조 | +| **`--quiet` 가드 범위** | schema/ir 의 `wrote N bytes` 메시지만 | 다른 stderr 출력은 모두 에러 메시지 (exit 1/2 와 함께) — quiet 무관하게 항상 출력. cli.md spec 의 "stderr 메시지 최소화" 본질은 progress 메시지 | +| **`BlockKindOpt` enum 11 종** | str Enum + `value` = IR `kind` 값 | typer 0.24 가 str Enum 자동 매핑 — 사용자 입력 (`--kind list_item`) 과 IR `block.kind` 값 1:1. Literal 도 가능하지만 typer 의 Click 바인딩이 안전한 Enum 패턴. global 룰 (Pydantic field 의 Literal 권장) 은 CLI option 컨텍스트에 무관 | +| **`--format` 기본값** | `parse`/`version` 사람 가독, `ir`/`schema` JSON, `blocks`/`chunks` NDJSON | cli.md § 결정 #3 정확 매핑 — kubectl/aws/gh 의 "스크립팅 primary 는 JSON" 관행 + jq streaming 친화 | +| **Exit code 규약** | 0 / 1 (사용자 오류) / 2 (extras 미설치) | cli.md § Exit code 규약 — typer 의 Click 기본은 usage error → exit 2. 우리는 cli.md spec 충실 위해 `path.exists()` 직접 검사 + `raise typer.Exit(code=1)` | +| **content-addressed alias 위치** | `pages/schema/hwp_ir/hwp_ir_v1-sha256-.json` (root level) | ir-expansion.md § 스키마 버저닝 의 정확 filename pattern. v1 디렉토리 하위 alias 도 가능했지만 spec text 의 "hwp_ir_v1-sha256-.json" 그대로 명시 | +| **alias 발행 스크립트** | bash + `shasum -a 256` | Linux runner (ubuntu-latest) 에서 표준. `awk '{print $1}'` 로 hash 추출 — `cut` 보다 공백 강건 | +| **`HwpLoader.include_furniture` default** | `False` | cli.md / ir-expansion.md spec 본문 — v0.2.0 시절 동작 보존 (body 만). True 면 footnote/endnote/header/footer Document 도 yield, 각 `metadata.scope="furniture"` | +| **single/paragraph 모드의 `include_furniture`** | 무시 | 두 모드는 텍스트 추출만 — body 만. furniture 본문은 IR 의 영역. 옵션을 명시적으로 raise 안 하고 silent ignore — 사용자가 모드를 자유로 전환 가능하도록 | +| **alias filename pattern 의 mutable scheme.json 와 공존** | `pages/schema/hwp_ir/v1/schema.json` (mutable, canonical `$id` URL) + `pages/schema/hwp_ir/hwp_ir_v1-sha256-.json` (immutable alias) | 둘은 같은 파일 복사본 — 사용자가 reproducible 한 snapshot 이 필요하면 alias URL, 항상 최신은 canonical. v2 추가 시 같은 루프가 v1/v2 alias 모두 발행 | +| **CI test-without-extras skip count 4 → 5** | typer 가 pytest 만 설치 시 부재 → test_cli.py file 레벨 importorskip → +1 | cli.md § CI 명시 — 새 extras-gated 테스트 파일 추가 시 ci.yml 의 skip count 동기 갱신 | + +## 비타협 제약 준수 + +- 모든 신규 IR 모델 변경 없음 — S3 까지 정착한 11 멤버 Block 유니온 + 10 known kinds 유지 (`_KNOWN_KINDS` SSOT) +- `Literal` / `Enum` 어휘 모두 닫힌 형태 — CLI Enum value 가 IR `kind` 값과 1:1 (`list_item` 등) +- `Field(ge=/le=/gt=/lt=)` 사용 **없음** (S3 부터 유지) +- mapper 도메인 분기 (caption direction 폴백, FieldKind 어휘 검증, list marker placeholder) 변경 없음 — Python 위치 유지 +- `__init__.pyi` 만 변경 (langchain.pyi) — `__init__.py` 는 추가 import 없음 +- 외부 LLM-facing 어휘 (FieldKind / caption direction / Block kind) 는 S3 의 SSOT 유지 +- CLI 도메인 분기는 모두 Python — Rust 변경 0 (clippy clean 자동 통과) +- 새 IR 도메인 모델 없음 — S4 는 publish + CLI + LangChain include_furniture 만 추가, IR 자체 진화 없음 + +## 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest -m "not slow"` | **429 passed** (S3 의 405 + S4 신규 24: CLI 20 + LangChain include_furniture 4; 2 skipped — 샘플에 수식·미주 부재) | +| `uv run pyright python/ tests/ + tests/test_cli.py` | **0 errors, 0 warnings, 0 informations** | +| `uv run ruff check python/rhwp/cli/ tests/test_cli.py tests/test_langchain_loader_ir.py` | All checks passed (m4 per-file-ignores 로 typer B008 회피) | +| `cargo clippy --all-targets -- -D warnings` | clean (Rust 변경 0) | +| Schema regenerate diff (`python -m rhwp.ir.schema` vs in-package JSON) | no diff — S3 에서 정착한 1,261 lines / 20 `$defs` JSON 그대로 | +| CLI smoke `rhwp-py --help` | 6 서브커맨드 모두 노출 (parse / version / schema / ir / blocks / chunks) | +| CLI 통합 — `rhwp-py blocks table-vpos-01.hwpx --kind table --format ndjson` | 표 9 개 NDJSON yield, 각 줄 독립 JSON parse 가능 | +| CLI extras 가드 — monkeypatch 로 `sys.modules["langchain_text_splitters"] = None` | exit 2 + stderr "rhwp-py chunks requires `langchain-text-splitters`" | +| `code-reviewer` fresh-context 검증 | Critical 0 / Major 3 / Minor 5 / Nit 3 — 모든 11 건 fix 적용 (M1 chunks help Rich markup escape, M2 ImportError 가드 transitive deps 확장, M3 `[langchain]` extras text-splitters 추가, m1 ndjson/json 도 빈 블록 skip, m2 furniture 테스트 invariant 강화, m3 CHANGELOG footer, m4 ruff B008 ignore, m5 publish-schema paths glob, n1 cli/app.py imports 정리, n2 README v0.3.0 신규 H3 분리, n3 text 모드 HTML 부재 검증) | + +### 테스트 커버리지 (cli.md §테스트 전략 → 실제 케이스) + +| cli.md 요구 | 테스트 | +|---|---| +| `typer.testing.CliRunner` 기반 smoke 전 커맨드 | `test_help_lists_all_subcommands`, `test_version_outputs_match_rhwp_module`, `test_parse_summary_format`, `test_schema_stdout_matches_export_schema`, `test_ir_default_compact_single_line`, `test_blocks_ndjson_each_line_is_independent_json`, `test_chunks_paragraph_default` | +| `--help` 출력에 모든 서브커맨드 포함 | `test_help_lists_all_subcommands` | +| `--format json` 출력이 `json.loads` 로 파싱 | `test_blocks_format_json_returns_array`, `test_chunks_paragraph_default` | +| `--format ndjson` 각 줄 독립 JSON | `test_blocks_ndjson_each_line_is_independent_json`, `test_chunks_paragraph_default` | +| `version` 출력이 `rhwp.version()` 과 일치 | `test_version_outputs_match_rhwp_module` | +| `schema` 출력이 `rhwp.ir.schema.export_schema()` 와 동등 | `test_schema_stdout_matches_export_schema` | +| Exit code 1 (파일 없음) | `test_parse_missing_file_exit_1`, `test_chunks_missing_file_exit_1` | +| Exit code 2 (extras 부재) | `test_chunks_missing_text_splitters_exit_2` | +| 실제 샘플 통합 — `blocks --kind table --format ndjson` | `test_blocks_kind_filter_table` | +| `chunks --mode ir-blocks --format ndjson` ↔ `HwpLoader(mode="ir-blocks")` 동등 | `test_chunks_ir_blocks_mode` | +| **`--include-furniture` 동작** (cli.md spec § HwpLoader 변경) | `test_chunks_include_furniture_adds_scope_meta`, `test_include_furniture_yields_extra_documents`, `test_include_furniture_marks_scope_metadata` | + +## 알려진 한계 (v0.4.0+ 검토) + +- **`--quiet` 가드 범위 협소** — 현재는 `wrote N bytes` 두 메시지만 가드. 향후 verbose 모드 (`--verbose/-v`) 도입 시 reverse polarity 로 통합 검토. 현 시점은 spec 본문 § 글로벌 옵션 충실 +- **`BlockKindOpt` 가 IR 의 `_KNOWN_KINDS` 와 별개 SSOT** — Python 어휘 두 곳에 11 종 Literal 중복. mapper 어휘 검증 set (`_VALID_FIELD_KINDS` 등) 과 같은 패턴이라 CLI 도 같은 trade-off — 도메인 별로 자체 어휘 보유 (Pydantic strict + typer Click 바인딩 양쪽 만족 위해) +- **`rhwp-py blocks --kind` 다중 선택 미지원** — cli.md spec 은 `` 라 다중 가능성 시사하지만 v0.3.0 은 단일 kind 또는 `all`. 다중 선택 (`--kind paragraph,table`) 은 v0.4.0+ 에서 typer 의 list-of-Enum 패턴 검토 +- **`chunks --mode single` 시 splitter 가 단일 거대 텍스트를 분할** — 사용자가 "single = 분할 안 함" 으로 오해 가능. cli.md spec 은 모드를 LangChain mapping 으로만 정의 — splitter 는 별개. v0.4.0+ 에서 `--no-split` 옵션 검토 +- **content-addressed alias 가 매 deploy 마다 새 hash 생성 → pages 디렉토리 무한 누적** — 현재 alias 는 단순 `cp` 라 같은 hash 는 idempotent 하지만 `actions/deploy-pages@v4` 의 replace-all 동작 상 직전 deploy 의 alias 만 살아남는다. 영구 보존하려면 GitHub Pages 외부 (예: SchemaStore) 등록 필요 — v0.4.0+ 검토 +- **`Document.to_ir(image_mode="embedded"|"external_dir")` 미구현** — ir-expansion.md § 비목표 명시. v0.3.0 은 `bin://` 단일 모드. embedded base64 inline 은 `[embed-images]` extras 후보 (v0.4.0+) +- **CLI 와 LangChain loader 의 평문 추출 정책 두 곳 작성** — `cli.ir._block_to_text` 와 `integrations.langchain._block_to_content_and_meta` 가 동일 정책. CLI 가 langchain-core 의존을 가질 수 없어 헬퍼 공유 불가 — 향후 별도 view module (`rhwp.ir._views`) 추출 검토 (v0.4.0+) +- **`blocks --format ndjson|json` 도 빈 블록 skip** (m1 fix 후 의도적 정책) — `_filter_blocks` 가 모든 포맷에서 `_block_to_text(block).strip() == ""` 인 블록을 skip 하여 LangChain loader 와 일관. 단점: 사용자가 "원시 IR 모든 블록" 을 보려면 `rhwp-py ir` 사용해야 (blocks 는 RAG-friendly stream 으로 위치). 결정 정당: ndjson/json 모드 ↔ LangChain Document 매핑 1:1 일관성, RAG 노이즈 회피 + +## v0.3.0 GA 진입 조건 (인수인계) + +S4 가 GA 의 마지막 stage. release 진입 전 다음 명시: + +1. **`Block` 유니온 + `_KNOWN_KINDS` 10 known** — S1~S3 에서 누적 정착, S4 변경 없음. 다음 MINOR 에서 새 kind 추가 시 `_KNOWN_KINDS` set + Block Annotated Union + `BlockKindOpt` Enum 세 곳 동기 갱신 +2. **schema_version `"1.1"` GA** — JSON Schema in-package + GitHub Pages canonical URL + content-addressed alias 모두 발행 경로 정착. v2 (breaking change) 는 새 `python/rhwp/ir/schema/hwp_ir_v2.json` 추가만으로 발행 — `publish-schema.yml` 자동 처리 +3. **`rhwp-py` 명령 경로** — `[project.scripts]` entry point + typer 지연 import 가드 패턴이 안정. 새 서브커맨드 추가는 `register_*_commands(app)` 패턴으로 — spec stage 표 그대로 +4. **`HwpLoader.include_furniture` 패턴** — body 와 furniture 의 metadata 분리 (`scope="furniture"`) 가 v0.3.0 부터 contract. v0.4.0+ 에서 새 furniture 유형 (예: side-notes) 추가 시 같은 metadata 패턴 적용 +5. **상류 visibility 의존성 추적** — `marker_prov.char_start/char_end` (S2) 와 `cached_value` (S3) 둘 다 상류 `find_control_text_positions` / `field_ranges` 매핑 필요. [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) 머지 시점에 v0.3.x patch 또는 v0.4.0 에서 격상 검토 +6. **Cargo.toml version bump** — release tag `v0.3.0` 발행 전 Cargo.toml version 을 `0.3.0` 으로 갱신 (CLAUDE.md § Versioning / release 의 verify-version 규약 준수) +7. **실제 HWP 검증** — release 직전 examples/01~05 + `rhwp-py {parse,blocks,chunks,schema}` 를 본인 업무 HWP 파일 3 종 (일반 / 장문 / HWPX) 으로 돌려 출력 육안 확인 + +## 참조 + +- 상위 설계: [roadmap/v0.3.0/ir-expansion.md](../../../roadmap/v0.3.0/ir-expansion.md), [roadmap/v0.3.0/cli.md](../../../roadmap/v0.3.0/cli.md) +- Phase 위치: [roadmap/phase-2.md](../../../roadmap/phase-2.md) § v0.3.0 두 축의 연동 +- 결정 사항 증거: [design/v0.3.0/ir-expansion-research.md](../../../design/v0.3.0/ir-expansion-research.md), [design/v0.3.0/cli-design-research.md](../../../design/v0.3.0/cli-design-research.md) +- 선행 stage: [stage-1.md](stage-1.md), [stage-2.md](stage-2.md), [stage-3.md](stage-3.md) +- 상류 제안 이슈 (S2 시점 정리): [docs/upstream/issue-find-control-text-positions.md](../../../upstream/issue-find-control-text-positions.md) +- v0.2.0 선례: [implementation/v0.2.0/stages/](../../v0.2.0/stages/) (S1~S5) +- Typer 공식: +- Click 8.2+ CliRunner 변경 (`mix_stderr` 제거): diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 64a4f6d..3f297ba 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,11 +4,11 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe 본 문서는 Living — 자유 갱신. -## 현재 상태 (2026-04-26) +## 현재 상태 (2026-04-27) - **v0.1.0 / v0.1.1** — Frozen, PyPI 배포 완료 - **v0.2.0** — Frozen, Document IR v1 GA (2026-04-25) -- **v0.3.0** — Draft, Phase 2 (IR 확장 + `rhwp-py` CLI) 진행 중 +- **v0.3.0** — Draft, Phase 2 (IR 확장 + `rhwp-py` CLI) 진행 중. IR 확장 S1~S3 완료 (S4 Schema GA + CLI/LangChain 마무리 남음) - **v0.4.0+** — 미착수, Phase 3 이후 ## 활성 spec 인덱스 @@ -22,8 +22,6 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.3.0 (IR 확장) | Draft | [v0.3.0/ir-expansion.md](v0.3.0/ir-expansion.md) | [design/v0.3.0/ir-expansion-research.md](../design/v0.3.0/ir-expansion-research.md) | | v0.3.0 (CLI) | Draft | [v0.3.0/cli.md](v0.3.0/cli.md) | [design/v0.3.0/cli-design-research.md](../design/v0.3.0/cli-design-research.md) | -cross-version reference (버전 무관): [design/pyo3-bindings.md](../design/pyo3-bindings.md) (Active). - ## Phase 인덱스 Phase 는 여러 MINOR 릴리스에 걸친 기능 묶음. **구체 결정은 vX.Y.Z spec 이 보유** — phase 문서는 의도/스코프와 동시 GA 두 축 연동만 다룸. @@ -44,6 +42,7 @@ Phase 1 (v0.1.x) 은 GA 완료로 별도 phase 문서 없음. |---|---|---| | v0.1.0 | [implementation/v0.1.0/migration.md](../implementation/v0.1.0/migration.md) | [verification/v0.1.0/spinoff-review.md](../verification/v0.1.0/spinoff-review.md) | | v0.2.0 | [implementation/v0.2.0/stages/](../implementation/v0.2.0/stages/) (S1~S5) | — | +| v0.3.0 (in-progress) | [implementation/v0.3.0/stages/](../implementation/v0.3.0/stages/) (S1~S3) | — | ## 원칙 diff --git a/docs/roadmap/v0.3.0/ir-expansion.md b/docs/roadmap/v0.3.0/ir-expansion.md index 1e2752e..ff4342f 100644 --- a/docs/roadmap/v0.3.0/ir-expansion.md +++ b/docs/roadmap/v0.3.0/ir-expansion.md @@ -41,7 +41,7 @@ v0.2.0 ir.md 의 § 스키마 버저닝 표에 따라: 3. SchemaVersion `1.1` GA — JSON Schema in-place 갱신 + content-addressed alias 발행 4. `Document.iter_blocks` 가 신규 kind 를 yield, `kind` 필터로 선택 가능 5. `HwpLoader(mode="ir-blocks")` 가 신규 블록을 LangChain `Document` 로 매핑 (예: `PictureBlock` → caption + alt + URI 메타) -6. `rhwp-py blocks --kind picture|formula|...` CLI 노출 ([cli.md](cli.md) §S2 확장) +6. `rhwp-py blocks --kind picture|formula|...` CLI 노출 — CLI 축과의 연동은 본 spec 도입부의 phase-2 reference 참조 7. v0.2.0 모든 공개 API 보존 — `Document.to_ir()` 시그니처 동일, 기존 필드 동일 ### v0.3.0 비목표 (v0.4.0 이후) diff --git a/docs/upstream/issue-find-control-text-positions.md b/docs/upstream/issue-find-control-text-positions.md new file mode 100644 index 0000000..452d2ad --- /dev/null +++ b/docs/upstream/issue-find-control-text-positions.md @@ -0,0 +1,114 @@ +# 업스트림 제안 — `find_control_text_positions` 외부 노출 + +**Status**: Active · **Last updated**: 2026-04-27 + +> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. 업스트림 머지 시 본 파일은 archive (또는 삭제) 처리. + +## Summary + +`document_core::find_control_text_positions(&Paragraph) -> Vec` 가 외부 crate 에서 호출 불가합니다. 알고리즘과 contract 는 prod-stable 이며 WASM 측에는 이미 노출되어 있어, `pub(crate) mod helpers` → `pub mod helpers` 또는 `Paragraph` 메서드 캡슐화로 해결 가능해 보입니다. + +## 문제 상황 + +paragraph 안의 inline 컨트롤 (각주·미주 마커, 그림, 표, 수식) 이 `paragraph.text` 의 어느 character 인덱스에 위치하는지 외부 binding 에서 알 수 없습니다. + +외부 binding (third-party PyO3 / napi / JNI 등) 이 RAG / IR 매핑 작업에서 다음 정보를 필요로 합니다: + +- **각주·미주 마커 역추적**: 본문 paragraph 의 어느 character 위치에 각주 마커가 있는지 식별. 각주 본문은 별도 furniture 로 라우팅하더라도 마커의 정확한 character offset 이 RAG Provenance 에 보존되어야 검색 컨텍스트가 정확해집니다. +- **그림·수식의 paragraph 내 위치**: 단락 단위 (`para_idx`) 만으로는 텍스트 흐름 안의 정확한 위치 정보가 부족합니다. +- **InlineRun ↔ 컨트롤 매핑 검증**: 서식 런과 컨트롤이 같은 character 위치를 공유하는지 확인. + +각 binding 이 알고리즘을 자체 복사하는 방식은 상류 변경 시 silent drift 위험이 있어, 이미 검증된 단일 source-of-truth 를 외부 노출하는 방향이 안전해 보입니다. + +## 현재 상태 + +`src/document_core/helpers.rs:106` 에 함수가 존재합니다: + +```rust +/// 반환: positions[i] = para.controls[i]가 삽입되어야 할 텍스트 문자 인덱스 +pub(crate) fn find_control_text_positions(para: &Paragraph) -> Vec { + // 본문 생략 — char_offsets 갭 분석 알고리즘 (~60 줄) +} +``` + +`src/document_core/mod.rs:6` 의 `pub(crate) mod helpers;` 때문에 외부 crate 에서는 접근 불가합니다. + +이 함수는 `v0.5.0` initial commit 부터 존재해 온 helper 로, 현재 cursor / navigation / 렌더러 / 책갈피 쿼리 / 명령 (text editing, object ops) / WASM API 등 다양한 내부 경로에서 prod 사용 중이라 contract 가 안정된 상태로 보입니다. + +## WASM 측 노출 선례 + +`src/wasm_api.rs:966` 에 동일 helper 가 이미 `#[wasm_bindgen]` 으로 노출되어 있습니다: + +```rust +#[wasm_bindgen(js_name = getControlTextPositions)] +pub fn get_control_text_positions(&self, section_idx: u32, para_idx: u32) -> String { + let sections = &self.document.sections; + if let Some(sec) = sections.get(section_idx as usize) { + if let Some(para) = sec.paragraphs.get(para_idx as usize) { + let positions = crate::document_core::find_control_text_positions(para); + return format!("[{}]", positions.iter().map(|p| p.to_string()).collect::>().join(",")); + } + } + "[]".to_string() +} +``` + +WASM binding 사용 사례에서는 외부 노출이 이미 진행된 상태라, 다른 외부 crate 에서도 같은 정보가 필요한 경우 일관된 방식으로 노출이 가능하지 않을까 싶습니다. + +같은 crate 내 호출이라 `pub(crate)` 면 충분한 상황이라, 외부 crate 까지의 visibility 는 자연스럽게 누락된 것으로 추정됩니다 (현재 `v0.7.7` 시점에서도 변경 없음). + +## 제안 + +다음 중 하나를 검토 부탁드립니다: + +**옵션 A** — `Paragraph` 인스턴스 메서드로 캡슐화: + +```rust +// src/model/paragraph.rs (impl Paragraph 안) + +/// `controls[i]` 가 `text` 의 어느 character 인덱스에 위치하는지 반환. +/// `char_offsets` 의 갭 (컨트롤당 8 UTF-16 코드 유닛) 으로 위치 복원. +pub fn control_text_positions(&self) -> Vec { + // helpers::find_control_text_positions 와 동일 로직 +} +``` + +장점은 외부 API surface 를 좁게 유지하면서 `Paragraph` 에 의미가 응집되고, helpers 모듈은 내부 구현 세부 사항으로 자유롭게 진화 가능하다는 점입니다. + +**옵션 B** — `helpers` 모듈을 `pub` 으로 + 함수도 `pub`: + +```rust +// src/document_core/mod.rs +pub mod helpers; // pub(crate) → pub + +// src/document_core/helpers.rs +pub fn find_control_text_positions(para: &Paragraph) -> Vec { ... } +``` + +변경 범위가 작은 대신, helpers 모듈의 다른 함수들 (`logical_to_text_offset` 등) 도 함께 외부에 노출되어 향후 helpers 진화 시 외부 contract 부담이 생길 수 있습니다. + +옵션 A 가 외부 API 안정성 측면에서 좀 더 깔끔해 보이긴 합니다만, 메인테이너님 의견 듣고 싶습니다. + +## 영향 + +- 알고리즘 변경 없음 (visibility 또는 메서드 캡슐화만) — semver MINOR +- 기존 내부 사용처 (`logical_to_text_offset`, 직접 호출들) 영향 없음 +- 외부 binding 이 inline 컨트롤 마커의 character 위치를 IR Provenance 등에 활용 가능 + +## 관련 이슈 + +외부 binding API 노출 관련 이슈 시리즈와 같은 결로 보입니다: + +- #269 `[api] insert_paragraph(paraIdx=paragraphCount) rejected — no path to append at section end` +- #270 `[bug] set_field value is lost after save → reopen (in-memory OK, not persisted)` +- #271 `[api] No delete_paragraph WASM function — text-blanking only, structure cannot be removed` +- #272 `[api] Expose HwpCtrl / 312 Action registry via WASM (run_action or flattened methods)` + +위는 모두 WASM binding 측 누락 사례이고, 본 이슈는 같은 결의 외부 Rust crate 측 누락 사례에 해당합니다. + +## 참고 위치 + +- `src/document_core/helpers.rs:106` (현재 구현, `pub(crate)`) +- `src/document_core/mod.rs:6-7` (`pub(crate) mod helpers; pub(crate) use helpers::*;`) +- `src/wasm_api.rs:966` (WASM 측 노출 선례) +- `src/model/paragraph.rs:185` (옵션 A 시 메서드 추가 위치) diff --git a/external/rhwp b/external/rhwp index bea635b..033617e 160000 --- a/external/rhwp +++ b/external/rhwp @@ -1 +1 @@ -Subproject commit bea635bd708274a51ae3f557a71b07683d7c2454 +Subproject commit 033617e23847982135c02091a62f55031a3817b5 diff --git a/pyproject.toml b/pyproject.toml index 846a5ca..fe3d44f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,17 +46,32 @@ Issues = "https://github.com/DanMeon/rhwp-python/issues" Upstream = "https://github.com/edwardkim/rhwp" [project.optional-dependencies] -langchain = ["langchain-core>=0.2"] -# ^ aparse() 가 aiofiles.open 으로 파일 I/O 를 async 처리하기 위해 필요. -# 미설치 시 aparse 호출 시점에 ImportError — sync 경로 (parse) 는 무관 -async = ["aiofiles>=23"] -# ^ examples 는 01~03 예제 스크립트 일괄 실행용 우산 extras — typer + langchain-core + text-splitters 합집합 +# ^ v0.3.0+: text-splitters 는 RAG 사용처에서 거의 항상 동반되므로 [langchain] 에 +# 포함하여 [cli,langchain] 조합으로도 chunks 서브커맨드가 작동한다. +langchain = ["langchain-core>=0.2", "langchain-text-splitters>=0.2"] +# ^ v0.3.0 부터 aparse() 가 stdlib asyncio.to_thread 사용 — aiofiles 의존성 제거. +# `pip install rhwp[async]` 명령 호환을 위해 빈 배열 유지 (v0.4.0 에서 키 자체 제거 검토). +async = [] +# ^ rhwp-py CLI (v0.3.0+). chunks 서브커맨드는 별도 [cli-chunks] 또는 [cli,langchain] 조합 필요 +cli = ["typer>=0.12"] +# ^ rhwp-py chunks 서브커맨드 — RecursiveCharacterTextSplitter 사용 +cli-chunks = [ + "typer>=0.12", + "langchain-core>=0.2", + "langchain-text-splitters>=0.2", +] +# ^ examples 는 01~05 예제 스크립트 일괄 실행용 우산 extras — typer + langchain-core + text-splitters 합집합 examples = [ "typer>=0.12", "langchain-core>=0.2", "langchain-text-splitters>=0.2", ] +[project.scripts] +# ^ Entry point — typer 미설치 시 rhwp.cli.app() 가 친절 에러 + exit 2. +# 이름은 cli.md § 이름 선정 ("rhwp-py" 채택, 상류 rhwp 와 PATH 충돌 회피) +rhwp-py = "rhwp.cli:app" + [dependency-groups] dev = ["maturin>=1.7"] testing = [ @@ -67,8 +82,8 @@ testing = [ "langchain-text-splitters>=0.2", # ^ JSON Schema meta-validation (tests/test_ir_schema_export.py) "jsonschema>=4", - # ^ aparse() 경로 검증 — aiofiles.open + Document.from_bytes 조합 - "aiofiles>=23", + # ^ rhwp-py CLI smoke / integration tests (tests/test_cli.py) + "typer>=0.12", ] linting = [ {include-group = "dev"}, @@ -126,3 +141,9 @@ target-version = "py310" [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B"] + +[tool.ruff.lint.per-file-ignores] +# ^ typer 의 권장 패턴: 함수 default 로 typer.Argument(...) / typer.Option(...) 호출. +# B008 (function call in default arg) 와 충돌하나 typer 가 의도한 사용처라 ignore. +"python/rhwp/cli/*.py" = ["B008"] +"examples/*.py" = ["B008"] diff --git a/python/rhwp/_rhwp.pyi b/python/rhwp/_rhwp.pyi index ba32d1e..e67e8b5 100644 --- a/python/rhwp/_rhwp.pyi +++ b/python/rhwp/_rhwp.pyi @@ -41,4 +41,5 @@ class _Document: def export_pdf(self, output_path: str) -> int: ... def to_ir(self) -> HwpDocument: ... def to_ir_json(self, *, indent: int | None = None) -> str: ... + def bytes_for_image_id(self, bin_data_id: int) -> bytes | None: ... def __repr__(self) -> str: ... diff --git a/python/rhwp/cli/__init__.py b/python/rhwp/cli/__init__.py new file mode 100644 index 0000000..6c9e49f --- /dev/null +++ b/python/rhwp/cli/__init__.py @@ -0,0 +1,32 @@ +"""rhwp.cli — ``rhwp-py`` 명령 entry point. + +typer 는 ``[cli]`` extras (또는 ``[examples]``) 미설치 시 ImportError. ``app()`` +호출 시점까지 typer import 를 지연하여 패키지 import 자체는 typer 없이도 +성공한다. ``[project.scripts] rhwp-py = "rhwp.cli:app"`` 로 등록. + +관련 spec: ``docs/roadmap/v0.3.0/cli.md``. +""" + +import sys + + +def app() -> None: + """``rhwp-py`` 명령 entry point — typer 또는 그 transitive deps 미설치 시 친절 에러 + exit 2. + + ``rhwp.cli.app`` import chain 안에서 발생하는 ImportError 는 ``rhwp.cli`` 자체 + 모듈 외 라이브러리 부재로 간주 — typer / click / rich / shellingham 등 어느 + 것이 빠져도 같은 친절 메시지를 출력하고 exit 2. ``rhwp`` 자체 모듈 결함은 + 원본 ImportError 가 그대로 노출 (e.name 이 ``rhwp.*`` 또는 ``rhwp`` 시작). + """ + try: + from rhwp.cli.app import app as _app + except ImportError as e: + # ^ rhwp 자체 모듈 import 실패는 진단 단서 보존을 위해 그대로 raise + if e.name and (e.name == "rhwp" or e.name.startswith("rhwp.")): + raise + sys.stderr.write( + f"rhwp-py requires typer (missing module: {e.name!r}). Install with:\n" + ' pip install "rhwp-python[cli]"\n' + ) + raise SystemExit(2) from e + _app() diff --git a/python/rhwp/cli/__main__.py b/python/rhwp/cli/__main__.py new file mode 100644 index 0000000..5b939fd --- /dev/null +++ b/python/rhwp/cli/__main__.py @@ -0,0 +1,6 @@ +"""``python -m rhwp.cli`` — entry point 와 동일 동작 (rhwp-py 명령 미등록 시 폴백).""" + +from rhwp.cli import app + +if __name__ == "__main__": + app() diff --git a/python/rhwp/cli/_state.py b/python/rhwp/cli/_state.py new file mode 100644 index 0000000..041e737 --- /dev/null +++ b/python/rhwp/cli/_state.py @@ -0,0 +1,16 @@ +"""CLI 전역 상태 — ``--quiet`` 플래그 공유용. + +Typer ``ctx.obj`` 를 거치지 않고 모듈 레벨 단순 변수로 처리한다 — CLI 한 번 +실행에 한 번만 셋되고 서브커맨드 진입 시점엔 callback 이 항상 먼저 호출된다. +""" + +_QUIET = False + + +def set_quiet(value: bool) -> None: + global _QUIET + _QUIET = value + + +def is_quiet() -> bool: + return _QUIET diff --git a/python/rhwp/cli/app.py b/python/rhwp/cli/app.py new file mode 100644 index 0000000..1181714 --- /dev/null +++ b/python/rhwp/cli/app.py @@ -0,0 +1,115 @@ +"""rhwp-py Typer 앱 — 서브커맨드 등록. + +cli.md §커맨드 트리 매핑: + +- ``parse`` (본 모듈) +- ``version`` (본 모듈) +- ``schema`` (본 모듈) +- ``ir`` (``rhwp.cli.ir.register_ir_commands``) +- ``blocks`` (``rhwp.cli.ir.register_ir_commands``) +- ``chunks`` (``rhwp.cli.chunks.register_chunks_command``) + +Exit code 규약 (cli.md § Exit code 규약): + +- ``0`` 성공 +- ``1`` 사용자 오류 (파일 없음 / 옵션 조합 / 파싱 실패) +- ``2`` extras 미설치 (``chunks`` 의 ``langchain-text-splitters`` 등) +""" + +import json +import sys +from pathlib import Path + +import typer + +import rhwp +from rhwp.cli._state import is_quiet, set_quiet +from rhwp.cli.chunks import register_chunks_command +from rhwp.cli.ir import register_ir_commands + +app = typer.Typer( + name="rhwp-py", + help="rhwp-python 의 얇은 CLI — IR / blocks / chunks / schema / parse / version", + no_args_is_help=True, + add_completion=False, +) + + +@app.callback() +def _global_options( + quiet: bool = typer.Option( + False, + "--quiet", + "-q", + help="stderr progress 메시지 최소화 (오류 메시지는 항상 출력).", + ), +) -> None: + """전역 옵션 — 서브커맨드 실행 전에 한 번 호출된다.""" + set_quiet(quiet) + + +@app.command("parse", help="기본 정보 (섹션/단락/페이지 수 + 버전) 한 줄 요약 출력.") +def parse_cmd( + path: Path = typer.Argument( + ..., + help="HWP 또는 HWPX 파일 경로", + # ^ exists=False — typer 의 기본 검증을 비활성화하여 cli.md exit code 규약 (1) + # 을 직접 보장. 자동 검증은 click.UsageError 로 exit 2 라 규약 어긋남. + exists=False, + ), +) -> None: + if not path.exists(): + typer.echo(f"file not found: {path}", err=True) + raise typer.Exit(code=1) + try: + doc = rhwp.parse(str(path)) + except (ValueError, OSError) as e: + typer.echo(f"parse error: {e}", err=True) + raise typer.Exit(code=1) from e + typer.echo( + f"sections={doc.section_count} " + f"paragraphs={doc.paragraph_count} " + f"pages={doc.page_count}" + ) + typer.echo(f"rhwp-python={rhwp.version()} rhwp-core={rhwp.rhwp_core_version()}") + + +@app.command("version", help="rhwp-python 과 rhwp-core 버전 출력.") +def version_cmd() -> None: + typer.echo(f"rhwp-python {rhwp.version()}") + typer.echo(f"rhwp-core {rhwp.rhwp_core_version()}") + + +@app.command("schema", help="in-package Document IR JSON Schema (Draft 2020-12) 출력.") +def schema_cmd( + out: Path | None = typer.Option( + None, + "--out", + "-o", + help="출력 파일 경로 (없으면 stdout).", + ), + indent: int | None = typer.Option( + 2, + "--indent", + help="JSON 들여쓰기 칸 수 (0 또는 음수 = 한 줄로 직렬화).", + ), +) -> None: + # ^ schema export 는 Pydantic model_rebuild 비용이 있어 함수 안 import — + # 다른 서브커맨드 startup 시 본 비용 회피. + from rhwp.ir.schema import export_schema + + schema_dict = export_schema() + indent_arg = indent if indent is not None and indent > 0 else None + text = json.dumps(schema_dict, ensure_ascii=False, indent=indent_arg) + if out is None: + sys.stdout.write(text + "\n") + return + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text + "\n", encoding="utf-8") + if not is_quiet(): + typer.echo(f"wrote {len(text):,} bytes to {out}", err=True) + + +# * 서브커맨드 등록 — 본 모듈에서 직접 정의 안 한 ir / blocks / chunks +register_ir_commands(app) +register_chunks_command(app) diff --git a/python/rhwp/cli/chunks.py b/python/rhwp/cli/chunks.py new file mode 100644 index 0000000..bb67243 --- /dev/null +++ b/python/rhwp/cli/chunks.py @@ -0,0 +1,103 @@ +"""rhwp-py chunks 서브커맨드 — LangChain ``RecursiveCharacterTextSplitter`` 결과 출고. + +cli.md §S3. ``langchain-text-splitters`` 미설치 시 exit 2 — typer (``cli``) +만 설치한 사용자에게 langchain 의존성을 강요하지 않도록 별도 extras +(``cli-chunks`` 또는 ``cli,langchain`` 조합) 게이팅. +""" + +import json +import sys +from enum import Enum +from pathlib import Path +from typing import Literal, cast + +import typer + + +class ChunksMode(str, Enum): + single = "single" + paragraph = "paragraph" + ir_blocks = "ir-blocks" + + +class ChunksFormat(str, Enum): + json = "json" + ndjson = "ndjson" + text = "text" + + +def register_chunks_command(app: typer.Typer) -> None: + @app.command( + "chunks", + # ^ Typer Rich help 가 [bracket] 을 markup tag 로 해석 — backtick 으로 escape + help="LangChain Document 청크 스트림 — `cli-chunks` 또는 `cli,langchain` extras 필요.", + ) + def chunks_cmd( + path: Path = typer.Argument(..., help="HWP / HWPX 파일 경로.", exists=False), + mode: ChunksMode = typer.Option( + ChunksMode.paragraph, + "--mode", + help="LangChain 매핑 모드 (single / paragraph / ir-blocks).", + ), + size: int = typer.Option( + 500, "--size", help="청크 최대 문자 수 (RecursiveCharacterTextSplitter)." + ), + overlap: int = typer.Option(50, "--overlap", help="청크 간 오버랩."), + fmt: ChunksFormat = typer.Option( + ChunksFormat.ndjson, "--format", help="출력 포맷 (ndjson/json/text)." + ), + include_furniture: bool = typer.Option( + False, + "--include-furniture/--no-include-furniture", + help=( + "ir-blocks 모드에서 furniture (footnote/endnote/header/footer) 도 포함. " + "다른 모드에선 무시." + ), + ), + ) -> None: + # * extras 가드 — langchain-text-splitters 미설치 시 exit 2 (cli.md §exit code) + try: + from langchain_text_splitters import RecursiveCharacterTextSplitter + except ImportError: + typer.echo( + "rhwp-py chunks requires `langchain-text-splitters`. Install with:\n" + ' pip install "rhwp-python[cli-chunks]"', + err=True, + ) + raise typer.Exit(code=2) from None + + if not path.exists(): + typer.echo(f"file not found: {path}", err=True) + raise typer.Exit(code=1) + + # ^ HwpLoader 의 LoadMode Literal 어휘로 cast — Enum.value 가 1:1 매핑 + from rhwp.integrations.langchain import HwpLoader + + load_mode = cast(Literal["single", "paragraph", "ir-blocks"], mode.value) + loader = HwpLoader(str(path), mode=load_mode, include_furniture=include_furniture) + try: + docs = loader.load() + except (ValueError, OSError) as e: + typer.echo(f"load error: {e}", err=True) + raise typer.Exit(code=1) from e + + splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap) + split_docs = splitter.split_documents(docs) + + if fmt == ChunksFormat.ndjson: + for d in split_docs: + sys.stdout.write( + json.dumps( + {"page_content": d.page_content, "metadata": d.metadata}, + ensure_ascii=False, + ) + + "\n" + ) + return + if fmt == ChunksFormat.json: + data = [{"page_content": d.page_content, "metadata": d.metadata} for d in split_docs] + sys.stdout.write(json.dumps(data, ensure_ascii=False) + "\n") + return + # text — 청크 사이를 빈 줄로 구분 + for d in split_docs: + sys.stdout.write(d.page_content + "\n\n") diff --git a/python/rhwp/cli/ir.py b/python/rhwp/cli/ir.py new file mode 100644 index 0000000..6e3effd --- /dev/null +++ b/python/rhwp/cli/ir.py @@ -0,0 +1,227 @@ +"""rhwp-py ir / blocks 서브커맨드. + +cli.md §S2 (ir / blocks) + phase-2.md § 두 축 연동 — IR 확장 8 신규 kind 를 +``--kind`` enum 에 노출하여 IR 확장 GA (S4) 와 동기. 출력 포맷은 cli.md +§기본 출력 포맷 채택: ``ir`` 은 단일 JSON, ``blocks`` 는 NDJSON 기본 +(jq streaming 친화). +""" + +import json +import sys +from collections.abc import Iterable, Iterator +from enum import Enum +from pathlib import Path +from typing import Literal, cast + +import typer + +import rhwp +from rhwp.cli._state import is_quiet +from rhwp.ir.nodes import ( + Block, + CaptionBlock, + EndnoteBlock, + FieldBlock, + FootnoteBlock, + FormulaBlock, + ListItemBlock, + ParagraphBlock, + PictureBlock, + TableBlock, + TocBlock, + UnknownBlock, +) + + +# ^ Click/Typer 는 str Enum 을 자동 매핑한다. enum member name 은 Python +# identifier, value 는 사용자 입력 어휘 — IR ``kind`` 값과 동일 (list_item). +class BlockKindOpt(str, Enum): + all = "all" + paragraph = "paragraph" + table = "table" + picture = "picture" + formula = "formula" + footnote = "footnote" + endnote = "endnote" + list_item = "list_item" + caption = "caption" + toc = "toc" + field = "field" + + +class BlockScopeOpt(str, Enum): + body = "body" + furniture = "furniture" + all = "all" + + +class BlocksFormatOpt(str, Enum): + json = "json" + ndjson = "ndjson" + text = "text" + + +def register_ir_commands(app: typer.Typer) -> None: + """``ir`` / ``blocks`` 서브커맨드를 ``app`` 에 등록.""" + + @app.command("ir", help="전체 Document IR 을 JSON 으로 출력 (stdout 또는 --out FILE).") + def ir_cmd( + path: Path = typer.Argument(..., help="HWP / HWPX 파일 경로.", exists=False), + out: Path | None = typer.Option( + None, "--out", "-o", help="출력 파일 경로 (없으면 stdout)." + ), + indent: int | None = typer.Option( + None, "--indent", help="JSON 들여쓰기 (없으면 한 줄로 직렬화)." + ), + ) -> None: + if not path.exists(): + typer.echo(f"file not found: {path}", err=True) + raise typer.Exit(code=1) + try: + doc = rhwp.parse(str(path)) + except (ValueError, OSError) as e: + typer.echo(f"parse error: {e}", err=True) + raise typer.Exit(code=1) from e + text = doc.to_ir_json(indent=indent if indent and indent > 0 else None) + if out is None: + sys.stdout.write(text + "\n") + return + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text + "\n", encoding="utf-8") + if not is_quiet(): + typer.echo(f"wrote {len(text):,} bytes to {out}", err=True) + + @app.command( + "blocks", + help="iter_blocks 기반 블록 스트림 (NDJSON 기본 — jq streaming 친화).", + ) + def blocks_cmd( + path: Path = typer.Argument(..., help="HWP / HWPX 파일 경로.", exists=False), + scope: BlockScopeOpt = typer.Option( + BlockScopeOpt.body, "--scope", help="순회 대상 (body/furniture/all)." + ), + kind: BlockKindOpt = typer.Option( + BlockKindOpt.all, + "--kind", + help="블록 종류 필터 (all 이면 모든 종류 yield).", + ), + recurse: bool = typer.Option( + True, "--recurse/--no-recurse", help="컨테이너 블록 재귀 진입." + ), + fmt: BlocksFormatOpt = typer.Option( + BlocksFormatOpt.ndjson, + "--format", + help="출력 포맷 (ndjson/json/text).", + ), + limit: int | None = typer.Option( + None, "--limit", help="최대 출고 개수 (None = 전체)." + ), + ) -> None: + if not path.exists(): + typer.echo(f"file not found: {path}", err=True) + raise typer.Exit(code=1) + try: + doc = rhwp.parse(str(path)) + except (ValueError, OSError) as e: + typer.echo(f"parse error: {e}", err=True) + raise typer.Exit(code=1) from e + ir_doc = doc.to_ir() + # ^ Enum.value 는 BlockScope Literal 어휘와 1:1 — cast 로 type checker 통과 + scope_lit = cast(Literal["body", "furniture", "all"], scope.value) + block_iter = _filter_blocks( + ir_doc.iter_blocks(scope=scope_lit, recurse=recurse), + kind, + limit, + ) + _emit_blocks(block_iter, fmt) + + +def _filter_blocks( + blocks: Iterable[Block], + kind: BlockKindOpt, + limit: int | None, +) -> Iterator[Block]: + """--kind / --limit 필터 적용 — limit None 이면 무제한. + + 평문이 빈 블록 (UnknownBlock / 빈 ParagraphBlock / 캡션 없는 PictureBlock 등) + 은 RAG 노이즈로 간주하여 모든 포맷에서 skip — LangChain loader 의 + ``_yield_documents`` 가 동일 정책 (`if not content.strip(): continue`). + """ + n = 0 + for block in blocks: + if kind != BlockKindOpt.all and block.kind != kind.value: + continue + if not _block_to_text(block).strip(): + continue + yield block + n += 1 + if limit is not None and n >= limit: + return + + +def _emit_blocks(blocks: Iterable[Block], fmt: BlocksFormatOpt) -> None: + """포맷별 stdout 직렬화 — 빈 컨텐츠 블록은 _filter_blocks 가 이미 skip.""" + if fmt == BlocksFormatOpt.ndjson: + for block in blocks: + sys.stdout.write(block.model_dump_json() + "\n") + return + if fmt == BlocksFormatOpt.json: + # ^ stream 으로 모으지 않고 list 평가 — JSON array 는 한 번에 출력해야 valid + data = [block.model_dump(mode="json") for block in blocks] + sys.stdout.write(json.dumps(data, ensure_ascii=False) + "\n") + return + # text — 평문 추출 (이미 non-empty 가 보장됨) + for block in blocks: + sys.stdout.write(_block_to_text(block) + "\n") + + +def _block_to_text(block: Block) -> str: + """``--format text`` 용 평문 추출 — LangChain ir-blocks 매핑과 같은 우선순위. + + Picture 는 caption.blocks 평문 우선 + description 폴백, Formula 는 text_alt + 우선 + script 폴백 등. RAG fallback 텍스트와 일관 — 사용자가 CLI 결과를 + 그대로 vector index 에 흘려도 의미 텍스트만 노출된다. + """ + if isinstance(block, ParagraphBlock): + return block.text + if isinstance(block, TableBlock): + return block.text + if isinstance(block, PictureBlock): + if block.caption is not None: + cap = _caption_plain(block.caption) + if cap: + return cap + return block.description or "" + if isinstance(block, FormulaBlock): + return block.text_alt or block.script + if isinstance(block, (FootnoteBlock, EndnoteBlock)): + return "\n".join(b.text for b in block.blocks if isinstance(b, ParagraphBlock) and b.text) + if isinstance(block, ListItemBlock): + return f"{block.marker} {block.text}".strip() + if isinstance(block, CaptionBlock): + return _caption_plain(block) + if isinstance(block, TocBlock): + return "\n".join(e.text for e in block.entries if e.text) + if isinstance(block, FieldBlock): + return block.cached_value or "" + # ^ 새 Block variant 추가 시 위 분기를 먼저 확장 — UnknownBlock 폴백은 빈 텍스트 + assert isinstance(block, UnknownBlock) + return "" + + +def _caption_plain(caption: CaptionBlock) -> str: + """CaptionBlock.blocks 평문 추출 — Paragraph + Formula(text_alt|script) 결합. + + LangChain loader (_caption_plain_text) 와 의도적 동일 정책 — RAG 일관성 보존. + """ + parts: list[str] = [] + for b in caption.blocks: + if isinstance(b, ParagraphBlock) and b.text: + parts.append(b.text) + elif isinstance(b, FormulaBlock): + text = b.text_alt or b.script + if text: + parts.append(text) + elif isinstance(b, FieldBlock) and b.cached_value: + parts.append(b.cached_value) + return "\n".join(parts) diff --git a/python/rhwp/document.py b/python/rhwp/document.py index e91ced9..9de6250 100644 --- a/python/rhwp/document.py +++ b/python/rhwp/document.py @@ -22,8 +22,9 @@ - ``rhwp.Document`` 인스턴스는 생성한 스레드 에서만 사용 async 환경에서 파일 I/O 를 non-blocking 으로 처리하려면 :func:`aparse` 사용 — -``aiofiles`` 가 파일 읽기만 async 로 수행하고, 파싱 (``Document.from_bytes``) 은 -호출 스레드에서 동기 실행한다. 파싱 구간의 GIL 은 Rust ``py.detach`` 가 해제. +stdlib ``asyncio.to_thread`` 가 파일 읽기만 thread pool 에 offload 하고, 파싱 +(``Document.from_bytes``) 은 호출 스레드에서 동기 실행한다. 파싱 구간의 GIL 은 +Rust ``py.detach`` 가 해제. ### IR 캐싱 @@ -36,7 +37,7 @@ from rhwp._rhwp import _Document if TYPE_CHECKING: - from rhwp.ir.nodes import HwpDocument + from rhwp.ir.nodes import HwpDocument, PictureBlock class Document: @@ -146,6 +147,54 @@ def to_ir_json(self, *, indent: int | None = None) -> str: """ return self._inner.to_ir_json(indent=indent) + def bytes_for_image(self, picture: "PictureBlock") -> bytes: + """``PictureBlock`` 의 ``bin://`` URI 를 raw bytes 로 해석. + + IR JSON 에 image binary 가 inline 되지 않으므로 (모델은 source 보존, + 직렬화 시점 결정), raw bytes 가 필요할 때 본 헬퍼로 접근한다. + + ``data:image/...`` (embedded) 또는 ``file://...`` (external) 모드의 + ImageRef 는 v0.4.0+ opt-in 이며 v0.3.0 시점에는 ValueError. broken + reference (``picture.image is None``) 도 ValueError. + + Args: + picture: ``Document.to_ir()`` 결과 트리에서 얻은 PictureBlock. + 다른 Document 의 PictureBlock 을 넘기면 잘못된 binary 를 반환할 + 수 있다 — bin_data_id 는 같은 문서 안에서만 유효한 인덱스다. + + Returns: + 이미지 raw bytes (PNG/JPEG/BMP/... — ``picture.image.mime_type`` 참조). + + Raises: + ValueError: image=None (broken reference), URI 가 bin:// 스킴이 아님, + bin_data_id 파싱 실패, 또는 lookup 실패 (Embedding 이 아니거나 + bin_data_content 누락). + """ + if picture.image is None: + raise ValueError("PictureBlock.image is None (broken reference) — no bytes available") + uri = picture.image.uri + # ^ v0.3.0 S1 은 bin:// 스킴만 출고한다. embedded/external 은 직렬화 + # 시점 모드 (v0.4.0+) — 읽기 경로에서 마주치면 명시적 에러. + if not uri.startswith("bin://"): + raise ValueError( + f"bytes_for_image only supports 'bin://' URIs, got {uri!r}. " + "embedded (data:) and external (file:) modes are v0.4.0+ opt-in." + ) + try: + bin_data_id = int(uri[len("bin://") :]) + except ValueError as e: + raise ValueError(f"invalid bin:// URI {uri!r} — expected bin://") from e + if not 0 <= bin_data_id <= 0xFFFF: + raise ValueError(f"bin_data_id {bin_data_id} out of u16 range — corrupt URI {uri!r}") + result = self._inner.bytes_for_image_id(bin_data_id) + if result is None: + raise ValueError( + f"bin_data_id {bin_data_id} not found in document. " + "BinData may be Link/Storage type (not Embedding) or content was " + "not loaded by the parser." + ) + return result + # * Rendering def render_svg(self, page: int) -> str: @@ -231,12 +280,17 @@ async def aparse(path: str) -> Document: ``#[pyclass(unsendable)]`` 제약 상 Document 는 스레드 경계를 넘을 수 없다. 따라서 ``asyncio.to_thread(parse, path)`` 패턴은 panic 을 일으킨다. 대신 - ``aiofiles`` 로 **파일 I/O 만** async 로 수행하고, bytes 파싱은 호출 스레드 - 에서 동기 실행 (GIL 은 Rust ``py.detach`` 가 해제). 이 경로는 Document - 인스턴스를 이벤트 루프 스레드에 유지하므로 panic 이 없다. + 파일 read 만 stdlib ``asyncio.to_thread`` 로 thread pool 에 offload 하고, + bytes 파싱은 호출 스레드 (event loop) 에서 동기 실행 (GIL 은 Rust + ``py.detach`` 가 해제). 이 경로는 Document 인스턴스를 event loop 스레드에 + 유지하므로 panic 이 없다. + + Python ``asyncio`` 가 native async file I/O 를 미지원하는 한 모든 async + file lib (aiofiles 등) 도 결국 thread pool wrapping — 본 구현이 stdlib 만 + 으로 동등 효과 달성. 외부 의존성 없음. - ``aiofiles`` 는 optional dependency — ``pip install rhwp-python[async]`` 또는 - ``pip install aiofiles``. 미설치 시 ``ImportError``. + Cancellation: thread 위 blocking read 라 한 번 시작되면 cancel 어려움 — + aiofiles 도 동일 한계. 단발 read 이므로 실용 영향 없음. Args: path: HWP 또는 HWPX 파일 경로. @@ -245,20 +299,18 @@ async def aparse(path: str) -> Document: 파싱된 Document. 호출 스레드에 묶인다. Raises: - ImportError: ``aiofiles`` 미설치. FileNotFoundError: 파일이 존재하지 않을 때. PermissionError: 파일 접근 권한이 없을 때. OSError: 그 외 I/O 오류. ValueError: 파일 포맷이 유효하지 않을 때. """ - try: - import aiofiles - except ImportError as e: - raise ImportError( - "rhwp.aparse requires aiofiles. " - "Install via `pip install rhwp-python[async]` or `pip install aiofiles`." - ) from e - - async with aiofiles.open(path, "rb") as f: - data = await f.read() + import asyncio + + data = await asyncio.to_thread(_read_bytes, path) return Document.from_bytes(data, source_uri=path) + + +def _read_bytes(path: str) -> bytes: + """동기 파일 read 헬퍼 — ``aparse`` 가 ``asyncio.to_thread`` 로 offload 하는 단위.""" + with open(path, "rb") as f: + return f.read() diff --git a/python/rhwp/integrations/langchain.py b/python/rhwp/integrations/langchain.py index 206f5ac..7845838 100644 --- a/python/rhwp/integrations/langchain.py +++ b/python/rhwp/integrations/langchain.py @@ -16,7 +16,7 @@ HwpLoader("report.hwp", mode="ir-blocks").load() async 사용은 :meth:`aload` / :meth:`alazy_load` — 내부적으로 :func:`rhwp.aparse` -(aiofiles 기반 파일 I/O) 를 호출하므로 ``pip install rhwp[async]`` 필요. +(stdlib ``asyncio.to_thread`` 기반 파일 I/O) 를 호출. 추가 의존성 없음. """ from collections.abc import AsyncIterator, Iterator @@ -26,7 +26,20 @@ from langchain_core.documents import Document import rhwp -from rhwp.ir.nodes import Block, ParagraphBlock, TableBlock, UnknownBlock +from rhwp.ir.nodes import ( + Block, + CaptionBlock, + EndnoteBlock, + FieldBlock, + FootnoteBlock, + FormulaBlock, + ListItemBlock, + ParagraphBlock, + PictureBlock, + TableBlock, + TocBlock, + UnknownBlock, +) LoadMode = Literal["single", "paragraph", "ir-blocks"] @@ -40,21 +53,32 @@ class HwpLoader(BaseLoader): - ``"single"`` : 전체 문서를 단일 Document 로 (기본) - ``"paragraph"``: 문단 텍스트별 Document (RAG 청킹용) - ``"ir-blocks"``: Document IR 의 Block 단위 — 표 구조 보존 + Provenance 메타데이터 + include_furniture: ``mode="ir-blocks"`` 일 때만 의미. True 면 본문 블록 다음에 + furniture (page_headers / page_footers / footnotes / endnotes) 도 + LangChain Document 로 추가 yield 한다 (각 Document metadata 에 + ``scope="furniture"``). 다른 모드에서는 무시. 기본 False — RAG body + 검색 오염 회피. Raises: ValueError: ``mode`` 값이 유효하지 않거나, 파일 포맷이 유효하지 않을 때. FileNotFoundError: 파일이 존재하지 않을 때. OSError: 그 외 I/O 오류. - ImportError: async 변형 사용 시 ``aiofiles`` 미설치. """ - def __init__(self, path: str, *, mode: LoadMode = "single") -> None: + def __init__( + self, + path: str, + *, + mode: LoadMode = "single", + include_furniture: bool = False, + ) -> None: if mode not in ("single", "paragraph", "ir-blocks"): raise ValueError( f"mode 는 'single' / 'paragraph' / 'ir-blocks' 중 하나여야 합니다: {mode!r}" ) self.path = path self.mode: LoadMode = mode + self.include_furniture: bool = include_furniture # * Sync @@ -71,20 +95,21 @@ def lazy_load(self) -> Iterator[Document]: """ yield from self._yield_documents(rhwp.parse(self.path)) - # * Async — rhwp.aparse (aiofiles 기반) 로 파일 I/O 만 async, 이후 yield 는 sync. - # Rust _Document 가 unsendable 이라 threadpool 오프로드 (to_thread) 는 panic — - # 대신 event loop 스레드에서 Document 를 생성하여 같은 스레드 에서 소비한다. + # * Async — rhwp.aparse (stdlib asyncio.to_thread 기반) 로 파일 I/O 만 async, + # 이후 yield 는 sync. Rust _Document 가 unsendable 이라 Document 자체의 + # threadpool 오프로드 (asyncio.to_thread(parse, path)) 는 panic — 대신 + # 파일 read 만 thread offload, Document 는 event loop 스레드에서 생성·소비. async def aload(self) -> list[Document]: - """:meth:`load` 의 async 변형. ``aiofiles`` 로 파일 읽기만 async 처리.""" + """:meth:`load` 의 async 변형. 파일 읽기만 async 처리, 추가 의존성 없음.""" return [doc async for doc in self.alazy_load()] async def alazy_load(self) -> AsyncIterator[Document]: """:meth:`lazy_load` 의 async 변형. - 파일 I/O 는 ``rhwp.aparse`` 가 aiofiles 로 async 처리, 이후 블록 순회는 - event loop 스레드에서 sync 실행 — 각 yield 사이에서 event loop 에 제어 - 반환 (async for 는 자동으로 checkpoint 를 제공). + 파일 I/O 는 ``rhwp.aparse`` 가 stdlib ``asyncio.to_thread`` 로 async 처리, + 이후 블록 순회는 event loop 스레드에서 sync 실행 — 각 yield 사이에서 + event loop 에 제어 반환 (async for 는 자동으로 checkpoint 를 제공). """ rhwp_doc = await rhwp.aparse(self.path) for doc in self._yield_documents(rhwp_doc): @@ -131,6 +156,19 @@ def _yield_documents(self, rhwp_doc: rhwp.Document) -> Iterator[Document]: metadata={**base_metadata, **extra_meta}, ) + # * include_furniture=True 면 page_headers/footers/footnotes/endnotes 도 yield. + # 각 Document 메타에 ``scope="furniture"`` — RAG 가 body/furniture 분리 색인. + if not self.include_furniture: + return + for block in ir.iter_blocks(scope="furniture", recurse=True): + content, extra_meta = _block_to_content_and_meta(block) + if not content.strip(): + continue + yield Document( + page_content=content, + metadata={**base_metadata, **extra_meta, "scope": "furniture"}, + ) + def _block_to_content_and_meta(block: Block) -> tuple[str, dict[str, Any]]: """Block → (page_content, block-specific metadata).""" @@ -143,7 +181,12 @@ def _block_to_content_and_meta(block: Block) -> tuple[str, dict[str, Any]]: "char_end": block.prov.char_end, } if isinstance(block, TableBlock): - # ^ HTML 을 page_content 로 — LLM 에 구조 정보 제공. 검색 색인용 평문은 메타로 노출 + # ^ HTML 을 page_content 로 — LLM 에 구조 정보 제공. 검색 색인용 평문은 메타로 노출. + # caption 은 v0.2.0 호환 평문 우선, 없으면 caption_block.blocks 평문 폴백 + # (PictureBlock 분기와 대칭 — caption 정보 손실 회피). + caption_text = block.caption or ( + _caption_plain_text(block.caption_block) if block.caption_block is not None else None + ) return block.html, { "kind": "table", "section_idx": block.prov.section_idx, @@ -151,7 +194,86 @@ def _block_to_content_and_meta(block: Block) -> tuple[str, dict[str, Any]]: "rows": block.rows, "cols": block.cols, "text": block.text, - "caption": block.caption, + "caption": caption_text, + } + if isinstance(block, PictureBlock): + # ^ caption.blocks 평문 우선 (S3 구조화), 없으면 description (S1 호환). + # image meta 는 RAG 가 picture 를 별도 색인할 때 활용. 빈 content 는 + # lazy_load 상위에서 strip 후 skip. + caption_text = _caption_plain_text(block.caption) if block.caption is not None else "" + content = caption_text or (block.description or "") + meta: dict[str, Any] = { + "kind": "picture", + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + } + if block.image is not None: + meta["image_uri"] = block.image.uri + meta["image_mime"] = block.image.mime_type + return content, meta + if isinstance(block, FormulaBlock): + # ^ text_alt (raw script 의 평문 근사) 우선, 없으면 raw script 자체. + # 사용자가 LaTeX/MathML 변환을 외부에서 적용한 경우 script_kind 가 갱신됨 + return block.text_alt or block.script, { + "kind": "formula", + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + "script_kind": block.script_kind, + "inline": block.inline, + } + if isinstance(block, (FootnoteBlock, EndnoteBlock)): + # ^ 각주/미주 본문 paragraphs 의 평문을 합쳐 content 로. marker_prov 는 본문 인용 + # 위치를 별도 메타로 노출 — RAG 가 "이 각주는 어디 paragraph 에서 인용됐나" 역추적 + text_parts = [b.text for b in block.blocks if isinstance(b, ParagraphBlock) and b.text] + kind_label = "footnote" if isinstance(block, FootnoteBlock) else "endnote" + return "\n".join(text_parts), { + "kind": kind_label, + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + "number": block.number, + "marker_section_idx": block.marker_prov.section_idx, + "marker_para_idx": block.marker_prov.para_idx, + } + if isinstance(block, ListItemBlock): + # ^ marker + " " + text 로 합쳐 content — RAG 가 항목 단위로 색인 가능. + # level/enumerated 는 청킹 시 hierarchy 보존 단서로 사용. + content = f"{block.marker} {block.text}".strip() + return content, { + "kind": "list_item", + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + "level": block.level, + "enumerated": block.enumerated, + } + if isinstance(block, CaptionBlock): + # ^ 단독 CaptionBlock 은 거의 없음 (Picture/Table 자식). 명시적으로 body 에 + # 넣은 사용자 경로만 — direction 메타로 노출. + return _caption_plain_text(block), { + "kind": "caption", + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + "direction": block.direction, + } + if isinstance(block, TocBlock): + # ^ entries 의 text 들을 개행 결합. v0.3.0 entries 는 빈 리스트가 일반적 + # (TOC entry 추출은 v0.4.0+) — 빈 content 는 lazy_load 상위에서 skip. + toc_text = "\n".join(e.text for e in block.entries if e.text) + return toc_text, { + "kind": "toc", + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + "entry_count": len(block.entries), + } + if isinstance(block, FieldBlock): + # ^ cached_value 가 있으면 그것이 content (예: 자동 날짜). raw_instruction 은 + # round-trip 보존용으로 메타에. v0.3.0 은 cached_value 가 항상 None 이므로 + # 대부분 빈 content — lazy_load 상위에서 skip 됨. + return block.cached_value or "", { + "kind": "field", + "section_idx": block.prov.section_idx, + "para_idx": block.prov.para_idx, + "field_kind": block.field_kind, + "raw_instruction": block.raw_instruction, } # 새 Block variant 가 추가되면 그 variant 의 elif 를 이 assert 보다 위에 먼저 # 추가해야 한다. 그러지 않으면 AssertionError 로 fail-fast (silent fallback 방지) @@ -161,3 +283,23 @@ def _block_to_content_and_meta(block: Block) -> tuple[str, dict[str, Any]]: "section_idx": block.prov.section_idx, "para_idx": block.prov.para_idx, } + + +def _caption_plain_text(caption: CaptionBlock) -> str: + """CaptionBlock.blocks 의 텍스트 표현을 개행 결합 (S3 신규 헬퍼). + + 포함 대상: ParagraphBlock.text + FormulaBlock.text_alt|script + FieldBlock.cached_value. + 캡션 안의 수식·필드도 평문 흐름의 일부 (spec § 5 "캡션 안의 인라인 수식·필드도 + 자연스럽게 표현") — RAG 색인에 자연 포함. 표/그림 등 구조 블록은 별도 색인. + """ + parts: list[str] = [] + for b in caption.blocks: + if isinstance(b, ParagraphBlock) and b.text: + parts.append(b.text) + elif isinstance(b, FormulaBlock): + text = b.text_alt or b.script + if text: + parts.append(text) + elif isinstance(b, FieldBlock) and b.cached_value: + parts.append(b.cached_value) + return "\n".join(parts) diff --git a/python/rhwp/integrations/langchain.pyi b/python/rhwp/integrations/langchain.pyi index 51d1bfa..d9a42c0 100644 --- a/python/rhwp/integrations/langchain.pyi +++ b/python/rhwp/integrations/langchain.pyi @@ -11,7 +11,14 @@ LoadMode = Literal["single", "paragraph", "ir-blocks"] class HwpLoader(BaseLoader): path: str mode: LoadMode + include_furniture: bool - def __init__(self, path: str, *, mode: LoadMode = "single") -> None: ... + def __init__( + self, + path: str, + *, + mode: LoadMode = "single", + include_furniture: bool = False, + ) -> None: ... def load(self) -> list[Document]: ... def lazy_load(self) -> Iterator[Document]: ... diff --git a/python/rhwp/ir/__init__.pyi b/python/rhwp/ir/__init__.pyi index 3741936..a1cceb7 100644 --- a/python/rhwp/ir/__init__.pyi +++ b/python/rhwp/ir/__init__.pyi @@ -6,24 +6,51 @@ from rhwp.ir.nodes import ( from rhwp.ir.nodes import ( Block as Block, ) +from rhwp.ir.nodes import ( + CaptionBlock as CaptionBlock, +) from rhwp.ir.nodes import ( DocumentMetadata as DocumentMetadata, ) from rhwp.ir.nodes import ( DocumentSource as DocumentSource, ) +from rhwp.ir.nodes import ( + EndnoteBlock as EndnoteBlock, +) +from rhwp.ir.nodes import ( + FieldBlock as FieldBlock, +) +from rhwp.ir.nodes import ( + FieldKind as FieldKind, +) +from rhwp.ir.nodes import ( + FootnoteBlock as FootnoteBlock, +) +from rhwp.ir.nodes import ( + FormulaBlock as FormulaBlock, +) from rhwp.ir.nodes import ( Furniture as Furniture, ) from rhwp.ir.nodes import ( HwpDocument as HwpDocument, ) +from rhwp.ir.nodes import ( + ImageRef as ImageRef, +) from rhwp.ir.nodes import ( InlineRun as InlineRun, ) +from rhwp.ir.nodes import ( + ListItemBlock as ListItemBlock, +) from rhwp.ir.nodes import ( ParagraphBlock as ParagraphBlock, ) +from rhwp.ir.nodes import ( + PictureBlock as PictureBlock, +) from rhwp.ir.nodes import ( Provenance as Provenance, ) @@ -39,6 +66,12 @@ from rhwp.ir.nodes import ( from rhwp.ir.nodes import ( TableCell as TableCell, ) +from rhwp.ir.nodes import ( + TocBlock as TocBlock, +) +from rhwp.ir.nodes import ( + TocEntryBlock as TocEntryBlock, +) from rhwp.ir.nodes import ( UnknownBlock as UnknownBlock, ) @@ -58,12 +91,21 @@ from rhwp.ir.schema import ( __all__ = [ "CURRENT_SCHEMA_VERSION", "Block", + "CaptionBlock", "DocumentMetadata", "DocumentSource", + "EndnoteBlock", + "FieldBlock", + "FieldKind", + "FootnoteBlock", + "FormulaBlock", "Furniture", "HwpDocument", + "ImageRef", "InlineRun", + "ListItemBlock", "ParagraphBlock", + "PictureBlock", "Provenance", "SCHEMA_DIALECT", "SCHEMA_ID", @@ -71,6 +113,8 @@ __all__ = [ "Section", "TableBlock", "TableCell", + "TocBlock", + "TocEntryBlock", "UnknownBlock", "export_schema", "load_schema", diff --git a/python/rhwp/ir/_mapper.py b/python/rhwp/ir/_mapper.py index 9b1487f..64cb74a 100644 --- a/python/rhwp/ir/_mapper.py +++ b/python/rhwp/ir/_mapper.py @@ -2,8 +2,8 @@ ``src/ir.rs`` 의 ``#[derive(IntoPyObject)]`` struct 들이 내보내는 dict 트리를 입력으로 받아 ``rhwp.ir.nodes`` 의 모델 트리를 구성한다. 도메인 규칙 (cell role -분류, HTML 직렬화, inline run 폴백 정책) 은 여기서 결정하여 IR 진화 시 -maturin rebuild 를 회피한다. +분류, HTML 직렬화, mime 매핑, inline run 폴백 정책, FieldKind 매핑) 은 여기서 +결정하여 IR 진화 시 maturin rebuild 를 회피한다. Rust 출력 계약은 ``src/ir.rs`` 의 struct field 이름과 1:1 대응 — 구조는 ``rhwp.ir._raw_types`` 의 TypedDict 가 정적으로 고정한다. key 변경 시 양쪽 @@ -11,28 +11,85 @@ 따른다 (소비자는 ``rhwp.ir.nodes`` 의 공개 IR 모델만 사용). """ -from typing import Literal +from typing import Final, Literal, get_args from rhwp.ir._raw_types import ( + RawCaption, RawCell, RawCharRun, RawDocument, + RawEndnote, + RawField, + RawFootnote, + RawFormula, + RawImageRef, RawParagraph, + RawPicture, RawTable, + RawToc, + RawTocEntry, ) from rhwp.ir.nodes import ( Block, + CaptionBlock, DocumentSource, + EndnoteBlock, + FieldBlock, + FieldKind, + FootnoteBlock, + FormulaBlock, Furniture, HwpDocument, + ImageRef, InlineRun, + ListItemBlock, ParagraphBlock, + PictureBlock, Provenance, Section, TableBlock, TableCell, + TocBlock, + TocEntryBlock, ) +# ^ 상류 BinData.extension → 표준 mime type. 누락/미지 확장자는 폴백. +# Embedding 만 채워지므로 PDF 등 비-이미지 확장자는 등장하지 않음. +_MIME_BY_EXT: Final[dict[str, str]] = { + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "bmp": "image/bmp", + "gif": "image/gif", + "tif": "image/tiff", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + "svg": "image/svg+xml", + "webp": "image/webp", +} +_FALLBACK_MIME: Final = "application/octet-stream" + +# ^ FieldKind Literal 의 value set — Rust 가 출고한 lowercase string 이 본 set 에 +# 있어야 한다. 미지 값은 mapper 가 "unknown" 으로 강제 — silent fallback 회피 +# 하면서 (forward-compat 보존) 정확한 enum 검증 동시 충족. +_VALID_FIELD_KINDS: Final[frozenset[str]] = frozenset(get_args(FieldKind)) + +# ^ Caption.direction 닫힌 어휘 — Rust 가 lowercase 출고. CaptionBlock.direction +# Literal 과 동일. +_VALID_CAPTION_DIRECTIONS: Final[frozenset[str]] = frozenset(("top", "bottom", "left", "right")) + +# ^ ParaShape.head_type → (marker placeholder, enumerated) 매핑. v0.3.0 단순 정책 — +# 정확한 marker (``"가."`` / ``"(a)"`` 등) 는 Numbering.level_formats lookup 필요해 +# v0.4.0+ 검토 (spec § 4 결정). 미지의 head_type 은 bullet 폴백 (마커가 없는 paragraph +# 는 애초에 build_raw_list_info 가 None 반환하므로 미지 케이스는 forward-compat 만). +_LIST_MARKER_BY_HEAD: Final[dict[str, tuple[str, bool]]] = { + "number": ("1.", True), + "outline": ("1.", True), + "bullet": ("•", False), +} +_FALLBACK_LIST_MARKER: Final[tuple[str, bool]] = ("-", False) + def build_hwp_document(raw: RawDocument) -> HwpDocument: """Rust raw dict → Pydantic ``HwpDocument``. @@ -48,25 +105,57 @@ def build_hwp_document(raw: RawDocument) -> HwpDocument: for raw_para in raw["paragraphs"]: body.extend(_flatten_paragraph(raw_para)) + page_headers: list[Block] = [] + for raw_hdr in raw["headers"]: + page_headers.extend(_flatten_paragraph(raw_hdr)) + page_footers: list[Block] = [] + for raw_ftr in raw["footers"]: + page_footers.extend(_flatten_paragraph(raw_ftr)) + footnotes = [_build_footnote_block(fn) for fn in raw["footnotes"]] + endnotes = [_build_endnote_block(en) for en in raw["endnotes"]] + return HwpDocument( source=source, sections=sections, body=body, - furniture=Furniture(), + furniture=Furniture( + page_headers=page_headers, + page_footers=page_footers, + footnotes=footnotes, + endnotes=endnotes, + ), ) def _flatten_paragraph(raw_para: RawParagraph) -> list[Block]: - """Paragraph → ParagraphBlock + 각 내부 표마다 TableBlock. + """Paragraph → ParagraphBlock|ListItemBlock + 표/그림/수식/TOC/필드 파생 블록. 파생 블록들은 외부 paragraph 의 ``(section_idx, para_idx)`` Provenance 를 공유 — iter_blocks 소비자가 동일 문단 파생임을 식별 가능. ``_build_table_cell`` 이 본 함수를 재호출해 셀 내부 문단까지 평탄화 — 중첩 표 (표 안의 표) 를 - 자연스럽게 지원한다. + 자연스럽게 지원. + + 출고 순서: paragraph_or_list_item → tables → pictures → formulas → tocs → fields. + HWP controls 의 원래 시각 순서 보존은 v0.4.0+ 에서 ``order: int`` 필드 검토. + + paragraph 자체는 ``list_info`` 가 있으면 ``ListItemBlock``, 없으면 ``ParagraphBlock``. """ - blocks: list[Block] = [_build_paragraph_block(raw_para)] + head: Block + if raw_para["list_info"] is not None: + head = _build_list_item_block(raw_para) + else: + head = _build_paragraph_block(raw_para) + blocks: list[Block] = [head] for raw_table in raw_para["tables"]: blocks.append(_build_table_block(raw_para, raw_table)) + for raw_pic in raw_para["pictures"]: + blocks.append(_build_picture_block(raw_pic)) + for raw_eq in raw_para["formulas"]: + blocks.append(_build_formula_block(raw_eq)) + for raw_toc in raw_para["tocs"]: + blocks.append(_build_toc_block(raw_toc)) + for raw_field in raw_para["fields"]: + blocks.append(_build_field_block(raw_field)) return blocks @@ -84,6 +173,41 @@ def _build_paragraph_block(raw_para: RawParagraph) -> ParagraphBlock: ) +def _build_list_item_block(raw_para: RawParagraph) -> ListItemBlock: + """Paragraph + list_info → ListItemBlock. + + text/inlines 는 ParagraphBlock 과 동일 (mapper 는 마커 split 하지 않음 — + 상류는 paragraph.text 에 마커를 포함하지 않으므로 그대로 평문). + + marker placeholder + enumerated 는 ``head_type`` (Rust 가 ParaShape.head_type + 을 lowercase string 으로 출고) 으로 결정 — 정확한 marker (``"가."`` 등) 는 + v0.4.0+ 의 Numbering.level_formats lookup 에서. + """ + text = raw_para["text"] + li = raw_para["list_info"] + if li is None: + # ^ caller (_flatten_paragraph) 의 사전 분기를 신뢰하지만, fail-fast 로 + # silent fallback 회피 — Python -O 에서 assert 제거되어도 명확한 에러. + raise ValueError( + "_build_list_item_block requires raw_para['list_info'] != None — " + "use _build_paragraph_block for non-list paragraphs" + ) + marker, enumerated = _LIST_MARKER_BY_HEAD.get(li["head_type"], _FALLBACK_LIST_MARKER) + return ListItemBlock( + text=text, + inlines=_build_inline_runs(text, raw_para["char_runs"]), + enumerated=enumerated, + marker=marker, + level=li["level"], + prov=Provenance( + section_idx=raw_para["section_idx"], + para_idx=raw_para["para_idx"], + char_start=0, + char_end=len(text), + ), + ) + + def _build_inline_runs(text: str, char_runs: list[RawCharRun]) -> list[InlineRun]: """``char_runs`` 는 Rust 에서 codepoint range 로 해소된 상태로 입고된다. @@ -126,6 +250,8 @@ def _build_inline_runs(text: str, char_runs: list[RawCharRun]) -> list[InlineRun def _build_table_block(raw_para: RawParagraph, raw_table: RawTable) -> TableBlock: cols = raw_table["cols"] cells = [_build_table_cell(c, cols) for c in raw_table["cells"]] + raw_caption_block = raw_table["caption_block"] + caption_block = _build_caption_block(raw_caption_block) if raw_caption_block else None return TableBlock( rows=raw_table["rows"], cols=cols, @@ -133,6 +259,7 @@ def _build_table_block(raw_para: RawParagraph, raw_table: RawTable) -> TableBloc html=_table_to_html(raw_table), text=_table_to_text(raw_table), caption=raw_table["caption"], + caption_block=caption_block, prov=Provenance( section_idx=raw_para["section_idx"], para_idx=raw_para["para_idx"], @@ -175,6 +302,198 @@ def _cell_role( return "data" +def _build_picture_block(raw_pic: RawPicture) -> PictureBlock: + """RawPicture → PictureBlock. + + image=None 케이스: 상류 Picture 가 bin_data_id=0 (미할당) 으로 들어왔거나 + bin_data_list 에서 lookup 실패 — broken reference 로 그대로 보존한다 (소비자가 + ``picture.image is None`` 으로 분기 가능). + + caption 필드 (S3 신규) 는 raw_pic["caption"] 이 있으면 CaptionBlock 합성. + description (S1 호환) 은 그대로 보존 — caption 평문 fallback path. + """ + image: ImageRef | None = None + raw_img = raw_pic["image"] + if raw_img is not None: + image = ImageRef( + uri=_image_uri(raw_img), + mime_type=_mime_for_extension(raw_img["extension"]), + ) + raw_cap = raw_pic["caption"] + caption = _build_caption_block(raw_cap) if raw_cap is not None else None + return PictureBlock( + image=image, + caption=caption, + description=raw_pic["description"], + prov=Provenance( + section_idx=raw_pic["section_idx"], + para_idx=raw_pic["para_idx"], + char_start=None, + char_end=None, + ), + ) + + +def _build_formula_block(raw_eq: RawFormula) -> FormulaBlock: + """RawFormula → FormulaBlock. v0.3.0 시점 ``script_kind`` 는 항상 ``"hwp_eq"``. + + ``inline`` 은 v0.3.0 미식별 (모든 수식을 별도 디스플레이로 처리). 본문 inline + 여부는 상류 ``Equation.common.inline_object`` 등에서 추론할 수 있지만 현 시점 + 1차 사용처 (RAG) 에서 차이 의미 없음 — 모두 False 출고. + """ + return FormulaBlock( + script=raw_eq["script"], + text_alt=raw_eq["text_alt"], + prov=Provenance( + section_idx=raw_eq["section_idx"], + para_idx=raw_eq["para_idx"], + char_start=None, + char_end=None, + ), + ) + + +def _build_footnote_block(raw_fn: RawFootnote) -> FootnoteBlock: + """RawFootnote → FootnoteBlock. blocks 는 각주 본문 paragraph 들을 평탄화.""" + inner_blocks: list[Block] = [] + for inner in raw_fn["blocks"]: + inner_blocks.extend(_flatten_paragraph(inner)) + # ^ char_start/char_end 는 None — 본문 마커 character 정확 위치는 상류 field_ranges + # 매핑 필요로 v0.4.0+ 검토. nodes.FootnoteBlock docstring §marker precision 참조. + marker = Provenance( + section_idx=raw_fn["marker_section_idx"], + para_idx=raw_fn["marker_para_idx"], + char_start=None, + char_end=None, + ) + return FootnoteBlock( + number=raw_fn["number"], + blocks=inner_blocks, + marker_prov=marker, + prov=marker, + ) + + +def _build_endnote_block(raw_en: RawEndnote) -> EndnoteBlock: + """RawEndnote → EndnoteBlock — Footnote 와 동일 패턴 (marker precision 동일 deferral).""" + inner_blocks: list[Block] = [] + for inner in raw_en["blocks"]: + inner_blocks.extend(_flatten_paragraph(inner)) + marker = Provenance( + section_idx=raw_en["marker_section_idx"], + para_idx=raw_en["marker_para_idx"], + char_start=None, + char_end=None, + ) + return EndnoteBlock( + number=raw_en["number"], + blocks=inner_blocks, + marker_prov=marker, + prov=marker, + ) + + +def _build_caption_block(raw_cap: RawCaption) -> CaptionBlock: + """RawCaption → CaptionBlock. paragraphs 는 ``_flatten_paragraph`` 로 평탄화. + + ``direction`` 은 Rust 가 lowercase string 으로 출고 — 닫힌 어휘 set 검증. + 미지 값은 ``"bottom"`` 폴백 (CaptionDirection::default 와 일치). + """ + inner_blocks: list[Block] = [] + for inner in raw_cap["paragraphs"]: + inner_blocks.extend(_flatten_paragraph(inner)) + direction = ( + raw_cap["direction"] if raw_cap["direction"] in _VALID_CAPTION_DIRECTIONS else "bottom" + ) + return CaptionBlock( + blocks=inner_blocks, + direction=direction, # type: ignore[arg-type] + prov=Provenance( + section_idx=raw_cap["section_idx"], + para_idx=raw_cap["para_idx"], + char_start=None, + char_end=None, + ), + ) + + +def _build_toc_block(raw_toc: RawToc) -> TocBlock: + """RawToc → TocBlock. v0.3.0 entries 는 빈 리스트가 일반 — Rust placeholder. + + entries 채움은 v0.4.0+ — TOC field 안의 paragraphs 추출 + bookmark resolver + 필요. spec § 6 결정 사항 7 참조. + """ + entries = [_build_toc_entry_block(e, raw_toc) for e in raw_toc["entries"]] + return TocBlock( + entries=entries, + prov=Provenance( + section_idx=raw_toc["section_idx"], + para_idx=raw_toc["para_idx"], + char_start=None, + char_end=None, + ), + ) + + +def _build_toc_entry_block(raw_entry: RawTocEntry, raw_toc: RawToc) -> TocEntryBlock: + """RawTocEntry → TocEntryBlock — entry 가 속한 TOC 의 paragraph prov 공유.""" + return TocEntryBlock( + text=raw_entry["text"], + level=raw_entry["level"], + target_bookmark_name=raw_entry["target_bookmark_name"], + target_section_idx=None, # ^ v0.3.0 은 미해소 (resolver 미도입) + cached_page=raw_entry["cached_page"], + is_stale=False, # ^ v0.3.0 은 미검출 (cached 만 노출) + prov=Provenance( + section_idx=raw_toc["section_idx"], + para_idx=raw_toc["para_idx"], + char_start=None, + char_end=None, + ), + ) + + +def _build_field_block(raw_field: RawField) -> FieldBlock: + """RawField → FieldBlock. + + ``field_kind`` 가 닫힌 ``FieldKind`` Literal 어휘에 없으면 ``"unknown"`` 으로 + 강제 + ``field_type_code`` 보존 — 상류가 새 FieldType 을 추가해도 graceful + skip. silent fallback 이지만 ``field_type_code`` 가 forensics 신호로 남으므로 + "원인 추적 가능" 조건 충족. + """ + raw_kind = raw_field["field_kind"] + field_kind: FieldKind = raw_kind if raw_kind in _VALID_FIELD_KINDS else "unknown" # type: ignore[assignment] + return FieldBlock( + field_kind=field_kind, + cached_value=raw_field["cached_value"], + raw_instruction=raw_field["raw_instruction"], + field_type_code=raw_field["field_type_code"], + prov=Provenance( + section_idx=raw_field["section_idx"], + para_idx=raw_field["para_idx"], + char_start=None, + char_end=None, + ), + ) + + +def _image_uri(raw_img: RawImageRef) -> str: + """기본 ``bin://`` URI — embedded/external 모드는 v0.4.0+ opt-in.""" + return f"bin://{raw_img['bin_data_id']}" + + +def _mime_for_extension(extension: str | None) -> str: + """확장자 → 표준 mime. 누락/미지 시 ``application/octet-stream`` 폴백. + + HWP BinData 는 Embedding 타입에만 ``extension`` 을 채운다 — Link 타입은 + extension=None 이라 폴백. 사용자가 mime 정확도가 필요하면 magic byte 검증을 + 별도 적용한다 (예: ``Document.bytes_for_image`` 로 raw 받아 imghdr/PIL). + """ + if extension is None: + return _FALLBACK_MIME + return _MIME_BY_EXT.get(extension.lower(), _FALLBACK_MIME) + + def _table_to_html(raw_table: RawTable) -> str: """Table → HTML 문자열 (HtmlRAG 호환, ir.md §테이블 표현). diff --git a/python/rhwp/ir/_raw_types.py b/python/rhwp/ir/_raw_types.py index 7a2e7ff..cf7a076 100644 --- a/python/rhwp/ir/_raw_types.py +++ b/python/rhwp/ir/_raw_types.py @@ -1,7 +1,9 @@ """rhwp.ir._raw_types — Rust `#[derive(IntoPyObject)]` 출력의 TypedDict 미러. ``src/ir.rs`` 의 ``RawDocument`` / ``RawParagraph`` / ``RawTable`` / ``RawCell`` / -``RawCharRun`` struct 가 Python 에 PyDict 로 출고되는데, 그 dict 의 key 구조를 +``RawCharRun`` / ``RawPicture`` / ``RawImageRef`` / ``RawFormula`` / ``RawFootnote`` +/ ``RawEndnote`` / ``RawListInfo`` / ``RawCaption`` / ``RawToc`` / ``RawTocEntry`` +/ ``RawField`` struct 가 Python 에 PyDict 로 출고되는데, 그 dict 의 key 구조를 정적 타입으로 고정한다. ### 왜 TypedDict 인가 @@ -43,21 +45,150 @@ class RawCell(TypedDict): paragraphs: list["RawParagraph"] +class RawCaption(TypedDict): + """``src/ir.rs::RawCaption`` (S3 신규). + + HWP ``shape::Caption`` (Picture/Table 양쪽) 의 paragraphs + direction 추출. + Python mapper 가 paragraphs 를 ``_flatten_paragraph`` 로 평탄화 → ``CaptionBlock.blocks``. + """ + + direction: str # ^ "top" | "bottom" | "left" | "right" — Rust 가 lowercase 출고 + section_idx: int + para_idx: int + paragraphs: list["RawParagraph"] + + class RawTable(TypedDict): - """``src/ir.rs::RawTable``. ``rows``/``cols`` 는 upstream 원값 그대로 (보정 없음).""" + """``src/ir.rs::RawTable``. ``rows``/``cols`` 는 upstream 원값 그대로 (보정 없음). + + ``caption`` (S1) 은 평문 fallback (호환). ``caption_block`` (S3 신규) 은 구조화 + 캡션 — 둘 다 source 가 같은 HWP Table.caption 이지만 표현 형태만 다름. + """ rows: int cols: int cells: list[RawCell] caption: str | None + caption_block: RawCaption | None + + +class RawImageRef(TypedDict): + """``src/ir.rs::RawImageRef``. URI 합성 / mime 매핑은 mapper 책임.""" + + bin_data_id: int + extension: str | None + has_content: bool + + +class RawPicture(TypedDict): + """``src/ir.rs::RawPicture``. ``image=None`` 은 broken reference (bin_data_id=0). + + ``description`` (S1) 은 caption 평문 fallback 호환. ``caption`` (S3 신규) 은 + 구조화 캡션 — Picture 가 caption 을 가지면 둘 다 채워진다. + """ + + section_idx: int + para_idx: int + image: RawImageRef | None + description: str | None + caption: RawCaption | None + + +class RawFormula(TypedDict): + """``src/ir.rs::RawFormula``. ``text_alt`` 는 raw script 의 단순 정규화 결과 (S2 신규).""" + + section_idx: int + para_idx: int + script: str + text_alt: str | None + + +class RawFootnote(TypedDict): + """``src/ir.rs::RawFootnote``. ``blocks`` 는 각주 본문의 paragraph 들 (S2 신규). + + ``marker_section_idx`` / ``marker_para_idx`` 는 본문 인용 마커가 등장한 parent + paragraph 위치 — RAG 가 각주 → 본문 역추적 시 사용. + """ + + marker_section_idx: int + marker_para_idx: int + number: int + blocks: list["RawParagraph"] + + +class RawEndnote(TypedDict): + """``src/ir.rs::RawEndnote``. Footnote 와 동일 구조 — 배치만 다름 (페이지 하단 vs 문서 끝).""" + + marker_section_idx: int + marker_para_idx: int + number: int + blocks: list["RawParagraph"] + + +class RawListInfo(TypedDict): + """``src/ir.rs::RawListInfo`` (S3 신규). + + Paragraph 가 list item 인지 표시 — 상류 ``ParaShape.head_type`` 가 비-None + 일 때 채워진다. mapper 가 본 dict 가 있으면 ParagraphBlock 대신 ListItemBlock + 을 emit. + + ``head_type`` 은 ``"number"`` / ``"bullet"`` / ``"outline"`` lowercase string — + Python mapper 가 marker placeholder + enumerated 를 결정 (도메인 분기는 Python + 책임). v0.4.0+ 의 정확 marker 추출 (Numbering.level_formats lookup) 도 동일 + 위치에서. + """ + + head_type: str + level: int + + +class RawTocEntry(TypedDict): + """``src/ir.rs::RawTocEntry`` (S3 신규). + + v0.3.0 에서는 매퍼가 빈 entries 만 출고 — 본 TypedDict 는 v0.4.0+ 의 + entry 추출 시점 forward-compat 용 placeholder. + """ + + text: str + level: int + target_bookmark_name: str | None + cached_page: int | None + + +class RawToc(TypedDict): + """``src/ir.rs::RawToc`` (S3 신규). ``FieldType::TableOfContents`` 검출 시 emit.""" + + section_idx: int + para_idx: int + entries: list[RawTocEntry] + + +class RawField(TypedDict): + """``src/ir.rs::RawField`` (S3 신규). + + ``field_kind`` 는 Rust 에서 lowercase string 으로 직렬화된 ``FieldType`` — + Python 측 ``FieldKind`` Literal 과 정확히 같은 어휘여야 한다 (mapper 가 + Literal 검증). 미지의 FieldType 은 ``"unknown"`` + ``field_type_code`` 채움. + """ + + section_idx: int + para_idx: int + field_kind: str + cached_value: str | None + raw_instruction: str | None + field_type_code: int | None class RawParagraph(TypedDict): """``src/ir.rs::RawParagraph``. - ``tables`` 는 문단의 ``controls`` 중 ``Control::Table`` 만 추출된 리스트. - ``section_idx`` / ``para_idx`` 는 외부 paragraph 의 위치 — 셀 내부 문단이라도 - 외부 표가 속한 문단의 값을 공유한다 (Provenance 계약). + ``tables`` / ``pictures`` / ``formulas`` / ``tocs`` / ``fields`` 는 문단의 + ``controls`` 중 해당 타입만 추출된 리스트. ``section_idx`` / ``para_idx`` 는 + 외부 paragraph 의 위치 — 셀 내부 문단이라도 외부 표가 속한 문단의 값을 공유한다 + (Provenance 계약). + + ``list_info`` (S3 신규) 가 비-None 이면 mapper 가 ParagraphBlock 대신 + ListItemBlock 을 emit — paragraph 자체가 list item 으로 분류된다. """ section_idx: int @@ -65,11 +196,24 @@ class RawParagraph(TypedDict): text: str char_runs: list[RawCharRun] tables: list[RawTable] + pictures: list[RawPicture] + formulas: list[RawFormula] + tocs: list[RawToc] + fields: list[RawField] + list_info: RawListInfo | None class RawDocument(TypedDict): - """``src/ir.rs::RawDocument`` — ``to_ir`` Rust→Python 경계의 루트.""" + """``src/ir.rs::RawDocument`` — ``to_ir`` Rust→Python 경계의 루트. + + ``headers`` / ``footers`` 는 furniture.page_headers / page_footers 로, + ``footnotes`` / ``endnotes`` 는 furniture.footnotes / endnotes 로 매핑된다. + """ source_uri: str | None section_count: int paragraphs: list[RawParagraph] + headers: list[RawParagraph] + footers: list[RawParagraph] + footnotes: list[RawFootnote] + endnotes: list[RawEndnote] diff --git a/python/rhwp/ir/nodes.py b/python/rhwp/ir/nodes.py index d5ff1e0..50ddf51 100644 --- a/python/rhwp/ir/nodes.py +++ b/python/rhwp/ir/nodes.py @@ -1,12 +1,16 @@ -"""rhwp.ir.nodes — Document IR Pydantic 모델 (schema_version "1.0"). +"""rhwp.ir.nodes — Document IR Pydantic 모델 (schema_version "1.1"). -재귀 구조 (``TableCell.blocks`` → ``Block`` → ``TableBlock.cells`` → ``TableCell``) -는 문자열 전방 참조 + 파일 하단 ``model_rebuild()`` 로 해소한다. +재귀 구조 (``TableCell.blocks`` → ``Block`` → ``TableBlock.cells`` → ``TableCell``, +``FootnoteBlock.blocks`` / ``EndnoteBlock.blocks`` / ``CaptionBlock.blocks`` → +``Block``) 는 문자열 전방 참조 + 파일 하단 ``model_rebuild()`` 로 해소한다. + +스키마 버전 1.1 (v0.3.0) — v1.0 의 paragraph/table 위에 picture (S1), formula / +footnote / endnote (S2), list_item / caption / toc / field (S3) 가 차례로 추가된다. """ import warnings -from collections.abc import Iterator -from typing import Annotated, Any, Final, Literal, Optional, Union +from collections.abc import Iterator, Sequence +from typing import Annotated, Any, Final, Literal from pydantic import ( BaseModel, @@ -21,22 +25,33 @@ __all__ = [ "CURRENT_SCHEMA_VERSION", "Block", + "CaptionBlock", "DocumentMetadata", "DocumentSource", + "EndnoteBlock", + "FieldBlock", + "FieldKind", + "FootnoteBlock", + "FormulaBlock", "Furniture", "HwpDocument", + "ImageRef", "InlineRun", + "ListItemBlock", "ParagraphBlock", + "PictureBlock", "Provenance", "SchemaVersion", "Section", "TableBlock", "TableCell", + "TocBlock", + "TocEntryBlock", "UnknownBlock", ] -CURRENT_SCHEMA_VERSION: Final = "1.0" +CURRENT_SCHEMA_VERSION: Final = "1.1" _SCHEMA_VERSION_PATTERN: Final = r"^\d+\.\d+(\.\d+)?$" SchemaVersion = Annotated[ @@ -45,6 +60,29 @@ ] +# ^ 상류 ``FieldType`` 14 종 + ``"unknown"`` 안전판. ``"calc"`` 는 상류 +# ``FieldType::Formula`` 매핑 — "수식 (eqed)" 와 이름 충돌 회피용 별도 어휘. +# 미래에 상류가 새 FieldType 을 추가하면 매퍼는 일단 ``field_kind="unknown"`` +# + ``field_type_code=`` 로 출고하고, 다음 MINOR 에서 Literal 확장. +FieldKind = Literal[ + "date", + "doc_date", + "path", + "bookmark", + "mailmerge", + "crossref", + "calc", + "clickhere", + "summary", + "userinfo", + "hyperlink", + "memo", + "private_info", + "toc", + "unknown", +] + + class Provenance(BaseModel): """블록의 원본 문서 내 위치. 다운스트림 청커가 원본을 역추적 가능하게 한다.""" @@ -150,6 +188,316 @@ class ParagraphBlock(BaseModel): prov: Provenance +class ListItemBlock(BaseModel): + """목록 항목 — HWP ``ParaShape`` 의 ``head_type`` 가 비-None 인 단락. + + HWP 상류는 list group 컨테이너가 없다 (``ParaShape.head_type`` 가 + Number/Bullet/Outline 인 단락이 곧 list item) — group container 는 도입하지 + 않고 평면 (``level + marker + enumerated``) 으로 표현. RAG 청킹 시 항목 단위 + 검색에 그대로 매핑. + + ``marker`` 는 v0.3.0 단순 정책: ``"•"`` (bullet) / ``"1."`` (number/outline). + 상류 ``Numbering.level_formats`` lookup 으로 정확한 마커 (예: ``"가."``, + ``"(a)"``) 추출은 v0.4.0+ 에서 검토 — 현 시점은 placeholder 만. + + ``text`` 는 마커 제외 본문 (``ParagraphBlock`` 과 동일) — 마커는 + ``marker`` 필드로 별도. ``"1. 제목"`` 이 아니라 ``marker="1."``, + ``text="제목"`` 형태. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["list_item"] = "list_item" + text: str = "" + inlines: list[InlineRun] = Field(default_factory=list) + enumerated: bool = Field( + default=False, + description="True: 번호 매김 (1./가./i. 등), False: 글머리표 (•/■/▶ 등).", + ) + marker: str = Field( + default="-", + description=( + '표시 마커 placeholder. v0.3.0 은 ``"•"`` / ``"1."`` 만 출고 — ' + "정확 마커는 상류 Numbering lookup 필요해 v0.4.0+ 검토." + ), + ) + level: int = Field( + default=0, + description=( + "0-indexed nesting depth. 상류 ``ParaShape.para_level`` (0~6, " + "1~7 수준 표시) 를 그대로 매핑." + ), + ) + prov: Provenance + + +class ImageRef(BaseModel): + """이미지 참조 — binary 자체는 IR JSON 에 inline 되지 않는다. + + URI 스킴: + + - ``bin://`` (기본): 상류 ``Picture.image_attr.bin_data_id`` 그대로. + ``Document.bytes_for_image(picture)`` 로 raw bytes 해석. + - ``data:image/...;base64,...``: embedded 모드 (v0.4.0+ opt-in 검토) + - ``file://path``: external 모드 (v0.4.0+ opt-in 검토) + + ``uri`` 는 strict 검증 회피를 위해 plain ``str`` — JSON Schema strict mode 가 + ``format: uri`` 를 거부하는 경우가 있고, ``bin://`` / ``data:`` 모두 허용해야 하므로. + URL 검증은 사용자 책임. + + width/height/dpi 는 v0.3.0 S1 에서 항상 ``None`` — 상류 Picture 가 픽셀 + dimension 을 직접 노출하지 않으며 (border 좌표만 노출) HWPUNIT 계산은 + v0.4.0+ 에서 검토. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + uri: str + mime_type: str + width: int | None = None + height: int | None = None + dpi: int | None = None + + +class CaptionBlock(BaseModel): + """캡션 블록 — 그림/표 등에 부착되는 보조 설명. + + HWP 는 ``Picture.caption: Option`` / ``Table.caption: Option`` + 으로 항상 1:1 부착 관계 — 캡션은 부모 블록의 필드로 컨테인먼트 (ref-id 미도입). + Azure DI / Docling 의 string-ref 패턴은 1:N 주소가 가능하지만 HWP 사용처에서 + 이점이 없고 소비자가 JSON-Pointer resolver 를 구현해야 하므로 거부 (spec § 5). + + ``blocks`` 가 재귀 ``Block`` 리스트 — 캡션 안의 인라인 수식·필드도 자연스럽게 + 표현 (예: ``"<그림 1> 회로도 ${}^{2}$"`` 같은 캡션). + + ``direction`` 은 캡션 배치 방향. 상류 ``CaptionDirection`` (Left/Right/Top/Bottom) + 을 lowercase Literal 로 매핑. 기본값 ``"bottom"`` 은 HWP 기본 + Docling 관례 + 일치. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["caption"] = "caption" + blocks: list["Block"] = Field(default_factory=list) + direction: Literal["top", "bottom", "left", "right"] = "bottom" + prov: Provenance + + +class PictureBlock(BaseModel): + """그림 블록 — HWP ``Control::Picture``. + + ``image is None`` 은 명시적 broken reference — 상류 ``Picture.image_attr.bin_data_id`` + 가 0 (미할당) 인 케이스만 해당. ``bin_data_id`` 가 0 이 아니어도 실제 binary + lookup 이 실패할 수 있다 (Link 타입이거나 bin_data_content 누락) — 이 경우 + ImageRef 는 ``mime_type="application/octet-stream"`` 으로 출고되고 실패는 + ``Document.bytes_for_image`` 호출 시점에 ValueError 로 표면화된다 (forensics + 위해 bin_data_id 자체는 URI 에 보존). + + ``caption`` 은 v0.3.0 S3 부터 채워지는 구조화 캡션 — 부모 ``PictureBlock`` 의 + 필드로 컨테인먼트 (ref-id 없이 직접 연결, spec § 5). + + ``description`` 은 HWP 의 alt-text 슬롯 — caption paragraph 의 평문 fallback + (S1 호환 보존). v0.4.0+ 에서 caption 충실하게 채워지면 description 은 deprecate + 검토. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["picture"] = "picture" + image: ImageRef | None = None + caption: "CaptionBlock | None" = Field( + default=None, + description=( + "구조화 캡션 — v0.3.0 S3 부터 채워짐. ``blocks`` 안에 표/수식/필드도 " + "재귀 표현. None 은 캡션 부재 (HWP Picture.caption == None)." + ), + ) + description: str | None = Field( + default=None, + description=( + "HWP 의 alt-text — 상류 caption paragraph 평문 fallback (S1 보존). " + "구조화 캡션이 필요하면 ``caption`` 필드 사용." + ), + ) + prov: Provenance + + +class FormulaBlock(BaseModel): + """수식 블록 — HWP ``Control::Equation``. + + HWP 수식은 자체 스크립트 (``script``) 로 저장된다 (예: ``"1 over 2 + sqrt{x^2}"``). + HWP equation script → LaTeX/MathML 자동 변환은 공개 도구 부재로 v0.3.0 미제공 + (spec § 비목표). ``script_kind="hwp_eq"`` 로 raw 출고 → 사용자가 외부 변환 후 + ``model_copy(update={"script": tex, "script_kind": "latex"})`` 로 재구성 가능. + + ``text_alt`` 는 RAG 폴백 — 단순 정규화 (``over`` → ``/``, ``sqrt{...}`` → ``√(...)``) + 까지만 적용. 실패 시 None. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["formula"] = "formula" + script: str + script_kind: Literal["hwp_eq", "latex", "mathml"] = "hwp_eq" + text_alt: str | None = Field( + default=None, + description=( + "평문 근사 — RAG fallback. ``script`` 의 사람이 읽을 수 있는 형태 " + "(``over`` → ``/`` 등) 를 단순 정규화. 실패 시 None." + ), + ) + inline: bool = Field( + default=False, + description="True: 본문 인라인 수식, False: 별도 디스플레이 수식.", + ) + prov: Provenance + + +class FootnoteBlock(BaseModel): + """각주 블록 — HWP ``Control::Footnote``. + + 각주 본문은 ``furniture.footnotes`` 로 라우팅되어 RAG 의 body 검색을 오염시키지 + 않는다. ``marker_prov`` 는 본문 인용 마커 (``…기존 연구[3]…`` 의 ``[3]`` 위치) 의 + parent paragraph (section_idx, para_idx) 를 가리킨다 — 정확한 char_offset 까지는 + 상류 ``field_ranges`` 매핑이 필요해 v0.4.0+ 검토. ``prov`` 는 각주 본문 자체의 + 위치 = 마커가 등장한 paragraph 와 동일 (각주는 그 paragraph 에서 파생). + + ``blocks`` 가 재귀 ``Block`` 리스트라 각주 본문 안의 표·그림·수식도 자연 지원. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["footnote"] = "footnote" + number: int = Field(description="표시 번호 (1, 2, 3, ...).") + blocks: list["Block"] = Field(default_factory=list) + marker_prov: Provenance = Field( + description="본문 인용 마커 위치 — RAG 가 각주가 어디서 인용됐는지 역추적.", + ) + prov: Provenance + + +class EndnoteBlock(BaseModel): + """미주 블록 — HWP ``Control::Endnote``. + + 각주와 같은 구조지만 배치가 다르다 (각주: 페이지 하단, 미주: 문서/구역 끝). + HWP 가 별도 struct 로 분리하므로 IR 도 분리 — 통합 시 정보 손실. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["endnote"] = "endnote" + number: int + blocks: list["Block"] = Field(default_factory=list) + marker_prov: Provenance + prov: Provenance + + +class TocEntryBlock(BaseModel): + """목차 항목 — ``TocBlock.entries`` 안에서만 살아 있는 leaf type. + + Block 유니온 멤버가 아니다 (``TableCell`` 과 같은 패턴) — ``iter_blocks`` 는 + ``TocBlock`` 만 yield 하고, 항목 순회는 ``toc.entries`` 직접 접근. + + ``cached_page`` 는 HWP 가 저장 시점에 박제한 페이지 번호 — 문서가 편집된 후에 + heading 이 이동하면 stale 가능 (`is_stale=True`). ``is_stale`` 정확 검출은 + heading hierarchy 와 cached text 비교 + bookmark resolution 필요 — v0.3.0 은 + cached value 만 노출하고 stale 검출은 v0.4.0+ 에 위임 (spec § 6 결정). + + ``target_section_idx`` 는 raw bookmark 이름 → section 인덱스 resolution 결과. + 상류 bookmark resolver 가 필요해 v0.3.0 은 항상 None — raw + ``target_bookmark_name`` 만 보존. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["toc_entry"] = "toc_entry" + text: str + level: int = Field( + default=1, + description="1-indexed (h1, h2, h3, ...) — TOC 표시 레벨.", + ) + target_bookmark_name: str | None = Field( + default=None, + description="HWP bookmark 이름 (raw) — v0.4.0+ 의 resolver 입력.", + ) + target_section_idx: int | None = Field( + default=None, + description="resolved section idx — v0.3.0 은 항상 None (resolver 미도입).", + ) + cached_page: int | None = Field( + default=None, + description=( + "저장 시점 페이지 번호 (HWP frozen at save). 편집 후 heading 이 " + "이동하면 stale 가능. 정확 navigation 은 heading hierarchy 쪽." + ), + ) + is_stale: bool = Field( + default=False, + description=( + "cached info ≠ 현재 heading 일치 여부. v0.3.0 은 항상 False — 정확 " + "검출은 v0.4.0+ 에서 (spec § 6)." + ), + ) + prov: Provenance + + +class TocBlock(BaseModel): + """목차 블록 — HWP ``Control::Field`` with ``FieldType::TableOfContents``. + + HWP TOC 는 frozen at save time — 소비자가 신뢰할 수 있는 navigation 은 + (있다면) heading hierarchy 쪽이며 TOC 는 사람이 마지막에 본 표시 그대로의 + 스냅샷이다. + + v0.3.0 S3 매퍼는 TOC field 검출만 수행 — 항목 추출은 v0.4.0+ 에서 검토 + (spec § 6 결정). 따라서 v0.3.0 출고 시 ``entries`` 는 빈 리스트가 일반적. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["toc"] = "toc" + entries: list[TocEntryBlock] = Field(default_factory=list) + prov: Provenance + + +class FieldBlock(BaseModel): + """필드 컨트롤 — HWP ``Control::Field`` (TOC 제외 13 종 + Unknown). + + 상류 ``FieldType`` 14 종 → 닫힌 ``FieldKind`` Literal 매핑. ``TableOfContents`` + 는 별도 ``TocBlock`` 으로 라우팅되므로 ``FieldBlock`` 에는 ``"toc"`` 가 + 원칙적으로 등장 안 함 — Literal 로 보존만 하고 (사용자가 직접 구성 시 호환). + + ``InlineRun.href`` 와의 중복: HWP ``Hyperlink`` / ``Bookmark`` Field 는 본문 + InlineRun 의 ``href`` 로 표현될 수도 있지만 v0.3.0 은 모든 Field control 을 + 별도 FieldBlock 으로 emit 한다 — InlineRun.href 자동 채움 path 는 미구현. + + ``raw_instruction`` 은 round-trip 보존용 — Word ```` 와 같은 raw + 명령 문자열. v0.3.0 소비자는 보통 ``cached_value`` 만 사용하지만, 미래 + writeback 시 raw 가 필요. + + ``field_type_code`` 는 forward-compat — 상류가 새 FieldType 을 추가하면 + 매퍼는 ``field_kind="unknown"`` + ``field_type_code=`` 로 출고하고, + 다음 MINOR (v0.4.0) 에서 Literal 확장. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["field"] = "field" + field_kind: FieldKind = "unknown" + cached_value: str | None = Field( + default=None, + description='저장 시점 표시 값 (예: ``"2026-04-26"``). 동적 필드는 stale 가능.', + ) + raw_instruction: str | None = Field( + default=None, + description="HWP field command (Word ```` 대응) — round-trip 보존.", + ) + field_type_code: int | None = Field( + default=None, + description="미지의 raw 코드 — 상류 FieldType 추가 시 forward-compat.", + ) + prov: Provenance + + class UnknownBlock(BaseModel): """Forward-compatibility catch-all. @@ -205,6 +553,9 @@ class TableBlock(BaseModel): - ``cells`` : 프로그래매틱 접근 (SQL 생성, 셀 순회) - ``html`` : LLM 에 제공, rowspan/colspan 보존 (HtmlRAG 호환) - ``text`` : 단순 검색·diff 용 폴백 (행은 개행, 셀은 탭 구분) + + ``caption`` (str) 은 v0.2.0 호환 평문 슬롯 — caption_block 의 첫 paragraph + 텍스트 fallback. 구조화 캡션이 필요하면 ``caption_block`` 사용. """ model_config = ConfigDict(extra="forbid", frozen=True) @@ -215,11 +566,42 @@ class TableBlock(BaseModel): cells: list[TableCell] = Field(default_factory=list) html: str = "" text: str = "" - caption: str | None = None + caption: str | None = Field( + default=None, + description=( + "v0.2.0 호환 평문 캡션 — ``caption_block.blocks`` 첫 ParagraphBlock 의 " + "평문이면 일관성 유지. 구조화 캡션은 ``caption_block`` 사용." + ), + ) + caption_block: "CaptionBlock | None" = Field( + default=None, + description=( + "v0.3.0 S3 신규 — 구조화 캡션. v0.2.0 ``caption: str`` 필드는 그대로 " + "유지하고 옵셔널 신설." + ), + ) prov: Provenance -_KNOWN_KINDS: Final = frozenset({"paragraph", "table"}) +_KNOWN_KINDS: Final = frozenset( + { + # ^ v0.2.0 + "paragraph", + "table", + # ^ v0.3.0 S1 + "picture", + # ^ v0.3.0 S2 + "formula", + "footnote", + "endnote", + # ^ v0.3.0 S3 + "list_item", + "caption", + "toc", + "field", + } +) +# ^ TocEntryBlock 은 union 멤버 아님 (TocBlock.entries 안 leaf type) — _KNOWN_KINDS 미포함. def _block_discriminator(v: Any) -> str: @@ -235,6 +617,14 @@ def _block_discriminator(v: Any) -> str: Block = Annotated[ Annotated[ParagraphBlock, Tag("paragraph")] | Annotated[TableBlock, Tag("table")] + | Annotated[PictureBlock, Tag("picture")] + | Annotated[FormulaBlock, Tag("formula")] + | Annotated[FootnoteBlock, Tag("footnote")] + | Annotated[EndnoteBlock, Tag("endnote")] + | Annotated[ListItemBlock, Tag("list_item")] + | Annotated[CaptionBlock, Tag("caption")] + | Annotated[TocBlock, Tag("toc")] + | Annotated[FieldBlock, Tag("field")] | Annotated[UnknownBlock, Tag("unknown")], Discriminator(_block_discriminator), ] @@ -243,14 +633,20 @@ def _block_discriminator(v: Any) -> str: class Furniture(BaseModel): """장식 노드 컨테이너 — RAG 가 임베딩에서 필터링 가능. - 현재 파서는 본문 블록을 넣지 않으므로 세 리스트는 기본적으로 비어있다. + v0.3.0 S1 부터 ``page_headers`` / ``page_footers`` 가 실제 채워진다. + v0.3.0 S2 부터 ``footnotes`` / ``endnotes`` 도 실제 채워지며 타입이 + ``list[FootnoteBlock]`` / ``list[EndnoteBlock]`` 으로 강화된다. + + iter_blocks(scope="furniture") 순서: page_headers → page_footers → + footnotes → endnotes (spec § 8 furniture 순서 계약). """ model_config = ConfigDict(extra="forbid", frozen=True) page_headers: list["Block"] = Field(default_factory=list) page_footers: list["Block"] = Field(default_factory=list) - footnotes: list["Block"] = Field(default_factory=list) + footnotes: list[FootnoteBlock] = Field(default_factory=list) + endnotes: list[EndnoteBlock] = Field(default_factory=list) class HwpDocument(BaseModel): @@ -298,9 +694,14 @@ def iter_blocks( scope: 순회 대상. - ``"body"`` (기본, RAG-safe): 본문 블록만 - - ``"furniture"``: 머리글 → 꼬리말 → 각주 순 + - ``"furniture"``: 머리글 → 꼬리말 → 각주 → 미주 순 - ``"all"``: 본문 먼저, 이어서 장식 - recurse: True 면 ``TableCell.blocks`` 재귀 진입 (중첩 표 내부까지). + recurse: True 면 컨테이너 블록 (TableCell.blocks, FootnoteBlock.blocks, + EndnoteBlock.blocks, CaptionBlock.blocks) 재귀 진입. + + ``PictureBlock.caption`` / ``TableBlock.caption_block`` 은 부모 블록의 + metadata 로 간주되어 ``recurse=True`` 여도 진입하지 않는다 (LangChain + loader 가 caption 을 별도 Document 로 중복 로드하는 noise 회피). 구조 기반 작업에는 ``doc.body`` / ``doc.furniture`` 속성 직접 접근이 더 간결하다. 본 메서드는 scope + recurse 조합이 필요한 경우용 @@ -312,18 +713,39 @@ def iter_blocks( yield from _walk_blocks(self.furniture.page_headers, recurse) yield from _walk_blocks(self.furniture.page_footers, recurse) yield from _walk_blocks(self.furniture.footnotes, recurse) + yield from _walk_blocks(self.furniture.endnotes, recurse) + +def _walk_blocks(blocks: Sequence["Block"], recurse: bool) -> Iterator["Block"]: + """블록 리스트 DFS 순회 — recurse=True 면 컨테이너 블록 내부까지 진입. -def _walk_blocks(blocks: list["Block"], recurse: bool) -> Iterator["Block"]: - """블록 리스트 DFS 순회 — recurse=True 면 TableCell.blocks 내부까지 진입.""" + 재귀 진입 컨테이너: ``TableCell.blocks``, ``FootnoteBlock.blocks``, + ``EndnoteBlock.blocks``, ``CaptionBlock.blocks``. ``PictureBlock.caption`` / + ``TableBlock.caption_block`` 은 부모 블록 metadata 로 간주되어 진입하지 않음 + (RAG 노이즈 회피). + + Sequence 로 받아 furniture.footnotes (list[FootnoteBlock]) / endnotes + (list[EndnoteBlock]) 같은 협소 타입 list 도 invariant 충돌 없이 수용한다. + """ for block in blocks: yield block - if recurse and isinstance(block, TableBlock): + if not recurse: + continue + if isinstance(block, TableBlock): for cell in block.cells: yield from _walk_blocks(cell.blocks, recurse) + elif isinstance(block, (FootnoteBlock, EndnoteBlock, CaptionBlock)): + yield from _walk_blocks(block.blocks, recurse) -# 재귀 유니온 (Block ↔ TableCell ↔ TableBlock) forward reference 해소 +# 재귀 유니온 (Block ↔ TableCell ↔ TableBlock ↔ FootnoteBlock/EndnoteBlock +# ↔ CaptionBlock ↔ PictureBlock.caption ↔ TableBlock.caption_block) +# forward reference 해소. TableCell.model_rebuild() +FootnoteBlock.model_rebuild() +EndnoteBlock.model_rebuild() +CaptionBlock.model_rebuild() +PictureBlock.model_rebuild() +TableBlock.model_rebuild() Furniture.model_rebuild() HwpDocument.model_rebuild() diff --git a/python/rhwp/ir/schema.py b/python/rhwp/ir/schema.py index d0819cf..0baeff0 100644 --- a/python/rhwp/ir/schema.py +++ b/python/rhwp/ir/schema.py @@ -17,7 +17,7 @@ from importlib.resources import files from typing import Any, Final -from rhwp.ir.nodes import HwpDocument +from rhwp.ir.nodes import _KNOWN_KINDS, HwpDocument __all__ = [ "SCHEMA_ID", @@ -49,30 +49,28 @@ def export_schema() -> dict[str, Any]: def _harden_unknown_variant(schema: dict[str, Any]) -> None: - """UnknownBlock 스키마에 "kind 가 known 값이 아님" 제약을 주입한다. + """UnknownBlock 스키마에 "kind 가 known Block 유니온 값이 아님" 제약을 주입한다. Pydantic V2 callable Discriminator 는 JSON Schema ``discriminator`` 키워드로 표현 불가 — 기본 출력은 단순 ``oneOf`` 이라 UnknownBlock (``extra="allow"``) 이 ParagraphBlock/TableBlock 인스턴스에도 매치되어 oneOf 가 실패한다. - known variant 들의 ``kind.const`` 를 모아 UnknownBlock.kind 에 ``not.enum`` - 으로 주입하면 oneOf 가 정확히 하나로 수렴한다. + Block 유니온 멤버의 kind 값을 ``not.enum`` 으로 주입하면 oneOf 가 정확히 + 하나로 수렴한다. + + ``_KNOWN_KINDS`` (Pydantic 디스크리미네이터 SSOT) 를 직접 사용한다 — schema 내 + ``$defs`` 의 ``kind.const`` 를 walk 하면 Block 유니온 멤버가 아닌 leaf 타입 + (예: ``TocEntryBlock.kind="toc_entry"``) 까지 포함되어 디스크리미네이터와 schema + 가 어긋난다 (Pydantic 은 toc_entry 를 UnknownBlock 으로 라우팅하는데 schema 가 + not.enum 에 toc_entry 포함 → round-trip 깨짐). """ defs = schema.get("$defs", {}) unknown = defs.get("UnknownBlock") if not unknown: return - known_kinds: list[str] = [] - for name, body in defs.items(): - if name == "UnknownBlock": - continue - kind_prop = body.get("properties", {}).get("kind", {}) - const = kind_prop.get("const") - if isinstance(const, str): - known_kinds.append(const) - if not known_kinds: + if not _KNOWN_KINDS: return kind_schema = unknown.setdefault("properties", {}).setdefault("kind", {}) - kind_schema["not"] = {"enum": sorted(known_kinds)} + kind_schema["not"] = {"enum": sorted(_KNOWN_KINDS)} def load_schema() -> dict[str, Any]: diff --git a/python/rhwp/ir/schema/hwp_ir_v1.json b/python/rhwp/ir/schema/hwp_ir_v1.json index 62fbfa7..f4443bf 100644 --- a/python/rhwp/ir/schema/hwp_ir_v1.json +++ b/python/rhwp/ir/schema/hwp_ir_v1.json @@ -2,6 +2,78 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://danmeon.github.io/rhwp-python/schema/hwp_ir/v1/schema.json", "$defs": { + "CaptionBlock": { + "additionalProperties": false, + "description": "캡션 블록 — 그림/표 등에 부착되는 보조 설명.\n\nHWP 는 ``Picture.caption: Option`` / ``Table.caption: Option``\n으로 항상 1:1 부착 관계 — 캡션은 부모 블록의 필드로 컨테인먼트 (ref-id 미도입).\nAzure DI / Docling 의 string-ref 패턴은 1:N 주소가 가능하지만 HWP 사용처에서\n이점이 없고 소비자가 JSON-Pointer resolver 를 구현해야 하므로 거부 (spec § 5).\n\n``blocks`` 가 재귀 ``Block`` 리스트 — 캡션 안의 인라인 수식·필드도 자연스럽게\n표현 (예: ``\"<그림 1> 회로도 ${}^{2}$\"`` 같은 캡션).\n\n``direction`` 은 캡션 배치 방향. 상류 ``CaptionDirection`` (Left/Right/Top/Bottom)\n을 lowercase Literal 로 매핑. 기본값 ``\"bottom\"`` 은 HWP 기본 + Docling 관례\n일치.", + "properties": { + "kind": { + "const": "caption", + "default": "caption", + "title": "Kind", + "type": "string" + }, + "blocks": { + "items": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphBlock" + }, + { + "$ref": "#/$defs/TableBlock" + }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, + { + "$ref": "#/$defs/UnknownBlock" + } + ] + }, + "title": "Blocks", + "type": "array" + }, + "direction": { + "default": "bottom", + "enum": [ + "top", + "bottom", + "left", + "right" + ], + "title": "Direction", + "type": "string" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "prov" + ], + "title": "CaptionBlock", + "type": "object" + }, "DocumentMetadata": { "additionalProperties": false, "description": "문서 레벨 메타데이터. 시각은 ISO 8601 문자열로 출고한다.", @@ -74,11 +146,21 @@ "title": "DocumentSource", "type": "object" }, - "Furniture": { + "EndnoteBlock": { "additionalProperties": false, - "description": "장식 노드 컨테이너 — RAG 가 임베딩에서 필터링 가능.\n\n현재 파서는 본문 블록을 넣지 않으므로 세 리스트는 기본적으로 비어있다.", + "description": "미주 블록 — HWP ``Control::Endnote``.\n\n각주와 같은 구조지만 배치가 다르다 (각주: 페이지 하단, 미주: 문서/구역 끝).\nHWP 가 별도 struct 로 분리하므로 IR 도 분리 — 통합 시 정보 손실.", "properties": { - "page_headers": { + "kind": { + "const": "endnote", + "default": "endnote", + "title": "Kind", + "type": "string" + }, + "number": { + "title": "Number", + "type": "integer" + }, + "blocks": { "items": { "oneOf": [ { @@ -87,15 +169,150 @@ { "$ref": "#/$defs/TableBlock" }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, { "$ref": "#/$defs/UnknownBlock" } ] }, - "title": "Page Headers", + "title": "Blocks", "type": "array" }, - "page_footers": { + "marker_prov": { + "$ref": "#/$defs/Provenance" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "number", + "marker_prov", + "prov" + ], + "title": "EndnoteBlock", + "type": "object" + }, + "FieldBlock": { + "additionalProperties": false, + "description": "필드 컨트롤 — HWP ``Control::Field`` (TOC 제외 13 종 + Unknown).\n\n상류 ``FieldType`` 14 종 → 닫힌 ``FieldKind`` Literal 매핑. ``TableOfContents``\n는 별도 ``TocBlock`` 으로 라우팅되므로 ``FieldBlock`` 에는 ``\"toc\"`` 가\n원칙적으로 등장 안 함 — Literal 로 보존만 하고 (사용자가 직접 구성 시 호환).\n\n``InlineRun.href`` 와의 중복: HWP ``Hyperlink`` / ``Bookmark`` Field 는 본문\nInlineRun 의 ``href`` 로 표현될 수도 있지만 v0.3.0 은 모든 Field control 을\n별도 FieldBlock 으로 emit 한다 — InlineRun.href 자동 채움 path 는 미구현.\n\n``raw_instruction`` 은 round-trip 보존용 — Word ```` 와 같은 raw\n명령 문자열. v0.3.0 소비자는 보통 ``cached_value`` 만 사용하지만, 미래\nwriteback 시 raw 가 필요.\n\n``field_type_code`` 는 forward-compat — 상류가 새 FieldType 을 추가하면\n매퍼는 ``field_kind=\"unknown\"`` + ``field_type_code=`` 로 출고하고,\n다음 MINOR (v0.4.0) 에서 Literal 확장.", + "properties": { + "kind": { + "const": "field", + "default": "field", + "title": "Kind", + "type": "string" + }, + "field_kind": { + "default": "unknown", + "enum": [ + "date", + "doc_date", + "path", + "bookmark", + "mailmerge", + "crossref", + "calc", + "clickhere", + "summary", + "userinfo", + "hyperlink", + "memo", + "private_info", + "toc", + "unknown" + ], + "title": "Field Kind", + "type": "string" + }, + "cached_value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "저장 시점 표시 값 (예: ``\"2026-04-26\"``). 동적 필드는 stale 가능.", + "title": "Cached Value" + }, + "raw_instruction": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HWP field command (Word ```` 대응) — round-trip 보존.", + "title": "Raw Instruction" + }, + "field_type_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "미지의 raw 코드 — 상류 FieldType 추가 시 forward-compat.", + "title": "Field Type Code" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "prov" + ], + "title": "FieldBlock", + "type": "object" + }, + "FootnoteBlock": { + "additionalProperties": false, + "description": "각주 블록 — HWP ``Control::Footnote``.\n\n각주 본문은 ``furniture.footnotes`` 로 라우팅되어 RAG 의 body 검색을 오염시키지\n않는다. ``marker_prov`` 는 본문 인용 마커 (``…기존 연구[3]…`` 의 ``[3]`` 위치) 의\nparent paragraph (section_idx, para_idx) 를 가리킨다 — 정확한 char_offset 까지는\n상류 ``field_ranges`` 매핑이 필요해 v0.4.0+ 검토. ``prov`` 는 각주 본문 자체의\n위치 = 마커가 등장한 paragraph 와 동일 (각주는 그 paragraph 에서 파생).\n\n``blocks`` 가 재귀 ``Block`` 리스트라 각주 본문 안의 표·그림·수식도 자연 지원.", + "properties": { + "kind": { + "const": "footnote", + "default": "footnote", + "title": "Kind", + "type": "string" + }, + "number": { + "description": "표시 번호 (1, 2, 3, ...).", + "title": "Number", + "type": "integer" + }, + "blocks": { "items": { "oneOf": [ { @@ -104,15 +321,113 @@ { "$ref": "#/$defs/TableBlock" }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, { "$ref": "#/$defs/UnknownBlock" } ] }, - "title": "Page Footers", + "title": "Blocks", "type": "array" }, - "footnotes": { + "marker_prov": { + "$ref": "#/$defs/Provenance", + "description": "본문 인용 마커 위치 — RAG 가 각주가 어디서 인용됐는지 역추적." + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "number", + "marker_prov", + "prov" + ], + "title": "FootnoteBlock", + "type": "object" + }, + "FormulaBlock": { + "additionalProperties": false, + "description": "수식 블록 — HWP ``Control::Equation``.\n\nHWP 수식은 자체 스크립트 (``script``) 로 저장된다 (예: ``\"1 over 2 + sqrt{x^2}\"``).\nHWP equation script → LaTeX/MathML 자동 변환은 공개 도구 부재로 v0.3.0 미제공\n(spec § 비목표). ``script_kind=\"hwp_eq\"`` 로 raw 출고 → 사용자가 외부 변환 후\n``model_copy(update={\"script\": tex, \"script_kind\": \"latex\"})`` 로 재구성 가능.\n\n``text_alt`` 는 RAG 폴백 — 단순 정규화 (``over`` → ``/``, ``sqrt{...}`` → ``√(...)``)\n까지만 적용. 실패 시 None.", + "properties": { + "kind": { + "const": "formula", + "default": "formula", + "title": "Kind", + "type": "string" + }, + "script": { + "title": "Script", + "type": "string" + }, + "script_kind": { + "default": "hwp_eq", + "enum": [ + "hwp_eq", + "latex", + "mathml" + ], + "title": "Script Kind", + "type": "string" + }, + "text_alt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "평문 근사 — RAG fallback. ``script`` 의 사람이 읽을 수 있는 형태 (``over`` → ``/`` 등) 를 단순 정규화. 실패 시 None.", + "title": "Text Alt" + }, + "inline": { + "default": false, + "description": "True: 본문 인라인 수식, False: 별도 디스플레이 수식.", + "title": "Inline", + "type": "boolean" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "script", + "prov" + ], + "title": "FormulaBlock", + "type": "object" + }, + "Furniture": { + "additionalProperties": false, + "description": "장식 노드 컨테이너 — RAG 가 임베딩에서 필터링 가능.\n\nv0.3.0 S1 부터 ``page_headers`` / ``page_footers`` 가 실제 채워진다.\nv0.3.0 S2 부터 ``footnotes`` / ``endnotes`` 도 실제 채워지며 타입이\n``list[FootnoteBlock]`` / ``list[EndnoteBlock]`` 으로 강화된다.\n\niter_blocks(scope=\"furniture\") 순서: page_headers → page_footers →\nfootnotes → endnotes (spec § 8 furniture 순서 계약).", + "properties": { + "page_headers": { "items": { "oneOf": [ { @@ -121,18 +436,153 @@ { "$ref": "#/$defs/TableBlock" }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, + { + "$ref": "#/$defs/UnknownBlock" + } + ] + }, + "title": "Page Headers", + "type": "array" + }, + "page_footers": { + "items": { + "oneOf": [ + { + "$ref": "#/$defs/ParagraphBlock" + }, + { + "$ref": "#/$defs/TableBlock" + }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, { "$ref": "#/$defs/UnknownBlock" } ] }, + "title": "Page Footers", + "type": "array" + }, + "footnotes": { + "items": { + "$ref": "#/$defs/FootnoteBlock" + }, "title": "Footnotes", "type": "array" + }, + "endnotes": { + "items": { + "$ref": "#/$defs/EndnoteBlock" + }, + "title": "Endnotes", + "type": "array" } }, "title": "Furniture", "type": "object" }, + "ImageRef": { + "additionalProperties": false, + "description": "이미지 참조 — binary 자체는 IR JSON 에 inline 되지 않는다.\n\nURI 스킴:\n\n- ``bin://`` (기본): 상류 ``Picture.image_attr.bin_data_id`` 그대로.\n ``Document.bytes_for_image(picture)`` 로 raw bytes 해석.\n- ``data:image/...;base64,...``: embedded 모드 (v0.4.0+ opt-in 검토)\n- ``file://path``: external 모드 (v0.4.0+ opt-in 검토)\n\n``uri`` 는 strict 검증 회피를 위해 plain ``str`` — JSON Schema strict mode 가\n``format: uri`` 를 거부하는 경우가 있고, ``bin://`` / ``data:`` 모두 허용해야 하므로.\nURL 검증은 사용자 책임.\n\nwidth/height/dpi 는 v0.3.0 S1 에서 항상 ``None`` — 상류 Picture 가 픽셀\ndimension 을 직접 노출하지 않으며 (border 좌표만 노출) HWPUNIT 계산은\nv0.4.0+ 에서 검토.", + "properties": { + "uri": { + "title": "Uri", + "type": "string" + }, + "mime_type": { + "title": "Mime Type", + "type": "string" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Height" + }, + "dpi": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dpi" + } + }, + "required": [ + "uri", + "mime_type" + ], + "title": "ImageRef", + "type": "object" + }, "InlineRun": { "additionalProperties": false, "description": "서식이 동일한 연속 문자 런.\n\nbold/italic/underline/strikethrough/href/ruby 외의 서식 속성 (폰트, 크기,\n색상 등) 은 ``raw_style_id`` 로 escape 된다 — 상류 ``doc_info`` 스타일 인덱스.", @@ -205,6 +655,56 @@ "title": "InlineRun", "type": "object" }, + "ListItemBlock": { + "additionalProperties": false, + "description": "목록 항목 — HWP ``ParaShape`` 의 ``head_type`` 가 비-None 인 단락.\n\nHWP 상류는 list group 컨테이너가 없다 (``ParaShape.head_type`` 가\nNumber/Bullet/Outline 인 단락이 곧 list item) — group container 는 도입하지\n않고 평면 (``level + marker + enumerated``) 으로 표현. RAG 청킹 시 항목 단위\n검색에 그대로 매핑.\n\n``marker`` 는 v0.3.0 단순 정책: ``\"•\"`` (bullet) / ``\"1.\"`` (number/outline).\n상류 ``Numbering.level_formats`` lookup 으로 정확한 마커 (예: ``\"가.\"``,\n``\"(a)\"``) 추출은 v0.4.0+ 에서 검토 — 현 시점은 placeholder 만.\n\n``text`` 는 마커 제외 본문 (``ParagraphBlock`` 과 동일) — 마커는\n``marker`` 필드로 별도. ``\"1. 제목\"`` 이 아니라 ``marker=\"1.\"``,\n``text=\"제목\"`` 형태.", + "properties": { + "kind": { + "const": "list_item", + "default": "list_item", + "title": "Kind", + "type": "string" + }, + "text": { + "default": "", + "title": "Text", + "type": "string" + }, + "inlines": { + "items": { + "$ref": "#/$defs/InlineRun" + }, + "title": "Inlines", + "type": "array" + }, + "enumerated": { + "default": false, + "description": "True: 번호 매김 (1./가./i. 등), False: 글머리표 (•/■/▶ 등).", + "title": "Enumerated", + "type": "boolean" + }, + "marker": { + "default": "-", + "description": "표시 마커 placeholder. v0.3.0 은 ``\"•\"`` / ``\"1.\"`` 만 출고 — 정확 마커는 상류 Numbering lookup 필요해 v0.4.0+ 검토.", + "title": "Marker", + "type": "string" + }, + "level": { + "default": 0, + "description": "0-indexed nesting depth. 상류 ``ParaShape.para_level`` (0~6, 1~7 수준 표시) 를 그대로 매핑.", + "title": "Level", + "type": "integer" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "prov" + ], + "title": "ListItemBlock", + "type": "object" + }, "ParagraphBlock": { "additionalProperties": false, "description": "단락 블록. 서식 런 리스트 + 평탄 텍스트 파생 필드를 병기한다.\n\n``text`` 는 ``inlines`` 의 ``text`` 필드를 이어붙인 결과 — LLM 에 넘기는\n평문화 경로. 원본 서식 보존이 필요한 소비자만 ``inlines`` 를 직접 순회한다.", @@ -237,6 +737,62 @@ "title": "ParagraphBlock", "type": "object" }, + "PictureBlock": { + "additionalProperties": false, + "description": "그림 블록 — HWP ``Control::Picture``.\n\n``image is None`` 은 명시적 broken reference — 상류 ``Picture.image_attr.bin_data_id``\n가 0 (미할당) 인 케이스만 해당. ``bin_data_id`` 가 0 이 아니어도 실제 binary\nlookup 이 실패할 수 있다 (Link 타입이거나 bin_data_content 누락) — 이 경우\nImageRef 는 ``mime_type=\"application/octet-stream\"`` 으로 출고되고 실패는\n``Document.bytes_for_image`` 호출 시점에 ValueError 로 표면화된다 (forensics\n위해 bin_data_id 자체는 URI 에 보존).\n\n``caption`` 은 v0.3.0 S3 부터 채워지는 구조화 캡션 — 부모 ``PictureBlock`` 의\n필드로 컨테인먼트 (ref-id 없이 직접 연결, spec § 5).\n\n``description`` 은 HWP 의 alt-text 슬롯 — caption paragraph 의 평문 fallback\n(S1 호환 보존). v0.4.0+ 에서 caption 충실하게 채워지면 description 은 deprecate\n검토.", + "properties": { + "kind": { + "const": "picture", + "default": "picture", + "title": "Kind", + "type": "string" + }, + "image": { + "anyOf": [ + { + "$ref": "#/$defs/ImageRef" + }, + { + "type": "null" + } + ], + "default": null + }, + "caption": { + "anyOf": [ + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "type": "null" + } + ], + "default": null, + "description": "구조화 캡션 — v0.3.0 S3 부터 채워짐. ``blocks`` 안에 표/수식/필드도 재귀 표현. None 은 캡션 부재 (HWP Picture.caption == None)." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HWP 의 alt-text — 상류 caption paragraph 평문 fallback (S1 보존). 구조화 캡션이 필요하면 ``caption`` 필드 사용.", + "title": "Description" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "prov" + ], + "title": "PictureBlock", + "type": "object" + }, "Provenance": { "additionalProperties": false, "description": "블록의 원본 문서 내 위치. 다운스트림 청커가 원본을 역추적 가능하게 한다.", @@ -323,7 +879,7 @@ }, "TableBlock": { "additionalProperties": false, - "description": "표 블록. 단일 표현으로 RAG 품질 최대화 불가 → 3중 표현 병기.\n\n- ``cells`` : 프로그래매틱 접근 (SQL 생성, 셀 순회)\n- ``html`` : LLM 에 제공, rowspan/colspan 보존 (HtmlRAG 호환)\n- ``text`` : 단순 검색·diff 용 폴백 (행은 개행, 셀은 탭 구분)", + "description": "표 블록. 단일 표현으로 RAG 품질 최대화 불가 → 3중 표현 병기.\n\n- ``cells`` : 프로그래매틱 접근 (SQL 생성, 셀 순회)\n- ``html`` : LLM 에 제공, rowspan/colspan 보존 (HtmlRAG 호환)\n- ``text`` : 단순 검색·diff 용 폴백 (행은 개행, 셀은 탭 구분)\n\n``caption`` (str) 은 v0.2.0 호환 평문 슬롯 — caption_block 의 첫 paragraph\n텍스트 fallback. 구조화 캡션이 필요하면 ``caption_block`` 사용.", "properties": { "kind": { "const": "table", @@ -366,8 +922,21 @@ } ], "default": null, + "description": "v0.2.0 호환 평문 캡션 — ``caption_block.blocks`` 첫 ParagraphBlock 의 평문이면 일관성 유지. 구조화 캡션은 ``caption_block`` 사용.", "title": "Caption" }, + "caption_block": { + "anyOf": [ + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "type": "null" + } + ], + "default": null, + "description": "v0.3.0 S3 신규 — 구조화 캡션. v0.2.0 ``caption: str`` 필드는 그대로 유지하고 옵셔널 신설." + }, "prov": { "$ref": "#/$defs/Provenance" } @@ -428,6 +997,30 @@ { "$ref": "#/$defs/TableBlock" }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, { "$ref": "#/$defs/UnknownBlock" } @@ -445,6 +1038,109 @@ "title": "TableCell", "type": "object" }, + "TocBlock": { + "additionalProperties": false, + "description": "목차 블록 — HWP ``Control::Field`` with ``FieldType::TableOfContents``.\n\nHWP TOC 는 frozen at save time — 소비자가 신뢰할 수 있는 navigation 은\n(있다면) heading hierarchy 쪽이며 TOC 는 사람이 마지막에 본 표시 그대로의\n스냅샷이다.\n\nv0.3.0 S3 매퍼는 TOC field 검출만 수행 — 항목 추출은 v0.4.0+ 에서 검토\n(spec § 6 결정). 따라서 v0.3.0 출고 시 ``entries`` 는 빈 리스트가 일반적.", + "properties": { + "kind": { + "const": "toc", + "default": "toc", + "title": "Kind", + "type": "string" + }, + "entries": { + "items": { + "$ref": "#/$defs/TocEntryBlock" + }, + "title": "Entries", + "type": "array" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "prov" + ], + "title": "TocBlock", + "type": "object" + }, + "TocEntryBlock": { + "additionalProperties": false, + "description": "목차 항목 — ``TocBlock.entries`` 안에서만 살아 있는 leaf type.\n\nBlock 유니온 멤버가 아니다 (``TableCell`` 과 같은 패턴) — ``iter_blocks`` 는\n``TocBlock`` 만 yield 하고, 항목 순회는 ``toc.entries`` 직접 접근.\n\n``cached_page`` 는 HWP 가 저장 시점에 박제한 페이지 번호 — 문서가 편집된 후에\nheading 이 이동하면 stale 가능 (`is_stale=True`). ``is_stale`` 정확 검출은\nheading hierarchy 와 cached text 비교 + bookmark resolution 필요 — v0.3.0 은\ncached value 만 노출하고 stale 검출은 v0.4.0+ 에 위임 (spec § 6 결정).\n\n``target_section_idx`` 는 raw bookmark 이름 → section 인덱스 resolution 결과.\n상류 bookmark resolver 가 필요해 v0.3.0 은 항상 None — raw\n``target_bookmark_name`` 만 보존.", + "properties": { + "kind": { + "const": "toc_entry", + "default": "toc_entry", + "title": "Kind", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "level": { + "default": 1, + "description": "1-indexed (h1, h2, h3, ...) — TOC 표시 레벨.", + "title": "Level", + "type": "integer" + }, + "target_bookmark_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "HWP bookmark 이름 (raw) — v0.4.0+ 의 resolver 입력.", + "title": "Target Bookmark Name" + }, + "target_section_idx": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "resolved section idx — v0.3.0 은 항상 None (resolver 미도입).", + "title": "Target Section Idx" + }, + "cached_page": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "저장 시점 페이지 번호 (HWP frozen at save). 편집 후 heading 이 이동하면 stale 가능. 정확 navigation 은 heading hierarchy 쪽.", + "title": "Cached Page" + }, + "is_stale": { + "default": false, + "description": "cached info ≠ 현재 heading 일치 여부. v0.3.0 은 항상 False — 정확 검출은 v0.4.0+ 에서 (spec § 6).", + "title": "Is Stale", + "type": "boolean" + }, + "prov": { + "$ref": "#/$defs/Provenance" + } + }, + "required": [ + "text", + "prov" + ], + "title": "TocEntryBlock", + "type": "object" + }, "UnknownBlock": { "additionalProperties": true, "description": "Forward-compatibility catch-all.\n\nPydantic V2 의 기본 string discriminator 는 미지의 ``kind`` 를 만나면\n``union_tag_invalid`` 로 문서 전체 파싱을 거부한다. callable Discriminator\n로 미지 ``kind`` 를 본 variant 로 라우팅하여, 나중에 새로운 블록 타입이\n추가되어도 구 버전 소비자가 읽기-불가 상태가 되지 않게 한다.\n\n소비자는 ``case UnknownBlock(): skip`` 패턴을 사용한다. ``assert_never``\n패턴은 새 variant 추가 시 빌드가 깨지므로 **사용 금지**.", @@ -454,8 +1150,16 @@ "type": "string", "not": { "enum": [ + "caption", + "endnote", + "field", + "footnote", + "formula", + "list_item", "paragraph", - "table" + "picture", + "table", + "toc" ] } }, @@ -481,7 +1185,7 @@ "type": "string" }, "schema_version": { - "default": "1.0", + "default": "1.1", "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", "title": "Schema Version", "type": "string" @@ -516,6 +1220,30 @@ { "$ref": "#/$defs/TableBlock" }, + { + "$ref": "#/$defs/PictureBlock" + }, + { + "$ref": "#/$defs/FormulaBlock" + }, + { + "$ref": "#/$defs/FootnoteBlock" + }, + { + "$ref": "#/$defs/EndnoteBlock" + }, + { + "$ref": "#/$defs/ListItemBlock" + }, + { + "$ref": "#/$defs/CaptionBlock" + }, + { + "$ref": "#/$defs/TocBlock" + }, + { + "$ref": "#/$defs/FieldBlock" + }, { "$ref": "#/$defs/UnknownBlock" } diff --git a/src/document.rs b/src/document.rs index af19527..108eab6 100644 --- a/src/document.rs +++ b/src/document.rs @@ -182,6 +182,10 @@ impl PyDocument { } // ^ Rust 는 raw 평탄 구조만 출고. 도메인 변환 (HTML/role/Pydantic 합성) // 은 rhwp.ir._mapper 가 담당 — IR 진화 시 maturin rebuild 회피. + // GIL 해제 불가: self.inner (DocumentCore) 가 RefCell 캐시로 !Sync — + // closure 가 &self 를 캡처하면 py.detach 의 Ungil 바운드 불만족. + // parse 단계 (from_bytes — owned bytes) 와 render_pdf/export_pdf + // (owned svgs) 만 GIL 해제 가능. let raw = ir::build_raw_document(self.inner.document(), self.source_uri.as_deref()); let mapper = py.import("rhwp.ir._mapper")?; let ir = mapper.call_method1("build_hwp_document", (raw,))?.unbind(); @@ -195,6 +199,21 @@ impl PyDocument { .clone_ref(py)) } + /// `bin_data_id` (1-based) 에 해당하는 이미지 raw bytes 를 반환. + /// + /// `Document.bytes_for_image(picture)` Python 헬퍼가 ``picture.image.uri`` 의 + /// ``bin://`` 스킴을 파싱한 결과를 본 메서드에 위임한다. 상류 BinData 가 + /// Embedding 타입이 아니거나 (Link/Storage) `bin_data_content` 에 누락된 + /// 경우 None — Python wrapper 가 ValueError 로 변환. + fn bytes_for_image_id<'py>( + &self, + py: Python<'py>, + bin_data_id: u16, + ) -> PyResult>> { + Ok(ir::lookup_bin_data_bytes(self.inner.document(), bin_data_id) + .map(|bytes| PyBytes::new(py, bytes))) + } + /// IR 을 JSON 문자열로 반환한다. `to_ir()` 캐시를 공유한다. /// /// `indent` 를 주면 Pydantic `model_dump_json(indent=...)` 으로 들여쓰기. diff --git a/src/ir.rs b/src/ir.rs index 55d28ec..d8ce14c 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -1,7 +1,7 @@ //! Document raw 추출기 — Rust `Document` → Python primitive 트리. //! -//! IR 도메인 변환 (HTML 직렬화, cell role 분류, Pydantic 모델 합성) 은 Python -//! `rhwp.ir._mapper` 에 위임한다. 이 모듈의 책임은: +//! IR 도메인 변환 (HTML 직렬화, cell role 분류, mime 매핑, Pydantic 모델 합성) +//! 은 Python `rhwp.ir._mapper` 에 위임한다. 이 모듈의 책임은: //! //! - HWP binary 모델 (rhwp upstream) 을 Python 친화 평탄 구조로 펼치기 //! - upstream 내부 표현 (UTF-16 char offset, char_shape 테이블 등) 을 캡슐화 — @@ -12,10 +12,13 @@ use pyo3::prelude::*; -use rhwp::model::control::Control; +use rhwp::model::control::{Control, Equation, Field, FieldType}; use rhwp::model::document::{DocInfo, Document}; +use rhwp::model::footnote::{Endnote, Footnote}; +use rhwp::model::image::Picture; use rhwp::model::paragraph::Paragraph; -use rhwp::model::style::UnderlineType; +use rhwp::model::shape::{Caption, CaptionDirection}; +use rhwp::model::style::{HeadType, UnderlineType}; use rhwp::model::table::{Cell, Table}; #[derive(IntoPyObject)] @@ -29,6 +32,19 @@ pub(crate) struct RawCharRun { pub strikethrough: bool, } +#[derive(IntoPyObject)] +pub(crate) struct RawListInfo { + // ^ 상류 ParaShape.head_type 가 비-None 일 때 채워진다. mapper 가 이 dict 를 보면 + // ParagraphBlock 대신 ListItemBlock 을 emit. spec § 4 ListItemBlock 매핑. + // + // head_type 은 lowercase string ("number"/"bullet"/"outline") — Python mapper 가 + // marker placeholder + enumerated 결정 (도메인 분기는 Python 책임, IR 진화 시 + // maturin rebuild 회피). 미래 v0.4.0+ 의 정확 marker 추출은 raw + // numbering_id 추가 + Python 의 Numbering.level_formats lookup. + pub head_type: String, + pub level: u32, +} + #[derive(IntoPyObject)] pub(crate) struct RawCell { pub row: usize, @@ -39,12 +55,121 @@ pub(crate) struct RawCell { pub paragraphs: Vec, } +#[derive(IntoPyObject)] +pub(crate) struct RawCaption { + // ^ Picture/Table 양쪽의 캡션 paragraphs + direction 추출. Python mapper 가 + // _flatten_paragraph 로 평탄화 → CaptionBlock.blocks. v0.3.0 S3 신규. + // direction 은 lowercase string ("top"/"bottom"/"left"/"right") — Python + // Literal 어휘와 1:1. + pub direction: String, + pub section_idx: usize, + pub para_idx: usize, + pub paragraphs: Vec, +} + #[derive(IntoPyObject)] pub(crate) struct RawTable { pub rows: usize, pub cols: usize, pub cells: Vec, pub caption: Option, + // ^ v0.3.0 S3 신규 — 구조화 캡션. caption (str) 은 v0.2.0 호환 평문 fallback. + pub caption_block: Option, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawImageRef { + pub bin_data_id: u16, + // ^ 상류 BinData 의 extension (e.g. "jpg", "png", "bmp"). Python mapper 가 mime 매핑. + // Embedding 이 아닌 타입 (Link 등) 또는 누락 시 None — mapper 가 + // "application/octet-stream" 으로 폴백한다. + pub extension: Option, + // ^ Embedding 타입에 대해 binary 가 실제 로드됐는지 — broken reference 진단. + // true: bin_data_content 에 entry 존재. false: 누락 또는 Link/Storage 타입. + pub has_content: bool, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawPicture { + // ^ 그림 자체의 위치 = 부모 paragraph 의 (section_idx, para_idx) 공유. + // Provenance 계약: 컨트롤은 부모 문단 위치를 가리킨다 (Table 과 동일). + pub section_idx: usize, + pub para_idx: usize, + pub image: Option, + // ^ caption.paragraphs 첫 비-빈 텍스트 — S1 호환 평문 fallback (description). + // v0.3.0 S3 부터 caption (RawCaption) 으로 구조화 노출되며 description 은 + // 호환 보존만. + pub description: Option, + // ^ v0.3.0 S3 신규 — 구조화 캡션. Picture.caption 이 None 이면 None. + pub caption: Option, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawFormula { + pub section_idx: usize, + pub para_idx: usize, + pub script: String, + // ^ HWP equation script 는 항상 "hwp_eq" — LaTeX/MathML 변환은 Python 사용자 + // 책임 (spec § 비목표). text_alt 는 raw script 의 단순 정규화 결과. + pub text_alt: Option, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawFootnote { + // ^ 본문 인용 마커 위치 (parent paragraph 의 section_idx, para_idx). + // 각주 본문은 같은 paragraph 에서 파생되므로 prov 도 동일 위치를 공유한다. + // 정확한 char_offset 은 상류 field_ranges 매핑 필요 — v0.4.0+ 검토. + pub marker_section_idx: usize, + pub marker_para_idx: usize, + pub number: u16, + pub blocks: Vec, + // ^ 각주 본문의 내부 paragraph — Python mapper 가 _flatten_paragraph 로 + // 처리해 표/그림/수식 등 nested 컨텐츠도 자연 지원 +} + +#[derive(IntoPyObject)] +pub(crate) struct RawEndnote { + pub marker_section_idx: usize, + pub marker_para_idx: usize, + pub number: u16, + pub blocks: Vec, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawTocEntry { + // ^ v0.3.0 placeholder — 실제 TOC entry 추출은 v0.4.0+ (bookmark resolver + // 필요). 본 struct 는 forward-compat 를 위해 미리 정의. + pub text: String, + pub level: u32, + pub target_bookmark_name: Option, + pub cached_page: Option, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawToc { + // ^ FieldType::TableOfContents 검출 시 emit. v0.3.0 entries 는 빈 Vec — + // spec § 6 결정 사항 7. + pub section_idx: usize, + pub para_idx: usize, + pub entries: Vec, +} + +#[derive(IntoPyObject)] +pub(crate) struct RawField { + pub section_idx: usize, + pub para_idx: usize, + // ^ FieldType lowercase 표현 — Python Literal 어휘와 1:1. 미지 variant 는 + // "unknown" + field_type_code 채움 (현재는 모두 알려져 있어 None). + pub field_kind: String, + // ^ HWP Field 는 cached_value 를 직접 노출하지 않는다 (paragraph text 안에 + // inline 으로 들어있음). v0.3.0 은 None 출고 — 정확 추출은 field_ranges + // 매핑 필요 (v0.4.0+ 검토). + pub cached_value: Option, + // ^ HWP Field.command — Word 대응. round-trip 보존용. + pub raw_instruction: Option, + // ^ 미지의 raw 코드 — 상류 FieldType 추가 시 forward-compat. v0.3.0 은 모든 + // variant 가 알려져 있으므로 항상 None. + pub field_type_code: Option, } #[derive(IntoPyObject)] @@ -54,6 +179,15 @@ pub(crate) struct RawParagraph { pub text: String, pub char_runs: Vec, pub tables: Vec, + pub pictures: Vec, + pub formulas: Vec, + // ^ v0.3.0 S3 신규 — TOC field 와 일반 field 분리 출고. mapper 가 각각 + // TocBlock / FieldBlock 으로 합성. + pub tocs: Vec, + pub fields: Vec, + // ^ v0.3.0 S3 신규 — paragraph 가 list item 인지 표시. Some 이면 mapper 가 + // ParagraphBlock 대신 ListItemBlock 을 emit. + pub list_info: Option, } #[derive(IntoPyObject)] @@ -61,6 +195,12 @@ pub(crate) struct RawDocument { pub source_uri: Option, pub section_count: usize, pub paragraphs: Vec, + // ^ furniture.page_headers / page_footers 로 매핑. + pub headers: Vec, + pub footers: Vec, + // ^ furniture.footnotes / endnotes 로 매핑. v0.3.0 S2 신규. + pub footnotes: Vec, + pub endnotes: Vec, } /// 문서 전체를 raw 평탄 구조로 추출한다. @@ -69,21 +209,38 @@ pub(crate) struct RawDocument { /// `py.detach()` 로 GIL 을 해제할 수 있다. 결과 반환 시점에 PyO3 derive 가 /// 한 번에 PyDict 트리로 변환한다. pub(crate) fn build_raw_document(doc: &Document, source_uri: Option<&str>) -> RawDocument { - let mut paragraphs = Vec::new(); + // ^ 총 단락 수 사전 계산으로 push 중 realloc 을 회피 — 큰 문서에서 의미 있음. + let total_paras: usize = doc.sections.iter().map(|s| s.paragraphs.len()).sum(); + let mut paragraphs = Vec::with_capacity(total_paras); + let mut acc = FurnitureAcc::default(); for (section_idx, section) in doc.sections.iter().enumerate() { for (para_idx, para) in section.paragraphs.iter().enumerate() { - paragraphs.push(build_raw_paragraph( - section_idx, - para_idx, - para, - &doc.doc_info, - )); + paragraphs.push(build_raw_paragraph(section_idx, para_idx, para, doc)); + collect_furniture_from_paragraph(section_idx, para_idx, para, doc, &mut acc); + } + // ^ 바탕쪽 안의 Header/Footer 컨트롤도 furniture 로 라우팅 (spec § 8 매퍼 정책). + // 바탕쪽 paragraph 자체는 furniture 에 넣지 않는다 — 페이지 배경 템플릿이지 + // 머리글/꼬리말이 아니므로. Header/Footer 컨트롤이 그 안에 있을 때만 추출. + // `enumerate()` 를 flat_map 바깥에 두어 여러 MasterPage 의 paragraph 가 + // 고유한 flat 인덱스를 받게 한다 (MasterPage 내부 별 0 부터 재시작 회피). + for (mp_flat_idx, mp_para) in section + .section_def + .master_pages + .iter() + .flat_map(|mp| mp.paragraphs.iter()) + .enumerate() + { + collect_furniture_from_paragraph(section_idx, mp_flat_idx, mp_para, doc, &mut acc); } } RawDocument { source_uri: source_uri.map(String::from), section_count: doc.sections.len(), paragraphs, + headers: acc.headers, + footers: acc.footers, + footnotes: acc.footnotes, + endnotes: acc.endnotes, } } @@ -91,25 +248,51 @@ fn build_raw_paragraph( section_idx: usize, para_idx: usize, para: &Paragraph, - doc_info: &DocInfo, + doc: &Document, ) -> RawParagraph { - let char_runs = build_char_runs(para, doc_info); - // ^ 문단의 controls 중 Table 만 추출 — 내부 paragraph 들은 외부 (section, para) - // 를 공유한다 (Provenance 계약: 표는 부모 문단 위치를 가리킨다) - let tables: Vec = para - .controls - .iter() - .filter_map(|c| match c { - Control::Table(t) => Some(build_raw_table(t, section_idx, para_idx, doc_info)), - _ => None, - }) - .collect(); + let char_runs = build_char_runs(para, &doc.doc_info); + // ^ 문단의 controls 중 Table / Picture / Equation / Field 만 추출 — 내부 + // paragraph 들은 외부 (section, para) 를 공유한다 (Provenance 계약). + // Footnote / Endnote / Header / Footer 는 본문이 아니라 furniture 로 + // 라우팅되므로 여기서 처리하지 않음. + let mut tables = Vec::new(); + let mut pictures = Vec::new(); + let mut formulas = Vec::new(); + let mut tocs = Vec::new(); + let mut fields = Vec::new(); + for ctrl in ¶.controls { + match ctrl { + Control::Table(t) => { + tables.push(build_raw_table(t, section_idx, para_idx, doc)); + } + Control::Picture(p) => { + pictures.push(build_raw_picture(p, section_idx, para_idx, doc)); + } + Control::Equation(e) => { + formulas.push(build_raw_formula(e, section_idx, para_idx)); + } + Control::Field(f) => { + if f.field_type == FieldType::TableOfContents { + tocs.push(build_raw_toc(f, section_idx, para_idx)); + } else { + fields.push(build_raw_field(f, section_idx, para_idx)); + } + } + _ => {} + } + } + let list_info = build_raw_list_info(para, &doc.doc_info); RawParagraph { section_idx, para_idx, text: para.text.clone(), char_runs, tables, + pictures, + formulas, + tocs, + fields, + list_info, } } @@ -180,32 +363,32 @@ fn build_raw_table( table: &Table, outer_section: usize, outer_para: usize, - doc_info: &DocInfo, + doc: &Document, ) -> RawTable { let cells = table .cells .iter() - .map(|c| build_raw_cell(c, outer_section, outer_para, doc_info)) + .map(|c| build_raw_cell(c, outer_section, outer_para, doc)) .collect(); let caption = table.caption.as_ref().and_then(extract_caption_text); + let caption_block = table + .caption + .as_ref() + .map(|c| build_raw_caption(c, outer_section, outer_para, doc)); RawTable { rows: table.row_count as usize, cols: table.col_count as usize, cells, caption, + caption_block, } } -fn build_raw_cell( - cell: &Cell, - outer_section: usize, - outer_para: usize, - doc_info: &DocInfo, -) -> RawCell { +fn build_raw_cell(cell: &Cell, outer_section: usize, outer_para: usize, doc: &Document) -> RawCell { let paragraphs = cell .paragraphs .iter() - .map(|p| build_raw_paragraph(outer_section, outer_para, p, doc_info)) + .map(|p| build_raw_paragraph(outer_section, outer_para, p, doc)) .collect(); RawCell { row: cell.row as usize, @@ -217,8 +400,244 @@ fn build_raw_cell( } } -/// Caption 에서 텍스트만 추출한다 (복합 캡션 구조는 미지원). -fn extract_caption_text(caption: &rhwp::model::shape::Caption) -> Option { +/// Picture 컨트롤 → raw 평탄 구조. +/// +/// `bin_data_id` 는 상류 Picture 가 가리키는 BinData 인덱스 (1-based). extension / +/// has_content 는 doc.doc_info.bin_data_list 와 doc.bin_data_content 를 lookup 해서 +/// 채운다. 0 (미할당) 또는 lookup 실패 시 image=None 으로 broken reference 표현. +fn build_raw_picture( + pic: &Picture, + section_idx: usize, + para_idx: usize, + doc: &Document, +) -> RawPicture { + let bin_data_id = pic.image_attr.bin_data_id; + let image = if bin_data_id == 0 { + None + } else { + let bd_meta = doc + .doc_info + .bin_data_list + .get((bin_data_id as usize).saturating_sub(1)); + let extension = bd_meta.and_then(|bd| bd.extension.clone()); + // ^ bin_data_content 는 Embedding 만 채워지므로 Link/Storage 는 false. + // 상류 utils.rs::find_bin_data 와 동일한 인덱싱 (bin_data_id - 1). + let has_content = doc + .bin_data_content + .get((bin_data_id as usize).saturating_sub(1)) + .is_some(); + Some(RawImageRef { + bin_data_id, + extension, + has_content, + }) + }; + let description = pic.caption.as_ref().and_then(extract_caption_text); + let caption = pic + .caption + .as_ref() + .map(|c| build_raw_caption(c, section_idx, para_idx, doc)); + RawPicture { + section_idx, + para_idx, + image, + description, + caption, + } +} + +/// 본문 paragraph 에서 추출되는 furniture 누적 컨테이너. +#[derive(Default)] +struct FurnitureAcc { + headers: Vec, + footers: Vec, + footnotes: Vec, + endnotes: Vec, +} + +/// 본문 paragraph 안의 furniture 컨트롤 (Header/Footer/Footnote/Endnote) 을 누적한다. +/// +/// 각 furniture 컨트롤이 가지는 자체 paragraphs 들을 외부 (section_idx, para_idx) 와 +/// 공유한 RawParagraph 로 변환한다. 본 paragraphs 는 furniture 가 어디서 +/// "선언" 됐는지 (Provenance) 만 보존하면 충분 — 페이지별 반복 출현은 렌더 단계. +fn collect_furniture_from_paragraph( + section_idx: usize, + para_idx: usize, + para: &Paragraph, + doc: &Document, + acc: &mut FurnitureAcc, +) { + for ctrl in ¶.controls { + match ctrl { + Control::Header(h) => { + for hp in &h.paragraphs { + acc.headers + .push(build_raw_paragraph(section_idx, para_idx, hp, doc)); + } + } + Control::Footer(f) => { + for fp in &f.paragraphs { + acc.footers + .push(build_raw_paragraph(section_idx, para_idx, fp, doc)); + } + } + Control::Footnote(fn_) => { + acc.footnotes + .push(build_raw_footnote(fn_, section_idx, para_idx, doc)); + } + Control::Endnote(en) => { + acc.endnotes + .push(build_raw_endnote(en, section_idx, para_idx, doc)); + } + _ => {} + } + } +} + +/// Equation 컨트롤 → RawFormula. text_alt 는 raw script 의 단순 정규화 결과 — +/// 정상 변환 대신 RAG 폴백용으로만 충분. 실패하면 None (mapper 가 그대로 보존). +fn build_raw_formula(eq: &Equation, section_idx: usize, para_idx: usize) -> RawFormula { + let script = eq.script.clone(); + let text_alt = simple_eq_text_alt(&script); + RawFormula { + section_idx, + para_idx, + script, + text_alt, + } +} + +/// HWP equation script 의 단순 정규화 → 평문 근사. 완전한 변환이 아니라 +/// RAG 검색용 폴백 — 정확한 LaTeX 가 필요하면 사용자가 외부 변환기 사용. +/// +/// 적용 규칙 (모두 토큰 경계 인식 — `[A-Za-z0-9_]` 의 연속을 한 식별자로 본다): +/// - 식별자 토큰 `over` → `/` (분수). 예: `1 over 2` → `1 / 2`. `discover` 는 그대로. +/// - 식별자 토큰 `sqrt` → `√` (제곱근). 예: `sqrt{x}` → `√(x)`. `sqrtish` 는 그대로. +/// - 그룹 괄호 `{` → `(`, `}` → `)`. spec § 2 의 정규화 규약. +/// +/// 빈 스크립트는 None 반환. UTF-8 multi-byte char (한글 등) 는 그대로 통과. +fn simple_eq_text_alt(script: &str) -> Option { + let trimmed = script.trim(); + if trimmed.is_empty() { + return None; + } + let mut out = String::with_capacity(trimmed.len()); + let mut chars = trimmed.chars().peekable(); + while let Some(c) = chars.next() { + if is_ident_start(c) { + // ^ 식별자 토큰 시작 — 끝까지 읽고 키워드 비교 + let mut token = String::new(); + token.push(c); + while let Some(&next) = chars.peek() { + if is_ident_continue(next) { + token.push(next); + chars.next(); + } else { + break; + } + } + match token.as_str() { + "over" => out.push('/'), + "sqrt" => out.push('√'), + _ => out.push_str(&token), + } + } else { + // ^ 비-식별자 char (공백, 괄호, 연산자, 한글 등) + match c { + '{' => out.push('('), + '}' => out.push(')'), + _ => out.push(c), + } + } + } + Some(out) +} + +#[inline] +fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +#[inline] +fn is_ident_continue(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// Footnote → RawFootnote. 본문 인용 마커 위치 (parent paragraph) 를 보존하고 +/// 각주 본문의 paragraph 들을 평탄화한다. +fn build_raw_footnote( + fn_: &Footnote, + marker_section_idx: usize, + marker_para_idx: usize, + doc: &Document, +) -> RawFootnote { + let blocks = fn_ + .paragraphs + .iter() + .map(|p| build_raw_paragraph(marker_section_idx, marker_para_idx, p, doc)) + .collect(); + RawFootnote { + marker_section_idx, + marker_para_idx, + number: fn_.number, + blocks, + } +} + +fn build_raw_endnote( + en: &Endnote, + marker_section_idx: usize, + marker_para_idx: usize, + doc: &Document, +) -> RawEndnote { + let blocks = en + .paragraphs + .iter() + .map(|p| build_raw_paragraph(marker_section_idx, marker_para_idx, p, doc)) + .collect(); + RawEndnote { + marker_section_idx, + marker_para_idx, + number: en.number, + blocks, + } +} + +/// Caption → RawCaption (구조화 캡션, S3 신규). +/// +/// shape::Caption 의 paragraphs 를 RawParagraph 로 평탄화. direction 은 +/// CaptionDirection enum → lowercase string 으로 변환 (Python Literal 매칭). +/// section_idx / para_idx 는 부모 (Picture/Table) 의 위치 공유 — Provenance 계약. +fn build_raw_caption( + cap: &Caption, + section_idx: usize, + para_idx: usize, + doc: &Document, +) -> RawCaption { + let paragraphs = cap + .paragraphs + .iter() + .map(|p| build_raw_paragraph(section_idx, para_idx, p, doc)) + .collect(); + RawCaption { + direction: caption_direction_to_str(cap.direction).to_string(), + section_idx, + para_idx, + paragraphs, + } +} + +fn caption_direction_to_str(d: CaptionDirection) -> &'static str { + match d { + CaptionDirection::Top => "top", + CaptionDirection::Bottom => "bottom", + CaptionDirection::Left => "left", + CaptionDirection::Right => "right", + } +} + +/// Caption 에서 텍스트만 추출한다 (S1 호환 description fallback 경로). +fn extract_caption_text(caption: &Caption) -> Option { let text: Vec = caption .paragraphs .iter() @@ -232,6 +651,108 @@ fn extract_caption_text(caption: &rhwp::model::shape::Caption) -> Option } } +/// ParaShape.head_type → list_info (S3 신규). +/// +/// ``HeadType::None`` 이면 None 반환 → mapper 가 ParagraphBlock 으로 emit. +/// 그 외 (Number / Bullet / Outline) 면 lowercase string 출고 → Python mapper 가 +/// ListItemBlock 합성 (marker placeholder / enumerated 결정). +/// +/// para_shape_id lookup 실패 시에도 None — 손상 파일 대비. +fn build_raw_list_info(para: &Paragraph, doc_info: &DocInfo) -> Option { + let ps = doc_info.para_shapes.get(para.para_shape_id as usize)?; + let head_type = match ps.head_type { + HeadType::None => return None, + HeadType::Number => "number", + HeadType::Outline => "outline", + HeadType::Bullet => "bullet", + }; + Some(RawListInfo { + head_type: head_type.to_string(), + level: ps.para_level as u32, + }) +} + +/// FieldType (TableOfContents 제외) → RawField. cached_value 는 v0.3.0 미추출. +fn build_raw_field(field: &Field, section_idx: usize, para_idx: usize) -> RawField { + RawField { + section_idx, + para_idx, + field_kind: field_type_to_str(field.field_type).to_string(), + cached_value: None, + raw_instruction: if field.command.is_empty() { + None + } else { + Some(field.command.clone()) + }, + // ^ v0.3.0 은 모든 FieldType variant 가 알려져 있으므로 None — 상류가 + // 새 variant 추가 시 mapper 가 raw u32 채워야 한다 (v0.4.0+). + field_type_code: None, + } +} + +/// FieldType::TableOfContents → RawToc. v0.3.0 은 entries 빈 Vec — +/// 실제 TOC 항목 추출은 v0.4.0+ (bookmark resolver 필요, spec § 6 결정). +fn build_raw_toc(_field: &Field, section_idx: usize, para_idx: usize) -> RawToc { + RawToc { + section_idx, + para_idx, + entries: Vec::new(), + } +} + +/// FieldType → Python FieldKind Literal value (lowercase string, 1:1 매핑). +/// +/// 상류 ``Field::field_type_str`` 와 어휘가 다른 항목 (DocDate → "doc_date", +/// PrivateInfoSecurity → "private_info", Formula → "calc") 은 Python 어휘에 +/// 맞추기 위해 자체 구현. ``"calc"`` 는 Equation ("formula" kind) 과의 이름 +/// 충돌 회피 — spec § 7 FieldKind 표. +/// +/// **TableOfContents arm**: 현 라우팅은 ``build_raw_paragraph`` 가 +/// ``FieldType::TableOfContents`` 를 ``tocs`` 로 사전 분리하므로 본 arm 은 +/// dead code. 그러나 (1) Python ``FieldKind`` Literal 어휘 동기 (15 종 일치) +/// (2) 미래 routing 정책 변경 시 (예: TocBlock 도 FieldBlock 통합) 활성화 +/// — 두 이유로 어휘 보존. +fn field_type_to_str(ft: FieldType) -> &'static str { + match ft { + FieldType::Unknown => "unknown", + FieldType::Date => "date", + FieldType::DocDate => "doc_date", + FieldType::Path => "path", + FieldType::Bookmark => "bookmark", + FieldType::MailMerge => "mailmerge", + FieldType::CrossRef => "crossref", + FieldType::Formula => "calc", + FieldType::ClickHere => "clickhere", + FieldType::Summary => "summary", + FieldType::UserInfo => "userinfo", + FieldType::Hyperlink => "hyperlink", + FieldType::Memo => "memo", + FieldType::PrivateInfoSecurity => "private_info", + // ^ 현 라우팅에서는 도달 안 함 — 어휘 보존 + 미래 routing 변경 대비 + FieldType::TableOfContents => "toc", + } +} + +/// `bin_data_id` (1-based) 에 해당하는 raw bytes 를 반환. +/// +/// 상류 `renderer/layout/utils.rs::find_bin_data` 와 동일한 lookup — +/// `bin_data_content` 는 Embedding 타입만 채워져 있고 인덱스는 1-based. +/// Embedding 이 아니거나 (`Link` / `Storage`) 누락 시 None. +/// +/// **인덱스 정합성 가정**: `bin_data_content` 와 `bin_data_list` 는 같은 순서로 +/// 같은 길이여야 한다 — 즉 모든 BinData entry 가 Embedding 타입이어야 정확. +/// 혼합 (Link + Embedding) 문서에서는 상류 `bin_data_content` 가 Embedding 만 +/// 추려 더 짧으므로 잘못된 entry 를 반환할 수 있다 — 상류 renderer 도 같은 +/// 가정을 공유하므로 SVG/PDF 렌더링도 같은 잘못된 lookup 을 한다 (상류 패리티). +pub(crate) fn lookup_bin_data_bytes(doc: &Document, bin_data_id: u16) -> Option<&[u8]> { + if bin_data_id == 0 { + return None; + } + doc.bin_data_content + .get((bin_data_id as usize) - 1) + .map(|bdc| bdc.data.as_slice()) +} + #[cfg(test)] mod tests { use super::*; @@ -251,4 +772,100 @@ mod tests { assert_eq!(utf16_to_cp(&offsets, 3, 4), 2); assert_eq!(utf16_to_cp(&offsets, 5, 4), 4); // fallback } + + #[test] + fn lookup_bin_data_zero_id_returns_none() { + let doc = Document::default(); + assert!(lookup_bin_data_bytes(&doc, 0).is_none()); + } + + // * simple_eq_text_alt — 토큰 경계 인식 검증 + + #[test] + fn simple_eq_text_alt_empty_returns_none() { + assert_eq!(simple_eq_text_alt(""), None); + assert_eq!(simple_eq_text_alt(" "), None); + } + + #[test] + fn simple_eq_text_alt_over_keyword_replaced() { + assert_eq!(simple_eq_text_alt("1 over 2").as_deref(), Some("1 / 2")); + assert_eq!(simple_eq_text_alt("over").as_deref(), Some("/")); + } + + #[test] + fn simple_eq_text_alt_sqrt_keyword_replaced() { + assert_eq!(simple_eq_text_alt("sqrt{x}").as_deref(), Some("√(x)")); + assert_eq!( + simple_eq_text_alt("sqrt{x^2 + 1}").as_deref(), + Some("√(x^2 + 1)") + ); + } + + #[test] + fn simple_eq_text_alt_braces_become_parens() { + assert_eq!(simple_eq_text_alt("{a + b}").as_deref(), Some("(a + b)")); + } + + #[test] + fn simple_eq_text_alt_keywords_inside_identifier_not_replaced() { + // ^ "sqrt" 가 식별자 일부면 변환되면 안 됨 + assert_eq!(simple_eq_text_alt("sqrtish").as_deref(), Some("sqrtish")); + // ^ "over" 가 다른 식별자 일부일 때도 + assert_eq!(simple_eq_text_alt("discover").as_deref(), Some("discover")); + assert_eq!(simple_eq_text_alt("overflow").as_deref(), Some("overflow")); + // ^ underscore 식별자 + assert_eq!(simple_eq_text_alt("_over_").as_deref(), Some("_over_")); + } + + #[test] + fn simple_eq_text_alt_combined_expression() { + assert_eq!( + simple_eq_text_alt("1 over 2 + sqrt{x^2 + 1}").as_deref(), + Some("1 / 2 + √(x^2 + 1)") + ); + } + + #[test] + fn simple_eq_text_alt_unicode_passes_through() { + // ^ 한글이 들어와도 변환 안 함 (HWP equation script 는 보통 ASCII 만) + assert_eq!(simple_eq_text_alt("α + β").as_deref(), Some("α + β")); + } + + // * field_type_to_str — Python FieldKind Literal 어휘와 1:1 일치 + + #[test] + fn field_type_to_str_all_variants_lowercase() { + // ^ Python FieldKind Literal 의 14 + unknown 어휘. 추가/이름 변경 시 mapper.py + // _VALID_FIELD_KINDS 와 양방향 동기화 필요. + assert_eq!(field_type_to_str(FieldType::Unknown), "unknown"); + assert_eq!(field_type_to_str(FieldType::Date), "date"); + assert_eq!(field_type_to_str(FieldType::DocDate), "doc_date"); + assert_eq!(field_type_to_str(FieldType::Path), "path"); + assert_eq!(field_type_to_str(FieldType::Bookmark), "bookmark"); + assert_eq!(field_type_to_str(FieldType::MailMerge), "mailmerge"); + assert_eq!(field_type_to_str(FieldType::CrossRef), "crossref"); + // ^ Formula → "calc" — Equation ("formula" kind) 와의 이름 충돌 회피 + assert_eq!(field_type_to_str(FieldType::Formula), "calc"); + assert_eq!(field_type_to_str(FieldType::ClickHere), "clickhere"); + assert_eq!(field_type_to_str(FieldType::Summary), "summary"); + assert_eq!(field_type_to_str(FieldType::UserInfo), "userinfo"); + assert_eq!(field_type_to_str(FieldType::Hyperlink), "hyperlink"); + assert_eq!(field_type_to_str(FieldType::Memo), "memo"); + assert_eq!( + field_type_to_str(FieldType::PrivateInfoSecurity), + "private_info" + ); + assert_eq!(field_type_to_str(FieldType::TableOfContents), "toc"); + } + + // * caption_direction_to_str — Python CaptionBlock.direction Literal 과 1:1 + + #[test] + fn caption_direction_lowercase() { + assert_eq!(caption_direction_to_str(CaptionDirection::Top), "top"); + assert_eq!(caption_direction_to_str(CaptionDirection::Bottom), "bottom"); + assert_eq!(caption_direction_to_str(CaptionDirection::Left), "left"); + assert_eq!(caption_direction_to_str(CaptionDirection::Right), "right"); + } } diff --git a/tests/test_async.py b/tests/test_async.py index ccd7f1d..378d4cd 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -1,12 +1,14 @@ -"""Async API 검증 — ``rhwp.aparse`` (aiofiles 기반 파일 I/O + sync 파싱). +"""Async API 검증 — ``rhwp.aparse`` (stdlib ``asyncio.to_thread`` 기반 파일 I/O + sync 파싱). ``#[pyclass(unsendable)]`` 제약 상 ``asyncio.to_thread(parse, path)`` 는 panic — -Document 가 스레드 경계를 넘기 때문. 대신 파일 읽기만 aiofiles 로 async 처리 -하고, 파싱은 event loop 스레드에서 sync 실행 (GIL 은 Rust 가 해제). +Document 가 스레드 경계를 넘기 때문. 대신 파일 read 만 stdlib ``asyncio.to_thread`` +로 thread pool offload 하고, 파싱은 event loop 스레드에서 sync 실행 (GIL 은 Rust 가 해제). 따라서 Document 객체 수준의 ``a-`` prefix 메서드 (``ato_ir`` 등) 는 제공되지 않는다. async 환경에서 Document 를 쓰려면 파싱만 :func:`rhwp.aparse` 로 하고, 이후 메서드 호출은 sync 로 한다. + +v0.3.0+ : aiofiles 의존성 제거 — stdlib 만으로 동등 효과 (둘 다 thread pool wrapping). """ import asyncio @@ -14,10 +16,7 @@ import pytest -# ^ aiofiles 미설치 시 모듈 전체 skip — `[async]` extras 없는 CI job 에서 ImportError fail 회피 -pytest.importorskip("aiofiles") - -import rhwp # noqa: E402 +import rhwp def test_aparse_returns_document_instance(hwp_sample: Path) -> None: @@ -69,18 +68,16 @@ async def flow(): assert ir1 is ir2 -def test_aparse_raises_import_error_without_aiofiles(hwp_sample: Path, monkeypatch) -> None: - """``aiofiles`` 미설치 시뮬레이션 — ``rhwp.aparse`` 는 명시적 ``ImportError``.""" - import builtins - - original_import = builtins.__import__ +def test_aparse_no_external_dependency(hwp_sample: Path) -> None: + """v0.3.0+ : aparse 는 외부 lib 의존성 없음 — stdlib 만으로 동작. - def no_aiofiles(name: str, *args, **kwargs): - if name == "aiofiles": - raise ImportError("simulated no aiofiles") - return original_import(name, *args, **kwargs) + aiofiles 설치 여부와 무관하게 동작해야 한다 (이전 버전은 ImportError 였음). + """ + doc = asyncio.run(rhwp.aparse(str(hwp_sample))) + assert isinstance(doc, rhwp.Document) - monkeypatch.setattr(builtins, "__import__", no_aiofiles) - with pytest.raises(ImportError, match="aiofiles"): - asyncio.run(rhwp.aparse(str(hwp_sample))) +def test_aparse_raises_file_not_found_for_missing_path() -> None: + """존재하지 않는 경로는 FileNotFoundError — async 경로도 동일.""" + with pytest.raises(FileNotFoundError): + asyncio.run(rhwp.aparse("/nonexistent/path/file.hwp")) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8ad325d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,298 @@ +"""rhwp-py CLI 서브커맨드 smoke + 통합 테스트. + +cli.md § 테스트 전략 — typer.testing.CliRunner 기반 smoke + 실제 sample 통합. +파일 레벨 ``importorskip("typer")`` 로 typer 미설치 시 file 전체 skip +(CI ``test-without-extras`` 잡의 5 skipped 카운트 중 1). +""" + +import json +import sys +from pathlib import Path + +import pytest + +# ^ typer / langchain extras 가드 — typer 미설치 시 file 전체 skip (1 카운트) +pytest.importorskip("typer") +import rhwp # noqa: E402 +from rhwp.cli.app import app # noqa: E402 +from typer.testing import CliRunner # noqa: E402 (importorskip 뒤 import) + +# * Click 8.2+ 부터 CliRunner 가 stdout/stderr 를 기본 분리 — result.stderr 단독 검증 가능 +_RUNNER = CliRunner() + + +def _run(*args: str): + return _RUNNER.invoke(app, list(args)) + + +# * --help 검증 — 모든 6 서브커맨드 노출 + + +def test_help_lists_all_subcommands() -> None: + result = _run("--help") + assert result.exit_code == 0 + for cmd in ("parse", "version", "schema", "ir", "blocks", "chunks"): + assert cmd in result.stdout, f"subcommand {cmd!r} missing from --help" + + +# * version — rhwp.version() / rhwp_core_version() 일치 + + +def test_version_outputs_match_rhwp_module() -> None: + result = _run("version") + assert result.exit_code == 0 + assert rhwp.version() in result.stdout + assert rhwp.rhwp_core_version() in result.stdout + + +# * parse — 한 줄 sections=N paragraphs=N pages=N + 한 줄 버전 + + +def test_parse_summary_format(hwp_sample: Path) -> None: + result = _run("parse", str(hwp_sample)) + assert result.exit_code == 0 + assert "sections=" in result.stdout + assert "paragraphs=" in result.stdout + assert "pages=" in result.stdout + assert "rhwp-python=" in result.stdout + assert "rhwp-core=" in result.stdout + + +def test_parse_missing_file_exit_1(tmp_path: Path) -> None: + result = _run("parse", str(tmp_path / "missing.hwp")) + assert result.exit_code == 1 + assert "file not found" in result.stderr + + +# * schema — stdout 결과가 export_schema() 와 동일 + + +def test_schema_stdout_matches_export_schema() -> None: + from rhwp.ir.schema import export_schema + + result = _run("schema") + assert result.exit_code == 0 + assert json.loads(result.stdout) == export_schema() + + +def test_schema_to_file_writes_valid_json(tmp_path: Path) -> None: + out = tmp_path / "schema.json" + result = _run("schema", "--out", str(out)) + assert result.exit_code == 0 + data = json.loads(out.read_text(encoding="utf-8")) + assert data["$id"].endswith("/schema/hwp_ir/v1/schema.json") + assert data["$schema"] == "https://json-schema.org/draft/2020-12/schema" + + +# * ir — to_ir_json 위에. default 한 줄, --indent 들여쓰기 + + +def test_ir_default_compact_single_line(hwpx_sample: Path) -> None: + result = _run("ir", str(hwpx_sample)) + assert result.exit_code == 0 + body = result.stdout.rstrip("\n") + assert body.count("\n") == 0 + assert '"schema_version"' in body + + +def test_ir_indent_produces_multiline(hwpx_sample: Path) -> None: + result = _run("ir", str(hwpx_sample), "--indent", "2") + assert result.exit_code == 0 + assert result.stdout.count("\n") > 5 + + +def test_ir_to_file(hwpx_sample: Path, tmp_path: Path) -> None: + out = tmp_path / "ir.json" + result = _run("ir", str(hwpx_sample), "--out", str(out)) + assert result.exit_code == 0 + data = json.loads(out.read_text(encoding="utf-8")) + assert data["schema_name"] == "HwpDocument" + + +# * blocks — ndjson default, json 전체, text 평문 + + +def test_blocks_ndjson_each_line_is_independent_json(hwpx_sample: Path) -> None: + result = _run("blocks", str(hwpx_sample), "--format", "ndjson", "--limit", "5") + assert result.exit_code == 0 + lines = [line for line in result.stdout.splitlines() if line] + assert len(lines) <= 5 + assert len(lines) > 0 + for line in lines: + obj = json.loads(line) + assert "kind" in obj + assert "prov" in obj + + +def test_blocks_kind_filter_table(hwpx_sample: Path) -> None: + result = _run("blocks", str(hwpx_sample), "--kind", "table", "--format", "ndjson") + assert result.exit_code == 0 + lines = [line for line in result.stdout.splitlines() if line] + assert len(lines) > 0 # ^ 샘플에 표 9개 + for line in lines: + assert json.loads(line)["kind"] == "table" + + +def test_blocks_format_json_returns_array(hwpx_sample: Path) -> None: + result = _run("blocks", str(hwpx_sample), "--format", "json", "--limit", "3") + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert len(data) <= 3 + if data: + assert "kind" in data[0] + + +def test_blocks_format_text_outputs_plain_strings(hwp_sample: Path) -> None: + """text 모드는 평문만 — HTML 마크업 부재 + non-empty 라인.""" + result = _run( + "blocks", str(hwp_sample), "--format", "text", "--kind", "paragraph", "--limit", "5" + ) + assert result.exit_code == 0 + lines = [line for line in result.stdout.splitlines() if line] + assert len(lines) > 0 # ^ 빈 단락은 skip 됐지만 5 한도 안에 non-empty 단락이 충분 + for line in lines: + # ^ paragraph 만 필터했으므로 HTML 태그가 절대 등장하면 안 됨 + assert "" not in line + assert "

" not in line + + +def test_blocks_scope_furniture_exits_zero(hwp_sample: Path) -> None: + result = _run("blocks", str(hwp_sample), "--scope", "furniture", "--format", "ndjson") + assert result.exit_code == 0 + + +def test_blocks_no_recurse_skips_table_cells(hwpx_sample: Path) -> None: + """--no-recurse 면 TableCell.blocks 안의 paragraph 가 yield 되지 않는다.""" + result_recurse = _run("blocks", str(hwpx_sample), "--kind", "paragraph", "--format", "ndjson") + result_no_recurse = _run( + "blocks", + str(hwpx_sample), + "--kind", + "paragraph", + "--no-recurse", + "--format", + "ndjson", + ) + assert result_recurse.exit_code == 0 + assert result_no_recurse.exit_code == 0 + n_recurse = sum(1 for line in result_recurse.stdout.splitlines() if line) + n_no_recurse = sum(1 for line in result_no_recurse.stdout.splitlines() if line) + # ^ HWPX 샘플은 표 안에 단락이 있으므로 recurse=True 가 더 많은 단락을 yield + assert n_recurse >= n_no_recurse + + +# * chunks — langchain-text-splitters 미설치 시 exit 2 (monkeypatch 로 simulate) + + +def test_chunks_missing_text_splitters_exit_2( + hwp_sample: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """langchain_text_splitters 가 import 불가하면 exit 2 + stderr 메시지.""" + monkeypatch.setitem(sys.modules, "langchain_text_splitters", None) + result = _run("chunks", str(hwp_sample)) + assert result.exit_code == 2 + assert "langchain-text-splitters" in result.stderr + assert "rhwp-python[cli-chunks]" in result.stderr + + +@pytest.mark.langchain +def test_chunks_paragraph_default(hwp_sample: Path) -> None: + pytest.importorskip("langchain_text_splitters") + result = _run( + "chunks", + str(hwp_sample), + "--format", + "ndjson", + "--size", + "300", + "--overlap", + "30", + ) + assert result.exit_code == 0 + lines = [line for line in result.stdout.splitlines() if line] + assert len(lines) > 0 + for line in lines[:5]: + obj = json.loads(line) + assert "page_content" in obj + assert "metadata" in obj + + +@pytest.mark.langchain +def test_chunks_ir_blocks_mode(hwpx_sample: Path) -> None: + pytest.importorskip("langchain_text_splitters") + result = _run( + "chunks", + str(hwpx_sample), + "--mode", + "ir-blocks", + "--format", + "ndjson", + "--size", + "500", + ) + assert result.exit_code == 0 + lines = [line for line in result.stdout.splitlines() if line] + assert len(lines) > 0 + # ^ ir-blocks 모드: metadata.kind 가 paragraph/table/picture/... 중 하나 + obj = json.loads(lines[0]) + assert obj["metadata"]["kind"] in { + "paragraph", + "table", + "picture", + "formula", + "footnote", + "endnote", + "list_item", + "caption", + "toc", + "field", + } + + +@pytest.mark.langchain +def test_chunks_include_furniture_yields_more_or_equal(hwp_sample: Path) -> None: + """--include-furniture 면 body 만일 때보다 출력 청크 ≥ — fixture-agnostic invariant. + + aift.hwp 샘플에 footnote 가 있어 실제로는 strict greater 지만, 테스트가 샘플 + 구성 (footnote 유무) 에 brittle 하지 않게 ≥ 만 검증. furniture 블록의 + metadata.scope 검증은 별도 LangChain loader 테스트가 수행. + """ + pytest.importorskip("langchain_text_splitters") + args_common = [ + "chunks", + str(hwp_sample), + "--mode", + "ir-blocks", + "--format", + "ndjson", + "--size", + "500", + ] + body_only = _run(*args_common) + with_furn = _run(*args_common, "--include-furniture") + assert body_only.exit_code == 0 + assert with_furn.exit_code == 0 + n_body = sum(1 for line in body_only.stdout.splitlines() if line) + n_with = sum(1 for line in with_furn.stdout.splitlines() if line) + assert n_with >= n_body, ( + f"--include-furniture 가 body-only 보다 적은 청크를 반환: body={n_body}, with={n_with}" + ) + # ^ furniture 추가 청크가 있다면 그 중 적어도 하나는 scope=furniture metadata 보유 + if n_with > n_body: + scopes = { + json.loads(line)["metadata"].get("scope") + for line in with_furn.stdout.splitlines() + if line + } + assert "furniture" in scopes + + +# * typer 미설치 환경의 entry point 검증은 ci.yml test-without-extras 잡이 담당 + + +def test_chunks_missing_file_exit_1(tmp_path: Path) -> None: + """존재하지 않는 파일 — exit 1 (extras 미설치 가드보다 뒤 검사 순서지만 monkeypatch 없이).""" + pytest.importorskip("langchain_text_splitters") + result = _run("chunks", str(tmp_path / "missing.hwp")) + assert result.exit_code == 1 diff --git a/tests/test_ir_caption.py b/tests/test_ir_caption.py new file mode 100644 index 0000000..c7294ac --- /dev/null +++ b/tests/test_ir_caption.py @@ -0,0 +1,338 @@ +"""tests/test_ir_caption.py — Stage S3 CaptionBlock + Picture/Table caption 부착. + +ir-expansion.md §S3 + § 5 CaptionBlock 검증: + +- CaptionBlock 직렬화 왕복 + frozen + extra=forbid + direction Literal 닫힌 어휘 +- mapper RawCaption → CaptionBlock + paragraphs 평탄화 +- PictureBlock.caption 부착 (S3 신규) +- TableBlock.caption_block 부착 (v0.2.0 caption: str 호환 보존) +- iter_blocks recurse=True 가 PictureBlock.caption 에 진입하지 않음 (RAG 노이즈 회피) +- iter_blocks recurse=True 가 단독 body CaptionBlock.blocks 에는 진입 +""" + +import pytest +from pydantic import ValidationError +from rhwp.ir._mapper import _build_caption_block, _build_picture_block, build_hwp_document +from rhwp.ir._raw_types import ( + RawCaption, + RawDocument, + RawImageRef, + RawParagraph, + RawPicture, + RawTable, +) +from rhwp.ir.nodes import ( + CaptionBlock, + HwpDocument, + ParagraphBlock, + PictureBlock, + Provenance, + TableBlock, + TableCell, +) + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +def _raw_para(text: str = "", section_idx: int = 0, para_idx: int = 0) -> RawParagraph: + return RawParagraph( + section_idx=section_idx, + para_idx=para_idx, + text=text, + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) + + +# * 모델 단독 + + +def test_caption_block_minimal_roundtrip(): + cap = CaptionBlock( + blocks=[ParagraphBlock(text="<그림 1> 회로도", prov=_prov())], + direction="bottom", + prov=_prov(), + ) + reloaded = CaptionBlock.model_validate_json(cap.model_dump_json()) + assert reloaded == cap + assert reloaded.kind == "caption" + + +def test_caption_block_default_direction_is_bottom(): + cap = CaptionBlock(prov=_prov()) + assert cap.direction == "bottom" + assert cap.blocks == [] + + +def test_caption_block_extra_forbidden(): + with pytest.raises(ValidationError): + CaptionBlock.model_validate( + {"kind": "caption", "prov": {"section_idx": 0, "para_idx": 0}, "extra": "x"} + ) + + +def test_caption_block_frozen(): + cap = CaptionBlock(prov=_prov()) + with pytest.raises(ValidationError): + cap.direction = "top" # type: ignore[misc] + + +@pytest.mark.parametrize("direction", ["top", "bottom", "left", "right"]) +def test_caption_block_accepts_valid_directions(direction: str): + cap = CaptionBlock(direction=direction, prov=_prov()) # type: ignore[arg-type] + assert cap.direction == direction + + +def test_caption_block_rejects_unknown_direction(): + with pytest.raises(ValidationError): + CaptionBlock.model_validate( + { + "kind": "caption", + "direction": "diagonal", + "prov": {"section_idx": 0, "para_idx": 0}, + } + ) + + +def test_caption_block_routes_via_discriminator_in_body(): + raw = { + "kind": "caption", + "blocks": [], + "direction": "top", + "prov": {"section_idx": 0, "para_idx": 0}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + assert isinstance(blk, CaptionBlock) + assert blk.direction == "top" + + +# * PictureBlock.caption 컨테인먼트 + + +def test_picture_block_caption_field_default_none(): + pic = PictureBlock(prov=_prov()) + assert pic.caption is None + + +def test_picture_block_caption_roundtrip(): + cap = CaptionBlock( + blocks=[ParagraphBlock(text="설명", prov=_prov())], + direction="bottom", + prov=_prov(), + ) + pic = PictureBlock(caption=cap, description="설명", prov=_prov()) + reloaded = PictureBlock.model_validate_json(pic.model_dump_json()) + assert reloaded == pic + assert reloaded.caption is not None + assert reloaded.caption.direction == "bottom" + + +# * TableBlock.caption_block 컨테인먼트 + v0.2.0 caption 호환 + + +def test_table_block_caption_block_field_default_none(): + tbl = TableBlock(rows=1, cols=1, prov=_prov()) + assert tbl.caption_block is None + assert tbl.caption is None # ^ v0.2.0 호환 필드 기본값 + + +def test_table_block_caption_str_and_caption_block_coexist(): + """v0.2.0 호환 caption: str 와 신규 caption_block: CaptionBlock 둘 다 채울 수 있다.""" + cap = CaptionBlock( + blocks=[ParagraphBlock(text="<표 1> 결과", prov=_prov())], + prov=_prov(), + ) + tbl = TableBlock( + rows=1, + cols=1, + caption="<표 1> 결과", + caption_block=cap, + prov=_prov(), + ) + reloaded = TableBlock.model_validate_json(tbl.model_dump_json()) + assert reloaded.caption == "<표 1> 결과" + assert reloaded.caption_block is not None + assert reloaded.caption_block.direction == "bottom" + + +def test_table_block_caption_str_only_v0_2_0_pattern(): + """v0.2.0 시대처럼 caption_block 없이 caption: str 만 채워도 호환 유지.""" + tbl = TableBlock(rows=1, cols=1, caption="단순 캡션", prov=_prov()) + reloaded = TableBlock.model_validate_json(tbl.model_dump_json()) + assert reloaded.caption == "단순 캡션" + assert reloaded.caption_block is None + + +# * mapper — RawCaption → CaptionBlock + + +def _raw_caption(direction: str = "bottom", texts: tuple[str, ...] = ("캡션",)) -> RawCaption: + return RawCaption( + direction=direction, + section_idx=0, + para_idx=0, + paragraphs=[_raw_para(text=t) for t in texts], + ) + + +def test_build_caption_block_paragraphs_flattened(): + raw = _raw_caption(direction="bottom", texts=("줄1", "줄2")) + cap = _build_caption_block(raw) + assert cap.direction == "bottom" + assert len(cap.blocks) == 2 + assert all(isinstance(b, ParagraphBlock) for b in cap.blocks) + texts = [b.text for b in cap.blocks if isinstance(b, ParagraphBlock)] + assert texts == ["줄1", "줄2"] + + +def test_build_caption_block_unknown_direction_falls_back_to_bottom(): + """Rust 가 새 CaptionDirection variant 를 추가할 때 forward-compat — bottom 폴백.""" + raw = _raw_caption(direction="diagonal", texts=("x",)) # ^ Literal 어휘 외 + cap = _build_caption_block(raw) + assert cap.direction == "bottom" + + +@pytest.mark.parametrize("direction", ["top", "bottom", "left", "right"]) +def test_build_caption_block_preserves_known_directions(direction: str): + raw = _raw_caption(direction=direction, texts=("x",)) + cap = _build_caption_block(raw) + assert cap.direction == direction + + +# * mapper — RawPicture.caption (S3 신규) + + +def test_build_picture_block_with_caption_field(): + raw_pic = RawPicture( + section_idx=0, + para_idx=0, + image=RawImageRef(bin_data_id=1, extension="png", has_content=True), + description="alt-text", + caption=_raw_caption(direction="bottom", texts=("<그림 1> 회로도",)), + ) + pic = _build_picture_block(raw_pic) + assert pic.caption is not None + assert pic.caption.direction == "bottom" + assert len(pic.caption.blocks) == 1 + blk = pic.caption.blocks[0] + assert isinstance(blk, ParagraphBlock) + assert blk.text == "<그림 1> 회로도" + + +def test_build_picture_block_caption_none_when_raw_caption_none(): + raw_pic = RawPicture( + section_idx=0, + para_idx=0, + image=None, + description=None, + caption=None, + ) + pic = _build_picture_block(raw_pic) + assert pic.caption is None + + +def test_build_picture_preserves_description_alongside_caption(): + """description (S1 호환) 과 caption (S3 신규) 모두 채울 수 있다 — 둘은 다른 source path.""" + raw_pic = RawPicture( + section_idx=0, + para_idx=0, + image=None, + description="caption text fallback", + caption=_raw_caption(direction="bottom", texts=("caption text fallback",)), + ) + pic = _build_picture_block(raw_pic) + assert pic.description == "caption text fallback" + assert pic.caption is not None + + +# * mapper — RawTable.caption_block (S3 신규) + + +def test_build_hwp_document_table_with_caption_block_routed(): + raw_table = RawTable( + rows=1, + cols=1, + cells=[], + caption="단순 캡션", + caption_block=_raw_caption(direction="top", texts=("단순 캡션",)), + ) + raw_para = RawParagraph( + section_idx=0, + para_idx=0, + text="", + char_runs=[], + tables=[raw_table], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) + raw_doc = RawDocument( + source_uri=None, + section_count=1, + paragraphs=[raw_para], + headers=[], + footers=[], + footnotes=[], + endnotes=[], + ) + ir = build_hwp_document(raw_doc) + tbl = next(b for b in ir.body if isinstance(b, TableBlock)) + assert tbl.caption == "단순 캡션" + assert tbl.caption_block is not None + assert tbl.caption_block.direction == "top" + + +# * iter_blocks 재귀 — CaptionBlock 정책 + + +def test_iter_blocks_recurse_does_not_enter_picture_caption(): + """spec: PictureBlock.caption 은 부모 metadata 로 간주 — recurse 진입 안 함.""" + inner = ParagraphBlock(text="caption text", prov=_prov()) + cap = CaptionBlock(blocks=[inner], prov=_prov()) + pic = PictureBlock(caption=cap, prov=_prov()) + ir = HwpDocument(body=[pic]) + seq = list(ir.iter_blocks(scope="body", recurse=True)) + # ^ PictureBlock 자체는 yield, caption.blocks 안의 paragraph 는 yield 안 됨 + assert pic in seq + assert inner not in seq + + +def test_iter_blocks_recurse_does_not_enter_table_caption_block(): + inner = ParagraphBlock(text="caption text", prov=_prov()) + cap = CaptionBlock(blocks=[inner], prov=_prov()) + tbl = TableBlock(rows=1, cols=1, caption_block=cap, prov=_prov()) + ir = HwpDocument(body=[tbl]) + seq = list(ir.iter_blocks(scope="body", recurse=True)) + assert tbl in seq + assert inner not in seq + + +def test_iter_blocks_recurse_enters_standalone_caption_in_body(): + """단독 body CaptionBlock 의 blocks 는 재귀 진입 (TableCell.blocks 와 같은 패턴).""" + inner = ParagraphBlock(text="standalone", prov=_prov()) + cap = CaptionBlock(blocks=[inner], prov=_prov()) + ir = HwpDocument(body=[cap]) + seq = list(ir.iter_blocks(scope="body", recurse=True)) + assert cap in seq + assert inner in seq + + +def test_iter_blocks_recurse_enters_caption_inside_table_cell(): + """CaptionBlock 안에 표가 있어 재귀해야 하는 경우 (사용자 직접 구성 경로).""" + leaf = ParagraphBlock(text="cell text", prov=_prov()) + inner_cell = TableCell(row=0, col=0, grid_index=0, blocks=[leaf]) + inner_table = TableBlock(rows=1, cols=1, cells=[inner_cell], prov=_prov()) + cap = CaptionBlock(blocks=[inner_table], prov=_prov()) + ir = HwpDocument(body=[cap]) + recursed = list(ir.iter_blocks(scope="body", recurse=True)) + assert recursed == [cap, inner_table, leaf] diff --git a/tests/test_ir_field.py b/tests/test_ir_field.py new file mode 100644 index 0000000..ba86e96 --- /dev/null +++ b/tests/test_ir_field.py @@ -0,0 +1,256 @@ +"""tests/test_ir_field.py — Stage S3 FieldBlock + FieldKind 매퍼. + +ir-expansion.md §S3 + § 7 FieldBlock 검증: + +- FieldBlock 직렬화 왕복 + frozen + extra=forbid +- FieldKind 닫힌 Literal 14 종 + "unknown" 안전판 +- mapper RawField → FieldBlock — 미지 field_kind 는 "unknown" + field_type_code 보존 +- TocOfContents 는 FieldBlock 으로 가지 않음 (TocBlock 으로 별도 라우팅) +- _flatten_paragraph 가 FieldBlock 도 emit +""" + +import pytest +from pydantic import ValidationError +from rhwp.ir._mapper import _VALID_FIELD_KINDS, _build_field_block, _flatten_paragraph +from rhwp.ir._raw_types import RawField, RawParagraph +from rhwp.ir.nodes import ( + FieldBlock, + FieldKind, + HwpDocument, + ParagraphBlock, + Provenance, +) + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +def _raw_field( + *, + field_kind: str = "date", + cached_value: str | None = None, + raw_instruction: str | None = None, + field_type_code: int | None = None, + section_idx: int = 0, + para_idx: int = 0, +) -> RawField: + return RawField( + section_idx=section_idx, + para_idx=para_idx, + field_kind=field_kind, + cached_value=cached_value, + raw_instruction=raw_instruction, + field_type_code=field_type_code, + ) + + +def _raw_para_with_fields(fields: list[RawField]) -> RawParagraph: + return RawParagraph( + section_idx=0, + para_idx=0, + text="", + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=fields, + list_info=None, + ) + + +# * 모델 단독 + + +def test_field_block_minimal_roundtrip(): + f = FieldBlock(field_kind="date", prov=_prov()) + reloaded = FieldBlock.model_validate_json(f.model_dump_json()) + assert reloaded == f + assert reloaded.kind == "field" + assert reloaded.field_kind == "date" + + +def test_field_block_default_field_kind_is_unknown(): + f = FieldBlock(prov=_prov()) + assert f.field_kind == "unknown" + assert f.cached_value is None + assert f.raw_instruction is None + assert f.field_type_code is None + + +def test_field_block_full_roundtrip(): + f = FieldBlock( + field_kind="hyperlink", + cached_value="https://example.com", + raw_instruction='HYPERLINK "https://example.com"', + field_type_code=42, + prov=_prov(para_idx=5), + ) + reloaded = FieldBlock.model_validate_json(f.model_dump_json()) + assert reloaded == f + + +def test_field_block_extra_forbidden(): + with pytest.raises(ValidationError): + FieldBlock.model_validate( + {"kind": "field", "prov": {"section_idx": 0, "para_idx": 0}, "extra": True} + ) + + +def test_field_block_frozen(): + f = FieldBlock(field_kind="date", prov=_prov()) + with pytest.raises(ValidationError): + f.field_kind = "path" # type: ignore[misc] + + +# * FieldKind 닫힌 Literal — 14 + unknown + + +def test_field_kind_literal_has_15_values(): + """spec § 7: 14 known FieldType + "unknown".""" + from typing import get_args + + values = get_args(FieldKind) + assert len(values) == 15 + assert "unknown" in values + + +@pytest.mark.parametrize( + "field_kind", + [ + "date", + "doc_date", + "path", + "bookmark", + "mailmerge", + "crossref", + "calc", + "clickhere", + "summary", + "userinfo", + "hyperlink", + "memo", + "private_info", + "toc", + "unknown", + ], +) +def test_field_block_accepts_all_known_kinds(field_kind: str): + f = FieldBlock(field_kind=field_kind, prov=_prov()) # type: ignore[arg-type] + assert f.field_kind == field_kind + + +def test_field_block_rejects_invalid_field_kind(): + with pytest.raises(ValidationError): + FieldBlock.model_validate( + { + "kind": "field", + "field_kind": "foo_bar_kind", + "prov": {"section_idx": 0, "para_idx": 0}, + } + ) + + +def test_field_block_calc_distinguishes_from_formula_block(): + """spec § 7: ``"calc"`` 는 HWP FieldType::Formula (계산 필드, 표 합계 등) — 수식 (Equation) 과 다름. + + 이름 충돌 회피용 별도 어휘 — Equation 은 ``FormulaBlock`` (kind="formula") 로 매핑. + """ + f = FieldBlock(field_kind="calc", prov=_prov()) + assert f.field_kind == "calc" + assert f.kind == "field" + # ^ FormulaBlock 의 kind="formula" 와 다름 + + +def test_field_block_routes_via_discriminator(): + raw = { + "kind": "field", + "field_kind": "crossref", + "cached_value": "[표 1 참조]", + "prov": {"section_idx": 0, "para_idx": 5}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + assert isinstance(blk, FieldBlock) + assert blk.field_kind == "crossref" + + +# * mapper — RawField → FieldBlock + + +def test_build_field_block_known_kind_passes_through(): + blk = _build_field_block(_raw_field(field_kind="date", cached_value="2026-04-26")) + assert blk.field_kind == "date" + assert blk.cached_value == "2026-04-26" + assert blk.raw_instruction is None + + +def test_build_field_block_unknown_kind_falls_back_to_unknown(): + """spec § 7: 미지의 field_kind 는 "unknown" 으로 강제 + field_type_code 보존.""" + blk = _build_field_block( + _raw_field(field_kind="future_kind_unknown_to_v0_3_0", field_type_code=99) + ) + assert blk.field_kind == "unknown" + assert blk.field_type_code == 99 + + +def test_build_field_block_preserves_provenance(): + blk = _build_field_block(_raw_field(field_kind="path", section_idx=2, para_idx=10)) + assert blk.prov.section_idx == 2 + assert blk.prov.para_idx == 10 + assert blk.prov.char_start is None + assert blk.prov.char_end is None + + +def test_build_field_block_preserves_raw_instruction(): + """raw_instruction 은 round-trip 보존용 — Word 대응.""" + blk = _build_field_block( + _raw_field(field_kind="hyperlink", raw_instruction='HYPERLINK "https://x"') + ) + assert blk.raw_instruction == 'HYPERLINK "https://x"' + + +@pytest.mark.parametrize("field_kind", sorted(_VALID_FIELD_KINDS)) +def test_valid_field_kinds_set_matches_literal(field_kind: str): + """``_VALID_FIELD_KINDS`` 는 FieldKind Literal 의 모든 value 와 정확히 일치.""" + blk = _build_field_block(_raw_field(field_kind=field_kind)) + assert blk.field_kind == field_kind + + +# * _flatten_paragraph 가 FieldBlock emit + + +def test_flatten_paragraph_yields_field_block_when_fields_present(): + raw_para = _raw_para_with_fields([_raw_field(field_kind="date")]) + blocks = _flatten_paragraph(raw_para) + # ^ paragraph + field 두 블록 + assert len(blocks) == 2 + assert isinstance(blocks[0], ParagraphBlock) + assert isinstance(blocks[1], FieldBlock) + + +def test_flatten_paragraph_multiple_fields_in_order(): + raw_para = _raw_para_with_fields( + [ + _raw_field(field_kind="date"), + _raw_field(field_kind="path"), + _raw_field(field_kind="hyperlink"), + ] + ) + blocks = _flatten_paragraph(raw_para) + field_kinds = [b.field_kind for b in blocks if isinstance(b, FieldBlock)] + assert field_kinds == ["date", "path", "hyperlink"] + + +# * TableOfContents 는 FieldBlock 이 아니라 TocBlock 으로 (별도 테스트는 test_ir_toc.py) + + +def test_field_block_with_toc_kind_is_user_constructible_only(): + """``"toc"`` field_kind 는 Literal 에 있지만 mapper 는 항상 TocBlock 으로 라우팅한다. + + 사용자가 직접 ``FieldBlock(field_kind="toc")`` 로 구성하면 호환을 위해 허용 — + 그러나 to_ir() 경로에서는 TocBlock 으로 분리되므로 등장하지 않는다. + """ + blk = FieldBlock(field_kind="toc", prov=_prov()) + assert blk.field_kind == "toc" diff --git a/tests/test_ir_footnote.py b/tests/test_ir_footnote.py new file mode 100644 index 0000000..498fa55 --- /dev/null +++ b/tests/test_ir_footnote.py @@ -0,0 +1,337 @@ +"""tests/test_ir_footnote.py — Stage S2 FootnoteBlock + EndnoteBlock 매퍼 + furniture 라우팅. + +ir-expansion.md §S2 + § 3 검증: + +- FootnoteBlock / EndnoteBlock 직렬화 왕복 + frozen + extra=forbid + 분리된 두 타입 +- mapper RawFootnote/RawEndnote → 블록 변환 (number, blocks 평탄화, marker_prov) +- build_hwp_document 가 footnotes / endnotes 를 furniture 로 라우팅 +- iter_blocks(scope="furniture") 순서 보장: page_headers → page_footers → footnotes → endnotes +- recurse=True 가 FootnoteBlock.blocks 안 paragraphs 를 yield (재귀) +""" + +import pytest +import rhwp +from pydantic import ValidationError +from rhwp.ir._mapper import _build_endnote_block, _build_footnote_block, build_hwp_document +from rhwp.ir._raw_types import RawDocument, RawEndnote, RawFootnote, RawParagraph +from rhwp.ir.nodes import ( + EndnoteBlock, + FootnoteBlock, + Furniture, + HwpDocument, + ParagraphBlock, + Provenance, + TableBlock, + TableCell, +) + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +def _empty_raw_para(*, section_idx: int = 0, para_idx: int = 0, text: str = "") -> RawParagraph: + return RawParagraph( + section_idx=section_idx, + para_idx=para_idx, + text=text, + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) + + +def _empty_raw_doc( + *, + footnotes: list[RawFootnote] | None = None, + endnotes: list[RawEndnote] | None = None, +) -> RawDocument: + return RawDocument( + source_uri=None, + section_count=1, + paragraphs=[], + headers=[], + footers=[], + footnotes=footnotes or [], + endnotes=endnotes or [], + ) + + +# * 모델 단독 — FootnoteBlock / EndnoteBlock + + +def test_footnote_block_minimal_roundtrip(): + fn = FootnoteBlock( + number=1, + blocks=[ParagraphBlock(text="각주 본문", prov=_prov(para_idx=10))], + marker_prov=_prov(para_idx=5), + prov=_prov(para_idx=5), + ) + reloaded = FootnoteBlock.model_validate_json(fn.model_dump_json()) + assert reloaded == fn + assert reloaded.kind == "footnote" + assert reloaded.number == 1 + assert len(reloaded.blocks) == 1 + + +def test_endnote_block_minimal_roundtrip(): + en = EndnoteBlock( + number=2, + blocks=[ParagraphBlock(text="미주 본문", prov=_prov(para_idx=12))], + marker_prov=_prov(para_idx=6), + prov=_prov(para_idx=6), + ) + reloaded = EndnoteBlock.model_validate_json(en.model_dump_json()) + assert reloaded == en + assert reloaded.kind == "endnote" + + +def test_footnote_block_separate_from_endnote_block(): + """FootnoteBlock 과 EndnoteBlock 은 별개 타입 — HWP 가 분리하므로 IR 도 분리.""" + fn = FootnoteBlock(number=1, marker_prov=_prov(), prov=_prov()) + en = EndnoteBlock(number=1, marker_prov=_prov(), prov=_prov()) + assert fn.kind != en.kind + assert not isinstance(fn, EndnoteBlock) + assert not isinstance(en, FootnoteBlock) + + +def test_footnote_block_extra_forbidden(): + with pytest.raises(ValidationError): + FootnoteBlock.model_validate( + { + "kind": "footnote", + "number": 1, + "blocks": [], + "marker_prov": {"section_idx": 0, "para_idx": 0}, + "prov": {"section_idx": 0, "para_idx": 0}, + "extra": "x", + } + ) + + +def test_footnote_block_frozen(): + fn = FootnoteBlock(number=1, marker_prov=_prov(), prov=_prov()) + with pytest.raises(ValidationError): + fn.number = 2 # type: ignore[misc] + + +def test_footnote_block_routes_via_discriminator(): + raw = { + "kind": "footnote", + "number": 7, + "blocks": [], + "marker_prov": {"section_idx": 0, "para_idx": 5}, + "prov": {"section_idx": 0, "para_idx": 5}, + } + fn = HwpDocument.model_validate( + {"furniture": {"page_headers": [], "page_footers": [], "footnotes": [raw], "endnotes": []}} + ).furniture.footnotes[0] + assert isinstance(fn, FootnoteBlock) + assert fn.number == 7 + + +def test_footnote_block_marker_and_prov_separately_assignable(): + """spec § 3: marker_prov 는 본문 인용 위치, prov 는 각주 본문 위치 — 다른 값 가능.""" + fn = FootnoteBlock( + number=1, + marker_prov=_prov(section_idx=0, para_idx=3), + prov=_prov(section_idx=0, para_idx=3), + ) + assert fn.marker_prov.para_idx == 3 + assert fn.prov.para_idx == 3 + + +def test_footnote_block_blocks_supports_recursion_with_table(): + """spec § 3: 각주 본문 안에 표가 있어도 ``blocks`` 재귀로 자연 지원.""" + inner = ParagraphBlock(text="셀 텍스트", prov=_prov(para_idx=99)) + cell = TableCell(row=0, col=0, grid_index=0, blocks=[inner]) + inner_table = TableBlock(rows=1, cols=1, cells=[cell], prov=_prov(para_idx=99)) + fn = FootnoteBlock( + number=1, + blocks=[inner_table], + marker_prov=_prov(), + prov=_prov(), + ) + reloaded = FootnoteBlock.model_validate_json(fn.model_dump_json()) + assert isinstance(reloaded.blocks[0], TableBlock) + + +# * mapper — RawFootnote → FootnoteBlock + + +def test_build_footnote_block_preserves_number_and_marker(): + raw = RawFootnote( + marker_section_idx=2, + marker_para_idx=15, + number=3, + blocks=[_empty_raw_para(text="본문")], + ) + fn = _build_footnote_block(raw) + assert fn.number == 3 + assert fn.marker_prov.section_idx == 2 + assert fn.marker_prov.para_idx == 15 + assert fn.prov.section_idx == 2 + assert fn.prov.para_idx == 15 + assert len(fn.blocks) == 1 + + +def test_build_footnote_block_flattens_inner_paragraphs(): + """각주 본문 안에 여러 paragraph + 표 가 있으면 평탄화 적용.""" + raw = RawFootnote( + marker_section_idx=0, + marker_para_idx=5, + number=1, + blocks=[_empty_raw_para(text="첫 줄"), _empty_raw_para(text="둘째 줄")], + ) + fn = _build_footnote_block(raw) + assert len(fn.blocks) == 2 + texts = [b.text for b in fn.blocks if isinstance(b, ParagraphBlock)] + assert texts == ["첫 줄", "둘째 줄"] + + +def test_build_endnote_block_mirrors_footnote_pattern(): + raw = RawEndnote( + marker_section_idx=1, + marker_para_idx=8, + number=42, + blocks=[_empty_raw_para(text="미주 텍스트")], + ) + en = _build_endnote_block(raw) + assert en.number == 42 + assert en.marker_prov.section_idx == 1 + assert en.marker_prov.para_idx == 8 + + +# * build_hwp_document — furniture.footnotes / endnotes 라우팅 + + +def test_build_hwp_document_routes_footnotes_to_furniture(): + raw = _empty_raw_doc( + footnotes=[ + RawFootnote( + marker_section_idx=0, + marker_para_idx=2, + number=1, + blocks=[_empty_raw_para(text="footnote body")], + ) + ] + ) + ir = build_hwp_document(raw) + assert ir.body == [] + assert len(ir.furniture.footnotes) == 1 + fn = ir.furniture.footnotes[0] + assert isinstance(fn, FootnoteBlock) + assert fn.number == 1 + + +def test_build_hwp_document_routes_endnotes_to_furniture(): + raw = _empty_raw_doc( + endnotes=[ + RawEndnote( + marker_section_idx=0, + marker_para_idx=10, + number=5, + blocks=[_empty_raw_para(text="endnote body")], + ) + ] + ) + ir = build_hwp_document(raw) + assert len(ir.furniture.endnotes) == 1 + en = ir.furniture.endnotes[0] + assert isinstance(en, EndnoteBlock) + assert en.number == 5 + + +def test_iter_blocks_furniture_order_includes_footnotes_endnotes(): + """순서: page_headers → page_footers → footnotes → endnotes (수동 주입).""" + para_h = ParagraphBlock(text="H", prov=_prov(para_idx=1)) + para_f = ParagraphBlock(text="F", prov=_prov(para_idx=2)) + fn = FootnoteBlock(number=1, marker_prov=_prov(), prov=_prov()) + en = EndnoteBlock(number=1, marker_prov=_prov(), prov=_prov()) + ir = HwpDocument( + furniture=Furniture( + page_headers=[para_h], + page_footers=[para_f], + footnotes=[fn], + endnotes=[en], + ) + ) + seq = list(ir.iter_blocks(scope="furniture", recurse=False)) + assert seq == [para_h, para_f, fn, en] + + +def test_iter_blocks_recurse_enters_footnote_blocks(): + """recurse=True 면 FootnoteBlock.blocks 의 inner paragraph 까지 yield.""" + inner = ParagraphBlock(text="각주 텍스트", prov=_prov(para_idx=99)) + fn = FootnoteBlock(number=1, blocks=[inner], marker_prov=_prov(), prov=_prov()) + ir = HwpDocument(furniture=Furniture(footnotes=[fn])) + recursed = list(ir.iter_blocks(scope="furniture", recurse=True)) + assert recursed == [fn, inner] + no_rec = list(ir.iter_blocks(scope="furniture", recurse=False)) + assert no_rec == [fn] + + +def test_iter_blocks_recurse_enters_endnote_blocks(): + inner = ParagraphBlock(text="미주 텍스트", prov=_prov(para_idx=99)) + en = EndnoteBlock(number=1, blocks=[inner], marker_prov=_prov(), prov=_prov()) + ir = HwpDocument(furniture=Furniture(endnotes=[en])) + recursed = list(ir.iter_blocks(scope="furniture", recurse=True)) + assert recursed == [en, inner] + + +# * 본문 vs furniture 분리 — body 에 footnote/endnote 가 안 나타남 (계약) + + +def test_footnotes_endnotes_never_appear_in_body(): + """spec § 3 body vs furniture 배치: 각주/미주 본문은 furniture 로만 라우팅. + + 본문 인라인 마커는 InlineRun.text 그대로 보존되지만 FootnoteBlock 자체는 + body 에 나오지 않는다 — RAG body 검색 오염 회피. + """ + raw = _empty_raw_doc( + footnotes=[ + RawFootnote( + marker_section_idx=0, + marker_para_idx=0, + number=1, + blocks=[_empty_raw_para(text="x")], + ) + ], + endnotes=[ + RawEndnote( + marker_section_idx=0, + marker_para_idx=0, + number=1, + blocks=[_empty_raw_para(text="y")], + ) + ], + ) + ir = build_hwp_document(raw) + assert ir.body == [] + for blk in ir.body: + assert not isinstance(blk, (FootnoteBlock, EndnoteBlock)) + + +# * 실제 샘플 — 각주가 있으면 furniture.footnotes 에 노출, 없으면 skip + + +def test_real_sample_footnotes_exposed_in_furniture(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + if not ir.furniture.footnotes: + pytest.skip("aift.hwp 샘플에 각주 컨트롤 없음") + for fn in ir.furniture.footnotes: + assert isinstance(fn, FootnoteBlock) + assert fn.number >= 1 + assert isinstance(fn.marker_prov, Provenance) + + +def test_real_sample_endnotes_exposed_in_furniture(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + if not ir.furniture.endnotes: + pytest.skip("aift.hwp 샘플에 미주 컨트롤 없음") + for en in ir.furniture.endnotes: + assert isinstance(en, EndnoteBlock) diff --git a/tests/test_ir_formula.py b/tests/test_ir_formula.py new file mode 100644 index 0000000..4e6a915 --- /dev/null +++ b/tests/test_ir_formula.py @@ -0,0 +1,306 @@ +"""tests/test_ir_formula.py — Stage S2 FormulaBlock + simple_eq_text_alt 매퍼. + +ir-expansion.md §S2 + § 2 FormulaBlock 검증: + +- FormulaBlock 직렬화 왕복 + frozen + extra=forbid +- script_kind 닫힌 Literal 검증 ("hwp_eq" / "latex" / "mathml" / 그 외 거부) +- mapper RawFormula → FormulaBlock 변환 (script + text_alt + Provenance) +- text_alt 단순 정규화 (over → /, sqrt → √, {} → ()) 동작 확인 +- model_copy 패턴 (사용자가 외부 LaTeX 변환 후 script_kind 갱신) 가능 +""" + +import pytest +import rhwp +from pydantic import ValidationError +from rhwp.ir._mapper import _build_formula_block +from rhwp.ir._raw_types import RawFormula +from rhwp.ir.nodes import FormulaBlock, HwpDocument, Provenance + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +# * 모델 단독 + + +def test_formula_block_minimal_roundtrip(): + f = FormulaBlock(script="1 over 2", prov=_prov()) + reloaded = FormulaBlock.model_validate_json(f.model_dump_json()) + assert reloaded == f + assert reloaded.script_kind == "hwp_eq" + assert reloaded.inline is False + assert reloaded.text_alt is None + + +def test_formula_block_full_roundtrip(): + f = FormulaBlock( + script=r"\frac{1}{2}", + script_kind="latex", + text_alt="1/2", + inline=True, + prov=_prov(para_idx=3), + ) + reloaded = FormulaBlock.model_validate_json(f.model_dump_json()) + assert reloaded == f + + +def test_formula_block_kind_is_formula(): + assert FormulaBlock(script="x", prov=_prov()).kind == "formula" + + +def test_formula_block_extra_forbidden(): + with pytest.raises(ValidationError): + FormulaBlock.model_validate( + { + "kind": "formula", + "script": "x", + "prov": {"section_idx": 0, "para_idx": 0}, + "extra": "y", + } + ) + + +def test_formula_block_frozen(): + f = FormulaBlock(script="x", prov=_prov()) + with pytest.raises(ValidationError): + f.script = "y" # type: ignore[misc] + + +@pytest.mark.parametrize("script_kind", ["hwp_eq", "latex", "mathml"]) +def test_formula_block_accepts_known_script_kinds(script_kind: str): + f = FormulaBlock(script="x", script_kind=script_kind, prov=_prov()) # type: ignore[arg-type] + assert f.script_kind == script_kind + + +def test_formula_block_rejects_unknown_script_kind(): + with pytest.raises(ValidationError): + FormulaBlock.model_validate( + { + "kind": "formula", + "script": "x", + "script_kind": "asciimath", + "prov": {"section_idx": 0, "para_idx": 0}, + } + ) + + +def test_formula_block_routes_via_discriminator(): + raw = { + "kind": "formula", + "script": "1 over 2", + "prov": {"section_idx": 0, "para_idx": 5}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + assert isinstance(blk, FormulaBlock) + assert blk.script == "1 over 2" + + +def test_formula_block_model_copy_pattern_for_external_latex_conversion(): + """사용자가 외부에서 LaTeX 변환 후 model_copy 로 IR 재구성 가능 (frozen 친화).""" + raw = FormulaBlock(script="1 over 2", prov=_prov()) + converted = raw.model_copy(update={"script": r"\frac{1}{2}", "script_kind": "latex"}) + assert converted.script == r"\frac{1}{2}" + assert converted.script_kind == "latex" + assert raw.script_kind == "hwp_eq" # ^ 원본 불변 + + +# * mapper — RawFormula → FormulaBlock + + +def _raw_formula( + *, + section_idx: int = 0, + para_idx: int = 0, + script: str = "1 over 2", + text_alt: str | None = None, +) -> RawFormula: + return RawFormula( + section_idx=section_idx, + para_idx=para_idx, + script=script, + text_alt=text_alt, + ) + + +def test_build_formula_block_preserves_script_and_prov(): + blk = _build_formula_block(_raw_formula(section_idx=1, para_idx=2, script="x^2 + y^2")) + assert blk.script == "x^2 + y^2" + assert blk.script_kind == "hwp_eq" + assert blk.prov.section_idx == 1 + assert blk.prov.para_idx == 2 + assert blk.prov.char_start is None + assert blk.prov.char_end is None + + +def test_build_formula_block_preserves_text_alt(): + blk = _build_formula_block(_raw_formula(script="1 over 2", text_alt="1 / 2")) + assert blk.text_alt == "1 / 2" + + +def test_build_formula_block_text_alt_can_be_none(): + blk = _build_formula_block(_raw_formula(text_alt=None)) + assert blk.text_alt is None + + +# * 실제 샘플 — equation 이 있으면 FormulaBlock 으로 노출, 없으면 skip + + +def _find_formulas(ir: HwpDocument) -> list[FormulaBlock]: + return [b for b in ir.iter_blocks(scope="all", recurse=True) if isinstance(b, FormulaBlock)] + + +def test_formula_inside_table_cell_is_flattened(): + """Formula 가 셀 paragraph.controls 에 있으면 cell.blocks 에 FormulaBlock 출현.""" + from rhwp.ir._mapper import build_hwp_document + from rhwp.ir._raw_types import RawDocument, RawParagraph + from rhwp.ir.nodes import TableBlock + + raw_inner_para: RawParagraph = { + "section_idx": 0, + "para_idx": 0, + "text": "셀 단락", + "char_runs": [], + "tables": [], + "pictures": [], + "formulas": [ + { + "section_idx": 0, + "para_idx": 0, + "script": "x^2", + "text_alt": None, + } + ], + "tocs": [], + "fields": [], + "list_info": None, + } + raw_para_with_table: RawParagraph = { + "section_idx": 0, + "para_idx": 0, + "text": "", + "char_runs": [], + "tables": [ + { + "rows": 1, + "cols": 1, + "caption": None, + "caption_block": None, + "cells": [ + { + "row": 0, + "col": 0, + "row_span": 1, + "col_span": 1, + "is_header": False, + "paragraphs": [raw_inner_para], + } + ], + } + ], + "pictures": [], + "formulas": [], + "tocs": [], + "fields": [], + "list_info": None, + } + raw = RawDocument( + source_uri=None, + section_count=1, + paragraphs=[raw_para_with_table], + headers=[], + footers=[], + footnotes=[], + endnotes=[], + ) + ir = build_hwp_document(raw) + table = next(b for b in ir.body if isinstance(b, TableBlock)) + cell_blocks = table.cells[0].blocks + formulas = [b for b in cell_blocks if isinstance(b, FormulaBlock)] + assert len(formulas) == 1 + assert formulas[0].script == "x^2" + + +def test_formula_inside_footnote_body_is_flattened(): + """Formula 가 각주 본문 paragraph.controls 에 있으면 footnote.blocks 에 출현.""" + from rhwp.ir._mapper import build_hwp_document + from rhwp.ir._raw_types import RawDocument, RawFootnote, RawParagraph + + raw_inner_para: RawParagraph = { + "section_idx": 0, + "para_idx": 0, + "text": "각주 본문", + "char_runs": [], + "tables": [], + "pictures": [], + "formulas": [ + { + "section_idx": 0, + "para_idx": 0, + "script": "1 over 2", + "text_alt": "1 / 2", + } + ], + "tocs": [], + "fields": [], + "list_info": None, + } + raw = RawDocument( + source_uri=None, + section_count=1, + paragraphs=[], + headers=[], + footers=[], + footnotes=[ + RawFootnote( + marker_section_idx=0, + marker_para_idx=5, + number=1, + blocks=[raw_inner_para], + ) + ], + endnotes=[], + ) + ir = build_hwp_document(raw) + fn = ir.furniture.footnotes[0] + formulas = [b for b in fn.blocks if isinstance(b, FormulaBlock)] + assert len(formulas) == 1 + assert formulas[0].script == "1 over 2" + + +@pytest.mark.parametrize( + "text_alt_input,expected", + [ + # ^ Rust 가 정규화한 결과가 들어왔을 때 Python mapper 는 보존만 한다. + # Rust 측 토큰 경계 단위 테스트는 src/ir.rs::tests::simple_eq_text_alt_*. + ("1 / 2", "1 / 2"), + ("√(x^2 + 1)", "√(x^2 + 1)"), + ("sqrtish", "sqrtish"), # ^ 토큰 경계 — 식별자 일부는 그대로 + ("discover", "discover"), + (None, None), + ], +) +def test_build_formula_block_preserves_text_alt_verbatim( + text_alt_input: str | None, expected: str | None +): + """mapper 는 Rust 가 정규화한 ``text_alt`` 를 그대로 보존 (재변환 금지). + + 토큰 경계 인식 (``sqrtish`` 같은 식별자 부분 매치 회피) 은 Rust 측 + ``simple_eq_text_alt`` 의 책임이며, 본 테스트는 Python mapper 가 결과를 + 재가공하지 않음을 픽스로 고정한다. + """ + blk = _build_formula_block(_raw_formula(script="raw", text_alt=text_alt_input)) + assert blk.text_alt == expected + + +def test_real_sample_formulas_have_required_fields(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + formulas = _find_formulas(ir) + if not formulas: + pytest.skip("aift.hwp 샘플에 수식 컨트롤 없음") + for f in formulas: + assert f.kind == "formula" + assert isinstance(f.script, str) + assert f.script_kind == "hwp_eq" + assert isinstance(f.prov, Provenance) diff --git a/tests/test_ir_furniture.py b/tests/test_ir_furniture.py new file mode 100644 index 0000000..95591c4 --- /dev/null +++ b/tests/test_ir_furniture.py @@ -0,0 +1,266 @@ +"""tests/test_ir_furniture.py — Stage S1 furniture (page_headers / page_footers) 채움. + +ir-expansion.md §S1 + §8 Furniture 채움 + §iter_blocks 순서 계약 검증: + +- mapper 가 ``Control::Header`` / ``Control::Footer`` 의 paragraphs 를 + furniture.page_headers / page_footers 로 평탄화 +- ``iter_blocks(scope="furniture")`` 순서: page_headers → page_footers → footnotes +- v0.3.0 S1 시점 footnotes 는 빈 리스트 (S2 에서 채움) +- 실제 샘플 (aift.hwp) 에 머리글/꼬리말이 있으면 ParagraphBlock 으로 노출 +""" + +import rhwp +from pydantic import ValidationError +from rhwp.ir._mapper import build_hwp_document +from rhwp.ir._raw_types import RawDocument, RawParagraph +from rhwp.ir.nodes import ( + Furniture, + HwpDocument, + ParagraphBlock, + Provenance, + TableBlock, + TableCell, +) + +# * 모델 단독 — Furniture frozen + extra=forbid + + +def test_furniture_default_lists_are_empty(): + f = Furniture() + assert f.page_headers == [] + assert f.page_footers == [] + assert f.footnotes == [] + assert f.endnotes == [] + + +def test_furniture_extra_forbidden(): + """ir-expansion.md §8 호환성 메모: extra="forbid" 유지로 v0.2.0 ↔ v0.3.0 schema 차이 강제.""" + import pytest + + with pytest.raises(ValidationError): + # ^ v0.4.0+ 에서 추가될 가능성 있는 새 필드 (예: side_notes) + Furniture.model_validate({"page_headers": [], "side_notes": []}) + + +def test_furniture_frozen_blocks_mutation(): + import pytest + + f = Furniture() + with pytest.raises(ValidationError): + f.page_headers = [] # type: ignore[misc] + + +# * mapper — RawDocument with header/footer paragraphs + + +def _empty_raw_para(section_idx: int = 0, para_idx: int = 0, text: str = "") -> RawParagraph: + return RawParagraph( + section_idx=section_idx, + para_idx=para_idx, + text=text, + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) + + +def _empty_raw_doc( + *, + headers: list[RawParagraph] | None = None, + footers: list[RawParagraph] | None = None, + paragraphs: list[RawParagraph] | None = None, +) -> RawDocument: + return RawDocument( + source_uri=None, + section_count=1, + paragraphs=paragraphs or [], + headers=headers or [], + footers=footers or [], + footnotes=[], + endnotes=[], + ) + + +def test_build_hwp_document_routes_headers_to_furniture(): + raw = _empty_raw_doc(headers=[_empty_raw_para(text="페이지 머리글")]) + ir = build_hwp_document(raw) + assert ir.body == [] + assert len(ir.furniture.page_headers) == 1 + blk = ir.furniture.page_headers[0] + assert isinstance(blk, ParagraphBlock) + assert blk.text == "페이지 머리글" + + +def test_build_hwp_document_routes_footers_to_furniture(): + raw = _empty_raw_doc(footers=[_empty_raw_para(text="© 2026 회사명", para_idx=2)]) + ir = build_hwp_document(raw) + assert len(ir.furniture.page_footers) == 1 + blk = ir.furniture.page_footers[0] + assert isinstance(blk, ParagraphBlock) + assert blk.text == "© 2026 회사명" + # ^ Provenance 는 부모 paragraph 위치 보존 (Header/Footer 컨트롤이 어디서 선언됐는지) + assert blk.prov.para_idx == 2 + + +def test_build_hwp_document_preserves_header_footer_order(): + """furniture iter 순서: page_headers → page_footers → footnotes → endnotes.""" + raw = _empty_raw_doc( + headers=[_empty_raw_para(text="H1"), _empty_raw_para(text="H2")], + footers=[_empty_raw_para(text="F1")], + ) + ir = build_hwp_document(raw) + iterated = list(ir.iter_blocks(scope="furniture", recurse=False)) + texts = [b.text for b in iterated if isinstance(b, ParagraphBlock)] + assert texts == ["H1", "H2", "F1"] + + +def test_build_hwp_document_footnotes_empty_when_raw_empty(): + """raw.footnotes 가 비어 있으면 furniture.footnotes 도 빈 리스트.""" + raw = _empty_raw_doc() + ir = build_hwp_document(raw) + assert ir.furniture.footnotes == [] + assert ir.furniture.endnotes == [] + + +def test_build_hwp_document_furniture_paragraphs_share_section_idx(): + """Header/Footer paragraphs Provenance 는 Rust 가 설정한 외부 위치 그대로 보존.""" + raw = _empty_raw_doc( + headers=[_empty_raw_para(section_idx=1, para_idx=5, text="섹션 2 머리글")], + ) + raw["section_count"] = 2 + ir = build_hwp_document(raw) + blk = ir.furniture.page_headers[0] + assert isinstance(blk, ParagraphBlock) + assert blk.prov.section_idx == 1 + assert blk.prov.para_idx == 5 + + +def test_build_hwp_document_header_with_table_flattens_to_furniture(): + """Header paragraphs 안의 표는 같은 평탄화 정책 적용 — TableBlock 도 page_headers 에 추가.""" + raw_para_with_table = RawParagraph( + section_idx=0, + para_idx=0, + text="제목 행", + char_runs=[], + tables=[ + { + "rows": 1, + "cols": 1, + "caption": None, + "caption_block": None, + "cells": [ + { + "row": 0, + "col": 0, + "row_span": 1, + "col_span": 1, + "is_header": False, + "paragraphs": [ + { + "section_idx": 0, + "para_idx": 0, + "text": "셀", + "char_runs": [], + "tables": [], + "pictures": [], + "formulas": [], + "tocs": [], + "fields": [], + "list_info": None, + } + ], + } + ], + } + ], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) + raw = _empty_raw_doc(headers=[raw_para_with_table]) + ir = build_hwp_document(raw) + assert len(ir.furniture.page_headers) == 2 + assert isinstance(ir.furniture.page_headers[0], ParagraphBlock) + assert isinstance(ir.furniture.page_headers[1], TableBlock) + + +# * 실제 샘플 — aift.hwp 가 머리글/꼬리말 포함 여부에 따라 분기 + + +def test_real_sample_furniture_yields_paragraph_blocks(parsed_hwp: rhwp.Document): + """aift.hwp 에 헤더/푸터가 있으면 ParagraphBlock 으로 노출, 없으면 빈 리스트.""" + ir = parsed_hwp.to_ir() + for blk in ir.furniture.page_headers + ir.furniture.page_footers: + # ^ 채워진 경우 모두 알려진 Block 타입 + assert isinstance(blk, (ParagraphBlock, TableBlock)) + + +def test_real_sample_iter_blocks_furniture_matches_lists(parsed_hwp: rhwp.Document): + """iter_blocks(scope=furniture) 결과가 page_headers + page_footers + footnotes 합과 동일.""" + ir = parsed_hwp.to_ir() + iterated = list(ir.iter_blocks(scope="furniture", recurse=False)) + expected = ( + list(ir.furniture.page_headers) + + list(ir.furniture.page_footers) + + list(ir.furniture.footnotes) + ) + assert iterated == expected + + +def test_real_sample_body_excludes_header_footer_text(parsed_hwp: rhwp.Document): + """body 와 furniture 는 분리된다 — 같은 paragraph 인스턴스가 양쪽 다 나타나면 안 됨.""" + ir = parsed_hwp.to_ir() + body_ids = {id(b) for b in ir.body} + for f in ir.furniture.page_headers + ir.furniture.page_footers: + assert id(f) not in body_ids + + +# * 호환성 메모 — v0.3.0 S2 에서 endnotes 정식 필드로 추가됨 + + +def test_furniture_accepts_endnotes_field_in_s2(): + """v0.3.0 S2 부터 endnotes 는 정식 필드 — Furniture 가 빈 리스트로 수용. + + ir-expansion.md §호환성: v0.2.0 소비자가 v0.3.0 IR JSON 을 읽으면 + `extra="forbid"` 에 걸려 ValidationError — schema_version 1.0 ≠ 1.1 + 분기 강제 (S2 에서 trigger 활성화). + """ + f = Furniture.model_validate({"endnotes": []}) + assert f.endnotes == [] + + +# * iter_blocks(scope=all) — body → furniture 순서 보존 (수동) + + +def test_iter_blocks_all_then_furniture_order(): + body = ParagraphBlock(text="본문", prov=Provenance(section_idx=0, para_idx=0)) + header = ParagraphBlock(text="머리글", prov=Provenance(section_idx=0, para_idx=99)) + footer = ParagraphBlock(text="꼬리말", prov=Provenance(section_idx=0, para_idx=100)) + ir = HwpDocument( + body=[body], + furniture=Furniture(page_headers=[header], page_footers=[footer]), + ) + seq = list(ir.iter_blocks(scope="all", recurse=False)) + assert seq == [body, header, footer] + + +# * recurse 정책 — Header 안의 Table 셀 내부 paragraph 도 진입 + + +def test_iter_blocks_furniture_recurse_enters_header_table_cells(): + inner = ParagraphBlock(text="cell", prov=Provenance(section_idx=0, para_idx=0)) + cell = TableCell(row=0, col=0, grid_index=0, blocks=[inner]) + header_table = TableBlock( + rows=1, cols=1, cells=[cell], prov=Provenance(section_idx=0, para_idx=0) + ) + ir = HwpDocument(furniture=Furniture(page_headers=[header_table])) + recursed = list(ir.iter_blocks(scope="furniture", recurse=True)) + assert recursed == [header_table, inner] + no_rec = list(ir.iter_blocks(scope="furniture", recurse=False)) + assert no_rec == [header_table] diff --git a/tests/test_ir_iter_blocks.py b/tests/test_ir_iter_blocks.py index 8fb04a9..c3e6209 100644 --- a/tests/test_ir_iter_blocks.py +++ b/tests/test_ir_iter_blocks.py @@ -50,11 +50,21 @@ def test_iter_blocks_recurse_false_matches_body_len(parsed_hwpx: rhwp.Document): # * furniture scope -def test_iter_blocks_furniture_is_empty_in_v0_2_0(parsed_hwpx: rhwp.Document): - """v0.2.0 은 Furniture 본문을 파싱 안 함 — 빈 iterator.""" +def test_iter_blocks_furniture_yields_consistent_with_lists(parsed_hwpx: rhwp.Document): + """v0.3.0 S1 부터 furniture 가 채워질 수 있다 — yield 결과 == 리스트 직접 합산. + + 실제 채워진 개수는 샘플에 따라 0 일 수도 있다 (table-vpos-01.hwpx 가 + 헤더/푸터 없으면 빈 리스트). 본 테스트는 iter_blocks(furniture) 가 항상 + page_headers + page_footers + footnotes 를 순서대로 평탄화하는 계약만 검증. + """ ir = parsed_hwpx.to_ir() - blocks = list(ir.iter_blocks(scope="furniture")) - assert blocks == [] + blocks = list(ir.iter_blocks(scope="furniture", recurse=False)) + expected = ( + list(ir.furniture.page_headers) + + list(ir.furniture.page_footers) + + list(ir.furniture.footnotes) + ) + assert blocks == expected def test_iter_blocks_all_scope_body_first_then_furniture(): @@ -76,18 +86,30 @@ def test_iter_blocks_all_scope_body_first_then_furniture(): def test_iter_blocks_furniture_order_is_headers_footers_footnotes(): - """Furniture 내부는 항상 page_headers → page_footers → footnotes 순 (ir.md 계약).""" + """Furniture 내부는 항상 page_headers → page_footers → footnotes → endnotes 순 (S2 갱신).""" + from rhwp.ir.nodes import EndnoteBlock, FootnoteBlock + header = ParagraphBlock(text="H", prov=Provenance(section_idx=0, para_idx=1)) footer = ParagraphBlock(text="F", prov=Provenance(section_idx=0, para_idx=2)) - footnote = ParagraphBlock(text="N", prov=Provenance(section_idx=0, para_idx=3)) + footnote = FootnoteBlock( + number=1, + marker_prov=Provenance(section_idx=0, para_idx=3), + prov=Provenance(section_idx=0, para_idx=3), + ) + endnote = EndnoteBlock( + number=1, + marker_prov=Provenance(section_idx=0, para_idx=4), + prov=Provenance(section_idx=0, para_idx=4), + ) ir = HwpDocument( furniture=Furniture( page_headers=[header], page_footers=[footer], footnotes=[footnote], + endnotes=[endnote], ), ) - assert list(ir.iter_blocks(scope="furniture")) == [header, footer, footnote] + assert list(ir.iter_blocks(scope="furniture")) == [header, footer, footnote, endnote] # * 재귀 순회 — 수동 구성 중첩 표로 계약 확정 @@ -171,7 +193,33 @@ def test_iter_blocks_recurse_yields_more_on_real_sample(parsed_hwpx: rhwp.Docume def test_iter_blocks_yields_only_known_block_types(parsed_hwpx: rhwp.Document): ir = parsed_hwpx.to_ir() - from rhwp.ir.nodes import UnknownBlock + from rhwp.ir.nodes import ( + CaptionBlock, + EndnoteBlock, + FieldBlock, + FootnoteBlock, + FormulaBlock, + ListItemBlock, + PictureBlock, + TocBlock, + UnknownBlock, + ) for block in ir.iter_blocks(scope="all", recurse=True): - assert isinstance(block, (ParagraphBlock, TableBlock, UnknownBlock)) + assert isinstance( + block, + ( + ParagraphBlock, + TableBlock, + PictureBlock, + FormulaBlock, + FootnoteBlock, + EndnoteBlock, + # ^ v0.3.0 S3 추가 + ListItemBlock, + CaptionBlock, + TocBlock, + FieldBlock, + UnknownBlock, + ), + ) diff --git a/tests/test_ir_list.py b/tests/test_ir_list.py new file mode 100644 index 0000000..56b6ff4 --- /dev/null +++ b/tests/test_ir_list.py @@ -0,0 +1,209 @@ +"""tests/test_ir_list.py — Stage S3 ListItemBlock + RawListInfo 매퍼. + +ir-expansion.md §S3 + § 4 ListItemBlock 검증: + +- ListItemBlock 직렬화 왕복 + frozen + extra=forbid +- mapper RawParagraph + list_info → ListItemBlock (paragraph 자체가 list item) +- list_info=None 인 paragraph 는 ParagraphBlock 으로 출고 (호환) +- enumerated/marker/level 조합 (HWP head_type Number/Bullet/Outline 매핑) +- ListItemBlock 이 Block 유니온의 list_item variant 로 라우팅 +- 본문 + 셀 안 + 각주 본문 안 모두에서 ListItemBlock 출현 가능 +""" + +import pytest +from pydantic import ValidationError +from rhwp.ir._mapper import _build_list_item_block, _flatten_paragraph +from rhwp.ir._raw_types import RawListInfo, RawParagraph +from rhwp.ir.nodes import ( + HwpDocument, + ListItemBlock, + ParagraphBlock, + Provenance, +) + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +def _raw_para( + *, + text: str = "", + list_info: RawListInfo | None = None, + section_idx: int = 0, + para_idx: int = 0, +) -> RawParagraph: + return RawParagraph( + section_idx=section_idx, + para_idx=para_idx, + text=text, + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=list_info, + ) + + +# * 모델 단독 + + +def test_list_item_block_minimal_roundtrip(): + li = ListItemBlock(text="첫 항목", marker="•", enumerated=False, level=0, prov=_prov()) + reloaded = ListItemBlock.model_validate_json(li.model_dump_json()) + assert reloaded == li + assert reloaded.kind == "list_item" + + +def test_list_item_block_default_fields(): + li = ListItemBlock(prov=_prov()) + assert li.text == "" + assert li.inlines == [] + assert li.enumerated is False + assert li.marker == "-" + assert li.level == 0 + + +def test_list_item_block_extra_forbidden(): + with pytest.raises(ValidationError): + ListItemBlock.model_validate( + { + "kind": "list_item", + "text": "x", + "prov": {"section_idx": 0, "para_idx": 0}, + "extra": True, + } + ) + + +def test_list_item_block_frozen(): + li = ListItemBlock(text="x", prov=_prov()) + with pytest.raises(ValidationError): + li.text = "y" # type: ignore[misc] + + +def test_list_item_block_routes_via_discriminator(): + raw = { + "kind": "list_item", + "text": "본문", + "marker": "1.", + "enumerated": True, + "level": 2, + "prov": {"section_idx": 0, "para_idx": 5}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + assert isinstance(blk, ListItemBlock) + assert blk.marker == "1." + assert blk.level == 2 + + +# * mapper — _flatten_paragraph 가 list_info 보고 분기 + + +def test_paragraph_without_list_info_yields_paragraph_block(): + raw = _raw_para(text="일반 단락", list_info=None) + blocks = _flatten_paragraph(raw) + assert len(blocks) == 1 + assert isinstance(blocks[0], ParagraphBlock) + assert blocks[0].text == "일반 단락" + + +def test_paragraph_with_list_info_yields_list_item_block(): + raw = _raw_para( + text="첫 항목", + list_info=RawListInfo(head_type="number", level=0), + ) + blocks = _flatten_paragraph(raw) + assert len(blocks) == 1 + assert isinstance(blocks[0], ListItemBlock) + assert blocks[0].text == "첫 항목" + assert blocks[0].marker == "1." + assert blocks[0].enumerated is True + + +@pytest.mark.parametrize( + "head_type,expected_marker,expected_enum", + [ + # ^ HWP HeadType 매핑 결과: Number/Outline → enumerated=True+"1.", Bullet → False+"•" + ("number", "1.", True), + ("outline", "1.", True), + ("bullet", "•", False), + ], +) +def test_build_list_item_marker_placeholder_by_head_type( + head_type: str, expected_marker: str, expected_enum: bool +): + raw = _raw_para(text="항목", list_info=RawListInfo(head_type=head_type, level=2)) + li = _build_list_item_block(raw) + assert li.marker == expected_marker + assert li.enumerated == expected_enum + assert li.level == 2 + + +def test_build_list_item_unknown_head_type_falls_back(): + """Rust 가 새 HeadType variant 추가 시 forward-compat — '-' / False 폴백.""" + raw = _raw_para(text="x", list_info=RawListInfo(head_type="future_head", level=0)) + li = _build_list_item_block(raw) + assert li.marker == "-" + assert li.enumerated is False + + +def test_build_list_item_preserves_provenance(): + raw = _raw_para( + text="x", + section_idx=2, + para_idx=10, + list_info=RawListInfo(head_type="bullet", level=0), + ) + li = _build_list_item_block(raw) + assert li.prov.section_idx == 2 + assert li.prov.para_idx == 10 + assert li.prov.char_start == 0 + assert li.prov.char_end == len("x") + + +def test_build_list_item_inlines_match_paragraph_pattern(): + """list_item 의 inlines 는 ParagraphBlock 과 동일 — char_runs 부재 시 단일 폴백 런.""" + raw = _raw_para( + text="항목", + list_info=RawListInfo(head_type="bullet", level=0), + ) + li = _build_list_item_block(raw) + assert len(li.inlines) == 1 + assert li.inlines[0].text == "항목" + assert li.inlines[0].raw_style_id is None # ^ char_runs 부재 폴백 + + +def test_list_item_blocks_can_appear_in_table_cell(): + """ListItemBlock 이 셀 안 paragraph (list_info 가짐) 의 평탄화 결과로도 등장.""" + inner_raw = _raw_para( + text="셀 안 항목", + list_info=RawListInfo(head_type="number", level=0), + ) + cell_blocks = _flatten_paragraph(inner_raw) + assert isinstance(cell_blocks[0], ListItemBlock) + + +def test_build_list_item_raises_when_list_info_none(): + """fail-fast — _flatten_paragraph 가 분기 보장하지만 직접 호출 시 명확한 에러.""" + raw = _raw_para(text="x", list_info=None) + with pytest.raises(ValueError, match="list_info"): + _build_list_item_block(raw) + + +# * level / marker placeholder 한계 — spec § 4 의 v0.3.0 단순 정책 명시 + + +def test_marker_is_placeholder_in_v0_3_0(): + """v0.3.0 매퍼는 marker placeholder 만 출고 — Numbering.level_formats lookup 은 v0.4.0+. + + 실제 marker 정확도가 필요한 사용자는 외부 후처리 또는 v0.4.0 업그레이드. + """ + raw = _raw_para(text="x", list_info=RawListInfo(head_type="number", level=3)) + li = _build_list_item_block(raw) + # ^ level=3 이어도 marker 는 "1." placeholder — 실제는 "(가)" 등일 수 있음 + assert li.marker == "1." + assert li.level == 3 diff --git a/tests/test_ir_mapper.py b/tests/test_ir_mapper.py index c7827ae..9f9740d 100644 --- a/tests/test_ir_mapper.py +++ b/tests/test_ir_mapper.py @@ -47,7 +47,18 @@ def test_escape_html_preserves_non_ascii(): def _paragraph(text: str) -> RawParagraph: - return RawParagraph(section_idx=0, para_idx=0, text=text, char_runs=[], tables=[]) + return RawParagraph( + section_idx=0, + para_idx=0, + text=text, + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) def _cell( @@ -162,7 +173,7 @@ def test_build_inline_runs_preserves_prefix_when_rest_zero_width(): def _table(cells: list[RawCell]) -> RawTable: - return RawTable(rows=1, cols=1, caption=None, cells=cells) + return RawTable(rows=1, cols=1, caption=None, caption_block=None, cells=cells) def test_table_to_html_rowspan_before_colspan(): diff --git a/tests/test_ir_picture.py b/tests/test_ir_picture.py new file mode 100644 index 0000000..39e83af --- /dev/null +++ b/tests/test_ir_picture.py @@ -0,0 +1,289 @@ +"""tests/test_ir_picture.py — Stage S1 PictureBlock + ImageRef + bytes_for_image. + +ir-expansion.md §S1 테스트 항목 매핑: + +- PictureBlock + ImageRef 직렬화 왕복 — 모델 단독 검증 +- mapper 의 bin:// URI 합성 / mime 매핑 / broken reference 폴백 +- ``Document.bytes_for_image`` 헬퍼 — bin_data_id 파싱, broken reference, + unsupported scheme, lookup 실패 케이스 +- 실제 샘플에 picture 가 있다면 lookup 성공까지 검증 (lenient — 샘플에 없으면 skip) +""" + +import pytest +import rhwp +from pydantic import ValidationError +from rhwp.ir._mapper import _build_picture_block, _mime_for_extension +from rhwp.ir._raw_types import RawImageRef, RawPicture +from rhwp.ir.nodes import ( + HwpDocument, + ImageRef, + PictureBlock, + Provenance, +) + +# * 모델 단독 — ImageRef + + +def test_image_ref_roundtrip(): + img = ImageRef(uri="bin://1", mime_type="image/png", width=640, height=480, dpi=96) + assert ImageRef.model_validate_json(img.model_dump_json()) == img + + +def test_image_ref_required_fields(): + """uri / mime_type 만 필수 — 나머지는 None 폴백.""" + img = ImageRef(uri="bin://1", mime_type="image/jpeg") + assert img.width is None + assert img.height is None + assert img.dpi is None + + +def test_image_ref_extra_forbidden(): + with pytest.raises(ValidationError): + ImageRef.model_validate({"uri": "bin://1", "mime_type": "image/png", "extra": True}) + + +def test_image_ref_frozen(): + img = ImageRef(uri="bin://1", mime_type="image/png") + with pytest.raises(ValidationError): + img.uri = "bin://2" # type: ignore[misc] + + +# * 모델 단독 — PictureBlock + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +def test_picture_block_roundtrip_with_image(): + pic = PictureBlock( + image=ImageRef(uri="bin://7", mime_type="image/png"), + description="회로도", + prov=_prov(), + ) + reloaded = PictureBlock.model_validate_json(pic.model_dump_json()) + assert reloaded == pic + assert reloaded.image is not None + assert reloaded.image.uri == "bin://7" + + +def test_picture_block_broken_reference(): + """image=None 은 명시적 broken reference 표현 — 왕복도 보존.""" + pic = PictureBlock(image=None, description=None, prov=_prov()) + reloaded = PictureBlock.model_validate_json(pic.model_dump_json()) + assert reloaded == pic + assert reloaded.image is None + + +def test_picture_block_kind_is_picture(): + pic = PictureBlock(prov=_prov()) + assert pic.kind == "picture" + + +def test_picture_block_extra_forbidden(): + with pytest.raises(ValidationError): + PictureBlock.model_validate( + {"kind": "picture", "prov": {"section_idx": 0, "para_idx": 0}, "extra": "x"} + ) + + +def test_picture_block_routes_via_discriminator(): + """본문에 PictureBlock dict 를 넣으면 picture variant 로 라우팅.""" + raw = { + "kind": "picture", + "image": {"uri": "bin://3", "mime_type": "image/jpeg"}, + "prov": {"section_idx": 0, "para_idx": 5}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + assert isinstance(blk, PictureBlock) + assert blk.image is not None + assert blk.image.mime_type == "image/jpeg" + + +# * mapper — mime 매핑 + + +@pytest.mark.parametrize( + "ext,expected", + [ + ("png", "image/png"), + ("PNG", "image/png"), # ^ 대소문자 무시 + ("jpg", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("bmp", "image/bmp"), + ("gif", "image/gif"), + ("tiff", "image/tiff"), + ("tif", "image/tiff"), + ("wmf", "image/x-wmf"), + ("emf", "image/x-emf"), + ("svg", "image/svg+xml"), + ("webp", "image/webp"), + ], +) +def test_mime_mapping_known_extensions(ext: str, expected: str): + assert _mime_for_extension(ext) == expected + + +def test_mime_mapping_unknown_falls_back(): + assert _mime_for_extension("xyz") == "application/octet-stream" + + +def test_mime_mapping_none_falls_back(): + assert _mime_for_extension(None) == "application/octet-stream" + + +# * mapper — RawPicture → PictureBlock + + +def _raw_picture( + *, + section_idx: int = 0, + para_idx: int = 0, + bin_data_id: int = 1, + extension: str | None = "png", + has_content: bool = True, + description: str | None = None, + image: bool = True, +) -> RawPicture: + img: RawImageRef | None = None + if image: + img = RawImageRef(bin_data_id=bin_data_id, extension=extension, has_content=has_content) + return RawPicture( + section_idx=section_idx, + para_idx=para_idx, + image=img, + description=description, + caption=None, + ) + + +def test_build_picture_block_with_known_extension(): + blk = _build_picture_block(_raw_picture(bin_data_id=4, extension="png")) + assert blk.image is not None + assert blk.image.uri == "bin://4" + assert blk.image.mime_type == "image/png" + assert blk.prov.section_idx == 0 + assert blk.prov.para_idx == 0 + assert blk.prov.char_start is None + assert blk.prov.char_end is None + + +def test_build_picture_block_unknown_extension_falls_back(): + blk = _build_picture_block(_raw_picture(extension="xyz")) + assert blk.image is not None + assert blk.image.mime_type == "application/octet-stream" + + +def test_build_picture_block_no_extension_falls_back(): + blk = _build_picture_block(_raw_picture(extension=None)) + assert blk.image is not None + assert blk.image.mime_type == "application/octet-stream" + + +def test_build_picture_block_broken_reference_routes_to_none(): + blk = _build_picture_block(_raw_picture(image=False)) + assert blk.image is None + + +def test_build_picture_block_preserves_description(): + blk = _build_picture_block(_raw_picture(description="<그림 1> 회로도")) + assert blk.description == "<그림 1> 회로도" + + +# * Document.bytes_for_image — 에러 경로 (broken/scheme/parse) + + +def test_bytes_for_image_raises_on_broken_reference(parsed_hwp: rhwp.Document): + """image=None 인 PictureBlock 은 ValueError.""" + pic = PictureBlock(image=None, prov=_prov()) + with pytest.raises(ValueError, match="broken reference"): + parsed_hwp.bytes_for_image(pic) + + +def test_bytes_for_image_raises_on_unsupported_scheme(parsed_hwp: rhwp.Document): + pic = PictureBlock( + image=ImageRef(uri="data:image/png;base64,iVBOR=", mime_type="image/png"), + prov=_prov(), + ) + with pytest.raises(ValueError, match="bin://"): + parsed_hwp.bytes_for_image(pic) + + +def test_bytes_for_image_raises_on_invalid_uri(parsed_hwp: rhwp.Document): + pic = PictureBlock( + image=ImageRef(uri="bin://not_a_number", mime_type="image/png"), prov=_prov() + ) + with pytest.raises(ValueError, match="invalid bin://"): + parsed_hwp.bytes_for_image(pic) + + +def test_bytes_for_image_raises_on_out_of_range(parsed_hwp: rhwp.Document): + pic = PictureBlock( + image=ImageRef(uri="bin://99999", mime_type="image/png"), # ^ u16 초과 + prov=_prov(), + ) + with pytest.raises(ValueError, match="out of u16 range"): + parsed_hwp.bytes_for_image(pic) + + +def test_bytes_for_image_raises_on_lookup_miss(parsed_hwp: rhwp.Document): + """존재하지 않는 bin_data_id (u16 범위 안) 도 ValueError.""" + pic = PictureBlock( + image=ImageRef(uri="bin://65000", mime_type="image/png"), + prov=_prov(), + ) + with pytest.raises(ValueError, match="not found"): + parsed_hwp.bytes_for_image(pic) + + +# * 실제 샘플 — 이미지가 있으면 lookup 성공, 없으면 skip + + +def _find_pictures(ir: HwpDocument) -> list[PictureBlock]: + return [b for b in ir.iter_blocks(scope="all", recurse=True) if isinstance(b, PictureBlock)] + + +def test_real_sample_picture_block_kind_is_picture(parsed_hwp: rhwp.Document): + """샘플에 그림이 있을 경우 PictureBlock 으로 노출, 없으면 skip.""" + ir = parsed_hwp.to_ir() + pictures = _find_pictures(ir) + if not pictures: + pytest.skip("aift.hwp 샘플에 그림 컨트롤 없음 — 검증 항목 없음") + for pic in pictures: + assert pic.kind == "picture" + assert isinstance(pic.prov, Provenance) + + +def test_real_sample_picture_uri_is_bin_scheme(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + pictures_with_image = [p for p in _find_pictures(ir) if p.image is not None] + if not pictures_with_image: + pytest.skip("샘플에 image=None 이 아닌 PictureBlock 없음") + for pic in pictures_with_image: + assert pic.image is not None + assert pic.image.uri.startswith("bin://"), pic.image.uri + # ^ bin_data_id 파싱 가능 + bid = int(pic.image.uri[len("bin://") :]) + assert bid >= 1 + + +def test_real_sample_bytes_for_image_returns_nonempty(parsed_hwp: rhwp.Document): + """샘플의 PictureBlock 중 lookup 가능한 것이 있으면 bytes 가 비어있지 않아야 함.""" + ir = parsed_hwp.to_ir() + candidates = [p for p in _find_pictures(ir) if p.image is not None] + if not candidates: + pytest.skip("샘플에 lookup 가능한 PictureBlock 없음") + found_any = False + for pic in candidates: + try: + data = parsed_hwp.bytes_for_image(pic) + except ValueError: + # ^ 특정 picture 는 Link/Storage 라 lookup 실패할 수 있음 — 다음 후보로 + continue + assert isinstance(data, bytes) + assert len(data) > 0 + found_any = True + break + if not found_any: + pytest.skip("모든 PictureBlock 이 Embedding 이 아니거나 content 누락") diff --git a/tests/test_ir_roundtrip.py b/tests/test_ir_roundtrip.py index 29f37ed..cacdfbb 100644 --- a/tests/test_ir_roundtrip.py +++ b/tests/test_ir_roundtrip.py @@ -28,7 +28,8 @@ def test_to_ir_returns_hwp_document(parsed_hwp: rhwp.Document): ir = parsed_hwp.to_ir() assert isinstance(ir, HwpDocument) assert ir.schema_name == "HwpDocument" - assert ir.schema_version == "1.0" + # ^ v0.3.0 S1 부터 1.1 — picture / header / footer 블록 추가 (ir-expansion.md §스키마 버저닝) + assert ir.schema_version == "1.1" def test_to_ir_caches_same_object(parsed_hwp: rhwp.Document): @@ -50,31 +51,62 @@ def test_ir_section_count_matches_document(parsed_hwp: rhwp.Document): def test_paragraph_block_count_matches(parsed_hwp: rhwp.Document): - """S3 계약: body 내 ParagraphBlock 개수 = Rust paragraph_count. + """v0.3.0 S3 계약: body 내 ParagraphBlock + ListItemBlock 개수 = Rust paragraph_count. - body 에는 TableBlock 도 섞여 있을 수 있으므로 ParagraphBlock 만 필터한다. + 각 paragraph 는 list_info 유무에 따라 ParagraphBlock 또는 ListItemBlock 중 + 하나로 emit 되므로 둘 다 카운트해야 정확. """ + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwp.to_ir() - para_blocks = [b for b in ir.body if isinstance(b, ParagraphBlock)] - assert len(para_blocks) == parsed_hwp.paragraph_count + para_or_list_blocks = [b for b in ir.body if isinstance(b, (ParagraphBlock, ListItemBlock))] + assert len(para_or_list_blocks) == parsed_hwp.paragraph_count def test_body_contains_only_known_block_kinds(parsed_hwp: rhwp.Document): - """v0.2.0 (S3) 는 ParagraphBlock / TableBlock 둘만 노출. UnknownBlock 출현 금지.""" + """v0.3.0 S3 까지: body 에 ParagraphBlock / TableBlock / PictureBlock / FormulaBlock / + ListItemBlock / TocBlock / FieldBlock 노출 가능. + + Footnote/Endnote 는 body 가 아니라 furniture 로 라우팅되므로 body 에는 안 나옴. + CaptionBlock 은 PictureBlock.caption / TableBlock.caption_block 안에 부착되므로 + 일반 파싱 경로에서는 body 단독 등장하지 않음 (사용자 직접 구성 시에만). + """ + from rhwp.ir.nodes import ( + FieldBlock, + FormulaBlock, + ListItemBlock, + PictureBlock, + TocBlock, + ) + ir = parsed_hwp.to_ir() for b in ir.body: - assert isinstance(b, (ParagraphBlock, TableBlock)), ( - f"unexpected block kind: {type(b).__name__}" - ) + assert isinstance( + b, + ( + ParagraphBlock, + TableBlock, + PictureBlock, + FormulaBlock, + ListItemBlock, + TocBlock, + FieldBlock, + ), + ), f"unexpected block kind: {type(b).__name__}" def test_ir_body_text_joined_matches_extract_text(parsed_hwp: rhwp.Document): - """ParagraphBlock 텍스트만 개행으로 연결하면 ``extract_text()`` 와 일치. + """ParagraphBlock + ListItemBlock 텍스트를 개행으로 연결하면 ``extract_text()`` 와 일치. - TableBlock 은 extract_text() 에 포함되지 않으므로 필터. + TableBlock 은 extract_text() 에 포함되지 않으므로 필터. v0.3.0 S3 부터 + ListItemBlock 도 paragraph-like 로 텍스트 흐름에 포함됨. """ + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwp.to_ir() - non_empty_texts = [b.text for b in ir.body if isinstance(b, ParagraphBlock) and b.text] + non_empty_texts = [ + b.text for b in ir.body if isinstance(b, (ParagraphBlock, ListItemBlock)) and b.text + ] assert "\n".join(non_empty_texts) == parsed_hwp.extract_text() @@ -82,15 +114,18 @@ def test_ir_body_text_joined_matches_extract_text(parsed_hwp: rhwp.Document): def test_provenance_monotonic(parsed_hwp: rhwp.Document): - """ParagraphBlock 만으로 (section_idx, para_idx) 가 순차 증가하는지 검증. + """ParagraphBlock + ListItemBlock 으로 (section_idx, para_idx) 가 순차 증가하는지 검증. - TableBlock 은 같은 Paragraph 에서 파생되어 동일 para_idx 를 공유하므로 - 본 테스트에서는 제외한다. + TableBlock 등은 같은 Paragraph 에서 파생되어 동일 para_idx 를 공유하므로 + 본 테스트에서는 제외한다. ListItemBlock 도 paragraph 자체를 대체하는 변형이므로 + 포함. """ + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwp.to_ir() prev = None for block in ir.body: - if not isinstance(block, ParagraphBlock): + if not isinstance(block, (ParagraphBlock, ListItemBlock)): continue prov = block.prov if prev is None: @@ -106,13 +141,15 @@ def test_provenance_monotonic(parsed_hwp: rhwp.Document): def test_provenance_char_end_matches_text_length(parsed_hwp: rhwp.Document): - """ParagraphBlock 의 prov.char_end 는 ``len(block.text)`` 와 일치. + """ParagraphBlock + ListItemBlock 의 prov.char_end 는 ``len(block.text)`` 와 일치. - TableBlock 은 char_start/char_end 가 None 이므로 별도 테스트에서 검증. + TableBlock 등 파생 블록은 char_start/char_end 가 None. """ + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwp.to_ir() for block in ir.body: - if not isinstance(block, ParagraphBlock): + if not isinstance(block, (ParagraphBlock, ListItemBlock)): continue assert block.prov.char_start == 0 # ^ codepoint 기준 길이 (ir.md §3) — Python len(str) 과 동일 @@ -124,14 +161,16 @@ def test_provenance_char_end_matches_text_length(parsed_hwp: rhwp.Document): def test_inline_run_text_concatenates_to_paragraph_text(parsed_hwp: rhwp.Document): - """InlineRun.text 를 이어붙이면 ParagraphBlock.text 와 같아야 한다. + """InlineRun.text 를 이어붙이면 ParagraphBlock/ListItemBlock.text 와 같아야 한다. 런 분할은 char_shapes 순회 기반이라 텍스트 전체를 커버해야 한다. 예외: 빈 문단은 inlines 도 빈 리스트. """ + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwp.to_ir() for block in ir.body: - if not isinstance(block, ParagraphBlock): + if not isinstance(block, (ParagraphBlock, ListItemBlock)): continue joined = "".join(r.text for r in block.inlines) if not block.text: @@ -142,10 +181,12 @@ def test_inline_run_text_concatenates_to_paragraph_text(parsed_hwp: rhwp.Documen def test_inline_run_has_styled_runs(parsed_hwp: rhwp.Document): """실제 샘플에서 최소 하나의 InlineRun 이 raw_style_id 를 가져야 한다.""" + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwp.to_ir() has_styled = False for block in ir.body: - if not isinstance(block, ParagraphBlock): + if not isinstance(block, (ParagraphBlock, ListItemBlock)): continue if any(run.raw_style_id is not None for run in block.inlines): has_styled = True @@ -157,11 +198,13 @@ def test_inline_run_has_styled_runs(parsed_hwp: rhwp.Document): def test_to_ir_on_hwpx_sample(parsed_hwpx: rhwp.Document): + from rhwp.ir.nodes import ListItemBlock + ir = parsed_hwpx.to_ir() assert isinstance(ir, HwpDocument) assert len(ir.sections) == parsed_hwpx.section_count - para_blocks = [b for b in ir.body if isinstance(b, ParagraphBlock)] - assert len(para_blocks) == parsed_hwpx.paragraph_count + para_or_list_blocks = [b for b in ir.body if isinstance(b, (ParagraphBlock, ListItemBlock))] + assert len(para_or_list_blocks) == parsed_hwpx.paragraph_count # * to_ir_json 왕복 @@ -191,14 +234,52 @@ def test_ir_is_frozen(parsed_hwp: rhwp.Document): ir.body = [] # type: ignore[misc] -# * Furniture / metadata — v0.2.0 은 전부 비어있거나 None +# * Furniture / metadata -def test_furniture_is_empty(parsed_hwp: rhwp.Document): +def test_furniture_lists_have_correct_types(parsed_hwp: rhwp.Document): + """v0.3.0 S2 furniture 4 종 (page_headers / page_footers / footnotes / endnotes) 모두 채워질 수 있다. + + 채움 여부는 샘플 의존 (test_ir_furniture.py 에서 구체 검증). 본 테스트는 리스트 + 타입 일관성만 — page_headers/page_footers 의 entry 가 known Block 유니온 멤버, + footnotes/endnotes 는 각각 FootnoteBlock/EndnoteBlock 인스턴스. + """ + from rhwp.ir.nodes import ( + CaptionBlock, + EndnoteBlock, + FieldBlock, + FootnoteBlock, + FormulaBlock, + ListItemBlock, + PictureBlock, + TocBlock, + UnknownBlock, + ) + ir = parsed_hwp.to_ir() - assert ir.furniture.page_headers == [] - assert ir.furniture.page_footers == [] - assert ir.furniture.footnotes == [] + assert isinstance(ir.furniture.page_headers, list) + assert isinstance(ir.furniture.page_footers, list) + assert isinstance(ir.furniture.footnotes, list) + assert isinstance(ir.furniture.endnotes, list) + # ^ 채워진 page_headers/footers entry 는 모두 known Block 유니온 멤버 + body_block_types = ( + ParagraphBlock, + TableBlock, + PictureBlock, + FormulaBlock, + # ^ v0.3.0 S3 추가 + ListItemBlock, + CaptionBlock, + TocBlock, + FieldBlock, + UnknownBlock, + ) + for entry in ir.furniture.page_headers + ir.furniture.page_footers: + assert isinstance(entry, body_block_types) + for fn in ir.furniture.footnotes: + assert isinstance(fn, FootnoteBlock) + for en in ir.furniture.endnotes: + assert isinstance(en, EndnoteBlock) def test_metadata_fields_are_none(parsed_hwp: rhwp.Document): @@ -239,7 +320,8 @@ def test_hwp_document_direct_construction_allows_null_source(): ir = HwpDocument() assert ir.source is None assert ir.schema_name == "HwpDocument" - assert ir.schema_version == "1.0" + # ^ v0.3.0 S1 부터 1.1 + assert ir.schema_version == "1.1" def test_hwp_document_json_null_source_roundtrip(): diff --git a/tests/test_ir_schema.py b/tests/test_ir_schema.py index a652944..32a446e 100644 --- a/tests/test_ir_schema.py +++ b/tests/test_ir_schema.py @@ -139,16 +139,18 @@ def test_table_nested_three_levels(): def test_discriminator_routes_unknown_kind(): raw = { - "kind": "picture", + # ^ v0.3.0 S3 시점 known: paragraph/table/picture/formula/footnote/endnote/ + # list_item/caption/toc/field. 새 미지 kind 후보로 v0.4.0+ 가설적 변형 사용. + "kind": "revision_mark", "prov": {"section_idx": 0, "para_idx": 0}, - "src": "foo.png", # ^ extra="allow" 로 payload 보존 확인 + "level": 2, # ^ extra="allow" 로 payload 보존 확인 } doc = HwpDocument.model_validate({"body": [raw]}) blk = doc.body[0] assert isinstance(blk, UnknownBlock) - assert blk.kind == "picture" + assert blk.kind == "revision_mark" # ^ extra="allow" — 임의 필드 보존 - assert blk.model_extra == {"src": "foo.png"} + assert blk.model_extra == {"level": 2} # * Test 6 — 알려진 kind 는 해당 Block 으로 라우팅 @@ -255,8 +257,10 @@ def test_schema_version_minor_bump_does_not_warn(): # * Test 12 — UnknownBlock 은 임의 kind 수용 -@pytest.mark.parametrize("k", ["picture", "custom_x", "footnote", "hwp_field"]) +@pytest.mark.parametrize("k", ["custom_x", "hwp_field", "revision_mark", "side_note", "highlight"]) def test_unknown_block_preserves_arbitrary_kind(k): + """v0.3.0 S1-S3 시점 known kinds (paragraph/table/picture/formula/footnote/endnote/ + list_item/caption/toc/field) 외의 가설적 미래 변형이 UnknownBlock 으로 라우팅.""" u = UnknownBlock(kind=k, prov=_prov()) assert u.kind == k reloaded = UnknownBlock.model_validate_json(u.model_dump_json()) diff --git a/tests/test_ir_schema_export.py b/tests/test_ir_schema_export.py index 5d2ae80..c6aec49 100644 --- a/tests/test_ir_schema_export.py +++ b/tests/test_ir_schema_export.py @@ -42,10 +42,11 @@ def test_export_schema_root_additional_properties_false(): def test_export_schema_defs_are_exactly_the_known_nodes(): - """`$defs` 는 HwpDocument (root) 를 제외한 10개 노드 정확히 일치. + """`$defs` 는 HwpDocument (root) 를 제외한 20개 노드 정확히 일치. - 새 block variant 가 v0.3.0+ 에 추가되면 이 set 도 갱신해야 한다 — 의도적인 - 강한 계약으로 스키마 형상 회귀를 조기에 탐지한다. + v0.3.0 S1: ImageRef + PictureBlock (10 → 12). + v0.3.0 S2: FormulaBlock + FootnoteBlock + EndnoteBlock (12 → 15). + v0.3.0 S3: ListItemBlock + CaptionBlock + TocBlock + TocEntryBlock + FieldBlock (15 → 20). """ schema = export_schema() defs = schema.get("$defs", {}) @@ -60,6 +61,17 @@ def test_export_schema_defs_are_exactly_the_known_nodes(): "TableCell", "UnknownBlock", "Furniture", + "ImageRef", + "PictureBlock", + "FormulaBlock", + "FootnoteBlock", + "EndnoteBlock", + # ^ S3 신규 5 + "ListItemBlock", + "CaptionBlock", + "TocBlock", + "TocEntryBlock", + "FieldBlock", } assert set(defs.keys()) == expected_nodes @@ -165,6 +177,33 @@ def test_paragraph_block_with_inlines_validates(): validator.validate(doc.model_dump(mode="json")) +def test_unknown_kind_routing_pydantic_matches_schema(): + """Pydantic UnknownBlock 라우팅과 JSON Schema 통과가 동일 어휘여야 한다. + + 회귀 방지 — UnknownBlock 의 ``not.enum`` 은 ``_KNOWN_KINDS`` (Block 유니온 + 멤버) 만 포함해야 한다. ``$defs`` walk 로 모든 ``kind.const`` 를 모으면 + leaf-only ``TocEntryBlock.kind="toc_entry"`` 가 포함되어 round-trip 깨짐. + """ + schema = export_schema() + validator = Draft202012Validator(schema) + # ^ Pydantic 은 "toc_entry" 를 Block 유니온 멤버 아님으로 보고 UnknownBlock 에 라우팅 + doc = HwpDocument.model_validate( + { + "body": [ + { + "kind": "toc_entry", + "text": "fake leaf-only kind", + "prov": {"section_idx": 0, "para_idx": 0}, + } + ] + } + ) + instance = doc.model_dump(mode="json") + # ^ schema 도 같은 어휘로 통과해야 함 — UnknownBlock 으로 매치 + errors = list(validator.iter_errors(instance)) + assert errors == [], f"Pydantic ↔ schema round-trip 깨짐: {[e.message for e in errors]}" + + def test_invalid_kind_fails_schema_validation(): """스키마 수준에서도 미지의 kind 는 UnknownBlock 으로 라우팅되는데, UnknownBlock 은 ``extra="allow"`` 이므로 검증 성공. 반면 ParagraphBlock diff --git a/tests/test_ir_tables.py b/tests/test_ir_tables.py index 3909c91..b3bbfbd 100644 --- a/tests/test_ir_tables.py +++ b/tests/test_ir_tables.py @@ -115,12 +115,31 @@ def test_layout_role_on_merged_empty_cells(parsed_hwpx: rhwp.Document): def test_table_cells_blocks_are_paragraph_or_table(parsed_hwpx: rhwp.Document): - """TableCell.blocks 는 ParagraphBlock 또는 TableBlock (중첩 표) 만.""" + """TableCell.blocks 는 known Block 유니온 멤버만. + + 셀 paragraph 의 controls 안에 Control::Table/Picture/Equation/Field 가 있을 때 + _flatten_paragraph 가 해당 블록을 emit. v0.3.0 S3 부터 ListItem/Toc/Field 도 + 셀 안에서 등장 가능. + """ + from rhwp.ir.nodes import FieldBlock, FormulaBlock, ListItemBlock, PictureBlock, TocBlock + ir = parsed_hwpx.to_ir() for t in (b for b in ir.body if isinstance(b, TableBlock)): for cell in t.cells: for blk in cell.blocks: - assert isinstance(blk, (ParagraphBlock, TableBlock)) + assert isinstance( + blk, + ( + ParagraphBlock, + TableBlock, + PictureBlock, + FormulaBlock, + # ^ S3 + ListItemBlock, + TocBlock, + FieldBlock, + ), + ) # * Provenance — ParagraphBlock 과 같은 para_idx 를 공유 diff --git a/tests/test_ir_toc.py b/tests/test_ir_toc.py new file mode 100644 index 0000000..cbe7fdf --- /dev/null +++ b/tests/test_ir_toc.py @@ -0,0 +1,234 @@ +"""tests/test_ir_toc.py — Stage S3 TocBlock + TocEntryBlock 매퍼. + +ir-expansion.md §S3 + § 6 TocBlock/TocEntryBlock 검증: + +- TocBlock 직렬화 왕복 + frozen + extra=forbid +- TocEntryBlock 은 Block 유니온 멤버 아님 (TocBlock.entries 안 leaf type) +- mapper RawToc → TocBlock (v0.3.0 entries 빈 리스트 placeholder) +- ``is_stale`` 항상 False / ``target_section_idx`` 항상 None (v0.3.0 정책) +- TocBlock 이 본문 평탄화에서 등장 (FieldType::TableOfContents → TocBlock) +- iter_blocks 가 TocBlock 만 yield, TocEntryBlock 은 yield 안 함 +""" + +import pytest +from pydantic import ValidationError +from rhwp.ir._mapper import _build_toc_block, _flatten_paragraph +from rhwp.ir._raw_types import RawParagraph, RawToc, RawTocEntry +from rhwp.ir.nodes import ( + HwpDocument, + ParagraphBlock, + Provenance, + TocBlock, + TocEntryBlock, + UnknownBlock, +) + + +def _prov(section_idx: int = 0, para_idx: int = 0) -> Provenance: + return Provenance(section_idx=section_idx, para_idx=para_idx) + + +def _raw_para_with_tocs(tocs: list[RawToc]) -> RawParagraph: + return RawParagraph( + section_idx=0, + para_idx=0, + text="", + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=tocs, + fields=[], + list_info=None, + ) + + +# * 모델 단독 — TocBlock + + +def test_toc_block_minimal_roundtrip(): + toc = TocBlock(entries=[], prov=_prov()) + reloaded = TocBlock.model_validate_json(toc.model_dump_json()) + assert reloaded == toc + assert reloaded.kind == "toc" + assert reloaded.entries == [] + + +def test_toc_block_with_entries_roundtrip(): + entries = [ + TocEntryBlock(text="1장 서론", level=1, cached_page=1, prov=_prov()), + TocEntryBlock(text="1.1 배경", level=2, cached_page=2, prov=_prov()), + ] + toc = TocBlock(entries=entries, prov=_prov()) + reloaded = TocBlock.model_validate_json(toc.model_dump_json()) + assert reloaded == toc + assert len(reloaded.entries) == 2 + + +def test_toc_block_extra_forbidden(): + with pytest.raises(ValidationError): + TocBlock.model_validate( + {"kind": "toc", "prov": {"section_idx": 0, "para_idx": 0}, "extra": "x"} + ) + + +def test_toc_block_frozen(): + toc = TocBlock(entries=[], prov=_prov()) + with pytest.raises(ValidationError): + toc.entries = [] # type: ignore[misc] + + +def test_toc_block_routes_via_discriminator(): + raw = { + "kind": "toc", + "entries": [], + "prov": {"section_idx": 0, "para_idx": 7}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + assert isinstance(blk, TocBlock) + + +# * 모델 단독 — TocEntryBlock + + +def test_toc_entry_block_minimal_roundtrip(): + e = TocEntryBlock(text="1장", prov=_prov()) + reloaded = TocEntryBlock.model_validate_json(e.model_dump_json()) + assert reloaded == e + assert reloaded.kind == "toc_entry" + + +def test_toc_entry_block_full_fields(): + e = TocEntryBlock( + text="1.1 절", + level=2, + target_bookmark_name="bookmark_1_1", + target_section_idx=None, + cached_page=15, + is_stale=False, + prov=_prov(), + ) + reloaded = TocEntryBlock.model_validate_json(e.model_dump_json()) + assert reloaded == e + + +def test_toc_entry_block_default_values(): + """v0.3.0 default: level=1, target_section_idx=None, cached_page=None, is_stale=False.""" + e = TocEntryBlock(text="x", prov=_prov()) + assert e.level == 1 + assert e.target_bookmark_name is None + assert e.target_section_idx is None + assert e.cached_page is None + assert e.is_stale is False + + +def test_toc_entry_block_extra_forbidden(): + with pytest.raises(ValidationError): + TocEntryBlock.model_validate( + { + "kind": "toc_entry", + "text": "x", + "prov": {"section_idx": 0, "para_idx": 0}, + "extra": True, + } + ) + + +def test_toc_entry_block_frozen(): + e = TocEntryBlock(text="x", prov=_prov()) + with pytest.raises(ValidationError): + e.text = "y" # type: ignore[misc] + + +def test_toc_entry_block_is_not_in_block_union(): + """TocEntryBlock 은 Block 유니온 멤버 아님 — body 에 dict 로 넣으면 UnknownBlock 라우팅.""" + raw = { + "kind": "toc_entry", + "text": "x", + "prov": {"section_idx": 0, "para_idx": 0}, + } + doc = HwpDocument.model_validate({"body": [raw]}) + blk = doc.body[0] + # ^ "toc_entry" 는 _KNOWN_KINDS 에 없으므로 UnknownBlock 으로 라우팅됨 + assert isinstance(blk, UnknownBlock) + + +# * mapper — RawToc → TocBlock + + +def _raw_toc(*, entries: list[RawTocEntry] | None = None) -> RawToc: + return RawToc(section_idx=1, para_idx=3, entries=entries or []) + + +def test_build_toc_block_empty_entries(): + """v0.3.0 일반 케이스 — Rust 가 entries 빈 Vec 출고.""" + toc = _build_toc_block(_raw_toc()) + assert toc.entries == [] + assert toc.prov.section_idx == 1 + assert toc.prov.para_idx == 3 + assert toc.prov.char_start is None + assert toc.prov.char_end is None + + +def test_build_toc_block_with_entries(): + """v0.4.0+ 에서 entries 가 채워질 때 — 본 테스트는 forward-compat 검증.""" + raw_entry = RawTocEntry( + text="1장", + level=1, + target_bookmark_name="bm1", + cached_page=10, + ) + toc = _build_toc_block(_raw_toc(entries=[raw_entry])) + assert len(toc.entries) == 1 + assert toc.entries[0].text == "1장" + assert toc.entries[0].level == 1 + assert toc.entries[0].target_bookmark_name == "bm1" + assert toc.entries[0].cached_page == 10 + + +def test_build_toc_entry_target_section_idx_always_none_v0_3_0(): + """spec § 6 결정 사항 — v0.3.0 은 bookmark resolver 미도입 → 항상 None.""" + raw_entry = RawTocEntry(text="x", level=1, target_bookmark_name="bm", cached_page=None) + toc = _build_toc_block(_raw_toc(entries=[raw_entry])) + assert toc.entries[0].target_section_idx is None + + +def test_build_toc_entry_is_stale_always_false_v0_3_0(): + """spec § 6 결정 사항 — v0.3.0 은 stale 검출 미구현 → 항상 False.""" + raw_entry = RawTocEntry(text="x", level=1, target_bookmark_name=None, cached_page=99) + toc = _build_toc_block(_raw_toc(entries=[raw_entry])) + assert toc.entries[0].is_stale is False + + +# * _flatten_paragraph 가 TocBlock 도 emit + + +def test_flatten_paragraph_yields_toc_block_when_tocs_present(): + raw_para = _raw_para_with_tocs([_raw_toc()]) + blocks = _flatten_paragraph(raw_para) + # ^ paragraph_or_list_item + toc 두 블록 + assert len(blocks) == 2 + assert isinstance(blocks[0], ParagraphBlock) + assert isinstance(blocks[1], TocBlock) + + +def test_flatten_paragraph_multiple_tocs(): + """한 paragraph 안에 여러 TOC field 가 (이상하지만) 있을 때 모두 emit.""" + raw_para = _raw_para_with_tocs([_raw_toc(), _raw_toc()]) + blocks = _flatten_paragraph(raw_para) + toc_count = sum(1 for b in blocks if isinstance(b, TocBlock)) + assert toc_count == 2 + + +# * iter_blocks — TocBlock 만 yield, TocEntryBlock 은 yield 안 함 + + +def test_iter_blocks_yields_toc_block_only(): + """recurse=True 여도 TocEntryBlock 은 진입 안 함 (TocBlock.entries 는 leaf 컬렉션).""" + entries = [TocEntryBlock(text="1장", prov=_prov()), TocEntryBlock(text="2장", prov=_prov())] + toc = TocBlock(entries=entries, prov=_prov()) + ir = HwpDocument(body=[toc]) + seq = list(ir.iter_blocks(scope="body", recurse=True)) + assert seq == [toc] + # ^ entries 는 직접 접근 (toc.entries) — iter_blocks 는 yield 안 함 diff --git a/tests/test_langchain_loader_ir.py b/tests/test_langchain_loader_ir.py index dee1e7d..bfc8eeb 100644 --- a/tests/test_langchain_loader_ir.py +++ b/tests/test_langchain_loader_ir.py @@ -144,3 +144,36 @@ def test_ir_blocks_preserves_iter_blocks_order(hwpx_sample: Path) -> None: def test_invalid_mode_still_rejects_after_ir_addition(hwp_sample: Path) -> None: with pytest.raises(ValueError, match="mode"): HwpLoader(str(hwp_sample), mode="page") # type: ignore[arg-type] + + +# * include_furniture (v0.3.0 S4 신규) + + +def test_include_furniture_default_false(hwp_sample: Path) -> None: + loader = HwpLoader(str(hwp_sample), mode="ir-blocks") + assert loader.include_furniture is False + + +def test_include_furniture_yields_extra_documents(hwp_sample: Path) -> None: + """include_furniture=True 면 body 다음에 furniture Document 가 추가 yield 된다.""" + body_only = HwpLoader(str(hwp_sample), mode="ir-blocks", include_furniture=False).load() + with_furn = HwpLoader(str(hwp_sample), mode="ir-blocks", include_furniture=True).load() + # ^ furniture 가 비어있는 샘플도 body_only ≤ with_furn 가 invariant + assert len(with_furn) >= len(body_only) + + +def test_include_furniture_marks_scope_metadata(hwp_sample: Path) -> None: + """furniture Document 는 metadata.scope == 'furniture', body 는 키 없음.""" + docs = HwpLoader(str(hwp_sample), mode="ir-blocks", include_furniture=True).load() + for d in docs: + scope = d.metadata.get("scope") + # ^ body 는 scope 키 부재 (None), furniture 만 'furniture' + assert scope in (None, "furniture") + + +def test_include_furniture_ignored_in_paragraph_mode(hwp_sample: Path) -> None: + """paragraph 모드는 include_furniture 무관하게 동일 결과 (옵션은 ir-blocks 전용).""" + a = HwpLoader(str(hwp_sample), mode="paragraph", include_furniture=False).load() + b = HwpLoader(str(hwp_sample), mode="paragraph", include_furniture=True).load() + assert len(a) == len(b) + assert [d.page_content for d in a] == [d.page_content for d in b] diff --git a/uv.lock b/uv.lock index 55274bd..82e3809 100644 --- a/uv.lock +++ b/uv.lock @@ -2,15 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.10" -[[package]] -name = "aiofiles" -version = "25.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -922,8 +913,13 @@ dependencies = [ ] [package.optional-dependencies] -async = [ - { name = "aiofiles" }, +cli = [ + { name = "typer" }, +] +cli-chunks = [ + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "typer" }, ] examples = [ { name = "langchain-core" }, @@ -932,11 +928,11 @@ examples = [ ] langchain = [ { name = "langchain-core" }, + { name = "langchain-text-splitters" }, ] [package.dev-dependencies] all = [ - { name = "aiofiles" }, { name = "jsonschema" }, { name = "langchain-core" }, { name = "langchain-text-splitters" }, @@ -945,6 +941,7 @@ all = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "typer" }, ] dev = [ { name = "maturin" }, @@ -955,29 +952,32 @@ linting = [ { name = "ruff" }, ] testing = [ - { name = "aiofiles" }, { name = "jsonschema" }, { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "maturin" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "typer" }, ] [package.metadata] requires-dist = [ - { name = "aiofiles", marker = "extra == 'async'", specifier = ">=23" }, + { name = "langchain-core", marker = "extra == 'cli-chunks'", specifier = ">=0.2" }, { name = "langchain-core", marker = "extra == 'examples'", specifier = ">=0.2" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=0.2" }, + { name = "langchain-text-splitters", marker = "extra == 'cli-chunks'", specifier = ">=0.2" }, { name = "langchain-text-splitters", marker = "extra == 'examples'", specifier = ">=0.2" }, + { name = "langchain-text-splitters", marker = "extra == 'langchain'", specifier = ">=0.2" }, { name = "pydantic", specifier = ">=2.5,<3" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12" }, + { name = "typer", marker = "extra == 'cli-chunks'", specifier = ">=0.12" }, { name = "typer", marker = "extra == 'examples'", specifier = ">=0.12" }, ] -provides-extras = ["async", "examples", "langchain"] +provides-extras = ["async", "cli", "cli-chunks", "examples", "langchain"] [package.metadata.requires-dev] all = [ - { name = "aiofiles", specifier = ">=23" }, { name = "jsonschema", specifier = ">=4" }, { name = "langchain-core", specifier = ">=0.2" }, { name = "langchain-text-splitters", specifier = ">=0.2" }, @@ -986,6 +986,7 @@ all = [ { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "typer", specifier = ">=0.12" }, ] dev = [{ name = "maturin", specifier = ">=1.7" }] linting = [ @@ -994,13 +995,13 @@ linting = [ { name = "ruff" }, ] testing = [ - { name = "aiofiles", specifier = ">=23" }, { name = "jsonschema", specifier = ">=4" }, { name = "langchain-core", specifier = ">=0.2" }, { name = "langchain-text-splitters", specifier = ">=0.2" }, { name = "maturin", specifier = ">=1.7" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, + { name = "typer", specifier = ">=0.12" }, ] [[package]]