From a7b87318c613950f744adfeebfda8fa0b138d889 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Wed, 29 Apr 2026 16:58:08 +0900 Subject: [PATCH 01/11] =?UTF-8?q?docs:=20v0.3.1=20ir-marker-char-offset=20?= =?UTF-8?q?spec=20+=20ADR=20draft=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - v0.3.1 spec 본문 (결정 사항 9 / AC 14 / 영구 비목표 8) — inline 컨트롤 마커의 Provenance.char_start/char_end 채움 정정 - 짝 ADR (4 결정 매트릭스: API source / char_start·end 의미 / 빈 char_offsets 폴백 / Schema 버전) - roadmap/README.md 활성 spec 인덱스에 v0.3.1 row 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v0.3.1/ir-marker-char-offset-research.md | 131 ++++++++++++++++++ docs/roadmap/README.md | 1 + docs/roadmap/v0.3.1/ir-marker-char-offset.md | 65 +++++++++ 3 files changed, 197 insertions(+) create mode 100644 docs/design/v0.3.1/ir-marker-char-offset-research.md create mode 100644 docs/roadmap/v0.3.1/ir-marker-char-offset.md diff --git a/docs/design/v0.3.1/ir-marker-char-offset-research.md b/docs/design/v0.3.1/ir-marker-char-offset-research.md new file mode 100644 index 0000000..359a133 --- /dev/null +++ b/docs/design/v0.3.1/ir-marker-char-offset-research.md @@ -0,0 +1,131 @@ +--- +status: Draft +target: v0.3.1 +last_updated: 2026-04-29 +--- + +# v0.3.1 ir-marker-char-offset — 설계 의사결정 리서치 요약 + +[v0.3.1/ir-marker-char-offset.md](../../roadmap/v0.3.1/ir-marker-char-offset.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 4건의 업계 선례·대안·실패 시나리오를 기록한다. spec 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. + +## 결정 매트릭스 + +| # | 항목 | 옵션 비교 | 채택 | 1차 근거 | +|---|---|---|---|---| +| 1 | API source | A: 알고리즘 자체 복사 / B: 상류 `Paragraph::control_text_positions()` (PR #405) / C: `paragraph.char_offsets` 갭 직접 분석 | **B** | 자체 복사는 silent drift 위험. C 는 사실상 A 의 변형. B 는 본 binding 이 [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) 로 제안 → 상류가 옵션 A (Paragraph 메서드) 채택 → 머지 → v0.7.8 GA | +| 2 | char_start / char_end 의미 | A: zero-width (`char_end = char_start`) / B: 1-width (`char_end = char_start + 1`) / C: 컨트롤별 가변 width | **A** | 상류 docstring 이 `positions[i]` 를 "삽입 위치 인덱스" 로 정의 — 점이지 범위 아님. CommonMark footnote `[^1]` / TEI `` 등도 마커는 점-위치로 모델링 | +| 3 | `char_offsets.is_empty()` paragraph | A: 폴백 position 그대로 출고 / B: `None` 폴백 | **B** | 상류 fallback 경로는 비-Shape/Table/Picture/Equation 컨트롤을 모두 position 0 에 누적 — 의미 손실. 텍스트 없는 paragraph 에 character index 부여 자체가 무의미. fail-fast 원칙 (CLAUDE.md "missing precondition → None at boundary") | +| 4 | Schema 버전 | A: `1.1` 유지 / B: `1.2` 로 bump (visible 변경) | **A** | `Provenance.char_start: int \| None` / `char_end: int \| None` 은 1.1 에 이미 정의된 슬롯. 슬롯에 값을 채우는 것은 schema 변경이 아니라 *실제 데이터 정밀도 개선*. JSON Schema 의 `nullable` 슬롯에 non-null 값이 추가되는 것은 SemVer 상 호환 | + +## 1. API source — 자체 복사 vs 상류 `pub` 노출 + +### 팩트 + +- 알고리즘은 `char_offsets` 갭 (각 inline 컨트롤이 char_offsets 상에서 8 단위 폭 표시자로 인코딩되어, `gap / 8` 로 위치 카운트 복원) 을 분석하여 `controls[i]` 의 character 위치를 복원. "8 UTF-16 코드 유닛" 은 *컨트롤당 텍스트 폭* 이 아니라 *char_offsets 배열 상의 인코딩 단위 폭* +- 상류 v0.7.7 까지는 `pub(crate) fn find_control_text_positions(para: &Paragraph) -> Vec` — 외부 crate 접근 불가 +- WASM binding 측은 `#[wasm_bindgen]` 으로 동일 helper 가 이미 노출 (선례 존재) +- 본 binding 이 [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) 로 옵션 A (`Paragraph` 인스턴스 메서드) 와 옵션 B (helpers 모듈 `pub` 화) 두 안 제시 +- 상류는 옵션 A 채택, [Task #390](https://github.com/edwardkim/rhwp/issues/390) / [PR #405](https://github.com/edwardkim/rhwp/pull/405) 로 머지, v0.7.8 GA (2026-04-29) + +### 검증자 반박 + +- "v0.7.8 머지 전까지 어떻게 했나?" → 안 했다. v0.3.0 은 `char_start = char_end = None` 출고로 GA. RAG provenance 정밀도 손실은 알려진 trade-off 였고, [CLAUDE.md "fail-fast, no silent fallback"](../../../CLAUDE.md) 원칙대로 fake offset 을 만들어내지 않음 +- "왜 옵션 A 가 채택됐나?" → helpers 모듈 전체를 `pub` 으로 노출하면 향후 helpers 진화가 외부 contract 부담. `Paragraph` 메서드 캡슐화는 외부 surface 를 좁게 유지 + +### 최종 결정 + +**옵션 B — 상류 `Paragraph::control_text_positions()` 사용**. 자체 알고리즘 복사 (옵션 A) 는 `char_offsets` 의 8 UTF-16 코드 유닛 가정이 상류 변경 시 silent 깨짐 — 상류가 변경됐다는 사실 자체가 우리 binding 의 컴파일 / 테스트로 드러나지 않는 종류의 실패. 상류 SSOT 활용이 정답. + +본 프로젝트는 `external/rhwp` 의 결함 / 누락은 상류 GitHub 이슈로 보고하고 자체 patch / wraparound / 알고리즘 복사를 금지하는 정책을 운영 — 본 결정과 정확히 부합. 알고리즘 복사 시 상류와의 **silent drift** 위험이 가장 큰 비용이고, 그 위험을 피하려면 SSOT 단일화가 필수. + +### 1차 소스 + +- 상류 PR: +- 상류 Task: +- 상류 commit: +- 상류 메서드 정의: `external/rhwp/src/model/paragraph.rs:730` + +## 2. char_start / char_end 의미 — zero-width vs 1-width + +### 팩트 + +- 상류 docstring: "`positions[i]` = `controls[i]` 가 삽입되어야 할 텍스트 character 인덱스" +- HWP 의 inline 컨트롤은 paragraph 의 `text` 필드에 직접 등장하지 않는다 — `controls` 배열에 별도 저장되며 character 위치는 갭 분석으로 복원 +- 따라서 마커의 "텍스트 폭" 자체가 정의되지 않음 — 0 으로 보는 것도, 1 로 보는 것도 합의 문제 +- v0.3.0 의 `Provenance.char_start` / `char_end` 는 `int | None` — 두 값을 명시적으로 받음 +- 비교 대상: + - **CommonMark footnote** (`[^1]`): 본문에 마커 텍스트 (`[^1]`) 가 존재 → char_end > char_start + - **TEI XML** (``): 인라인 element → 시작·끝 태그가 character 범위 형성 + - **DOCX ``**: zero-width — element 자체가 위치 마커 + - **HWP**: DOCX 와 같은 모델 (zero-width inline ref) + +### 검증자 반박 + +- "char_start == char_end 이면 빈 range 인데 RAG 검색에 의미가 있나?" → provenance 의 본질은 "어디서 왔나" 의 점-인덱스. 검색 시 `char_start - 30 .. char_start + 30` 같은 컨텍스트 윈도우는 consumer 책임. range 보다 point 가 합성 (parent paragraph + char_offset) 에 자연 +- "1-width 도 합리적 아닌가? 마커 자체가 한 글자 자리 차지하는 시각 모델" → HWP 의 inline 컨트롤은 *text* 자리 안 차지. text 와 controls 가 별도 배열 — UI 렌더 시 controls 가 자기 폭만큼 끼어들지만 그건 *render* 모델이지 *text* 모델 아님. char index 는 text 모델 기준 + +### 최종 결정 + +**옵션 A — zero-width**. `char_start = char_end = position`. HWP 도메인 모델과 일치 (text 자리 안 차지) + DOCX 선례 + 향후 1-width 가 필요해지면 별도 width 필드 (`marker_width: int` 등) 로 표현 가능 (현 결정 자체는 강한 가정 아님). + +### 옵션 D 비고 — char_end 슬롯 자체 제거 + `(char_start, width=0)` + +의미축이 가장 깨끗한 안. 마커가 점이라는 사실을 schema 레벨에서 표현 (`char_end` 슬롯 부재 → 마커는 무조건 폭 0). 미채택 이유: schema bump 가 강제됨 (Provenance.char_end 가 1.1 에 정의된 슬롯이라 제거는 breaking change). 본 v0.3.1 의 핵심 결정인 "schema 1.1 호환 유지" (AC-9) 와 직접 충돌. *의미 순수성* 보다 *consumer 호환성* 이 우선이라 본 spec 영구 비목표로 분류. + +### 1차 소스 + +- DOCX `w:footnoteReference` 모델: +- TEI 인용 모델: +- 상류 메서드 docstring: `external/rhwp/src/model/paragraph.rs:717-729` + +## 3. `char_offsets.is_empty()` 폴백 — 상류 fallback 채택 vs `None` 폴백 + +### 팩트 + +- 상류 `Paragraph::control_text_positions()` 의 `char_offsets.is_empty()` 분기 (`paragraph.rs:738-756`) 는 모든 컨트롤에 `positions.push(pos)` 후 Shape / Table / Picture / Equation 일 때만 `pos += 1`. 결과: 첫 컨트롤은 항상 0, 그 이후는 직전까지의 Shape/Table/Picture/Equation 카운트를 받음. 비-Shape/Table/Picture/Equation 은 *직전 위치 그대로* 누적 +- 텍스트가 없는 paragraph (= controls 만 있고 text 없음) 는 실제 문서에서 매우 드뭄 — 보통 secd / cold (SectionDef / ColumnDef) 같은 layout 컨트롤만 존재하는 경우 +- v0.3.0 의 `Provenance.char_start: int | None` 은 None 을 명시적으로 허용 + +### 검증자 반박 + +- "상류가 0 으로 폴백하는데 우리도 그대로 흘리면 되지 않나?" → 0 은 "0번째 character" 라는 *유의미한 값* 으로 보일 수 있음. consumer 가 0 을 valid offset 으로 신뢰하면 misleading. None 은 "값 없음" 명시 +- "그럼 상류 폴백을 그대로 신뢰 못 하는 건가?" → 신뢰의 문제가 아니라 *의미축* 차이. 상류는 *어떻게든* 위치를 부여해야 다음 단계 (렌더) 가 진행됨. 우리 IR 은 *consumer 가 신뢰 가능한 데이터만* 출고하는 것이 책임 + +### 최종 결정 + +**옵션 B — `char_start / char_end = None` 폴백**. 본 프로젝트의 fail-fast 원칙 (정확하지 않은 값을 "정확한 값" 인 척 노출하지 않음) 과 결이 같다 — 상류 폴백의 `0` 은 "valid 한 0번째 character" 로 보일 수 있어 misleading. None 은 boundary (HWP binary → IR) 의 명시적 신호. + +### 1차 소스 + +- 상류 fallback 분기: `external/rhwp/src/model/paragraph.rs:738-756` +- global CLAUDE.md 의 Error Philosophy 섹션 + +## 4. Schema 버전 — 1.1 유지 vs 1.2 로 bump + +### 팩트 + +- v0.3.0 schema (`SchemaVersion = "1.1"`) 는 `Provenance.char_start: int | None` / `char_end: int | None` 슬롯을 이미 정의 +- v0.3.0 IR 출고는 inline 컨트롤 마커에 대해 두 슬롯을 항상 `null` 로 채워 출고 — schema 위반 아님 +- 본 v0.3.1 작업은 *기존 슬롯에 non-null 값을 채우는 것* — 새 필드 추가도, 타입 변경도, 의미 변경도 아님 +- JSON Schema in-place 업데이트 정책 (v0.3.0 release note: "in-place v1 URL — major 안의 minor 추가") 는 *새 필드 추가* 에 대한 정책 + +### 검증자 반박 + +- "consumer 가 항상 null 로 받던 값이 갑자기 int 가 되는 건 visible 변화 아닌가?" → schema 정의는 처음부터 nullable 이었으므로 consumer 는 두 케이스 모두 처리 의무가 있었음. 실제로 처리하지 않은 consumer 는 schema 위반 +- "그래도 SchemaVersion 을 1.2 로 올려서 explicit 하게 알려주는 게 안전하지 않나?" → SemVer 의 minor bump 는 *호환 깨지는 추가* 일 때만. 본 변경은 schema 호환 100% — bump 가 정당화 안 됨 + +### 최종 결정 + +**옵션 A — `SchemaVersion = "1.1"` 유지**. 슬롯에 값을 채우는 것은 schema 변경이 아니다. JSON Schema 정의는 동일, validator 도 동일하게 통과. v0.3.0 IR 과 v0.3.1 IR 을 같은 validator 로 검증해도 둘 다 valid. + +CHANGELOG 에서 "Fixed: inline 컨트롤 마커의 `Provenance.char_start/char_end` 가 v0.3.0 까지 항상 null 이던 문제 정정" 으로 표현 — 사용자 visible "변화" 가 있다는 사실은 schema 가 아니라 CHANGELOG 가 전달. + +### 1차 소스 + +- v0.3.0 schema: `python/rhwp/ir/schema/hwp_ir_v1.json` +- SemVer 정의: + +## 참조 + +- [roadmap/v0.3.1/ir-marker-char-offset.md](../../roadmap/v0.3.1/ir-marker-char-offset.md) — 본 리서치의 결정 요약 +- [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) — 본 binding 이 상류에 제출한 이슈 초안 (PR #405 머지로 v0.3.1 GA 시점에 archive) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 38cd8c7..455fc77 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -21,6 +21,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.2.0 | Frozen | [v0.2.0/ir.md](v0.2.0/ir.md) | [design/v0.2.0/ir-research.md](../design/v0.2.0/ir-research.md) | | v0.3.0 (IR 확장) | Frozen | [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) | Frozen | [v0.3.0/cli.md](v0.3.0/cli.md) | [design/v0.3.0/cli-research.md](../design/v0.3.0/cli-research.md) | +| v0.3.1 (IR marker char offset) | Draft | [v0.3.1/ir-marker-char-offset.md](v0.3.1/ir-marker-char-offset.md) | [design/v0.3.1/ir-marker-char-offset-research.md](../design/v0.3.1/ir-marker-char-offset-research.md) | | v0.7.0 (MCP server) | Draft | [v0.7.0/mcp.md](v0.7.0/mcp.md) | [design/v0.7.0/mcp-research.md](../design/v0.7.0/mcp-research.md) | ## 미착수 작업 계획 diff --git a/docs/roadmap/v0.3.1/ir-marker-char-offset.md b/docs/roadmap/v0.3.1/ir-marker-char-offset.md new file mode 100644 index 0000000..ff11878 --- /dev/null +++ b/docs/roadmap/v0.3.1/ir-marker-char-offset.md @@ -0,0 +1,65 @@ +--- +status: Draft +target: v0.3.1 +last_updated: 2026-04-29 +--- + +# v0.3.1 — inline 컨트롤 마커의 character offset 출고 + +v0.3.0 의 IR 은 inline 컨트롤 (각주·미주 마커, 그림, 수식, 필드) 의 `Provenance.char_start/char_end` 를 항상 `None` 으로 출고했다. 본문 paragraph 안의 정확한 character 위치를 외부 crate 에서 알 길이 없었기 때문 — 알고리즘은 상류 `rhwp::document_core::find_control_text_positions` 에 `pub(crate)` 로 갇혀 있었다. + +v0.7.8 에서 상류가 [PR #405 (Task #390)](https://github.com/edwardkim/rhwp/pull/405) 로 `Paragraph::control_text_positions(&self) -> Vec` 를 `pub` 노출하면서 외부 crate 에서도 직접 호출 가능해졌다. 본 spec 은 이를 사용해 v0.3.0 시점에 deferred 처리했던 4 군데 `char_start=None / char_end=None` 슬롯을 채운다 — schema 변경 없음 (이미 `1.1` 에 정의된 슬롯), 추가 / 변경 / 제거된 API 없음, 기존 consumer 영향 0. + +주요 결정의 근거·대안·실패 시나리오는 짝 페어: [ir-marker-char-offset-research.md](../../design/v0.3.1/ir-marker-char-offset-research.md). + +## 결정 사항 + +본 표의 항목 1–4 는 *결정 비교가 필요한 선택* (외부 독자가 "왜 그렇게?" 를 던질 만한 4건) — 짝 페어 ADR §결정 매트릭스 가 옵션 / 검증자 반박 / 1차 소스를 다룬다. 항목 5–8 은 *operational scope* (이미 결정되어 ADR 가 별도로 정당화하지 않음) — 적용 대상, raw 데이터 형태, 사전 조건, 안전 검증 정책. + +| 항목 | 값 | 근거 | +|---|---|---| +| 1 — API source | 상류 `Paragraph::control_text_positions()` (v0.7.8) | PR #405 가 본 binding 이 제출한 [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) 옵션 A 를 그대로 채택. 자체 알고리즘 복사는 silent drift 위험 | +| 2 — char_start / char_end 의미 | `char_end = char_start = position` (zero-width point) | 상류 docstring "positions[i] = controls[i] 가 삽입되어야 할 텍스트 character 인덱스" — 마커는 점이지 범위 아님. char_shape 의 [start, end) 와 다른 의미축 | +| 3 — `char_offsets.is_empty()` paragraph | `char_start / char_end = None` 폴백 | 상류 fallback 분기는 모든 컨트롤에 `positions.push(pos)` 후 Shape/Table/Picture/Equation 일 때만 `pos += 1` 이라 정확 character offset 의미 없음. 텍스트가 없는 paragraph 에 char index 부여 자체가 무의미 | +| 4 — Schema 버전 | `SchemaVersion 1.1` 유지 (bump 없음) | `Provenance.char_start: int \| None` / `char_end: int \| None` 은 1.1 에 이미 정의. 슬롯 채움이지 새 필드 추가가 아님. forward-compat 100% | +| 5 — 적용 대상 (Python 블록) | `FootnoteBlock.marker_prov` / `EndnoteBlock.marker_prov` / `PictureBlock.prov` (TAC + floating 양쪽) / `FormulaBlock.prov` / `FieldBlock.prov` / `TocBlock.prov` / `TableBlock.prov` 7 종 | RawParagraph.controls 로 흘러나오는 모든 inline 컨트롤. 상류 `control_text_positions()` 의 fallback 분기에서도 Shape/Table/Picture/Equation 이 동등 취급 — TableBlock 포함이 일관. Picture 의 `treat_as_char` 상태와 무관하게 부모 paragraph 안의 anchor 위치를 가지므로 양쪽 모두 적용. Header/Footer 는 furniture 라우팅 후 별도 paragraph 가 되므로 제외 | +| 6 — Rust raw 필드 | `Option` 단일 필드 (`marker_char_offset` for Footnote/Endnote, `char_offset` for Picture/Formula/Field/Toc/Table) | Python 측은 `char_start / char_end` 두 슬롯이지만 zero-width 결정 (항목 2) 으로 raw 는 한 값만 운반. mapper 가 양쪽 슬롯에 동일 값 복제 | +| 7 — 상류 핀 bump | `033617e` (v0.7.7) → `42cf91b` (v0.7.8) — commit `cee3c1e chore: sync rhwp upstream` 에서 spec 작성 이전에 완료 | 상류 `pub fn control_text_positions` 가 v0.7.8 에 GA. 그 이전 핀에서는 컴파일 자체가 불가 — pin bump 가 본 spec 의 enabling change. 본 v0.3.1 작업으로 남은 일은 CHANGELOG 기재 의무 (AC-10) | +| 8 — controls / positions 길이 동기화 | `assert_eq!(controls.len(), positions.len())` 후 `zip` (fail-fast) | 상류 `control_text_positions()` 는 항상 `controls.len()` 개 position 반환 (paragraph.rs:734 / 765 / 786 / 796 의 모든 분기 가드). 길이 불일치 시 상류 contract 위반 — 정상 빌드에서도 panic 으로 즉시 드러내야 silent 잘못된 offset 출고 회피. release 에서 무력화되는 `debug_assert!` 는 본 정책에 부적합 | +| 9 — `control_text_positions()` 호출 분배 | paragraph 당 1회 호출, 결과 `Vec` 를 `build_raw_paragraph` 와 `collect_furniture_from_paragraph` 양쪽에서 공유 | 두 함수가 각각 독립적으로 `para.controls` 를 iterate (body controls 추출 / furniture 라우팅). control index 가 둘이 공유하는 단일 축이라 positions 배열도 공유 가능. paragraph 당 중복 호출은 동일 결과의 재계산 — 낭비 | + +## 인수조건 + +- **AC-1** — `FootnoteBlock.marker_prov.char_start` / `char_end` 가 부모 paragraph 의 `char_offsets` 가 비어있지 않을 때 동일한 정수값 (zero-width) 으로 채워진다 +- **AC-2** — `EndnoteBlock.marker_prov.char_start` / `char_end` 가 같은 규칙으로 채워진다 +- **AC-3** — `PictureBlock.prov.char_start` / `char_end` 가 같은 규칙으로 채워진다 (TAC / floating 무관) +- **AC-4** — `FormulaBlock.prov.char_start` / `char_end` 가 같은 규칙으로 채워진다 +- **AC-5** — `FieldBlock.prov.char_start` / `char_end` 가 같은 규칙으로 채워진다 +- **AC-6** — `TocBlock.prov.char_start` / `char_end` 가 같은 규칙으로 채워진다 +- **AC-7** — `TableBlock.prov.char_start` / `char_end` 가 같은 규칙으로 채워진다 +- **AC-8** — 부모 paragraph 의 `char_offsets` 가 비어있을 때 (`paragraph.char_offsets.is_empty()`) 위 모든 블록의 `prov.char_start` / `char_end` 는 `None` 으로 출고된다 +- **AC-9** — `SchemaVersion` 은 `"1.1"` 로 유지. `python/rhwp/ir/schema/hwp_ir_v1.json` 본문 변경 없음 +- **AC-10** — `python/rhwp/ir/schema/hwp_ir_v1.json` (또는 content-addressed alias `hwp_ir_v1-sha256-.json`) 으로 jsonschema validator 가 v0.3.1 IR JSON 을 검증할 때 통과한다 — `Provenance.char_start/char_end` 의 `anyOf [integer, null]` 정의와 호환됨을 실제 validator 호출로 확인 +- **AC-11** — non-None 출고된 모든 marker 의 `prov.char_start == prov.char_end` 이며 `isinstance(prov.char_start, int)` — zero-width point 결정 (항목 2) 의 invariant. mapper 의 양쪽 슬롯 동일 값 복제 (항목 6) 가 비대칭으로 깨지지 않음을 보증 +- **AC-12** — 상류 contract (`controls.len() == control_text_positions().len()`) 위반 시 Rust 빌드가 release / debug 무관 panic 한다 (항목 8 의 `assert_eq!` invariant) — 상류 silent regression 차단 가드 +- **AC-13** — `external/rhwp` submodule pin 이 `42cf91b` (v0.7.8) 이며 [CHANGELOG.md](../../../CHANGELOG.md) 에 v0.7.7 → v0.7.8 핀 bump 가 명시된다 (pin bump 자체는 commit `cee3c1e` 에서 spec 작성 이전 완료, 본 AC 는 CHANGELOG 기재 의무) +- **AC-14** — AC-1 ~ AC-8 검증용 fixture 는 우선 기존 `external/rhwp/samples/aift.hwp` / `table-vpos-01.hwpx` 로 시도하고, 부족 (특히 AC-8 의 빈 `char_offsets` paragraph + 인라인 컨트롤 조합) 시 minimal 합성 fixture 를 `tests/fixtures/v0_3_1/` 에 추가 + +## 영구 비목표 + +- **`Block.order: int` 필드 신설** — controls 의 시각 순서 보존을 위한 별도 필드. v0.4.0+ 검토. 본 v0.3.1 에서는 IR `paragraphs` 배열 등장 순서 = controls 등장 순서로 묵시적 처리 유지 +- **`ListItemBlock` 의 정확 marker (`"가."` / `"(a)"` 등) 추출** — `Numbering.level_formats` lookup 필요. v0.4.0+ 별도 spec +- **TOC entries 실제 추출** — bookmark resolver 필요. v0.4.0+ 별도 spec +- **`HwpField.cached_value` 추출** — `field_ranges` 매핑 필요. 본 spec 은 *위치* 만 채우고 *값* 은 다음 release 로 미룬다 +- **`Header` / `Footer` 컨트롤의 char_offset** — 본문 paragraph 가 아니라 `Furniture.page_headers` / `page_footers` 로 라우팅되어 별도 paragraph 객체가 됨. *부모 paragraph 안의 char position* 개념이 적용되지 않음 +- **char_end 의 1-width 의미 (`char_end = char_start + 1`)** — 마커가 char 한 칸을 차지한다고 보는 해석. 결정 항목 2 에서 zero-width 채택했으므로 본 spec 의 영구 비목표 +- **`char_end` 슬롯 자체의 schema 제거 + `(char_start, width=0)` 모델로 전환** — 의미축은 가장 깨끗하지만 schema bump 필요 (AC-9 위반). schema 안정성 우선이라 본 spec 영구 비목표 (ADR §2 "옵션 D 비고" 참조) +- **char_offset UTF-16 → codepoint 재변환** — 상류 API 가 이미 codepoint 단위 character index 를 반환하므로 별도 변환 없음. UTF-16 노출은 영구 안 함 + +## 참조 + +- 짝 페어 (ADR): [ir-marker-char-offset-research.md](../../design/v0.3.1/ir-marker-char-offset-research.md) +- 상류 PR: +- 상류 Task: +- 상류 메서드: `external/rhwp/src/model/paragraph.rs:730` — `pub fn control_text_positions` +- 자체 이슈 초안 (origin): [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) — PR #405 머지로 본 v0.3.1 GA 시점에 archive / 삭제 From 83e6da56611e0d3caf3e6bc97ee083539a21f54c Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 30 Apr 2026 12:44:40 +0900 Subject: [PATCH 02/11] =?UTF-8?q?docs:=20frontmatter=20description=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=8F=84=EC=9E=85=20+=20upstream/README?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - frontmatter 에 generic description 필드 도입 (모든 26 spec/ADR/impl/upstream/verification 파일에 retroactive 추가) - description 을 required 로 lint 룰 격상 (scripts/_doc_lint.py) - docs/upstream/README.md (Living) 신설 — 활성/해결 이슈 인덱스 + archive 정책 SSOT - docs/upstream/issue-*.md 본문에서 자체 추적 문장 분리 (GitHub 이슈 등록 가능 형태로) - docs/upstream/issue-utf16-pos-to-char-idx.md (Active) 신설 — helpers::utf16_pos_to_char_idx 외부 노출 제안 - docs/CONVENTIONS.md 갱신 — schema 표 description row + quoting 가이드 + 4 예시 production-style 통일 + § upstream/ + 디렉토리 트리 + Living 분류 - /new-spec skill 의 spec/ADR 템플릿에 description placeholder 추가 - docs/roadmap/README.md 헤더 날짜 2026-04-30 으로 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/new-spec/SKILL.md | 2 + docs/CONVENTIONS.md | 20 +++- docs/design/v0.2.0/ir-research.md | 1 + docs/design/v0.3.0/cli-research.md | 1 + docs/design/v0.3.0/ir-expansion-research.md | 1 + .../v0.3.1/ir-marker-char-offset-research.md | 3 +- docs/design/v0.7.0/mcp-research.md | 3 +- docs/implementation/spec-system-overhaul.md | 1 + docs/implementation/v0.1.0/migration.md | 1 + docs/implementation/v0.2.0/stages/stage-1.md | 1 + docs/implementation/v0.2.0/stages/stage-2.md | 1 + docs/implementation/v0.2.0/stages/stage-3.md | 1 + docs/implementation/v0.2.0/stages/stage-4.md | 1 + docs/implementation/v0.2.0/stages/stage-5.md | 1 + docs/implementation/v0.3.0/aparse-cleanup.md | 1 + docs/implementation/v0.3.0/stages/stage-1.md | 1 + docs/implementation/v0.3.0/stages/stage-2.md | 1 + docs/implementation/v0.3.0/stages/stage-3.md | 1 + docs/implementation/v0.3.0/stages/stage-4.md | 1 + docs/roadmap/README.md | 2 +- docs/roadmap/v0.1.0/rhwp-python.md | 1 + docs/roadmap/v0.2.0/ir.md | 1 + docs/roadmap/v0.3.0/cli.md | 1 + docs/roadmap/v0.3.0/ir-expansion.md | 1 + docs/roadmap/v0.3.1/ir-marker-char-offset.md | 3 +- docs/roadmap/v0.7.0/mcp.md | 3 +- docs/upstream/README.md | 20 ++++ .../issue-find-control-text-positions.md | 3 +- docs/upstream/issue-utf16-pos-to-char-idx.md | 109 ++++++++++++++++++ docs/verification/v0.1.0/spinoff-review.md | 1 + scripts/_doc_lint.py | 5 + 31 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 docs/upstream/README.md create mode 100644 docs/upstream/issue-utf16-pos-to-char-idx.md diff --git a/.claude/skills/new-spec/SKILL.md b/.claude/skills/new-spec/SKILL.md index 9c3765a..da104f0 100644 --- a/.claude/skills/new-spec/SKILL.md +++ b/.claude/skills/new-spec/SKILL.md @@ -38,6 +38,7 @@ When this skill is invoked, execute the following steps in order: ```markdown --- status: Draft + description: — <한 줄 요약: spec 이 도입하는 것 + 핵심 결정 압축, 50-150 자> target: last_updated: --- @@ -78,6 +79,7 @@ When this skill is invoked, execute the following steps in order: ```markdown --- status: Draft + description: ADR — <짝 spec 의 결정 N 건 / 핵심 옵션 비교 한 줄, 50-150 자> target: last_updated: --- diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index a576511..6639a3a 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -6,7 +6,7 @@ | 분류 | 의미 | 갱신 정책 | 예시 | |---|---|---|---| -| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `README.md` | +| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `docs/upstream/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `README.md` | | **Active** | 외부 시스템으로 흘러가기 전 staging | 큰 변경만, in-place 갱신 OK | `docs/upstream/.md` | | **Draft** | 작성 중인 spec — 해당 버전 GA 전까지 활발 갱신 | 버전 GA 전까지 자유 갱신, GA 후 Frozen 으로 전환 | `docs/roadmap/v0.7.0/mcp.md` (현재 v0.7.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` | @@ -36,6 +36,7 @@ Frozen 본문은 historical record. 시간 흐르며 외부 의존성 (라이브 ```markdown --- status: Frozen +description: <한 줄 요약 — optional, 50-150 자 권장> ga: v0.3.0 last_updated: 2026-04-28 --- @@ -50,12 +51,21 @@ last_updated: 2026-04-28 | 필드 | 타입 | 규칙 | |---|---|---| | `status` | enum: `Active` / `Draft` / `Frozen` / `Superseded` | 필수 | +| `description` | non-empty string (50-150 자 권장) | 필수. 한 줄 요약 — 인덱스/검색/툴팁용 (MkDocs / Hugo / Astro 패턴) | | `ga` | `vX.Y.Z` SemVer | `status: Frozen` 또는 `Superseded` 일 때 필수. `target` 과 mutex | | `target` | `vX.Y.Z` SemVer | `status: Draft` 일 때 필수. `ga` 와 mutex | | `supersedes` | `/.md` 또는 생략 | 새 spec 이 무엇을 대체하는지 | | `superseded_by` | `/.md` | `status: Superseded` 일 때 필수 | | `last_updated` | `YYYY-MM-DD` | 필수. 의미 변경 commit 시 자동 갱신 ([D3 hook](#last_updated-자동-갱신)) | +`description` 가이드: + +- 한 줄 요약 — 본문 첫 단락 또는 제목+핵심 결정 압축. 50-150 자 권장 (한국어 기준) +- 본문 의미 추가 금지 — 이미 본문에 있는 정보의 압축이어야 함. 새 결정 / 인용 / 사실 추가 안 됨 (특히 Frozen 본문은 immutability 원칙) +- non-semantic 형식 변경이라 Frozen 면제 조항 [Living-policy schema migration](#frozen-면제-조항--living-policy-schema-migration) 적용 — Frozen spec 도 in-place 추가 가능 +- 모든 문서 종류 (roadmap / design / implementation / upstream / verification) 에 의미 있어 schema 갈래 없음 +- **Quoting** — 값 전체를 큰따옴표 `"..."` 로 감싸고 inline code/식별자는 작은따옴표 `'rhwp-py'` 로 표기 (YAML 안전 + flat parser 호환). 예: `description: "v0.3.0 — 'rhwp-py' 얇은 CLI..."` + `Active` (예: `upstream/.md`) 는 `ga` / `target` 둘 다 생략. `Living` 은 frontmatter 없음 — 정의상 항상 최신. 대신 README 같은 인덱스가 다른 문서들의 Status 를 노출. @@ -67,6 +77,7 @@ last_updated: 2026-04-28 ```markdown --- status: Frozen +description: "v0.3.0 — 'rhwp-py' 얇은 CLI 재도입. 업스트림 'rhwp' 바이너리와 overlap=0 + Python 고유 가치 (IR / 청크) 집중" ga: v0.3.0 last_updated: 2026-04-28 --- @@ -81,6 +92,7 @@ v0.2.0 에서 폐기했던 CLI 를... ```markdown --- status: Draft +description: "v0.7.0 — 'rhwp-mcp' MCP 서버. LLM 에이전트가 HWP/HWPX 직접 파싱·요약·청크화 가능한 표준 프로토콜 표면" target: v0.7.0 last_updated: 2026-04-28 --- @@ -95,6 +107,7 @@ last_updated: 2026-04-28 ```markdown --- status: Active +description: "업스트림 제안 — 'find_control_text_positions' 외부 노출 (paragraph 안 inline 컨트롤 character 위치 helper)" last_updated: 2026-04-26 --- @@ -108,6 +121,7 @@ last_updated: 2026-04-26 ```markdown --- status: Superseded +description: "v0.2.0 Document IR v1 (v0.4.0/ir-correction.md 로 대체)" ga: v0.2.0 superseded_by: v0.4.0/ir-correction.md last_updated: 2026-04-25 @@ -138,6 +152,7 @@ docs/ ├── traces/ │ └── coverage.md Living — spec ↔ test 자동 매핑 ├── upstream/ +│ ├── README.md Living — 활성 / 해결 이슈 인덱스 + archive 정책 │ └── .md Active — 외부 (rhwp Rust 코어) 이슈 초안. 업스트림 머지 시 archive └── verification/ └── v/... Frozen — 큰 단위 작업 검증 리포트 (한정) @@ -160,7 +175,8 @@ docs/ ### upstream/ -- `.md` (Active) — 업스트림 (`edwardkim/rhwp` 등) 에 제출 검토 중인 이슈/제안 초안. per-version 매핑 없음 +- `README.md` (Living) — 활성 / 해결 이슈 인덱스 + archive 정책 SSOT. 자체 추적 메타 (상류 등록 여부 / RESOLVED 일자 / 관련 spec 참조) 보유 +- `.md` (Active) — 업스트림 (`edwardkim/rhwp` 등) 에 제출 검토 중인 이슈/제안 초안. per-version 매핑 없음. **본문은 가능한 한 GitHub 이슈 본문에 그대로 등록 가능한 형태** — 자체 추적 문장은 본문에 두지 않고 위 README 인덱스가 관리 - 본 디렉토리는 외부 시스템 (GitHub Issues) 으로 흘러가기 전 단계의 staging — 정식 spec 의 일부가 아님 - **해결 시** — 두 가지 옵션: - **삭제** — 다른 spec 이 본 파일을 참조하지 않을 때. 정보는 GitHub permalink + 본 PR commit history 가 보존 diff --git a/docs/design/v0.2.0/ir-research.md b/docs/design/v0.2.0/ir-research.md index 1b5fb5b..ff5b21b 100644 --- a/docs/design/v0.2.0/ir-research.md +++ b/docs/design/v0.2.0/ir-research.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 IR ADR — 8 미결 결정 (Block 유니온 / HTML 위치 / char 단위 / schema_version / iter API / to_ir 캐싱 / $id 호스팅) 의 14 에이전트 병렬 조사" ga: v0.2.0 last_updated: 2026-04-25 --- diff --git a/docs/design/v0.3.0/cli-research.md b/docs/design/v0.3.0/cli-research.md index ec4d48a..8f25726 100644 --- a/docs/design/v0.3.0/cli-research.md +++ b/docs/design/v0.3.0/cli-research.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 CLI ADR — 'rhwp-py' 이름 선정 / 업스트림 overlap=0 정책 / 기본 출력 포맷 (NDJSON / JSON) 결정의 업계 선례·근거" ga: v0.3.0 last_updated: 2026-04-28 --- diff --git a/docs/design/v0.3.0/ir-expansion-research.md b/docs/design/v0.3.0/ir-expansion-research.md index 7a10c3b..57f6489 100644 --- a/docs/design/v0.3.0/ir-expansion-research.md +++ b/docs/design/v0.3.0/ir-expansion-research.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 IR 확장 ADR — 8 결정 (PictureBlock 임베딩 / FormulaBlock 변환 / Footnote 분리 / FieldKind 어휘 등) 업계 선례·소스" ga: v0.3.0 last_updated: 2026-04-28 --- diff --git a/docs/design/v0.3.1/ir-marker-char-offset-research.md b/docs/design/v0.3.1/ir-marker-char-offset-research.md index 359a133..edfe8b0 100644 --- a/docs/design/v0.3.1/ir-marker-char-offset-research.md +++ b/docs/design/v0.3.1/ir-marker-char-offset-research.md @@ -1,7 +1,8 @@ --- status: Draft +description: "v0.3.1 ADR — API source / char_start·char_end 의미 / 빈 paragraph 폴백 / schema 버전 4 결정의 근거" target: v0.3.1 -last_updated: 2026-04-29 +last_updated: 2026-04-30 --- # v0.3.1 ir-marker-char-offset — 설계 의사결정 리서치 요약 diff --git a/docs/design/v0.7.0/mcp-research.md b/docs/design/v0.7.0/mcp-research.md index 7563b9c..e341939 100644 --- a/docs/design/v0.7.0/mcp-research.md +++ b/docs/design/v0.7.0/mcp-research.md @@ -1,7 +1,8 @@ --- status: Draft +description: "v0.7.0 MCP ADR — SDK 채택 (FastMCP) / transport (stdio + streamable-http) / handler 동시성 (sync 전용) / 도구 분할 (7 개) 결정 근거" target: v0.7.0 -last_updated: 2026-04-28 +last_updated: 2026-04-30 --- # v0.7.0 MCP server — 설계 의사결정 리서치 요약 diff --git a/docs/implementation/spec-system-overhaul.md b/docs/implementation/spec-system-overhaul.md index e5d588c..9d7a93d 100644 --- a/docs/implementation/spec-system-overhaul.md +++ b/docs/implementation/spec-system-overhaul.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "Spec system overhaul PR 의 historical record — 14 결정 / 9 commit 단계 / a/b/c 옵션 비교 / 22 spec 파일 마이그 invariant" last_updated: 2026-04-29 --- diff --git a/docs/implementation/v0.1.0/migration.md b/docs/implementation/v0.1.0/migration.md index fb92b55..77bfa11 100644 --- a/docs/implementation/v0.1.0/migration.md +++ b/docs/implementation/v0.1.0/migration.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.1.0 분사·이관 작업 로그 — 'rhwp-python-heuristic/rhwp-python/' → 'DanMeon/rhwp-python' 별도 리포 분사 + copier 템플릿 적용" ga: v0.1.0 last_updated: 2026-04-23 --- diff --git a/docs/implementation/v0.2.0/stages/stage-1.md b/docs/implementation/v0.2.0/stages/stage-1.md index 6276583..24cace6 100644 --- a/docs/implementation/v0.2.0/stages/stage-1.md +++ b/docs/implementation/v0.2.0/stages/stage-1.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 S1 작업 로그 — Pydantic IR 모델 초안 (10 모델 + Block tagged union + 35 테스트 케이스)" ga: v0.2.0 last_updated: 2026-04-24 --- diff --git a/docs/implementation/v0.2.0/stages/stage-2.md b/docs/implementation/v0.2.0/stages/stage-2.md index fc54cb3..7c6d8d6 100644 --- a/docs/implementation/v0.2.0/stages/stage-2.md +++ b/docs/implementation/v0.2.0/stages/stage-2.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 S2 작업 로그 — Rust → dict 매퍼 + 'Document.to_ir()' 바인딩 + Rust OnceCell lazy 캐시" ga: v0.2.0 last_updated: 2026-04-24 --- diff --git a/docs/implementation/v0.2.0/stages/stage-3.md b/docs/implementation/v0.2.0/stages/stage-3.md index 3c7dd52..1309a89 100644 --- a/docs/implementation/v0.2.0/stages/stage-3.md +++ b/docs/implementation/v0.2.0/stages/stage-3.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 S3 작업 로그 — TableBlock 통합 (cells + HTML + text 3중 표현) + 중첩 표 재귀" ga: v0.2.0 last_updated: 2026-04-24 --- diff --git a/docs/implementation/v0.2.0/stages/stage-4.md b/docs/implementation/v0.2.0/stages/stage-4.md index 1636ddf..20aaf21 100644 --- a/docs/implementation/v0.2.0/stages/stage-4.md +++ b/docs/implementation/v0.2.0/stages/stage-4.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 S4 작업 로그 — JSON Schema 공개 + Draft 2020-12 meta-validation + GitHub Pages 배포 파이프라인" ga: v0.2.0 last_updated: 2026-04-24 --- diff --git a/docs/implementation/v0.2.0/stages/stage-5.md b/docs/implementation/v0.2.0/stages/stage-5.md index 236ffde..9abe432 100644 --- a/docs/implementation/v0.2.0/stages/stage-5.md +++ b/docs/implementation/v0.2.0/stages/stage-5.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 S5 작업 로그 — 'iter_blocks' Python 메서드 + LangChain mode='ir-blocks' IR 통합 (v0.2.0 마지막 stage)" ga: v0.2.0 last_updated: 2026-04-24 --- diff --git a/docs/implementation/v0.3.0/aparse-cleanup.md b/docs/implementation/v0.3.0/aparse-cleanup.md index 2c30c41..1ab9f9a 100644 --- a/docs/implementation/v0.3.0/aparse-cleanup.md +++ b/docs/implementation/v0.3.0/aparse-cleanup.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 cleanup 로그 — 'rhwp.aparse' 의 'aiofiles' 우회를 stdlib 'asyncio.to_thread' 로 정리. extras 의존성 제거" ga: v0.3.0 last_updated: 2026-04-28 --- diff --git a/docs/implementation/v0.3.0/stages/stage-1.md b/docs/implementation/v0.3.0/stages/stage-1.md index 324daf3..e72d2a4 100644 --- a/docs/implementation/v0.3.0/stages/stage-1.md +++ b/docs/implementation/v0.3.0/stages/stage-1.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 S1 작업 로그 — PictureBlock + Furniture page_headers/footers 채움 + SchemaVersion 1.0 → 1.1" ga: v0.3.0 last_updated: 2026-04-26 --- diff --git a/docs/implementation/v0.3.0/stages/stage-2.md b/docs/implementation/v0.3.0/stages/stage-2.md index 54b8442..f8d1447 100644 --- a/docs/implementation/v0.3.0/stages/stage-2.md +++ b/docs/implementation/v0.3.0/stages/stage-2.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 S2 작업 로그 — FormulaBlock + Footnote/Endnote + Furniture 라우팅 + Provenance.marker_prov 패턴 도입" ga: v0.3.0 last_updated: 2026-04-26 --- diff --git a/docs/implementation/v0.3.0/stages/stage-3.md b/docs/implementation/v0.3.0/stages/stage-3.md index 69183c6..8f5b59b 100644 --- a/docs/implementation/v0.3.0/stages/stage-3.md +++ b/docs/implementation/v0.3.0/stages/stage-3.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 S3 작업 로그 — ListItem + Caption + Toc + Field 4 종 블록 일괄 도입 (Block 유니온 11 멤버)" ga: v0.3.0 last_updated: 2026-04-27 --- diff --git a/docs/implementation/v0.3.0/stages/stage-4.md b/docs/implementation/v0.3.0/stages/stage-4.md index 09a3666..550a038 100644 --- a/docs/implementation/v0.3.0/stages/stage-4.md +++ b/docs/implementation/v0.3.0/stages/stage-4.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 S4 작업 로그 — Schema v1.1 GA + 'rhwp-py' CLI 재도입 + LangChain include_furniture (IR 확장 + CLI 두 축 동시 GA)" ga: v0.3.0 last_updated: 2026-04-28 --- diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 455fc77..611c79b 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,7 +4,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe 본 문서는 Living — 자유 갱신. -## 현재 상태 (2026-04-28) +## 현재 상태 (2026-04-30) - **v0.1.0 / v0.1.1** — Frozen, PyPI 배포 완료 - **v0.2.0** — Frozen, Document IR v1 GA (2026-04-25) diff --git a/docs/roadmap/v0.1.0/rhwp-python.md b/docs/roadmap/v0.1.0/rhwp-python.md index 26c20d9..6cf45e7 100644 --- a/docs/roadmap/v0.1.0/rhwp-python.md +++ b/docs/roadmap/v0.1.0/rhwp-python.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.1.0 — PyO3 바인딩 분사 + PyPI 배포. 'rhwp-python-heuristic' 모노레포에서 별도 리포로 이관 (기능 추가 없음)" ga: v0.1.0 last_updated: 2026-04-23 --- diff --git a/docs/roadmap/v0.2.0/ir.md b/docs/roadmap/v0.2.0/ir.md index 3443a11..9d13243 100644 --- a/docs/roadmap/v0.2.0/ir.md +++ b/docs/roadmap/v0.2.0/ir.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.2.0 — Document IR v1. Pydantic V2 기반 RAG/LLM 파이프라인용 구조화 문서 모델 + JSON Schema 고정" ga: v0.2.0 last_updated: 2026-04-25 --- diff --git a/docs/roadmap/v0.3.0/cli.md b/docs/roadmap/v0.3.0/cli.md index 5f42d02..033aa14 100644 --- a/docs/roadmap/v0.3.0/cli.md +++ b/docs/roadmap/v0.3.0/cli.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 — 'rhwp-py' 얇은 CLI 재도입. 업스트림 'rhwp' 바이너리와 overlap=0 + Python 고유 가치 (IR / 청크) 집중" ga: v0.3.0 last_updated: 2026-04-28 --- diff --git a/docs/roadmap/v0.3.0/ir-expansion.md b/docs/roadmap/v0.3.0/ir-expansion.md index 26c14be..5ecc18d 100644 --- a/docs/roadmap/v0.3.0/ir-expansion.md +++ b/docs/roadmap/v0.3.0/ir-expansion.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.3.0 — Document IR v1.1. Picture / Formula / Footnote / List / Caption / Toc / Field 8 종 블록 확장 (UnknownBlock 위 MINOR 증분)" ga: v0.3.0 last_updated: 2026-04-28 --- diff --git a/docs/roadmap/v0.3.1/ir-marker-char-offset.md b/docs/roadmap/v0.3.1/ir-marker-char-offset.md index ff11878..afb91f8 100644 --- a/docs/roadmap/v0.3.1/ir-marker-char-offset.md +++ b/docs/roadmap/v0.3.1/ir-marker-char-offset.md @@ -1,7 +1,8 @@ --- status: Draft +description: "v0.3.1 — inline 컨트롤 마커 character offset 출고. 상류 v0.7.8 'Paragraph::control_text_positions()' 활용 (schema 변경 없음)" target: v0.3.1 -last_updated: 2026-04-29 +last_updated: 2026-04-30 --- # v0.3.1 — inline 컨트롤 마커의 character offset 출고 diff --git a/docs/roadmap/v0.7.0/mcp.md b/docs/roadmap/v0.7.0/mcp.md index b2f59bf..6cf51f4 100644 --- a/docs/roadmap/v0.7.0/mcp.md +++ b/docs/roadmap/v0.7.0/mcp.md @@ -1,7 +1,8 @@ --- status: Draft +description: "v0.7.0 — 'rhwp-mcp' MCP 서버. LLM 에이전트가 HWP/HWPX 직접 파싱·요약·청크화 가능한 표준 프로토콜 표면" target: v0.7.0 -last_updated: 2026-04-28 +last_updated: 2026-04-30 --- # v0.7.0 — MCP server (`rhwp-mcp`) diff --git a/docs/upstream/README.md b/docs/upstream/README.md new file mode 100644 index 0000000..f638acc --- /dev/null +++ b/docs/upstream/README.md @@ -0,0 +1,20 @@ +# Upstream Issue Drafts — 인덱스 + +본 디렉토리 (`docs/upstream/`) 는 외부 시스템 (`edwardkim/rhwp` GitHub Issues 등) 으로 흘러가기 전 단계의 staging. 정식 spec 의 일부가 아니며, 각 `.md` 는 가능한 한 GitHub 이슈 본문에 그대로 등록 가능한 형태로 작성한다 (본 인덱스는 그 외 자체 추적 메타를 보유). + +정책 SSOT: [docs/CONVENTIONS.md § upstream/](../CONVENTIONS.md#upstream). + +## 활성 / 해결 이슈 + +| 이슈 | Status | 상류 등록 | RESOLVED | 비고 | +|---|---|---|---|---| +| [issue-find-control-text-positions.md](issue-find-control-text-positions.md) | Frozen | [edwardkim/rhwp#390](https://github.com/edwardkim/rhwp/issues/390) | 2026-04-28 ([PR #405](https://github.com/edwardkim/rhwp/pull/405)) | `Paragraph::control_text_positions(&self)` 옵션 A 채택. v0.3.1 spec 이 본 파일 참조 → 삭제 대신 in-place Frozen | +| [issue-utf16-pos-to-char-idx.md](issue-utf16-pos-to-char-idx.md) | Active | (미등록) | — | #390 후속 같은 결. `helpers::utf16_pos_to_char_idx` 외부 노출 | + +## Archive 정책 + +- **Active → Frozen 전환** 두 가지 경로: + - **삭제** — 다른 spec 이 본 파일을 참조하지 않을 때. 정보는 GitHub permalink + git history 가 보존 + - **in-place Frozen 전환** — 다른 Frozen spec 이 본 파일을 참조할 때. frontmatter `status: Frozen` (`ga` 생략 — 특정 버전 미귀속), 본문 첫 헤더 위에 `> **RESOLVED YYYY-MM-DD** — 상류 PR/commit 참조 …` 한 줄 인용 블록 추가. 기존 body 보존 (historical record) + +본 인덱스는 위 전환을 추적 — 파일 본문에는 archive 정책 문장을 두지 않는다 (GitHub 이슈 등록 시 노이즈). diff --git a/docs/upstream/issue-find-control-text-positions.md b/docs/upstream/issue-find-control-text-positions.md index 9f4a4ba..a768359 100644 --- a/docs/upstream/issue-find-control-text-positions.md +++ b/docs/upstream/issue-find-control-text-positions.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "업스트림 제안 — 'find_control_text_positions' 외부 노출 (paragraph 안 inline 컨트롤 character 위치 helper). RESOLVED via PR #405" last_updated: 2026-04-29 --- @@ -7,7 +8,7 @@ last_updated: 2026-04-29 # 업스트림 제안 — `find_control_text_positions` 외부 노출 -> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. 업스트림 머지 시 본 파일은 archive (또는 삭제) 처리. +> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. ## Summary diff --git a/docs/upstream/issue-utf16-pos-to-char-idx.md b/docs/upstream/issue-utf16-pos-to-char-idx.md new file mode 100644 index 0000000..b2429c3 --- /dev/null +++ b/docs/upstream/issue-utf16-pos-to-char-idx.md @@ -0,0 +1,109 @@ +--- +status: Active +description: "업스트림 제안 — 'utf16_pos_to_char_idx' 외부 노출 (UTF-16 위치 → codepoint 인덱스 변환 helper). #390/PR #405 후속 같은 결" +last_updated: 2026-04-30 +--- + +# 업스트림 제안 — `utf16_pos_to_char_idx` 외부 노출 + +> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. + +## Summary + +`document_core::helpers::utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usize` 가 외부 crate 에서 호출 불가합니다. 알고리즘은 단순한 single-pass scan 이며 상류 자체에서도 prod 사용 중 (cursor / clipboard 경로) 이라, `Paragraph` 인스턴스 메서드 캡슐화 또는 helper visibility 완화로 해결 가능해 보입니다. + +본 이슈는 [#390](https://github.com/edwardkim/rhwp/issues/390) (`find_control_text_positions` 외부 노출, [PR #405](https://github.com/edwardkim/rhwp/pull/405) 가 cherry-pick 으로 v0.7.8 에 반영) 와 같은 결의 — 같은 `helpers` 모듈에 갇힌 다른 helper 의 외부 노출 사례입니다. + +## 문제 상황 + +`Paragraph.char_shapes[i].start_pos` 는 UTF-16 코드 유닛 단위 위치이고 (`paragraph.rs:121-126` `CharShapeRef.start_pos: u32`), `Paragraph.char_offsets` 의 doc-comment 도 "원본 UTF-16 코드 유닛 인덱스" 로 명시 (`paragraph.rs:23-24`) 되어 있습니다. + +외부 binding 이 IR / RAG 매핑 작업에서 character (codepoint) 단위 인덱스로 정규화된 character run 을 출고해야 하는 경우, 매 char_shape 의 `start_pos` 를 codepoint 인덱스로 변환할 helper 가 필요합니다. 알고리즘은 `char_offsets.iter().position(|&off| off >= utf16_pos)` 의 단일 라인이지만, 다음 이유로 외부 자체 구현 회피가 안전해 보입니다: + +- 단순해 보여도 boundary 처리 (utf16_pos > 모든 offsets 인 sentinel 케이스의 fallback) 가 정확히 일치해야 상류 렌더링·클립보드 결과와 어긋나지 않습니다. +- 상류 자체가 3 군데 직접 호출 (cursor / clipboard 경로) + 5 군데 inline 으로 동일 패턴을 사용 중이라, 외부 자체 복사는 silent drift 위험이 있습니다. +- `Paragraph.char_offsets` 의 의미 (UTF-16 코드 유닛, 컨트롤 갭 8 코드 유닛 등) 는 상류가 단일 source-of-truth 로 갖는 contract 입니다. + +## 현재 상태 + +`src/document_core/helpers.rs:189-192` 에 함수가 존재합니다: + +```rust +/// UTF-16 위치를 char 인덱스로 변환한다. +pub(crate) fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usize { + char_offsets.iter().position(|&off| off >= utf16_pos).unwrap_or(char_offsets.len()) +} +``` + +`src/document_core/mod.rs:6` 의 `pub(crate) mod helpers;` 때문에 외부 crate 에서는 접근 불가합니다. + +이 함수는 `v0.5.0` initial commit 부터 helpers 모듈에 존재해 온 helper 로, 현재 다음 경로에서 prod 사용 중이라 contract 가 안정된 상태로 보입니다: + +- `src/document_core/queries/cursor_rect.rs:8` (import) +- `src/document_core/queries/cursor_nav.rs:8, 112, 159` (import + 2 회 호출) +- `src/document_core/commands/clipboard.rs:11, 845` (import + 1 회 호출) + +추가로 동일 패턴이 inline 으로 사용된 경로 (총 5 군데): + +- `src/renderer/composer.rs:435, 441, 500` — `char_offsets.iter().position(|&off| off >= ...)` +- `src/document_core/commands/clipboard.rs:543, 548` — 같은 패턴 + +성격은 약간 다르나 같은 변환 컨텍스트의 역방향 검색 (`iter().rev().find(|cs| cs.start_pos <= utf16_pos)`) 도 `src/renderer/layout/paragraph_layout.rs:188, 323, 391` 에 있습니다. + +## 선례 + +같은 `helpers` 모듈의 다른 함수가 [#390](https://github.com/edwardkim/rhwp/issues/390) 에서 동일한 사유로 외부 노출 검토되었고, [PR #405](https://github.com/edwardkim/rhwp/pull/405) 에서 옵션 A (`Paragraph` 인스턴스 메서드 캡슐화) 로 채택되어 `Paragraph::control_text_positions(&self) -> Vec` (`paragraph.rs:730`) 로 v0.7.8 에 반영된 사례가 있습니다. 기존 `helpers::find_control_text_positions` 는 `paragraph.rs` 메서드를 호출하는 thin wrapper 로 보존되어 (`helpers.rs:105-111`) 기존 내부 호출 경로와 호환을 유지합니다. + +본 helper 도 같은 패턴이 자연스러워 보입니다 — `Paragraph` 가 `char_offsets` 를 자체 필드로 보유하므로 `&self` 로 캡슐화하면 시그니처가 더 깔끔해집니다. + +## 제안 + +다음 중 하나를 검토 부탁드립니다: + +**옵션 A** — `Paragraph` 인스턴스 메서드로 캡슐화: + +```rust +// src/model/paragraph.rs (impl Paragraph 안) + +/// `text` 의 codepoint 인덱스 (= `text.chars().nth(i)`) 중 UTF-16 위치 +/// `utf16_pos` 이상인 첫 번째 인덱스를 반환. 없으면 `text.chars().count()` +/// (= `char_offsets.len()`). +/// +/// `char_shapes[i].start_pos` 와 `line_segs[i].text_start` 같은 UTF-16 단위 +/// 위치 필드를 codepoint 인덱스로 정규화할 때 사용. +pub fn utf16_pos_to_char_idx(&self, utf16_pos: u32) -> usize { + self.char_offsets.iter().position(|&off| off >= utf16_pos) + .unwrap_or(self.char_offsets.len()) +} +``` + +장점은 외부 API surface 를 좁게 유지하면서 `Paragraph` 에 의미가 응집되고, helpers 모듈은 내부 구현 세부 사항으로 자유롭게 진화 가능하다는 점입니다. PR #405 와 같은 결입니다. + +**옵션 B** — `helpers::utf16_pos_to_char_idx` 함수 자체를 `pub` 으로: + +```rust +// src/document_core/helpers.rs +pub fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usize { ... } +``` + +`pub(crate) mod helpers` 는 그대로 두되 본 함수만 외부 노출 — 변경 범위 최소. 다만 외부 호출자가 `Paragraph` 가 아닌 raw `char_offsets` 슬라이스를 직접 다뤄야 해서 호출부 ergonomics 가 살짝 불편합니다. + +옵션 A 가 PR #405 의 결정과 일관되어 좀 더 자연스러워 보입니다만, 메인테이너님 의견 듣고 싶습니다. + +## 영향 + +- 알고리즘 변경 없음 (visibility 완화 또는 메서드 캡슐화만) — semver MINOR +- 기존 내부 사용처 (cursor_rect, cursor_nav, clipboard) 영향 없음 — 옵션 A 채택 시 기존 helper 는 thin wrapper 로 유지하거나 호출부를 `&self` 메서드로 점진 전환 가능 +- 외부 binding 이 char_shape / line_seg 의 UTF-16 위치 필드를 codepoint 인덱스로 변환 가능 + +## 관련 이슈 + +- [#390](https://github.com/edwardkim/rhwp/issues/390) `[api] document_core::find_control_text_positions 외부 crate 노출 검토 부탁드립니다` — 같은 helpers 모듈, 같은 결의 외부 노출 사례 (옵션 A 채택, [PR #405](https://github.com/edwardkim/rhwp/pull/405) 가 cherry-pick 으로 v0.7.8 에 반영) + +## 참고 위치 + +- `src/document_core/helpers.rs:189-192` (현재 구현, `pub(crate)`) +- `src/document_core/mod.rs:6` (`pub(crate) mod helpers;`) +- `src/model/paragraph.rs:23-24` (`Paragraph.char_offsets` doc-comment, UTF-16 단위 명시) +- `src/model/paragraph.rs:121-126` (`CharShapeRef.start_pos`, UTF-16 단위) +- `src/model/paragraph.rs:730` (옵션 A 시 메서드 추가 위치 — `control_text_positions` 인근) diff --git a/docs/verification/v0.1.0/spinoff-review.md b/docs/verification/v0.1.0/spinoff-review.md index 31e9238..3f10bba 100644 --- a/docs/verification/v0.1.0/spinoff-review.md +++ b/docs/verification/v0.1.0/spinoff-review.md @@ -1,5 +1,6 @@ --- status: Frozen +description: "v0.1.0 분사 작업 독립 검증 리포트 — code-reviewer + architect-reviewer 병렬 스폰. 8 건 즉시 반영, 나머지 운영 개선" ga: v0.1.0 last_updated: 2026-04-23 --- diff --git a/scripts/_doc_lint.py b/scripts/_doc_lint.py index 3d0a54f..9290ece 100644 --- a/scripts/_doc_lint.py +++ b/scripts/_doc_lint.py @@ -6,6 +6,7 @@ 1. **Frontmatter (YAML)** — Living 외 모든 spec - status enum: Active / Draft / Frozen / Superseded - last_updated: YYYY-MM-DD (필수) + - description: 필수 (non-empty string) - status:Active → ga / target 둘 다 금지 - status:Draft → target 필수, ga 금지 - status:Frozen → ga 필수 (예외: meta-level docs/implementation/.md / @@ -31,6 +32,7 @@ "docs/CONVENTIONS.md", "docs/roadmap/README.md", "docs/traces/coverage.md", + "docs/upstream/README.md", } HISTORICAL_FROZEN_PREFIXES = ("docs/implementation/v0.1.0/",) FORBIDDEN_KEYWORDS = ( @@ -101,6 +103,9 @@ def validate_frontmatter(rel_str: str, meta: dict[str, str], repo: Path) -> list if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", last_updated): errors.append(f"frontmatter: 'last_updated' must be YYYY-MM-DD (got {last_updated!r})") + if not meta.get("description", "").strip(): + errors.append("frontmatter: 'description' is required (non-empty string)") + has_ga = "ga" in meta has_target = "target" in meta From 40e550bcdab11fb1e832121212a559b33bfb5492 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 30 Apr 2026 12:49:51 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20bin=5Fdata=20lookup=20?= =?UTF-8?q?=EC=9D=84=20=EC=83=81=EB=A5=98=20get=5Fbin=5Fdata=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=ED=98=B8=EC=B6=9C=EB=A1=9C=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - src/ir.rs: lookup_bin_data_bytes helper 함수 + 단위 테스트 제거 - src/document.rs: bytes_for_image_id 가 상류 inner.get_bin_data((id-1) as usize) 를 직접 호출하도록 변경 + bin_data_id == 0 early return inline - 혼합 (Link + Embedding) 문서의 lookup 한계 docstring 을 메서드 자체로 이전 (상류 패리티 명시) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/document.rs | 11 ++++++++++- src/ir.rs | 26 -------------------------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/src/document.rs b/src/document.rs index 108eab6..63eee23 100644 --- a/src/document.rs +++ b/src/document.rs @@ -205,12 +205,21 @@ impl PyDocument { /// ``bin://`` 스킴을 파싱한 결과를 본 메서드에 위임한다. 상류 BinData 가 /// Embedding 타입이 아니거나 (Link/Storage) `bin_data_content` 에 누락된 /// 경우 None — Python wrapper 가 ValueError 로 변환. + /// + /// 혼합 (Link + Embedding) 문서에서는 상류 `bin_data_content` 가 Embedding + /// 만 추려 더 짧으므로 잘못된 entry 를 반환할 수 있다 — 상류 renderer 도 + /// 같은 가정을 공유하므로 SVG/PDF 렌더링과 동일한 lookup 결과 (상류 패리티). 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) + if bin_data_id == 0 { + return Ok(None); + } + Ok(self + .inner + .get_bin_data((bin_data_id - 1) as usize) .map(|bytes| PyBytes::new(py, bytes))) } diff --git a/src/ir.rs b/src/ir.rs index d8ce14c..af9f278 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -733,26 +733,6 @@ fn field_type_to_str(ft: FieldType) -> &'static str { } } -/// `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::*; @@ -773,12 +753,6 @@ mod tests { 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] From 0ff03d19db9fdcc6530d438ab42619d785248337 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 30 Apr 2026 13:39:16 +0900 Subject: [PATCH 04/11] =?UTF-8?q?docs:=20utf16=5Fpos=5Fto=5Fchar=5Fidx=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EB=85=B8=EC=B6=9C=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=EC=82=AC=EC=8B=A4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/upstream/issue-utf16-pos-to-char-idx.md | 29 +++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/upstream/issue-utf16-pos-to-char-idx.md b/docs/upstream/issue-utf16-pos-to-char-idx.md index b2429c3..b32f1df 100644 --- a/docs/upstream/issue-utf16-pos-to-char-idx.md +++ b/docs/upstream/issue-utf16-pos-to-char-idx.md @@ -6,7 +6,7 @@ last_updated: 2026-04-30 # 업스트림 제안 — `utf16_pos_to_char_idx` 외부 노출 -> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사를 진행한 결과입니다. +> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사 및 다차례 사실 검증을 거친 결과입니다. ## Summary @@ -16,13 +16,22 @@ last_updated: 2026-04-30 ## 문제 상황 -`Paragraph.char_shapes[i].start_pos` 는 UTF-16 코드 유닛 단위 위치이고 (`paragraph.rs:121-126` `CharShapeRef.start_pos: u32`), `Paragraph.char_offsets` 의 doc-comment 도 "원본 UTF-16 코드 유닛 인덱스" 로 명시 (`paragraph.rs:23-24`) 되어 있습니다. +`Paragraph.char_shapes[i].start_pos` 는 UTF-16 코드 유닛 단위 위치이고 (`paragraph.rs:121-126` `CharShapeRef.start_pos: u32`), `Paragraph.char_offsets` 의 doc-comment 도 "원본 UTF-16 코드 유닛 인덱스" 로 명시 (`paragraph.rs:22-24`) 되어 있습니다. 외부 binding 이 IR / RAG 매핑 작업에서 character (codepoint) 단위 인덱스로 정규화된 character run 을 출고해야 하는 경우, 매 char_shape 의 `start_pos` 를 codepoint 인덱스로 변환할 helper 가 필요합니다. 알고리즘은 `char_offsets.iter().position(|&off| off >= utf16_pos)` 의 단일 라인이지만, 다음 이유로 외부 자체 구현 회피가 안전해 보입니다: - 단순해 보여도 boundary 처리 (utf16_pos > 모든 offsets 인 sentinel 케이스의 fallback) 가 정확히 일치해야 상류 렌더링·클립보드 결과와 어긋나지 않습니다. -- 상류 자체가 3 군데 직접 호출 (cursor / clipboard 경로) + 5 군데 inline 으로 동일 패턴을 사용 중이라, 외부 자체 복사는 silent drift 위험이 있습니다. -- `Paragraph.char_offsets` 의 의미 (UTF-16 코드 유닛, 컨트롤 갭 8 코드 유닛 등) 는 상류가 단일 source-of-truth 로 갖는 contract 입니다. +- 상류 자체가 3 군데 직접 호출 (cursor / clipboard 경로) + 5 군데 inline 으로 같은 본체 패턴 (`char_offsets.iter().position(|&off| off >= ...)`) 을 사용 중입니다. inline 5 군데의 fallback (`unwrap_or(...)`) 은 호출자 컨텍스트별로 상이 — `text_len` / `text_end` / `0` / `inserted_chars` — helper 의 `char_offsets.len()` 과는 의도가 다릅니다. 외부 binding 은 paragraph 레벨 정규화 케이스라 helper 와 동일 fallback 이 필요한데, 외부 자체 복사 시 boundary 미묘한 차이로 렌더링 결과와 어긋날 위험이 있습니다. +- `Paragraph.char_offsets` 의 의미 (UTF-16 코드 유닛 인덱스, `paragraph.rs:22-24` doc-comment) 는 상류가 단일 source-of-truth 로 갖는 contract 입니다. + +본 binding 의 호출은 paragraph 레벨 character run 정규화 hot path 로, char_shapes 길이만큼 반복합니다 — 옵션 A 채택 시: + +```rust +for cs in ¶.char_shapes { + let char_idx = para.utf16_pos_to_char_idx(cs.start_pos); + // ... character run 의 시작 인덱스로 사용 +} +``` ## 현재 상태 @@ -39,10 +48,11 @@ pub(crate) fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usi 이 함수는 `v0.5.0` initial commit 부터 helpers 모듈에 존재해 온 helper 로, 현재 다음 경로에서 prod 사용 중이라 contract 가 안정된 상태로 보입니다: -- `src/document_core/queries/cursor_rect.rs:8` (import) - `src/document_core/queries/cursor_nav.rs:8, 112, 159` (import + 2 회 호출) - `src/document_core/commands/clipboard.rs:11, 845` (import + 1 회 호출) +(`src/document_core/queries/cursor_rect.rs:8` 에도 import 가 있으나 호출은 부재 — dead import 로 보이며, 옵션 A 채택 시 함께 제거 가능) + 추가로 동일 패턴이 inline 으로 사용된 경로 (총 5 군데): - `src/renderer/composer.rs:435, 441, 500` — `char_offsets.iter().position(|&off| off >= ...)` @@ -65,9 +75,8 @@ pub(crate) fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usi ```rust // src/model/paragraph.rs (impl Paragraph 안) -/// `text` 의 codepoint 인덱스 (= `text.chars().nth(i)`) 중 UTF-16 위치 -/// `utf16_pos` 이상인 첫 번째 인덱스를 반환. 없으면 `text.chars().count()` -/// (= `char_offsets.len()`). +/// `text` 의 codepoint 중 UTF-16 위치 `utf16_pos` 이상인 첫 번째 codepoint +/// 의 인덱스를 반환. 없으면 `text.chars().count()` (= `char_offsets.len()`). /// /// `char_shapes[i].start_pos` 와 `line_segs[i].text_start` 같은 UTF-16 단위 /// 위치 필드를 codepoint 인덱스로 정규화할 때 사용. @@ -93,7 +102,7 @@ pub fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usize { .. ## 영향 - 알고리즘 변경 없음 (visibility 완화 또는 메서드 캡슐화만) — semver MINOR -- 기존 내부 사용처 (cursor_rect, cursor_nav, clipboard) 영향 없음 — 옵션 A 채택 시 기존 helper 는 thin wrapper 로 유지하거나 호출부를 `&self` 메서드로 점진 전환 가능 +- 기존 내부 사용처 (cursor_nav, clipboard) 영향 없음 — 옵션 A 채택 시 기존 helper 는 thin wrapper 로 유지하거나 호출부를 `&self` 메서드로 점진 전환 가능 - 외부 binding 이 char_shape / line_seg 의 UTF-16 위치 필드를 codepoint 인덱스로 변환 가능 ## 관련 이슈 @@ -104,6 +113,6 @@ pub fn utf16_pos_to_char_idx(char_offsets: &[u32], utf16_pos: u32) -> usize { .. - `src/document_core/helpers.rs:189-192` (현재 구현, `pub(crate)`) - `src/document_core/mod.rs:6` (`pub(crate) mod helpers;`) -- `src/model/paragraph.rs:23-24` (`Paragraph.char_offsets` doc-comment, UTF-16 단위 명시) +- `src/model/paragraph.rs:22-24` (`Paragraph.char_offsets` doc-comment, UTF-16 단위 명시) - `src/model/paragraph.rs:121-126` (`CharShapeRef.start_pos`, UTF-16 단위) - `src/model/paragraph.rs:730` (옵션 A 시 메서드 추가 위치 — `control_text_positions` 인근) From a3b23fbee40815ebe073a3a18afec1ec477a980e Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 30 Apr 2026 14:17:57 +0900 Subject: [PATCH 05/11] =?UTF-8?q?docs:=20CONVENTIONS=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=A0=95=EB=A6=AC=20+=20v0.7.0=20AC-N=20retrofit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 폐기된 phase-N.md 잔존 인용 정리 — AGENTS.md / scripts/_doc_lint.py:285 / python/rhwp/cli/ir.py docstring / v0.7.0/mcp.md:21 (Frozen historical 6 곳은 immutability 정책에 따라 보존) - CONVENTIONS Living 예시 표에 docs/traces/coverage.md 추가 - CONVENTIONS frontmatter ga 행에 meta-level / RESOLVED upstream 면제 케이스 명시 - CONVENTIONS § last_updated 자동 갱신 의 "CI lint 가 git history 일치 여부 검증" 미구현 문장 삭제 - 인수조건 형식 cutoff 를 "v0.4.0+ 신규 spec" → "spec-system-overhaul (2026-04-29) 이후 신규 작성 spec" 시점 기준으로 명문화 (CONVENTIONS / traces/coverage.md 동기) - v0.7.0/mcp.md (발효일 이전 작성 grandfather Draft) 에 AC-1 ~ AC-11 일괄 retrofit - _doc_lint.py:228 의 "Grandfather" 주석을 "Pair-exempt" 로 변경 (CONVENTIONS 의 grandfather 와 의미 충돌 방지) Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 3 +-- docs/CONVENTIONS.md | 18 ++++++++++-------- docs/roadmap/v0.7.0/mcp.md | 16 +++++++++++++++- docs/traces/coverage.md | 2 +- python/rhwp/cli/ir.py | 7 +++---- scripts/_doc_lint.py | 7 ++----- 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3771ac..3680da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,8 +57,7 @@ Authoritative policy is `docs/CONVENTIONS.md` — read it before any docs work. Hard rules (auto-applied without further instruction): - Every per-version spec / ADR / impl-log / verification report carries a YAML frontmatter block as the first lines: `status: ` + `ga: vX.Y.Z` *or* `target: vX.Y.Z` + `last_updated: YYYY-MM-DD`. Living docs (README, CHANGELOG, AGENTS.md, CLAUDE.md, CONVENTIONS itself) skip the frontmatter. - **Frozen spec body is immutable** — typo / broken-link fixes only. Decision changes go to a *new* spec; the old one's frontmatter flips to `status: Superseded`, `superseded_by: ` (single-block edit). Exception: Living-policy schema migration (see CONVENTIONS § Frozen 면제 조항). -- **Spec ↔ spec direct cross-links are forbidden** even within the same `vX.Y.Z/` directory. Use `phase-N.md` § two-axis-integration sections (or `roadmap/README.md`) as the bridge. **Exception**: pair files `.md` ↔ `-research.md` (the spec ↔ ADR pair) link directly. -- **`phase-N.md` carries no concrete decisions / open issues** — those belong in `vX.Y.Z/*.md`. Phase docs hold intent, scope, and two-axis integration only. +- **Spec ↔ spec direct cross-links are forbidden** even within the same `vX.Y.Z/` directory. Use `roadmap/README.md` as the bridge. **Exception**: pair files `.md` ↔ `-research.md` (the spec ↔ ADR pair) link directly. - New version `vX.Y.Z`: invoke `/new-spec ` Claude Code skill (auto-scaffolds spec + paired ADR + README index row). Manual: create `docs/roadmap/vX.Y.Z/.md` + `docs/design/vX.Y.Z/-research.md` (frontmatter `status: Draft`, `target: vX.Y.Z`), then add a row to the active-spec index in `roadmap/README.md`. On GA: flip `status: Draft → Frozen`, swap `target` → `ga`, write `implementation/vX.Y.Z/...` (Frozen on creation), refresh README index. ### CI / secrets diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 6639a3a..127ffff 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -6,7 +6,7 @@ | 분류 | 의미 | 갱신 정책 | 예시 | |---|---|---|---| -| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `docs/upstream/README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `README.md` | +| **Living** | 항상 최신 — 다른 문서의 위치 포인터 + 시간선 + 규칙 | 자유 갱신, 매 변경 시 손봐도 무방 | `docs/CONVENTIONS.md` (자체), `docs/roadmap/README.md`, `docs/upstream/README.md`, `docs/traces/coverage.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `README.md` | | **Active** | 외부 시스템으로 흘러가기 전 staging | 큰 변경만, in-place 갱신 OK | `docs/upstream/.md` | | **Draft** | 작성 중인 spec — 해당 버전 GA 전까지 활발 갱신 | 버전 GA 전까지 자유 갱신, GA 후 Frozen 으로 전환 | `docs/roadmap/v0.7.0/mcp.md` (현재 v0.7.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` | @@ -52,7 +52,7 @@ last_updated: 2026-04-28 |---|---|---| | `status` | enum: `Active` / `Draft` / `Frozen` / `Superseded` | 필수 | | `description` | non-empty string (50-150 자 권장) | 필수. 한 줄 요약 — 인덱스/검색/툴팁용 (MkDocs / Hugo / Astro 패턴) | -| `ga` | `vX.Y.Z` SemVer | `status: Frozen` 또는 `Superseded` 일 때 필수. `target` 과 mutex | +| `ga` | `vX.Y.Z` SemVer | `status: Frozen` 또는 `Superseded` 일 때 필수 (예외: meta-level `docs/implementation/.md`, RESOLVED `docs/upstream/.md` — § Implementation log 구조 / § upstream/ 참조). `target` 과 mutex | | `target` | `vX.Y.Z` SemVer | `status: Draft` 일 때 필수. `ga` 와 mutex | | `supersedes` | `/.md` 또는 생략 | 새 spec 이 무엇을 대체하는지 | | `superseded_by` | `/.md` | `status: Superseded` 일 때 필수 | @@ -132,7 +132,7 @@ last_updated: 2026-04-25 ### last_updated 자동 갱신 -Claude Code PostToolUse hook (`.claude/hooks/update-last-updated.py`) 이 docs/*.md 의 frontmatter `last_updated` 를 편집 시점 오늘 날짜로 자동 갱신. CI lint 가 frontmatter `last_updated` 와 git history 일치 여부 검증. **수기 갱신 절차는 폐기** — frontmatter 에 직접 손대지 않는다. +Claude Code PostToolUse hook (`.claude/hooks/update-last-updated.py`) 이 docs/*.md 의 frontmatter `last_updated` 를 편집 시점 오늘 날짜로 자동 갱신. **수기 갱신 절차는 폐기** — frontmatter 에 직접 손대지 않는다. 면제 조항 활용 마이그 commit 은 hook 실행을 건너뛴다 (non-semantic — 본문 의미 변경 없음). 이 경우 `last_updated` 는 기존 값 그대로 frontmatter 로 이전. @@ -258,9 +258,11 @@ CHANGELOG 한 줄로 충분한 변경 (typo 정리, 단순 dep bump, 작은 docs 같은 사실 중복 기록 금지 — CHANGELOG 가 *what*, log 가 *why/how*. 결정 비교 (a/b/c) 가치가 없는 변경 (단순 dep bump, typo) 은 CHANGELOG 한 줄로 충분 — implementation log 작성 안 함. -## 인수조건 형식 (v0.4.0+ 신규 spec) +## 인수조건 형식 (spec-system-overhaul 이후 신규 spec) -v0.4.0+ 신규 spec 의 § 인수조건 섹션은 각 항목에 `AC-N` ID 를 부여한다 (테스트 marker 와 1:1 매핑용). 형식은 자유 — testable 하고 명확하면 plain prose 도 OK. 모호성이 우려되면 [EARS notation](https://alistairmavin.com/ears/) (`THE ... SHALL`, `WHEN ..., THE ... SHALL` 등) 같은 구조화 패턴을 참고 가능 (강제 아님). +spec-system-overhaul (2026-04-29) 이후 신규 작성 spec 의 § 인수조건 섹션은 각 항목에 `AC-N` ID 를 부여한다 (테스트 marker 와 1:1 매핑용). 형식은 자유 — testable 하고 명확하면 plain prose 도 OK. 모호성이 우려되면 [EARS notation](https://alistairmavin.com/ears/) (`THE ... SHALL`, `WHEN ..., THE ... SHALL` 등) 같은 구조화 패턴을 참고 가능 (강제 아님). + +**적용 시점**: overhaul 발효일 (2026-04-29) 이후 신규 작성 spec. 첫 적용 사례는 [v0.3.1/ir-marker-char-offset](roadmap/v0.3.1/ir-marker-char-offset.md) (PATCH minor 라도 발효 이후 신규면 적용 — cutoff 는 버전이 아닌 *시점*). 발효일 *이전* 작성된 Draft 도 본 PR 에서 일괄 retrofit ([v0.7.0/mcp.md](roadmap/v0.7.0/mcp.md)). 향후 grandfather 가 발생하면 다음 의미 변경 PR 시점에 함께 retrofit. ```markdown ## 인수조건 @@ -271,14 +273,14 @@ v0.4.0+ 신규 spec 의 § 인수조건 섹션은 각 항목에 `AC-N` ID 를 - **AC-4** — 입력 파일 없으면 exit 2 + stderr 에 에러 ``` -기존 v0.1.0 ~ v0.3.0 Frozen spec 은 본 형식 미적용 — historical record 보존. 단 트레이스 매핑은 spec 단위 (AC-N 생략) 로 retrofit 적용 (Trace report 섹션 참조). +기존 v0.1.0 ~ v0.3.0 Frozen spec 은 본 형식 미적용 — historical record 보존 (Frozen 본문 변경 금지 정책). 단 트레이스 매핑은 spec 단위 (AC-N 생략) 로 retrofit 적용 (Trace report 섹션 참조). ## Trace report — pytest spec markers 테스트는 `pytest.mark.spec(spec_id)` marker 로 spec 과 매핑. `spec_id` 형식: -- **v0.4.0+ spec**: `"vX.Y.Z/topic#AC-N"` — AC 단위 매핑 (full) -- **v0.1.0 ~ v0.3.0 spec**: `"vX.Y.Z/topic"` — spec 단위 매핑 (soft, AC 생략) +- **AC 부여 spec** (overhaul 발효일 2026-04-29 이후 신규 작성 + retrofit Draft): `"vX.Y.Z/topic#AC-N"` — AC 단위 매핑 (full) +- **AC 미부여 spec** (v0.1.0 ~ v0.3.0 Frozen — 본문 변경 금지): `"vX.Y.Z/topic"` — spec 단위 매핑 (soft, AC 생략) 파일 단위 적용 (모든 테스트가 같은 spec 검증): module top 에 `pytestmark = pytest.mark.spec("vX.Y.Z/topic")` 한 줄. 개별 테스트가 추가 spec 검증 시 `@pytest.mark.spec(...)` 데코레이터 추가 (양쪽 누적). diff --git a/docs/roadmap/v0.7.0/mcp.md b/docs/roadmap/v0.7.0/mcp.md index 6cf51f4..5618b6c 100644 --- a/docs/roadmap/v0.7.0/mcp.md +++ b/docs/roadmap/v0.7.0/mcp.md @@ -18,7 +18,7 @@ MCP 는 RAG 프레임워크 (LangChain / LlamaIndex / Haystack) 가 아니라 ** v0.7.0 시점이 sweet spot 인 이유: - **노출할 도구가 풍부**: parse + IR (v0.2~0.3 GA) + view (v0.4 GA) + LangChain chunks (v0.3 GA) + LlamaIndex node (v0.5 GA) + Haystack converter (v0.6 GA) — Phase 3 통합 wave 종료 후라 MCP tool surface 가 유의미한 기능을 모두 묶어낼 수 있음 -- **외부 의존성 0**: Phase 4 baseline (v0.7.0 약속) 은 rhwp Rust 코어의 writer API 안정에 좌우되어 일정 유동적 — phase-4.md 자체가 "Phase 4 시작 전 업스트림 상태 재평가" 명시. MCP 는 readonly 라 외부 의존 없어 v0.7.0 슬롯의 안정 채움 역할 +- **외부 의존성 0**: HWP writer API 안정에 좌우되는 작업 (IR → HWP 역생성, [roadmap/README.md § v0.8.0 ~ v1.0.0](../README.md)) 은 rhwp Rust 코어 일정에 좌우되어 유동적 — "시작 전 업스트림 상태 재평가" 명시. MCP 는 readonly 라 외부 의존 없어 v0.7.0 슬롯의 안정 채움 역할 - **통합 패턴 정착**: LangChain (v0.3) → LlamaIndex (v0.5) → Haystack (v0.6) 세 통합으로 `python/rhwp/integrations/.py` + 옵셔널 extras 패턴이 완전 정립된 이후 — MCP 도 동일 패턴 답습 ## 목표와 비목표 @@ -196,6 +196,20 @@ python/rhwp/mcp/ | 6 | extras 명명 | `[mcp]` / `[mcp-chunks]` | CLI extras (`[cli]` / `[cli-chunks]`) 와 일관 패턴 | | 7 | 모듈 위치 | `python/rhwp/mcp/` (top-level) | entry point + lifecycle 보유 — `integrations/` (passive) 와 성격 다름. CLI 와 같은 위계 | +## 인수조건 + +- **AC-1** — `mcp` extras 미설치 시 `rhwp-mcp` 호출이 친절 에러 + exit 2 (CLI extras gate 패턴 동일, 결정 6) +- **AC-2** — `rhwp-mcp` stdio 기동 후 MCP `tools/list` 응답이 7 개 도구 (`parse_hwp_summary` / `extract_text` / `get_ir` / `iter_blocks` / `to_markdown` / `to_html` / `chunks`) 정확히 노출 (§ 노출 도구, 결정 4) +- **AC-3** — `iter_blocks(kind="invalid_value")` 호출 시 Pydantic validation error → MCP `CallToolResult.isError=True` 응답 (panic 아님, § 단위 테스트) +- **AC-4** — `extract_text("nonexistent.hwp")` 호출 시 `FileNotFoundError` → MCP `isError=True` 응답 (§ 단위 테스트) +- **AC-5** — 모든 7 도구 handler 가 sync 함수 (`async def` 아님) — handler 안에서 `rhwp.parse(path)` → 소비 → primitive 반환 (Document 가 thread 경계 미보유). `asyncio.to_thread(rhwp.parse, ...)` 패턴 코드 내 부재 (결정 3, § `unsendable` 안전 패턴) +- **AC-6** — `to_markdown(path)` / `to_html(path, include_css=False)` 도구가 v0.4.0 view API (`HwpDocument.to_markdown()` / `HwpDocument.to_html()`) 위 thin wrapper 로 동작 (S2) +- **AC-7** — `chunks` 도구 호출 시 `langchain-text-splitters` 미설치면 MCP `isError=True` 응답 — 서버 기동은 정상 + 다른 6 도구는 사용 가능 (런타임 extras gate, S3) +- **AC-8** — `rhwp-mcp --transport streamable-http --port N` 옵션이 uvicorn ASGI 로 기동, MCP `initialize` + `tools/list` round-trip 정상 (결정 2, S4 — endpoint path 는 SDK 기본값 추종) +- **AC-9** — `pyproject.toml` 에 `[project.optional-dependencies]` `mcp = ["mcp>=1.12"]` + `mcp-chunks` extras 등록 + `[project.scripts]` `rhwp-mcp = "rhwp.mcp:run"` entry point 등록 (§ 의존성 / 배포) +- **AC-10** — `python/rhwp/mcp/` 모듈 위치 (top-level, `integrations/` 가 아님 — 결정 7). `__init__.py` 는 빈 파일 또는 docstring only (CLAUDE.md 규칙) +- **AC-11** — CI `test-without-extras` job 의 expected skip count 가 4 → 5 로 증가 (`tests/test_mcp_server.py` 가 module-level `pytest.importorskip("mcp")` 로 1 skip 기여). `.github/workflows/ci.yml` + `AGENTS.md` § Tests 동시 갱신 (§ 다른 산출물의 파급) + ## 미확정 이슈 - **`get_ir` 의 출력 크기** — 큰 문서는 IR JSON 이 수 MB. MCP `tools/call` 응답 한도 (클라이언트 별 상이) 와 충돌 가능. **검토**: `--max-bytes` 파라미터 추가 vs `Resource` 추상으로 재노출 (`hwp://path/ir`) diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index a9ab5bb..3c0ffdb 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -2,7 +2,7 @@ 자동 생성 — `scripts/generate_spec_trace.py`. Living. -v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 (CONVENTIONS § Trace report). +spec-system-overhaul (2026-04-29) 이후 신규 작성 spec + retrofit 적용된 Draft 의 인수조건 ↔ 테스트 매핑. v0.1.0 ~ v0.3.0 Frozen spec 은 본문 변경 금지로 AC ID 부여 안 함 (CONVENTIONS § Trace report). | Spec | AC | Tests | |---|---|---| diff --git a/python/rhwp/cli/ir.py b/python/rhwp/cli/ir.py index ecb257d..bca3689 100644 --- a/python/rhwp/cli/ir.py +++ b/python/rhwp/cli/ir.py @@ -1,9 +1,8 @@ """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 친화). +cli.md §S2 (ir / blocks) — IR 확장 8 신규 kind 를 ``--kind`` enum 에 +노출하여 IR 확장 GA (S4) 와 동기. 출력 포맷은 cli.md §기본 출력 포맷 +채택: ``ir`` 은 단일 JSON, ``blocks`` 는 NDJSON 기본 (jq streaming 친화). """ import json diff --git a/scripts/_doc_lint.py b/scripts/_doc_lint.py index 9290ece..8b3eacb 100644 --- a/scripts/_doc_lint.py +++ b/scripts/_doc_lint.py @@ -225,7 +225,7 @@ def validate_filename(rel_str: str) -> list[str]: # * Rule 5: .md ↔ -research.md pair existence -# ^ Grandfather: v0.1.0 (spinoff transfer, design research 미진행 — 역사적 예외). +# ^ Pair-exempt: v0.1.0 (spinoff transfer, design research 미진행 — 역사적 예외). PAIR_EXEMPT_VERSIONS = {"v0.1.0"} @@ -282,10 +282,7 @@ def validate_cross_link(rel_str: str, text: str) -> list[str]: 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" - ) + errors.append(f"same-version spec direct link {link!r} — route through roadmap/README.md") return errors From 9d8e6b3277123aab6e4b527bacefee1ef082e42d Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 30 Apr 2026 16:35:31 +0900 Subject: [PATCH 06/11] =?UTF-8?q?docs:=20utf16-pos-to-char-idx=20=EC=97=85?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EB=93=B1=EB=A1=9D=20#484=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/upstream/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upstream/README.md b/docs/upstream/README.md index f638acc..8d423fc 100644 --- a/docs/upstream/README.md +++ b/docs/upstream/README.md @@ -9,7 +9,7 @@ | 이슈 | Status | 상류 등록 | RESOLVED | 비고 | |---|---|---|---|---| | [issue-find-control-text-positions.md](issue-find-control-text-positions.md) | Frozen | [edwardkim/rhwp#390](https://github.com/edwardkim/rhwp/issues/390) | 2026-04-28 ([PR #405](https://github.com/edwardkim/rhwp/pull/405)) | `Paragraph::control_text_positions(&self)` 옵션 A 채택. v0.3.1 spec 이 본 파일 참조 → 삭제 대신 in-place Frozen | -| [issue-utf16-pos-to-char-idx.md](issue-utf16-pos-to-char-idx.md) | Active | (미등록) | — | #390 후속 같은 결. `helpers::utf16_pos_to_char_idx` 외부 노출 | +| [issue-utf16-pos-to-char-idx.md](issue-utf16-pos-to-char-idx.md) | Active | [edwardkim/rhwp#484](https://github.com/edwardkim/rhwp/issues/484) | — | #390 후속 같은 결. `helpers::utf16_pos_to_char_idx` 외부 노출 | ## Archive 정책 From f8b3ab5343b9946cb3cefaeaa82a54093b964bfd Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 3 May 2026 11:03:20 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20inline=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=20=EB=A7=88=EC=BB=A4=20char=5Fstart/char=5Fend=20null?= =?UTF-8?q?=20=EC=B6=9C=EA=B3=A0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 7종 블록 (각주/미주 marker, Picture/Formula/Field/TOC/Table) 의 prov.char_start/char_end 를 zero-width point (char_start == char_end == position) 로 채움 (이전 항상 None) - 상류 Paragraph::control_text_positions() (v0.7.8 PR #405) paragraph 당 1회 호출 + controls/positions 길이 invariant 를 release/debug 모두 assert_eq! 로 가드 - 부모 paragraph 의 char_offsets 빈 케이스 → None 폴백 (fail-safe) - external/rhwp submodule pin 033617e (v0.7.7) → 0fb3e67 (post-v0.7.8) - AC-1 ~ AC-14 회귀 가드 신규 (tests/test_v0_3_1_marker_char_offset.py) + 기존 IR 테스트 7종에 spec marker 부착 - Cargo.toml 0.3.0 → 0.3.1 (Schema 1.1 유지, 호환 100%) - CHANGELOG / spec frontmatter / docs/traces/coverage / CI gated test list 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 21 +- Cargo.toml | 2 +- docs/roadmap/v0.3.1/ir-marker-char-offset.md | 7 +- docs/traces/coverage.md | 46 +- python/rhwp/ir/_mapper.py | 46 +- python/rhwp/ir/_raw_types.py | 30 +- src/ir.rs | 198 +++++++- tests/test_ir_caption.py | 4 + tests/test_ir_field.py | 2 + tests/test_ir_footnote.py | 7 + tests/test_ir_formula.py | 6 + tests/test_ir_furniture.py | 3 +- tests/test_ir_mapper.py | 11 +- tests/test_ir_picture.py | 2 + tests/test_ir_schema_export.py | 8 +- tests/test_ir_toc.py | 4 +- tests/test_v0_3_1_marker_char_offset.py | 480 +++++++++++++++++++ 18 files changed, 822 insertions(+), 56 deletions(-) create mode 100644 tests/test_v0_3_1_marker_char_offset.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d8b84..37de190 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,7 @@ jobs: 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_v0_3_1_marker_char_offset.py \ tests/test_cli.py \ tests/conftest.py tests/type_check_samples.py - name: Run pyright (intentional errors — expect 4) diff --git a/CHANGELOG.md b/CHANGELOG.md index a763be3..67efbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 부수 정리: 상류 `edwardkim/rhwp#390` (find_control_text_positions) 옵션 A 채택 → cherry-pick 머지 → 본 spec in-place Frozen 전환 + RESOLVED notice. design 파일 `-design-research.md` → `-research.md` 명명 통일 (v0.2.0/ir, v0.3.0/cli rename + 24 cross-link 일괄 정정). - 본 PR 의 a/b/c 결정 비교 + 14개 결정 historical record 는 [docs/implementation/spec-system-overhaul.md](docs/implementation/spec-system-overhaul.md) (Frozen) 가 보유. +## [0.3.1] — 2026-05-02 + +PATCH release. v0.3.0 의 IR 출고에서 inline 컨트롤 마커의 `Provenance.char_start` / `char_end` 가 항상 null 이던 문제를 정정. 상류 v0.7.8 의 `Paragraph::control_text_positions()` ([PR #405](https://github.com/edwardkim/rhwp/pull/405) / [Task #390](https://github.com/edwardkim/rhwp/issues/390)) 노출을 활용해 7 종 블록 (각주·미주 마커, 그림, 수식, 필드, TOC, 표) 의 zero-width character 위치를 채운다. SchemaVersion 변경 없음 (`"1.1"` 유지) — 기존에 nullable 슬롯에 정의된 `int | None` 에 non-null 값을 출고할 뿐, schema 호환 100%. + +### Fixed + +- inline 컨트롤 마커 (각주/미주/그림/수식/필드/TOC/표) 의 `Provenance.char_start` / `char_end` 가 v0.3.0 까지 항상 null 이던 문제 정정. 부모 paragraph 안 zero-width character 위치 (`char_start == char_end == position`) 로 채운다. +- 상류 `Paragraph::control_text_positions()` (v0.7.8 GA, PR #405 / Task #390) 의 결과를 paragraph 당 1회 호출로 공유하여 `controls.len() == positions.len()` 길이 invariant 를 release/debug 모두에서 `assert_eq!` 로 가드 — 상류 contract 위반의 silent regression 차단. +- 부모 paragraph 의 `char_offsets` 가 빈 경우 (보통 layout-only 컨트롤만 있는 paragraph) `None` 폴백 — 상류 fallback 분기의 의미 손실 position 을 그대로 흘리지 않음 (fail-fast). + +### Build + +- `external/rhwp` submodule pin `033617e` (v0.7.7) → `0fb3e67` (post-v0.7.8). 본 v0.3.1 의 enabling change 는 v0.7.8 의 PR #405 (`pub fn Paragraph::control_text_positions`). 후속 commit 들은 직교 영역 (Task #484 `utf16_pos_to_char_idx` 등) 으로 본 PATCH 동작에 영향 없음. + +### Known limitations + +- 중첩 표 안 inline 컨트롤의 `Provenance.char_start/end` 와 `(section_idx, para_idx)` 가 다른 paragraph 를 가리켜 `text[char_start:char_end]` 슬라이싱이 잘못된 결과를 낸다. v0.3.0 부터 있던 Provenance 모델 한계 — v0.3.1 은 새 마커 채움이 동일 모델을 재사용. v0.4.0+ Provenance 정정 spec 에서 다룬다 (spec § 영구 비목표 마지막 항목). + ## [0.3.0] — 2026-04-28 ### Changed — async API 의존성 정리 @@ -225,7 +243,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.3.0...HEAD +[Unreleased]: https://github.com/DanMeon/rhwp-python/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/DanMeon/rhwp-python/releases/tag/v0.3.1 [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 diff --git a/Cargo.toml b/Cargo.toml index 5127917..f9cd6ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rhwp-python" -version = "0.3.0" +version = "0.3.1" edition = "2021" # ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수. # PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내 diff --git a/docs/roadmap/v0.3.1/ir-marker-char-offset.md b/docs/roadmap/v0.3.1/ir-marker-char-offset.md index afb91f8..e96dde2 100644 --- a/docs/roadmap/v0.3.1/ir-marker-char-offset.md +++ b/docs/roadmap/v0.3.1/ir-marker-char-offset.md @@ -2,7 +2,7 @@ status: Draft description: "v0.3.1 — inline 컨트롤 마커 character offset 출고. 상류 v0.7.8 'Paragraph::control_text_positions()' 활용 (schema 변경 없음)" target: v0.3.1 -last_updated: 2026-04-30 +last_updated: 2026-05-02 --- # v0.3.1 — inline 컨트롤 마커의 character offset 출고 @@ -25,7 +25,7 @@ v0.7.8 에서 상류가 [PR #405 (Task #390)](https://github.com/edwardkim/rhwp/ | 4 — Schema 버전 | `SchemaVersion 1.1` 유지 (bump 없음) | `Provenance.char_start: int \| None` / `char_end: int \| None` 은 1.1 에 이미 정의. 슬롯 채움이지 새 필드 추가가 아님. forward-compat 100% | | 5 — 적용 대상 (Python 블록) | `FootnoteBlock.marker_prov` / `EndnoteBlock.marker_prov` / `PictureBlock.prov` (TAC + floating 양쪽) / `FormulaBlock.prov` / `FieldBlock.prov` / `TocBlock.prov` / `TableBlock.prov` 7 종 | RawParagraph.controls 로 흘러나오는 모든 inline 컨트롤. 상류 `control_text_positions()` 의 fallback 분기에서도 Shape/Table/Picture/Equation 이 동등 취급 — TableBlock 포함이 일관. Picture 의 `treat_as_char` 상태와 무관하게 부모 paragraph 안의 anchor 위치를 가지므로 양쪽 모두 적용. Header/Footer 는 furniture 라우팅 후 별도 paragraph 가 되므로 제외 | | 6 — Rust raw 필드 | `Option` 단일 필드 (`marker_char_offset` for Footnote/Endnote, `char_offset` for Picture/Formula/Field/Toc/Table) | Python 측은 `char_start / char_end` 두 슬롯이지만 zero-width 결정 (항목 2) 으로 raw 는 한 값만 운반. mapper 가 양쪽 슬롯에 동일 값 복제 | -| 7 — 상류 핀 bump | `033617e` (v0.7.7) → `42cf91b` (v0.7.8) — commit `cee3c1e chore: sync rhwp upstream` 에서 spec 작성 이전에 완료 | 상류 `pub fn control_text_positions` 가 v0.7.8 에 GA. 그 이전 핀에서는 컴파일 자체가 불가 — pin bump 가 본 spec 의 enabling change. 본 v0.3.1 작업으로 남은 일은 CHANGELOG 기재 의무 (AC-10) | +| 7 — 상류 핀 bump | `033617e` (v0.7.7) → `0fb3e67` (post-v0.7.8) — enabling commit `cee3c1e chore: sync rhwp upstream` (v0.7.8 / `42cf91b`) 후 `8482555 chore: sync upstream rhwp` 로 추가 sync (직교 영역 변경, 본 spec 동작에 영향 없음) | 상류 `pub fn control_text_positions` 가 v0.7.8 에 GA. 그 이전 핀에서는 컴파일 자체가 불가 — v0.7.8 commit `cee3c1e` 가 본 spec 의 enabling change. 본 v0.3.1 작업으로 남은 일은 CHANGELOG 기재 의무 (AC-13) | | 8 — controls / positions 길이 동기화 | `assert_eq!(controls.len(), positions.len())` 후 `zip` (fail-fast) | 상류 `control_text_positions()` 는 항상 `controls.len()` 개 position 반환 (paragraph.rs:734 / 765 / 786 / 796 의 모든 분기 가드). 길이 불일치 시 상류 contract 위반 — 정상 빌드에서도 panic 으로 즉시 드러내야 silent 잘못된 offset 출고 회피. release 에서 무력화되는 `debug_assert!` 는 본 정책에 부적합 | | 9 — `control_text_positions()` 호출 분배 | paragraph 당 1회 호출, 결과 `Vec` 를 `build_raw_paragraph` 와 `collect_furniture_from_paragraph` 양쪽에서 공유 | 두 함수가 각각 독립적으로 `para.controls` 를 iterate (body controls 추출 / furniture 라우팅). control index 가 둘이 공유하는 단일 축이라 positions 배열도 공유 가능. paragraph 당 중복 호출은 동일 결과의 재계산 — 낭비 | @@ -43,7 +43,7 @@ v0.7.8 에서 상류가 [PR #405 (Task #390)](https://github.com/edwardkim/rhwp/ - **AC-10** — `python/rhwp/ir/schema/hwp_ir_v1.json` (또는 content-addressed alias `hwp_ir_v1-sha256-.json`) 으로 jsonschema validator 가 v0.3.1 IR JSON 을 검증할 때 통과한다 — `Provenance.char_start/char_end` 의 `anyOf [integer, null]` 정의와 호환됨을 실제 validator 호출로 확인 - **AC-11** — non-None 출고된 모든 marker 의 `prov.char_start == prov.char_end` 이며 `isinstance(prov.char_start, int)` — zero-width point 결정 (항목 2) 의 invariant. mapper 의 양쪽 슬롯 동일 값 복제 (항목 6) 가 비대칭으로 깨지지 않음을 보증 - **AC-12** — 상류 contract (`controls.len() == control_text_positions().len()`) 위반 시 Rust 빌드가 release / debug 무관 panic 한다 (항목 8 의 `assert_eq!` invariant) — 상류 silent regression 차단 가드 -- **AC-13** — `external/rhwp` submodule pin 이 `42cf91b` (v0.7.8) 이며 [CHANGELOG.md](../../../CHANGELOG.md) 에 v0.7.7 → v0.7.8 핀 bump 가 명시된다 (pin bump 자체는 commit `cee3c1e` 에서 spec 작성 이전 완료, 본 AC 는 CHANGELOG 기재 의무) +- **AC-13** — `external/rhwp` submodule pin 이 `0fb3e67` (post-v0.7.8) 이며 [CHANGELOG.md](../../../CHANGELOG.md) 에 v0.7.7 → 본 핀 bump 가 명시된다 (enabling commit 은 v0.7.8 의 `cee3c1e`, 후속 sync `8482555` 가 직교 영역 변경 — 본 AC 는 CHANGELOG 기재 의무) - **AC-14** — AC-1 ~ AC-8 검증용 fixture 는 우선 기존 `external/rhwp/samples/aift.hwp` / `table-vpos-01.hwpx` 로 시도하고, 부족 (특히 AC-8 의 빈 `char_offsets` paragraph + 인라인 컨트롤 조합) 시 minimal 합성 fixture 를 `tests/fixtures/v0_3_1/` 에 추가 ## 영구 비목표 @@ -56,6 +56,7 @@ v0.7.8 에서 상류가 [PR #405 (Task #390)](https://github.com/edwardkim/rhwp/ - **char_end 의 1-width 의미 (`char_end = char_start + 1`)** — 마커가 char 한 칸을 차지한다고 보는 해석. 결정 항목 2 에서 zero-width 채택했으므로 본 spec 의 영구 비목표 - **`char_end` 슬롯 자체의 schema 제거 + `(char_start, width=0)` 모델로 전환** — 의미축은 가장 깨끗하지만 schema bump 필요 (AC-9 위반). schema 안정성 우선이라 본 spec 영구 비목표 (ADR §2 "옵션 D 비고" 참조) - **char_offset UTF-16 → codepoint 재변환** — 상류 API 가 이미 codepoint 단위 character index 를 반환하므로 별도 변환 없음. UTF-16 노출은 영구 안 함 +- **중첩 표 안 inline 컨트롤의 좌표 일관성** — `TableCell.paragraphs` 안에 들어있는 inline 컨트롤은 `(section_idx, para_idx)` 가 외부 표의 부모 paragraph 를 가리키지만 (Provenance 계약), `char_offset` 은 셀 *내부* paragraph 안 위치를 가리킨다. 두 인덱스의 의미축이 다르므로 `text[char_start:char_end]` 같은 외부 paragraph 기준 슬라이싱이 잘못된 결과를 낸다. v0.3.0 부터 존재하던 Provenance 모델의 한계 — 본 v0.3.1 은 새 컨트롤 종류 (마커) 에 *propagate* 만 하며 모델 자체의 수정은 별도 spec 사안 (v0.4.0+ Provenance 정정 검토) ## 참조 diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index 3c0ffdb..03200f4 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -2,7 +2,7 @@ 자동 생성 — `scripts/generate_spec_trace.py`. Living. -spec-system-overhaul (2026-04-29) 이후 신규 작성 spec + retrofit 적용된 Draft 의 인수조건 ↔ 테스트 매핑. v0.1.0 ~ v0.3.0 Frozen spec 은 본문 변경 금지로 AC ID 부여 안 함 (CONVENTIONS § Trace report). +v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3.0 Frozen spec 은 AC ID 부여 안 함 (CONVENTIONS § Trace report). | Spec | AC | Tests | |---|---|---| @@ -390,3 +390,47 @@ spec-system-overhaul (2026-04-29) 이후 신규 작성 spec + retrofit 적용된 | v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_full_fields` | | v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_is_not_in_block_union` | | v0.3.0/ir-expansion | — | `tests/test_ir_toc.py::test_toc_entry_block_minimal_roundtrip` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_changelog_records_pin_bump` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_char_offsets_empty_falls_back_to_none` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_endnote_marker_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_field_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_footnote_marker_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_formula_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_no_synthetic_fixture_directory` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_picture_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_field_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_footnote_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_has_none_fallback_paragraphs` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_json_roundtrip` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_passes_jsonschema_validation` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_picture_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_table_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_zero_width_invariant` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_rust_uses_assert_eq_not_debug_assert` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_schema_provenance_char_start_anyof_integer_or_null` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_schema_version_remains_1_1` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_table_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_toc_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_zero_width_invariant_holds_across_distinct_offsets` | +| v0.3.1/ir-marker-char-offset | AC-1 | `tests/test_v0_3_1_marker_char_offset.py::test_footnote_marker_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-1 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_footnote_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | AC-10 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_json_roundtrip` | +| v0.3.1/ir-marker-char-offset | AC-10 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_passes_jsonschema_validation` | +| v0.3.1/ir-marker-char-offset | AC-11 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_zero_width_invariant` | +| v0.3.1/ir-marker-char-offset | AC-11 | `tests/test_v0_3_1_marker_char_offset.py::test_zero_width_invariant_holds_across_distinct_offsets` | +| v0.3.1/ir-marker-char-offset | AC-12 | `tests/test_v0_3_1_marker_char_offset.py::test_rust_uses_assert_eq_not_debug_assert` | +| v0.3.1/ir-marker-char-offset | AC-13 | `tests/test_v0_3_1_marker_char_offset.py::test_changelog_records_pin_bump` | +| v0.3.1/ir-marker-char-offset | AC-14 | `tests/test_v0_3_1_marker_char_offset.py::test_no_synthetic_fixture_directory` | +| v0.3.1/ir-marker-char-offset | AC-2 | `tests/test_v0_3_1_marker_char_offset.py::test_endnote_marker_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-3 | `tests/test_v0_3_1_marker_char_offset.py::test_picture_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-3 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_picture_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | AC-4 | `tests/test_v0_3_1_marker_char_offset.py::test_formula_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-5 | `tests/test_v0_3_1_marker_char_offset.py::test_field_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-5 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_field_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | AC-6 | `tests/test_v0_3_1_marker_char_offset.py::test_toc_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-7 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_table_marker_populates_or_falls_back` | +| v0.3.1/ir-marker-char-offset | AC-7 | `tests/test_v0_3_1_marker_char_offset.py::test_table_char_offset_populated_zero_width` | +| v0.3.1/ir-marker-char-offset | AC-8 | `tests/test_v0_3_1_marker_char_offset.py::test_char_offsets_empty_falls_back_to_none` | +| v0.3.1/ir-marker-char-offset | AC-8 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_has_none_fallback_paragraphs` | +| v0.3.1/ir-marker-char-offset | AC-9 | `tests/test_v0_3_1_marker_char_offset.py::test_schema_provenance_char_start_anyof_integer_or_null` | +| v0.3.1/ir-marker-char-offset | AC-9 | `tests/test_v0_3_1_marker_char_offset.py::test_schema_version_remains_1_1` | diff --git a/python/rhwp/ir/_mapper.py b/python/rhwp/ir/_mapper.py index 64cb74a..53f8cce 100644 --- a/python/rhwp/ir/_mapper.py +++ b/python/rhwp/ir/_mapper.py @@ -252,6 +252,7 @@ def _build_table_block(raw_para: RawParagraph, raw_table: RawTable) -> TableBloc 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 + char_offset = raw_table["char_offset"] return TableBlock( rows=raw_table["rows"], cols=cols, @@ -263,8 +264,8 @@ def _build_table_block(raw_para: RawParagraph, raw_table: RawTable) -> TableBloc prov=Provenance( section_idx=raw_para["section_idx"], para_idx=raw_para["para_idx"], - char_start=None, - char_end=None, + char_start=char_offset, + char_end=char_offset, ), ) @@ -321,6 +322,7 @@ def _build_picture_block(raw_pic: RawPicture) -> PictureBlock: ) raw_cap = raw_pic["caption"] caption = _build_caption_block(raw_cap) if raw_cap is not None else None + char_offset = raw_pic["char_offset"] return PictureBlock( image=image, caption=caption, @@ -328,8 +330,8 @@ def _build_picture_block(raw_pic: RawPicture) -> PictureBlock: prov=Provenance( section_idx=raw_pic["section_idx"], para_idx=raw_pic["para_idx"], - char_start=None, - char_end=None, + char_start=char_offset, + char_end=char_offset, ), ) @@ -341,30 +343,35 @@ def _build_formula_block(raw_eq: RawFormula) -> FormulaBlock: 여부는 상류 ``Equation.common.inline_object`` 등에서 추론할 수 있지만 현 시점 1차 사용처 (RAG) 에서 차이 의미 없음 — 모두 False 출고. """ + char_offset = raw_eq["char_offset"] 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, + char_start=char_offset, + char_end=char_offset, ), ) def _build_footnote_block(raw_fn: RawFootnote) -> FootnoteBlock: - """RawFootnote → FootnoteBlock. blocks 는 각주 본문 paragraph 들을 평탄화.""" + """RawFootnote → FootnoteBlock. blocks 는 각주 본문 paragraph 들을 평탄화. + + ``marker_char_offset`` (v0.3.1) 은 zero-width point — char_start/char_end + 양쪽에 동일 값 복제 (spec § 결정사항 6). 부모 paragraph 의 char_offsets 가 + 빈 경우 None. + """ 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_char_offset = raw_fn["marker_char_offset"] marker = Provenance( section_idx=raw_fn["marker_section_idx"], para_idx=raw_fn["marker_para_idx"], - char_start=None, - char_end=None, + char_start=marker_char_offset, + char_end=marker_char_offset, ) return FootnoteBlock( number=raw_fn["number"], @@ -375,15 +382,16 @@ def _build_footnote_block(raw_fn: RawFootnote) -> FootnoteBlock: def _build_endnote_block(raw_en: RawEndnote) -> EndnoteBlock: - """RawEndnote → EndnoteBlock — Footnote 와 동일 패턴 (marker precision 동일 deferral).""" + """RawEndnote → EndnoteBlock — Footnote 와 동일 패턴.""" inner_blocks: list[Block] = [] for inner in raw_en["blocks"]: inner_blocks.extend(_flatten_paragraph(inner)) + marker_char_offset = raw_en["marker_char_offset"] marker = Provenance( section_idx=raw_en["marker_section_idx"], para_idx=raw_en["marker_para_idx"], - char_start=None, - char_end=None, + char_start=marker_char_offset, + char_end=marker_char_offset, ) return EndnoteBlock( number=raw_en["number"], @@ -424,13 +432,14 @@ def _build_toc_block(raw_toc: RawToc) -> TocBlock: 필요. spec § 6 결정 사항 7 참조. """ entries = [_build_toc_entry_block(e, raw_toc) for e in raw_toc["entries"]] + char_offset = raw_toc["char_offset"] return TocBlock( entries=entries, prov=Provenance( section_idx=raw_toc["section_idx"], para_idx=raw_toc["para_idx"], - char_start=None, - char_end=None, + char_start=char_offset, + char_end=char_offset, ), ) @@ -463,6 +472,7 @@ def _build_field_block(raw_field: RawField) -> FieldBlock: """ raw_kind = raw_field["field_kind"] field_kind: FieldKind = raw_kind if raw_kind in _VALID_FIELD_KINDS else "unknown" # type: ignore[assignment] + char_offset = raw_field["char_offset"] return FieldBlock( field_kind=field_kind, cached_value=raw_field["cached_value"], @@ -471,8 +481,8 @@ def _build_field_block(raw_field: RawField) -> FieldBlock: prov=Provenance( section_idx=raw_field["section_idx"], para_idx=raw_field["para_idx"], - char_start=None, - char_end=None, + char_start=char_offset, + char_end=char_offset, ), ) diff --git a/python/rhwp/ir/_raw_types.py b/python/rhwp/ir/_raw_types.py index cf7a076..de94cd4 100644 --- a/python/rhwp/ir/_raw_types.py +++ b/python/rhwp/ir/_raw_types.py @@ -63,6 +63,10 @@ class RawTable(TypedDict): ``caption`` (S1) 은 평문 fallback (호환). ``caption_block`` (S3 신규) 은 구조화 캡션 — 둘 다 source 가 같은 HWP Table.caption 이지만 표현 형태만 다름. + + ``char_offset`` (v0.3.1) 은 부모 paragraph 안 zero-width character 위치 — mapper 가 + Provenance.char_start/char_end 양쪽에 동일 값 복제. None 은 부모의 char_offsets 가 + 빈 paragraph (정확 character index 의미 없음). """ rows: int @@ -70,6 +74,7 @@ class RawTable(TypedDict): cells: list[RawCell] caption: str | None caption_block: RawCaption | None + char_offset: int | None class RawImageRef(TypedDict): @@ -85,6 +90,9 @@ class RawPicture(TypedDict): ``description`` (S1) 은 caption 평문 fallback 호환. ``caption`` (S3 신규) 은 구조화 캡션 — Picture 가 caption 을 가지면 둘 다 채워진다. + + ``char_offset`` (v0.3.1) 은 부모 paragraph 안 zero-width character 위치 (TAC / + floating 무관). None 은 부모의 char_offsets 가 빈 paragraph. """ section_idx: int @@ -92,15 +100,20 @@ class RawPicture(TypedDict): image: RawImageRef | None description: str | None caption: RawCaption | None + char_offset: int | None class RawFormula(TypedDict): - """``src/ir.rs::RawFormula``. ``text_alt`` 는 raw script 의 단순 정규화 결과 (S2 신규).""" + """``src/ir.rs::RawFormula``. ``text_alt`` 는 raw script 의 단순 정규화 결과 (S2 신규). + + ``char_offset`` (v0.3.1) 은 부모 paragraph 안 zero-width character 위치. + """ section_idx: int para_idx: int script: str text_alt: str | None + char_offset: int | None class RawFootnote(TypedDict): @@ -108,10 +121,15 @@ class RawFootnote(TypedDict): ``marker_section_idx`` / ``marker_para_idx`` 는 본문 인용 마커가 등장한 parent paragraph 위치 — RAG 가 각주 → 본문 역추적 시 사용. + + ``marker_char_offset`` (v0.3.1) 은 본문 인용 마커의 zero-width character 위치 + (상류 ``Paragraph::control_text_positions`` v0.7.8 활용). 부모 paragraph 의 + ``char_offsets`` 가 빈 경우 None. """ marker_section_idx: int marker_para_idx: int + marker_char_offset: int | None number: int blocks: list["RawParagraph"] @@ -121,6 +139,7 @@ class RawEndnote(TypedDict): marker_section_idx: int marker_para_idx: int + marker_char_offset: int | None number: int blocks: list["RawParagraph"] @@ -156,11 +175,15 @@ class RawTocEntry(TypedDict): class RawToc(TypedDict): - """``src/ir.rs::RawToc`` (S3 신규). ``FieldType::TableOfContents`` 검출 시 emit.""" + """``src/ir.rs::RawToc`` (S3 신규). ``FieldType::TableOfContents`` 검출 시 emit. + + ``char_offset`` (v0.3.1) 은 부모 paragraph 안 zero-width character 위치. + """ section_idx: int para_idx: int entries: list[RawTocEntry] + char_offset: int | None class RawField(TypedDict): @@ -169,6 +192,8 @@ class RawField(TypedDict): ``field_kind`` 는 Rust 에서 lowercase string 으로 직렬화된 ``FieldType`` — Python 측 ``FieldKind`` Literal 과 정확히 같은 어휘여야 한다 (mapper 가 Literal 검증). 미지의 FieldType 은 ``"unknown"`` + ``field_type_code`` 채움. + + ``char_offset`` (v0.3.1) 은 부모 paragraph 안 zero-width character 위치. """ section_idx: int @@ -177,6 +202,7 @@ class RawField(TypedDict): cached_value: str | None raw_instruction: str | None field_type_code: int | None + char_offset: int | None class RawParagraph(TypedDict): diff --git a/src/ir.rs b/src/ir.rs index af9f278..c7c43b1 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -75,6 +75,11 @@ pub(crate) struct RawTable { pub caption: Option, // ^ v0.3.0 S3 신규 — 구조화 캡션. caption (str) 은 v0.2.0 호환 평문 fallback. pub caption_block: Option, + // ^ v0.3.1 신규 — 부모 paragraph 안 inline 컨트롤의 zero-width character 위치 + // (상류 Paragraph::control_text_positions(), v0.7.8). 부모 char_offsets 가 + // 비었거나 paragraph 가 master_page 등 char index 의미 없는 위치이면 None. + // mapper 가 Provenance.char_start/char_end 양쪽에 동일 값을 복제 (zero-width point). + pub char_offset: Option, } #[derive(IntoPyObject)] @@ -102,6 +107,8 @@ pub(crate) struct RawPicture { pub description: Option, // ^ v0.3.0 S3 신규 — 구조화 캡션. Picture.caption 이 None 이면 None. pub caption: Option, + // ^ v0.3.1 신규 — 부모 paragraph 안 zero-width character 위치 (TAC/floating 무관). + pub char_offset: Option, } #[derive(IntoPyObject)] @@ -112,15 +119,20 @@ pub(crate) struct RawFormula { // ^ HWP equation script 는 항상 "hwp_eq" — LaTeX/MathML 변환은 Python 사용자 // 책임 (spec § 비목표). text_alt 는 raw script 의 단순 정규화 결과. pub text_alt: Option, + // ^ v0.3.1 신규 — 부모 paragraph 안 zero-width character 위치. + pub char_offset: 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, + // ^ v0.3.1 신규 — 본문 인용 마커의 zero-width character 위치 (상류 + // Paragraph::control_text_positions, v0.7.8). 부모 paragraph 의 + // char_offsets 가 비었으면 None. + pub marker_char_offset: Option, pub number: u16, pub blocks: Vec, // ^ 각주 본문의 내부 paragraph — Python mapper 가 _flatten_paragraph 로 @@ -131,6 +143,8 @@ pub(crate) struct RawFootnote { pub(crate) struct RawEndnote { pub marker_section_idx: usize, pub marker_para_idx: usize, + // ^ v0.3.1 신규 — 본문 인용 마커의 zero-width character 위치. + pub marker_char_offset: Option, pub number: u16, pub blocks: Vec, } @@ -152,6 +166,8 @@ pub(crate) struct RawToc { pub section_idx: usize, pub para_idx: usize, pub entries: Vec, + // ^ v0.3.1 신규 — 부모 paragraph 안 zero-width character 위치. + pub char_offset: Option, } #[derive(IntoPyObject)] @@ -170,6 +186,8 @@ pub(crate) struct RawField { // ^ 미지의 raw 코드 — 상류 FieldType 추가 시 forward-compat. v0.3.0 은 모든 // variant 가 알려져 있으므로 항상 None. pub field_type_code: Option, + // ^ v0.3.1 신규 — 부모 paragraph 안 zero-width character 위치. + pub char_offset: Option, } #[derive(IntoPyObject)] @@ -215,8 +233,25 @@ pub(crate) fn build_raw_document(doc: &Document, source_uri: Option<&str>) -> Ra 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)); - collect_furniture_from_paragraph(section_idx, para_idx, para, doc, &mut acc); + // ^ 상류 control_text_positions() 는 paragraph 당 1회만 호출하고 + // build_raw_paragraph + collect_furniture 양쪽이 결과를 공유한다 + // (spec § 결정사항 9). 같은 controls 배열을 두 함수가 독립 iterate. + let positions = paragraph_positions(para); + paragraphs.push(build_raw_paragraph( + section_idx, + para_idx, + para, + doc, + positions.as_deref(), + )); + collect_furniture_from_paragraph( + section_idx, + para_idx, + para, + doc, + &mut acc, + positions.as_deref(), + ); } // ^ 바탕쪽 안의 Header/Footer 컨트롤도 furniture 로 라우팅 (spec § 8 매퍼 정책). // 바탕쪽 paragraph 자체는 furniture 에 넣지 않는다 — 페이지 배경 템플릿이지 @@ -230,7 +265,15 @@ pub(crate) fn build_raw_document(doc: &Document, source_uri: Option<&str>) -> Ra .flat_map(|mp| mp.paragraphs.iter()) .enumerate() { - collect_furniture_from_paragraph(section_idx, mp_flat_idx, mp_para, doc, &mut acc); + let mp_positions = paragraph_positions(mp_para); + collect_furniture_from_paragraph( + section_idx, + mp_flat_idx, + mp_para, + doc, + &mut acc, + mp_positions.as_deref(), + ); } } RawDocument { @@ -244,11 +287,43 @@ pub(crate) fn build_raw_document(doc: &Document, source_uri: Option<&str>) -> Ra } } +/// 상류 `Paragraph::control_text_positions()` (v0.7.8) 호출 + 컨트롤 길이 동기화 보증. +/// +/// 반환값은 `controls.len() == positions.len()` 검증 후의 positions. `char_offsets` +/// 가 비어있으면 (= text 가 없는 paragraph) 폴백 분기의 의미 없는 position 을 사용 +/// 하지 않고 `None` 을 반환 — spec § 결정사항 3 의 fail-fast 폴백 정책. +/// +/// `controls.len() != positions.len()` 시 release/debug 무관 panic — spec § 결정사항 8 +/// (silent regression 차단 가드, AC-12 invariant). +fn paragraph_positions(para: &Paragraph) -> Option> { + let positions = para.control_text_positions(); + assert_position_invariant(para.controls.len(), positions.len()); + if para.char_offsets.is_empty() { + None + } else { + Some(positions) + } +} + +/// 상류 contract `controls.len() == positions.len()` 위반 시 release/debug 무관 panic. +/// +/// `assert_eq!` (not `debug_assert_eq!`) — release 빌드에서도 활성. 별도 helper 로 +/// 분리하여 단위 테스트 (#[should_panic]) 로 invariant 자체를 검증 가능하게 한다. +#[inline] +fn assert_position_invariant(controls_len: usize, positions_len: usize) { + assert_eq!( + controls_len, positions_len, + "upstream control_text_positions() length mismatch: \ + controls.len()={controls_len}, positions.len()={positions_len}", + ); +} + fn build_raw_paragraph( section_idx: usize, para_idx: usize, para: &Paragraph, doc: &Document, + positions: Option<&[usize]>, ) -> RawParagraph { let char_runs = build_char_runs(para, &doc.doc_info); // ^ 문단의 controls 중 Table / Picture / Equation / Field 만 추출 — 내부 @@ -260,22 +335,23 @@ fn build_raw_paragraph( let mut formulas = Vec::new(); let mut tocs = Vec::new(); let mut fields = Vec::new(); - for ctrl in ¶.controls { + for (i, ctrl) in para.controls.iter().enumerate() { + let char_offset = positions.map(|p| p[i]); match ctrl { Control::Table(t) => { - tables.push(build_raw_table(t, section_idx, para_idx, doc)); + tables.push(build_raw_table(t, section_idx, para_idx, doc, char_offset)); } Control::Picture(p) => { - pictures.push(build_raw_picture(p, section_idx, para_idx, doc)); + pictures.push(build_raw_picture(p, section_idx, para_idx, doc, char_offset)); } Control::Equation(e) => { - formulas.push(build_raw_formula(e, section_idx, para_idx)); + formulas.push(build_raw_formula(e, section_idx, para_idx, char_offset)); } Control::Field(f) => { if f.field_type == FieldType::TableOfContents { - tocs.push(build_raw_toc(f, section_idx, para_idx)); + tocs.push(build_raw_toc(f, section_idx, para_idx, char_offset)); } else { - fields.push(build_raw_field(f, section_idx, para_idx)); + fields.push(build_raw_field(f, section_idx, para_idx, char_offset)); } } _ => {} @@ -364,6 +440,7 @@ fn build_raw_table( outer_section: usize, outer_para: usize, doc: &Document, + char_offset: Option, ) -> RawTable { let cells = table .cells @@ -381,6 +458,7 @@ fn build_raw_table( cells, caption, caption_block, + char_offset, } } @@ -388,7 +466,10 @@ fn build_raw_cell(cell: &Cell, outer_section: usize, outer_para: usize, doc: &Do let paragraphs = cell .paragraphs .iter() - .map(|p| build_raw_paragraph(outer_section, outer_para, p, doc)) + .map(|p| { + let pos = paragraph_positions(p); + build_raw_paragraph(outer_section, outer_para, p, doc, pos.as_deref()) + }) .collect(); RawCell { row: cell.row as usize, @@ -410,6 +491,7 @@ fn build_raw_picture( section_idx: usize, para_idx: usize, doc: &Document, + char_offset: Option, ) -> RawPicture { let bin_data_id = pic.image_attr.bin_data_id; let image = if bin_data_id == 0 { @@ -443,6 +525,7 @@ fn build_raw_picture( image, description, caption, + char_offset, } } @@ -460,34 +543,53 @@ struct FurnitureAcc { /// 각 furniture 컨트롤이 가지는 자체 paragraphs 들을 외부 (section_idx, para_idx) 와 /// 공유한 RawParagraph 로 변환한다. 본 paragraphs 는 furniture 가 어디서 /// "선언" 됐는지 (Provenance) 만 보존하면 충분 — 페이지별 반복 출현은 렌더 단계. +/// +/// `positions` 는 부모 paragraph 의 control_text_positions 결과 (build_raw_document +/// 에서 paragraph 당 1회 호출, build_raw_paragraph 와 공유 — spec § 결정사항 9). +/// Footnote/Endnote 마커의 char_offset 추출에 사용. None 이면 char_offsets 가 +/// 비어있는 paragraph (또는 master_page) — marker_char_offset 도 None 으로 출고. fn collect_furniture_from_paragraph( section_idx: usize, para_idx: usize, para: &Paragraph, doc: &Document, acc: &mut FurnitureAcc, + positions: Option<&[usize]>, ) { - for ctrl in ¶.controls { + for (i, ctrl) in para.controls.iter().enumerate() { + let char_offset = positions.map(|p| p[i]); match ctrl { Control::Header(h) => { for hp in &h.paragraphs { - acc.headers - .push(build_raw_paragraph(section_idx, para_idx, hp, doc)); + let hp_positions = paragraph_positions(hp); + acc.headers.push(build_raw_paragraph( + section_idx, + para_idx, + hp, + doc, + hp_positions.as_deref(), + )); } } Control::Footer(f) => { for fp in &f.paragraphs { - acc.footers - .push(build_raw_paragraph(section_idx, para_idx, fp, doc)); + let fp_positions = paragraph_positions(fp); + acc.footers.push(build_raw_paragraph( + section_idx, + para_idx, + fp, + doc, + fp_positions.as_deref(), + )); } } Control::Footnote(fn_) => { acc.footnotes - .push(build_raw_footnote(fn_, section_idx, para_idx, doc)); + .push(build_raw_footnote(fn_, section_idx, para_idx, doc, char_offset)); } Control::Endnote(en) => { acc.endnotes - .push(build_raw_endnote(en, section_idx, para_idx, doc)); + .push(build_raw_endnote(en, section_idx, para_idx, doc, char_offset)); } _ => {} } @@ -496,7 +598,12 @@ fn collect_furniture_from_paragraph( /// Equation 컨트롤 → RawFormula. text_alt 는 raw script 의 단순 정규화 결과 — /// 정상 변환 대신 RAG 폴백용으로만 충분. 실패하면 None (mapper 가 그대로 보존). -fn build_raw_formula(eq: &Equation, section_idx: usize, para_idx: usize) -> RawFormula { +fn build_raw_formula( + eq: &Equation, + section_idx: usize, + para_idx: usize, + char_offset: Option, +) -> RawFormula { let script = eq.script.clone(); let text_alt = simple_eq_text_alt(&script); RawFormula { @@ -504,6 +611,7 @@ fn build_raw_formula(eq: &Equation, section_idx: usize, para_idx: usize) -> RawF para_idx, script, text_alt, + char_offset, } } @@ -565,20 +673,28 @@ fn is_ident_continue(c: char) -> bool { /// Footnote → RawFootnote. 본문 인용 마커 위치 (parent paragraph) 를 보존하고 /// 각주 본문의 paragraph 들을 평탄화한다. +/// +/// `marker_char_offset` 은 부모 paragraph 안 zero-width character 위치 (v0.3.1) — +/// 부모의 char_offsets 가 비었으면 None. fn build_raw_footnote( fn_: &Footnote, marker_section_idx: usize, marker_para_idx: usize, doc: &Document, + marker_char_offset: Option, ) -> RawFootnote { let blocks = fn_ .paragraphs .iter() - .map(|p| build_raw_paragraph(marker_section_idx, marker_para_idx, p, doc)) + .map(|p| { + let pos = paragraph_positions(p); + build_raw_paragraph(marker_section_idx, marker_para_idx, p, doc, pos.as_deref()) + }) .collect(); RawFootnote { marker_section_idx, marker_para_idx, + marker_char_offset, number: fn_.number, blocks, } @@ -589,15 +705,20 @@ fn build_raw_endnote( marker_section_idx: usize, marker_para_idx: usize, doc: &Document, + marker_char_offset: Option, ) -> RawEndnote { let blocks = en .paragraphs .iter() - .map(|p| build_raw_paragraph(marker_section_idx, marker_para_idx, p, doc)) + .map(|p| { + let pos = paragraph_positions(p); + build_raw_paragraph(marker_section_idx, marker_para_idx, p, doc, pos.as_deref()) + }) .collect(); RawEndnote { marker_section_idx, marker_para_idx, + marker_char_offset, number: en.number, blocks, } @@ -617,7 +738,10 @@ fn build_raw_caption( let paragraphs = cap .paragraphs .iter() - .map(|p| build_raw_paragraph(section_idx, para_idx, p, doc)) + .map(|p| { + let pos = paragraph_positions(p); + build_raw_paragraph(section_idx, para_idx, p, doc, pos.as_deref()) + }) .collect(); RawCaption { direction: caption_direction_to_str(cap.direction).to_string(), @@ -673,7 +797,12 @@ fn build_raw_list_info(para: &Paragraph, doc_info: &DocInfo) -> Option RawField { +fn build_raw_field( + field: &Field, + section_idx: usize, + para_idx: usize, + char_offset: Option, +) -> RawField { RawField { section_idx, para_idx, @@ -687,16 +816,23 @@ fn build_raw_field(field: &Field, section_idx: usize, para_idx: usize) -> RawFie // ^ v0.3.0 은 모든 FieldType variant 가 알려져 있으므로 None — 상류가 // 새 variant 추가 시 mapper 가 raw u32 채워야 한다 (v0.4.0+). field_type_code: None, + char_offset, } } /// 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 { +fn build_raw_toc( + _field: &Field, + section_idx: usize, + para_idx: usize, + char_offset: Option, +) -> RawToc { RawToc { section_idx, para_idx, entries: Vec::new(), + char_offset, } } @@ -842,4 +978,18 @@ mod tests { assert_eq!(caption_direction_to_str(CaptionDirection::Left), "left"); assert_eq!(caption_direction_to_str(CaptionDirection::Right), "right"); } + + // * v0.3.1 AC-12 — controls / positions 길이 mismatch 는 release/debug 무관 panic + + #[test] + fn assert_position_invariant_passes_on_match() { + assert_position_invariant(0, 0); + assert_position_invariant(3, 3); + } + + #[test] + #[should_panic(expected = "upstream control_text_positions() length mismatch")] + fn assert_position_invariant_panics_on_mismatch() { + assert_position_invariant(3, 2); + } } diff --git a/tests/test_ir_caption.py b/tests/test_ir_caption.py index 7e63f46..063a4ce 100644 --- a/tests/test_ir_caption.py +++ b/tests/test_ir_caption.py @@ -220,6 +220,7 @@ def test_build_picture_block_with_caption_field(): image=RawImageRef(bin_data_id=1, extension="png", has_content=True), description="alt-text", caption=_raw_caption(direction="bottom", texts=("<그림 1> 회로도",)), + char_offset=None, ) pic = _build_picture_block(raw_pic) assert pic.caption is not None @@ -237,6 +238,7 @@ def test_build_picture_block_caption_none_when_raw_caption_none(): image=None, description=None, caption=None, + char_offset=None, ) pic = _build_picture_block(raw_pic) assert pic.caption is None @@ -250,6 +252,7 @@ def test_build_picture_preserves_description_alongside_caption(): image=None, description="caption text fallback", caption=_raw_caption(direction="bottom", texts=("caption text fallback",)), + char_offset=None, ) pic = _build_picture_block(raw_pic) assert pic.description == "caption text fallback" @@ -266,6 +269,7 @@ def test_build_hwp_document_table_with_caption_block_routed(): cells=[], caption="단순 캡션", caption_block=_raw_caption(direction="top", texts=("단순 캡션",)), + char_offset=None, ) raw_para = RawParagraph( section_idx=0, diff --git a/tests/test_ir_field.py b/tests/test_ir_field.py index 23781ea..a55f885 100644 --- a/tests/test_ir_field.py +++ b/tests/test_ir_field.py @@ -37,6 +37,7 @@ def _raw_field( field_type_code: int | None = None, section_idx: int = 0, para_idx: int = 0, + char_offset: int | None = None, ) -> RawField: return RawField( section_idx=section_idx, @@ -45,6 +46,7 @@ def _raw_field( cached_value=cached_value, raw_instruction=raw_instruction, field_type_code=field_type_code, + char_offset=char_offset, ) diff --git a/tests/test_ir_footnote.py b/tests/test_ir_footnote.py index 6932ced..c17a719 100644 --- a/tests/test_ir_footnote.py +++ b/tests/test_ir_footnote.py @@ -170,6 +170,7 @@ def test_build_footnote_block_preserves_number_and_marker(): raw = RawFootnote( marker_section_idx=2, marker_para_idx=15, + marker_char_offset=None, number=3, blocks=[_empty_raw_para(text="본문")], ) @@ -187,6 +188,7 @@ def test_build_footnote_block_flattens_inner_paragraphs(): raw = RawFootnote( marker_section_idx=0, marker_para_idx=5, + marker_char_offset=None, number=1, blocks=[_empty_raw_para(text="첫 줄"), _empty_raw_para(text="둘째 줄")], ) @@ -200,6 +202,7 @@ def test_build_endnote_block_mirrors_footnote_pattern(): raw = RawEndnote( marker_section_idx=1, marker_para_idx=8, + marker_char_offset=None, number=42, blocks=[_empty_raw_para(text="미주 텍스트")], ) @@ -218,6 +221,7 @@ def test_build_hwp_document_routes_footnotes_to_furniture(): RawFootnote( marker_section_idx=0, marker_para_idx=2, + marker_char_offset=None, number=1, blocks=[_empty_raw_para(text="footnote body")], ) @@ -237,6 +241,7 @@ def test_build_hwp_document_routes_endnotes_to_furniture(): RawEndnote( marker_section_idx=0, marker_para_idx=10, + marker_char_offset=None, number=5, blocks=[_empty_raw_para(text="endnote body")], ) @@ -300,6 +305,7 @@ def test_footnotes_endnotes_never_appear_in_body(): RawFootnote( marker_section_idx=0, marker_para_idx=0, + marker_char_offset=None, number=1, blocks=[_empty_raw_para(text="x")], ) @@ -308,6 +314,7 @@ def test_footnotes_endnotes_never_appear_in_body(): RawEndnote( marker_section_idx=0, marker_para_idx=0, + marker_char_offset=None, number=1, blocks=[_empty_raw_para(text="y")], ) diff --git a/tests/test_ir_formula.py b/tests/test_ir_formula.py index 31acc33..2668062 100644 --- a/tests/test_ir_formula.py +++ b/tests/test_ir_formula.py @@ -118,12 +118,14 @@ def _raw_formula( para_idx: int = 0, script: str = "1 over 2", text_alt: str | None = None, + char_offset: int | None = None, ) -> RawFormula: return RawFormula( section_idx=section_idx, para_idx=para_idx, script=script, text_alt=text_alt, + char_offset=char_offset, ) @@ -173,6 +175,7 @@ def test_formula_inside_table_cell_is_flattened(): "para_idx": 0, "script": "x^2", "text_alt": None, + "char_offset": None, } ], "tocs": [], @@ -190,6 +193,7 @@ def test_formula_inside_table_cell_is_flattened(): "cols": 1, "caption": None, "caption_block": None, + "char_offset": None, "cells": [ { "row": 0, @@ -243,6 +247,7 @@ def test_formula_inside_footnote_body_is_flattened(): "para_idx": 0, "script": "1 over 2", "text_alt": "1 / 2", + "char_offset": None, } ], "tocs": [], @@ -259,6 +264,7 @@ def test_formula_inside_footnote_body_is_flattened(): RawFootnote( marker_section_idx=0, marker_para_idx=5, + marker_char_offset=None, number=1, blocks=[raw_inner_para], ) diff --git a/tests/test_ir_furniture.py b/tests/test_ir_furniture.py index 9037eaa..68b2930 100644 --- a/tests/test_ir_furniture.py +++ b/tests/test_ir_furniture.py @@ -9,6 +9,7 @@ - 실제 샘플 (aift.hwp) 에 머리글/꼬리말이 있으면 ParagraphBlock 으로 노출 """ +import pytest import rhwp from pydantic import ValidationError from rhwp.ir._mapper import build_hwp_document @@ -22,7 +23,6 @@ TableCell, ) -import pytest pytestmark = pytest.mark.spec("v0.3.0/ir-expansion") # ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) @@ -156,6 +156,7 @@ def test_build_hwp_document_header_with_table_flattens_to_furniture(): "cols": 1, "caption": None, "caption_block": None, + "char_offset": None, "cells": [ { "row": 0, diff --git a/tests/test_ir_mapper.py b/tests/test_ir_mapper.py index a17c3b2..e3c91d2 100644 --- a/tests/test_ir_mapper.py +++ b/tests/test_ir_mapper.py @@ -10,6 +10,7 @@ 테스트 fixture 도 모든 필드를 채운 완전한 dict 여야 pyright 가 통과한다. """ +import pytest from rhwp.ir._mapper import ( _build_inline_runs, _cell_role, @@ -18,7 +19,6 @@ ) from rhwp.ir._raw_types import RawCell, RawCharRun, RawParagraph, RawTable -import pytest pytestmark = pytest.mark.spec("v0.2.0/ir") # ^ soft retrofit — file-level spec mapping; v0.4.0+ specs add #AC-N to specific tests (CONVENTIONS § Trace report) @@ -177,7 +177,14 @@ 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, caption_block=None, cells=cells) + return RawTable( + rows=1, + cols=1, + caption=None, + caption_block=None, + cells=cells, + char_offset=None, + ) def test_table_to_html_rowspan_before_colspan(): diff --git a/tests/test_ir_picture.py b/tests/test_ir_picture.py index cd86421..416a73d 100644 --- a/tests/test_ir_picture.py +++ b/tests/test_ir_picture.py @@ -148,6 +148,7 @@ def _raw_picture( has_content: bool = True, description: str | None = None, image: bool = True, + char_offset: int | None = None, ) -> RawPicture: img: RawImageRef | None = None if image: @@ -158,6 +159,7 @@ def _raw_picture( image=img, description=description, caption=None, + char_offset=char_offset, ) diff --git a/tests/test_ir_schema_export.py b/tests/test_ir_schema_export.py index 67a9956..6e1ffa2 100644 --- a/tests/test_ir_schema_export.py +++ b/tests/test_ir_schema_export.py @@ -147,8 +147,14 @@ def test_load_schema_is_valid_draft_2020_12(): # * 실제 인스턴스가 schema 를 통과 +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-10") def test_real_hwp_document_validates_against_schema(parsed_hwp): - """실제 HWP 파싱 결과가 JSON Schema validation 을 통과.""" + """실제 HWP 파싱 결과가 JSON Schema validation 을 통과. + + v0.3.1 AC-10 도 본 테스트가 동시 검증 — inline 컨트롤 마커의 + ``Provenance.char_start/char_end`` 가 v0.3.1 부터 non-null 정수로 채워지지만 + schema 의 ``anyOf [integer, null]`` 정의와 호환되어 validator 통과. + """ schema = export_schema() validator = Draft202012Validator(schema) doc = parsed_hwp.to_ir() diff --git a/tests/test_ir_toc.py b/tests/test_ir_toc.py index b893694..ab4f154 100644 --- a/tests/test_ir_toc.py +++ b/tests/test_ir_toc.py @@ -160,8 +160,8 @@ def test_toc_entry_block_is_not_in_block_union(): # * mapper — RawToc → TocBlock -def _raw_toc(*, entries: list[RawTocEntry] | None = None) -> RawToc: - return RawToc(section_idx=1, para_idx=3, entries=entries or []) +def _raw_toc(*, entries: list[RawTocEntry] | None = None, char_offset: int | None = None) -> RawToc: + return RawToc(section_idx=1, para_idx=3, entries=entries or [], char_offset=char_offset) def test_build_toc_block_empty_entries(): diff --git a/tests/test_v0_3_1_marker_char_offset.py b/tests/test_v0_3_1_marker_char_offset.py new file mode 100644 index 0000000..7abfe99 --- /dev/null +++ b/tests/test_v0_3_1_marker_char_offset.py @@ -0,0 +1,480 @@ +"""tests/test_v0_3_1_marker_char_offset.py — v0.3.1 inline 컨트롤 마커 character offset. + +[v0.3.1/ir-marker-char-offset](../docs/roadmap/v0.3.1/ir-marker-char-offset.md) +의 AC-1 ~ AC-14 검증. 짝 페어 ADR: +[v0.3.1/ir-marker-char-offset-research](../docs/design/v0.3.1/ir-marker-char-offset-research.md). + +전략: + +- AC-1 ~ AC-7 (블록별 char_start/char_end 채움): direct mapper 단위 + 가능하면 real + fixture 통합. mapper 단위 테스트는 결정론적으로 양쪽 슬롯 동일 정수값 보증. +- AC-8 (빈 char_offsets 폴백): mapper 에 ``char_offset=None`` 입력 → ``prov.char_start + /char_end`` 둘 다 ``None``. 실제 파일 데이터 (aift.hwp) 도 다수 None 케이스 보유 + — fail-fast 폴백이 실 사용 경로에서 작동함을 회귀 보증. +- AC-9 (Schema 1.1 유지): ``CURRENT_SCHEMA_VERSION == "1.1"`` + Provenance 의 + ``anyOf [integer, null]`` 정의 동결. +- AC-10 (jsonschema validator): 본 파일은 schema 슬롯 정의 + SchemaVersion 동결만 + 가드한다. 실제 jsonschema validator 호출은 + ``test_ir_schema_export.py::test_real_hwp_document_validates_against_schema`` 가 + 담당 — 본 spec 의 v0.3.1 AC-10 marker 도 그쪽에 부착. jsonschema extras 없는 + 환경에서 module-level importorskip 으로 1 skip 수렴 → ``test-without-extras`` CI + 카운트 4 보존. +- AC-11 (zero-width invariant): ``char_start == char_end`` 인 정수 슬롯의 프로젝트 + 전반 검증. +- AC-12 (Rust assert): ``src/ir.rs`` 에 ``assert_eq!`` (release-active) 와 + ``#[should_panic]`` 단위 테스트가 정의되어 있다 — ``cargo clippy --all-targets`` 가 + test target 컴파일을 검증한다. **runtime panic 검증** (실제 mismatch 시 panic 발생) + 은 PyO3 ``extension-module`` cdylib + libpython 링크 제약으로 본 프로젝트 CI 에서 + ``cargo test`` 가 실행되지 않아 미실행. Python 측 본 테스트는 source-level 회귀 가드 + (``assert_eq!`` 가 ``debug_assert_eq!`` 로 약화되거나 helper 가 제거되는 회귀 차단) + 까지만 담당. +- AC-13 (submodule pin + CHANGELOG): submodule HEAD 검증 + CHANGELOG 항목 양쪽 검사. +- AC-14 (fixture 정책): 본 파일이 기존 ``aift.hwp`` / ``table-vpos-01.hwpx`` 만으로 + AC-1 ~ AC-8 을 커버 — 합성 fixture 미도입을 사실로 가드 (``tests/fixtures/v0_3_1/`` + 부재 검증). +""" + +import re +import subprocess +from pathlib import Path + +import pytest +import rhwp +from rhwp.ir._mapper import ( + _build_endnote_block, + _build_field_block, + _build_footnote_block, + _build_formula_block, + _build_picture_block, + _build_table_block, + _build_toc_block, +) +from rhwp.ir._raw_types import ( + RawEndnote, + RawField, + RawFootnote, + RawFormula, + RawImageRef, + RawParagraph, + RawPicture, + RawTable, + RawToc, +) +from rhwp.ir.nodes import ( + CURRENT_SCHEMA_VERSION, + EndnoteBlock, + FieldBlock, + FootnoteBlock, + FormulaBlock, + HwpDocument, + PictureBlock, + TableBlock, + TocBlock, +) +from rhwp.ir.schema import load_schema + +pytestmark = pytest.mark.spec("v0.3.1/ir-marker-char-offset") + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +# * mapper helper factories — 각 raw struct 의 최소 필수 필드를 채운다 + + +def _empty_raw_para(*, section_idx: int = 0, para_idx: int = 0) -> RawParagraph: + return RawParagraph( + section_idx=section_idx, + para_idx=para_idx, + text="", + char_runs=[], + tables=[], + pictures=[], + formulas=[], + tocs=[], + fields=[], + list_info=None, + ) + + +def _raw_table(*, char_offset: int | None) -> RawTable: + return RawTable( + rows=1, + cols=1, + cells=[], + caption=None, + caption_block=None, + char_offset=char_offset, + ) + + +def _raw_picture(*, char_offset: int | None) -> RawPicture: + return RawPicture( + section_idx=2, + para_idx=4, + image=RawImageRef(bin_data_id=1, extension="png", has_content=True), + description=None, + caption=None, + char_offset=char_offset, + ) + + +def _raw_formula(*, char_offset: int | None) -> RawFormula: + return RawFormula( + section_idx=3, + para_idx=5, + script="x^2", + text_alt=None, + char_offset=char_offset, + ) + + +def _raw_field(*, char_offset: int | None) -> RawField: + return RawField( + section_idx=1, + para_idx=2, + field_kind="hyperlink", + cached_value=None, + raw_instruction=None, + field_type_code=None, + char_offset=char_offset, + ) + + +def _raw_toc(*, char_offset: int | None) -> RawToc: + return RawToc( + section_idx=4, + para_idx=6, + entries=[], + char_offset=char_offset, + ) + + +def _raw_footnote(*, marker_char_offset: int | None) -> RawFootnote: + return RawFootnote( + marker_section_idx=0, + marker_para_idx=10, + marker_char_offset=marker_char_offset, + number=1, + blocks=[_empty_raw_para()], + ) + + +def _raw_endnote(*, marker_char_offset: int | None) -> RawEndnote: + return RawEndnote( + marker_section_idx=0, + marker_para_idx=12, + marker_char_offset=marker_char_offset, + number=2, + blocks=[_empty_raw_para()], + ) + + +# * AC-1 — FootnoteBlock.marker_prov.char_start/char_end (zero-width) + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-1") +def test_footnote_marker_char_offset_populated_zero_width(): + """`marker_char_offset` 정수 입력 시 marker_prov 양쪽 슬롯에 동일 값 복제.""" + fn = _build_footnote_block(_raw_footnote(marker_char_offset=7)) + assert fn.marker_prov.char_start == 7 + assert fn.marker_prov.char_end == 7 + # ^ prov 도 marker 와 동일 위치 공유 (mapper 가 marker_prov 를 prov 로 재사용) + assert fn.prov.char_start == 7 + assert fn.prov.char_end == 7 + + +# * AC-2 — EndnoteBlock.marker_prov.char_start/char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-2") +def test_endnote_marker_char_offset_populated_zero_width(): + en = _build_endnote_block(_raw_endnote(marker_char_offset=11)) + assert en.marker_prov.char_start == 11 + assert en.marker_prov.char_end == 11 + assert en.prov.char_start == 11 + assert en.prov.char_end == 11 + + +# * AC-3 — PictureBlock.prov.char_start/char_end (TAC / floating 무관) + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-3") +def test_picture_char_offset_populated_zero_width(): + pic = _build_picture_block(_raw_picture(char_offset=3)) + assert pic.prov.char_start == 3 + assert pic.prov.char_end == 3 + + +# * AC-4 — FormulaBlock.prov.char_start/char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-4") +def test_formula_char_offset_populated_zero_width(): + eq = _build_formula_block(_raw_formula(char_offset=2)) + assert eq.prov.char_start == 2 + assert eq.prov.char_end == 2 + + +# * AC-5 — FieldBlock.prov.char_start/char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-5") +def test_field_char_offset_populated_zero_width(): + fld = _build_field_block(_raw_field(char_offset=5)) + assert fld.prov.char_start == 5 + assert fld.prov.char_end == 5 + + +# * AC-6 — TocBlock.prov.char_start/char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-6") +def test_toc_char_offset_populated_zero_width(): + toc = _build_toc_block(_raw_toc(char_offset=0)) + assert toc.prov.char_start == 0 + assert toc.prov.char_end == 0 + + +# * AC-7 — TableBlock.prov.char_start/char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-7") +def test_table_char_offset_populated_zero_width(): + raw_para = _empty_raw_para(section_idx=1, para_idx=8) + tbl = _build_table_block(raw_para, _raw_table(char_offset=4)) + assert tbl.prov.char_start == 4 + assert tbl.prov.char_end == 4 + + +# * AC-8 — empty char_offsets paragraph → None 폴백 (모든 7 종 블록) + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-8") +@pytest.mark.parametrize( + "label, build_fn", + [ + ( + "footnote_marker", + lambda: _build_footnote_block(_raw_footnote(marker_char_offset=None)).marker_prov, + ), + ( + "endnote_marker", + lambda: _build_endnote_block(_raw_endnote(marker_char_offset=None)).marker_prov, + ), + ("picture", lambda: _build_picture_block(_raw_picture(char_offset=None)).prov), + ("formula", lambda: _build_formula_block(_raw_formula(char_offset=None)).prov), + ("field", lambda: _build_field_block(_raw_field(char_offset=None)).prov), + ("toc", lambda: _build_toc_block(_raw_toc(char_offset=None)).prov), + ( + "table", + lambda: _build_table_block(_empty_raw_para(), _raw_table(char_offset=None)).prov, + ), + ], +) +def test_char_offsets_empty_falls_back_to_none(label, build_fn): + """부모 paragraph 의 char_offsets 가 빈 케이스 — char_start/char_end 둘 다 None.""" + prov = build_fn() + assert prov.char_start is None, label + assert prov.char_end is None, label + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-8") +def test_real_sample_has_none_fallback_paragraphs(parsed_hwp: rhwp.Document): + """aift.hwp 안 inline 컨트롤 중 일부는 char_offsets 가 빈 paragraph 안에 위치 — + spec § 결정사항 3 의 폴백이 실 사용 경로에서 None 으로 출고됨을 보증.""" + ir = parsed_hwp.to_ir() + target_types = (TableBlock, PictureBlock, FormulaBlock, FieldBlock) + blocks = [b for b in ir.iter_blocks(scope="all", recurse=True) if isinstance(b, target_types)] + none_count = sum(1 for b in blocks if b.prov.char_start is None) + assert none_count > 0, "샘플에 None 폴백 케이스가 하나도 없음 — AC-8 회귀 가드 무력화" + + +# * AC-9 — SchemaVersion 1.1 유지 + 슬롯 정의 동결 + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-9") +def test_schema_version_remains_1_1(): + assert CURRENT_SCHEMA_VERSION == "1.1" + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-9") +def test_schema_provenance_char_start_anyof_integer_or_null(): + """``Provenance.char_start`` schema 는 v0.3.0 과 동일 ``anyOf [integer, null]``. + 스키마 본문이 v0.3.1 에서 변경되지 않음을 보증.""" + schema = load_schema() + char_start = schema["$defs"]["Provenance"]["properties"]["char_start"] + assert char_start["anyOf"] == [{"type": "integer"}, {"type": "null"}] + char_end = schema["$defs"]["Provenance"]["properties"]["char_end"] + assert char_end["anyOf"] == [{"type": "integer"}, {"type": "null"}] + + +# * AC-10 — jsonschema validator 호출은 ``test_ir_schema_export.py`` 가 담당 +# (jsonschema extras gate — module-level importorskip 으로 카운트 1 수렴). + + +# * AC-11 — zero-width invariant: 모든 non-None marker 에 대해 char_start == char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-11") +def test_real_sample_zero_width_invariant(parsed_hwp: rhwp.Document): + """non-None 출고된 모든 marker 의 ``char_start == char_end`` (zero-width point).""" + ir = parsed_hwp.to_ir() + target_types = (TableBlock, PictureBlock, FormulaBlock, FieldBlock, TocBlock) + invariants_checked = 0 + for b in ir.iter_blocks(scope="all", recurse=True): + if isinstance(b, target_types) and b.prov.char_start is not None: + assert isinstance(b.prov.char_start, int) + assert b.prov.char_start == b.prov.char_end + invariants_checked += 1 + elif isinstance(b, (FootnoteBlock, EndnoteBlock)) and b.marker_prov.char_start is not None: + assert isinstance(b.marker_prov.char_start, int) + assert b.marker_prov.char_start == b.marker_prov.char_end + invariants_checked += 1 + assert invariants_checked > 0, "샘플에서 non-None marker 가 한 건도 발견되지 않음" + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-11") +def test_zero_width_invariant_holds_across_distinct_offsets(): + """동일 값 복제 (mapper 가 char_start = char_end = char_offset) 의 정수 입력 + 여러 개에 대해 비대칭이 발생하지 않음을 결정론적으로 보증.""" + for i in (0, 1, 7, 100): + pic = _build_picture_block(_raw_picture(char_offset=i)) + assert pic.prov.char_start == i + assert pic.prov.char_end == i + + +# * AC-12 — Rust 빌드의 fail-fast invariant (source-level 회귀 가드) + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-12") +def test_rust_uses_assert_eq_not_debug_assert(): + """``assert_position_invariant`` 가 ``assert_eq!`` (release-active) 사용 보증. + + 본 파일 docstring § AC-12 참고: runtime panic 검증은 ``cargo test`` 가 PyO3 + cdylib 제약으로 CI 미실행이라 source-level 만 가드한다. ``debug_assert_eq!`` 로 + 약화되거나 helper 가 제거되는 회귀를 차단.""" + src = (REPO_ROOT / "src" / "ir.rs").read_text(encoding="utf-8") + assert "fn assert_position_invariant" in src + impl_match = re.search(r"fn assert_position_invariant\([^)]*\) \{[^}]*\}", src, re.DOTALL) + assert impl_match is not None, "assert_position_invariant 함수 본체를 찾을 수 없음" + body = impl_match.group(0) + assert "assert_eq!" in body, "assert_eq! 가 사라짐 — fail-fast 보장 회귀" + assert "debug_assert" not in body, "debug_assert 로 약화됨 — release 빌드에서 무력화" + + +# * AC-13 — submodule pin + CHANGELOG 기재 + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-13") +def test_changelog_records_pin_bump(): + """CHANGELOG.md 의 v0.3.1 항목이 v0.7.7 → 신규 핀 (0fb3e67) bump 와 PR #405 / Task #390 인용을 모두 보유.""" + changelog = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + section = re.search(r"## \[0\.3\.1\].*?(?=^## \[)", changelog, re.DOTALL | re.MULTILINE) + assert section is not None, "CHANGELOG 에 v0.3.1 섹션 없음" + body = section.group(0) + assert "033617e" in body, "v0.7.7 핀 (033617e) 미기재" + assert "0fb3e67" in body, "post-v0.7.8 핀 (0fb3e67) 미기재" + assert "v0.7.7" in body + assert "PR #405" in body or "/pull/405" in body, "PR #405 인용 누락" + assert "Task #390" in body or "issues/390" in body, "Task #390 인용 누락" + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-13") +def test_submodule_pin_matches_changelog_record(): + """git ls-tree 로 실제 submodule HEAD 가 CHANGELOG 에 기재된 핀과 일치 — 반쪽 갱신 차단. + + git 미설치 / repo 외부에서 실행되는 환경에서는 skip (CI 는 항상 git 보유).""" + try: + result = subprocess.run( + ["git", "ls-tree", "HEAD", "external/rhwp"], + cwd=str(REPO_ROOT), + check=True, + capture_output=True, + text=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + pytest.skip(f"git ls-tree unavailable: {exc!r}") + # ^ 출력 포맷: " commit \texternal/rhwp" + parts = result.stdout.split() + assert len(parts) >= 3 and parts[1] == "commit", f"unexpected ls-tree output: {result.stdout!r}" + pin_sha = parts[2] + assert pin_sha.startswith("0fb3e67"), ( + f"submodule HEAD = {pin_sha} 이지만 CHANGELOG / spec 기재는 0fb3e67 — 반쪽 갱신" + ) + + +# * AC-14 — fixture 정책: 기존 sample 만 사용, 합성 fixture 미도입 + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-14") +def test_no_synthetic_fixture_directory(): + """spec § AC-14: 우선 기존 fixture 로 검증 시도. ``tests/fixtures/v0_3_1/`` 합성 fixture + 디렉토리는 *부족 시* 도입 — 본 v0.3.1 GA 시점에는 미도입 (AC-1 ~ AC-8 모두 기존 sample + + mapper 단위 테스트로 커버).""" + synthetic_dir = REPO_ROOT / "tests" / "fixtures" / "v0_3_1" + assert not synthetic_dir.exists(), ( + "tests/fixtures/v0_3_1/ 발견 — 합성 fixture 도입 시 본 가드 갱신 필요" + ) + + +# * 통합 테스트 — real sample 의 적용 대상 7 종에서 (필요 시 skip) 실데이터 회귀 보증 + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-7") +def test_real_sample_table_marker_populates_or_falls_back(parsed_hwp: rhwp.Document): + """aift.hwp 의 90개 표 중 일부는 부모 paragraph 가 텍스트를 가져 char_start 가 + int, 일부는 텍스트 없는 anchor paragraph 라 None — 둘 다 정상.""" + ir = parsed_hwp.to_ir() + tables = [b for b in ir.iter_blocks(scope="all", recurse=True) if isinstance(b, TableBlock)] + assert len(tables) > 0 + for tbl in tables: + if tbl.prov.char_start is not None: + assert isinstance(tbl.prov.char_start, int) + assert tbl.prov.char_start == tbl.prov.char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-3") +def test_real_sample_picture_marker_populates_or_falls_back(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + pictures = [b for b in ir.iter_blocks(scope="all", recurse=True) if isinstance(b, PictureBlock)] + if not pictures: + pytest.skip("aift.hwp 샘플에 그림 컨트롤 없음") + for pic in pictures: + if pic.prov.char_start is not None: + assert isinstance(pic.prov.char_start, int) + assert pic.prov.char_start == pic.prov.char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-1") +def test_real_sample_footnote_marker_populates_or_falls_back(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + if not ir.furniture.footnotes: + pytest.skip("aift.hwp 샘플에 각주 컨트롤 없음") + for fn in ir.furniture.footnotes: + cs = fn.marker_prov.char_start + if cs is not None: + assert isinstance(cs, int) + assert cs == fn.marker_prov.char_end + + +@pytest.mark.spec("v0.3.1/ir-marker-char-offset#AC-5") +def test_real_sample_field_marker_populates_or_falls_back(parsed_hwp: rhwp.Document): + ir = parsed_hwp.to_ir() + fields = [b for b in ir.iter_blocks(scope="all", recurse=True) if isinstance(b, FieldBlock)] + if not fields: + pytest.skip("aift.hwp 샘플에 field 컨트롤 없음") + for fld in fields: + if fld.prov.char_start is not None: + assert isinstance(fld.prov.char_start, int) + assert fld.prov.char_start == fld.prov.char_end + + +# * HwpDocument 통째 모델 단위 — Pydantic frozen 직렬화 왕복 보존 + + +def test_real_sample_ir_json_roundtrip(parsed_hwp: rhwp.Document): + """v0.3.1 출고 IR 의 JSON 왕복 — non-null char_start 가 보존되어 schema 호환.""" + ir = parsed_hwp.to_ir() + reloaded = HwpDocument.model_validate_json(ir.model_dump_json()) + assert reloaded.schema_version == "1.1" + assert reloaded == ir From 752166d24377dbef95883b7213777676b720b895 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 3 May 2026 11:08:00 +0900 Subject: [PATCH 08/11] =?UTF-8?q?docs:=20v0.3.1=20GA=20wrap-up=20=E2=80=94?= =?UTF-8?q?=20Frozen=20=EC=A0=84=ED=99=98=20+=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20+=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - spec/ADR frontmatter status Draft → Frozen, target → ga (v0.3.1) - docs/implementation/v0.3.1/migration.md 신규 (Frozen on creation): 산출물 / 호환성 / 검증 결과 / 이월 사항 - docs/roadmap/README.md 현재 상태 + 활성 spec 인덱스 + 구현 로그 표 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v0.3.1/ir-marker-char-offset-research.md | 6 +- docs/implementation/v0.3.1/migration.md | 106 ++++++++++++++++++ docs/roadmap/README.md | 6 +- docs/roadmap/v0.3.1/ir-marker-char-offset.md | 6 +- 4 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 docs/implementation/v0.3.1/migration.md diff --git a/docs/design/v0.3.1/ir-marker-char-offset-research.md b/docs/design/v0.3.1/ir-marker-char-offset-research.md index edfe8b0..6b87e6f 100644 --- a/docs/design/v0.3.1/ir-marker-char-offset-research.md +++ b/docs/design/v0.3.1/ir-marker-char-offset-research.md @@ -1,8 +1,8 @@ --- -status: Draft +status: Frozen description: "v0.3.1 ADR — API source / char_start·char_end 의미 / 빈 paragraph 폴백 / schema 버전 4 결정의 근거" -target: v0.3.1 -last_updated: 2026-04-30 +ga: v0.3.1 +last_updated: 2026-05-03 --- # v0.3.1 ir-marker-char-offset — 설계 의사결정 리서치 요약 diff --git a/docs/implementation/v0.3.1/migration.md b/docs/implementation/v0.3.1/migration.md new file mode 100644 index 0000000..246a317 --- /dev/null +++ b/docs/implementation/v0.3.1/migration.md @@ -0,0 +1,106 @@ +--- +status: Frozen +description: "v0.3.1 구현 로그 — inline 컨트롤 마커의 'char_start'/'char_end' null 출고 정정. 상류 v0.7.8 'Paragraph::control_text_positions()' 직접 호출" +ga: v0.3.1 +last_updated: 2026-05-03 +--- + +# v0.3.1 — inline 컨트롤 마커 char offset 출고 (구현 로그) + +[v0.3.1/ir-marker-char-offset](../../roadmap/v0.3.1/ir-marker-char-offset.md) (spec) + [design/v0.3.1/ir-marker-char-offset-research](../../design/v0.3.1/ir-marker-char-offset-research.md) (ADR) 의 구현 결과 로그. 결정의 근거·옵션 비교는 ADR 가 보유 — 본 문서는 *산출물 / 검증 결과 / 호환성 / 이월 사항* 만 기록한다 (CONVENTIONS § CHANGELOG ↔ implementation log 역할 분리). + +PATCH release. 단일 세션 규모로 단일 `migration.md` 채택 (CONVENTIONS § implementation log 구조 — "작은 작업 (단일 세션·수일 규모) 은 단일 migration.md"). + +## 1. 산출물 + +### Rust 코어 (`src/ir.rs`) + +| 항목 | 변경 | +|---|---| +| `assert_position_invariant` 헬퍼 신규 | `controls.len() == positions.len()` 을 `assert_eq!` (release-active) 로 가드. 상류 `control_text_positions()` contract 위반 시 panic | +| `build_raw_paragraph` | paragraph 당 1회 `para.control_text_positions()` 호출 → `Vec` 결과를 controls iteration 과 `zip` | +| `collect_furniture_from_paragraph` | 동일 `Vec` 를 인자로 받아 footnote/endnote marker char offset 채움. 중복 호출 회피 | +| `RawTable` / `RawPicture` / `RawFormula` / `RawField` / `RawToc` | `char_offset: Option` 필드 추가 | +| `RawFootnote` / `RawEndnote` | `marker_char_offset: Option` 필드 추가 | +| Rust 단위 테스트 | `#[should_panic]` 테스트 + 정상 zero-width 케이스 — release/debug 무관 panic 보증의 source-level 가드 | + +### Python mapper (`python/rhwp/ir/_mapper.py`) + +| 함수 | 변경 | +|---|---| +| `_build_table_block` / `_build_picture_block` / `_build_formula_block` / `_build_field_block` / `_build_toc_block` | `raw.char_offset` (`int \| None`) → `Provenance.char_start = char_end = char_offset` (zero-width 복제). `None` → 양쪽 슬롯 `None` 폴백 | +| `_build_footnote_block` / `_build_endnote_block` | `raw.marker_char_offset` 을 `marker_prov.char_start/char_end` 양쪽 슬롯에 동일 복제. `prov` (블록 자체 위치) 도 marker 와 같은 위치 공유 (각주/미주는 본문 paragraph 안 마커 위치 = 블록 위치) | + +### Python raw types (`python/rhwp/ir/_raw_types.py`) + +7 종 raw struct (`RawTable` / `RawPicture` / `RawFormula` / `RawField` / `RawToc` / `RawFootnote` / `RawEndnote`) 의 Pydantic 모델에 `char_offset` 또는 `marker_char_offset: int | None = None` 필드 추가. PyO3 `#[pyo3(get)]` 와 1:1 mirror. + +### Schema / IR 모델 + +변경 없음. `Provenance.char_start: int | None` / `char_end: int | None` 은 이미 v1.1 에 정의 (v0.3.0 에서 슬롯만 만들어두고 `None` 으로만 출고). v0.3.1 은 *이미 있는 슬롯에 non-null 값을 흘리는* 변경이라 schema bump 불필요. `CURRENT_SCHEMA_VERSION = "1.1"` 유지. + +### Submodule pin + +`external/rhwp` `033617e` (v0.7.7) → `0fb3e67` (post-v0.7.8). enabling commit 은 v0.7.8 의 `cee3c1e` (PR #405 머지) — `pub fn Paragraph::control_text_positions` GA. 후속 sync `8482555` 은 직교 영역 (Task #484 `utf16_pos_to_char_idx`) 으로 본 PATCH 동작에 영향 없음. + +### 테스트 + +| 파일 | 변경 | +|---|---| +| [tests/test_v0_3_1_marker_char_offset.py](../../../tests/test_v0_3_1_marker_char_offset.py) | 신규. AC-1 ~ AC-14 회귀 가드 (mapper 단위 + real fixture 통합) | +| `tests/test_ir_caption.py` / `test_ir_field.py` / `test_ir_footnote.py` / `test_ir_formula.py` / `test_ir_furniture.py` / `test_ir_mapper.py` / `test_ir_picture.py` / `test_ir_schema_export.py` / `test_ir_toc.py` | 기존 v0.3.0 테스트의 raw struct 생성자에 신규 `char_offset` / `marker_char_offset` 필드 추가 (Pydantic strict — 명시 필요). 일부 파일에 `pytest.mark.spec("v0.3.1/ir-marker-char-offset")` marker 추가 (trace report 매핑) | + +### 메타 + +| 파일 | 변경 | +|---|---| +| [Cargo.toml](../../../Cargo.toml) | `version = "0.3.0"` → `"0.3.1"` (Cargo.toml 이 SSOT — `pyproject.toml` 의 `dynamic = ["version"]` 가 여기를 읽음) | +| [CHANGELOG.md](../../../CHANGELOG.md) | `[0.3.1] — 2026-05-02` 섹션 신설 (Fixed / Build / Known limitations) | +| [.github/workflows/ci.yml](../../../.github/workflows/ci.yml) | pyright scope list 에 `tests/test_v0_3_1_marker_char_offset.py` 추가 | +| [docs/traces/coverage.md](../../../docs/traces/coverage.md) | trace report 자동 갱신 — v0.3.1 spec 의 22개 테스트 매핑 (AC-1 ~ AC-14 + 파일 레벨 marker) | + +## 2. 호환성 + +| 시나리오 | 결과 | +|---|---| +| 기존 consumer 가 `prov.char_start is None` 분기 처리 | 그대로 작동. `None` 케이스 (`char_offsets.is_empty()` paragraph) 는 여전히 발생 — fail-safe 폴백 (spec § 결정 3) | +| 기존 consumer 가 `prov.char_start` 를 정수로 직접 사용 | v0.3.0 까지 항상 `None` 이라 사실상 사용 불가했음. v0.3.1 부터 일부 케이스에 정수 출고 — 신규 capability | +| Schema validator (`hwp_ir_v1.json` 또는 content-addressed alias) | 변경 없음. `anyOf [integer, null]` 정의가 그대로 매칭 | +| JSON serialization round-trip | `int` 값 보존 — `HwpDocument.model_validate_json(ir.model_dump_json())` 동등 (AC-10) | +| API surface diff | 없음. 슬롯 채움이 유일한 외부 가시 변경 | + +**SemVer**: PATCH (0.3.0 → 0.3.1). API surface 미변경 + schema 미변경 + 기존 분기 작동 — strict backward-compat. + +## 3. 검증 + +| 검사 | 결과 | +|---|---| +| `cargo clippy --all-targets -- -D warnings` | clean | +| `uv run pytest -m "not slow"` (전체) | 246 passed, 2 skipped (`test_ir_footnote.py:345` 의 미주 케이스 + `test_ir_formula.py:310` 의 수식 케이스 — `aift.hwp` 샘플에 해당 컨트롤 부재. 합성 fixture 미도입 결정 = AC-14) | +| `uv run pytest tests/test_v0_3_1_marker_char_offset.py -v` | 22 passed (AC-1 ~ AC-14 회귀 가드 전부 그린) | +| Real fixture e2e (`aift.hwp`) | TableBlock 96 (5 populated, 91 None) / PictureBlock 14 (1 populated, 13 None) / FieldBlock 4 (4 populated, 0 None) / FootnoteBlock 1 (1 populated, char_start=333) — 모든 populated 케이스 `char_start == char_end` invariant 준수 | +| Real fixture e2e (`table-vpos-01.hwpx`) | TableBlock 11 / PictureBlock 4 모두 `None` 폴백 (HWPX 샘플은 char_offsets 빈 paragraph 비율 더 높음). FieldBlock 1 populated. 폴백 경로 회귀 가드로서 의미 큼 | +| Cargo 빌드 | `maturin develop --release` 성공. abi3-py310 wheel 단일 산출물 (Python 3.10–3.13+ 커버) 유지 | + +## 4. 이월 사항 + +다음 항목은 v0.3.1 범위 밖. spec § 영구 비목표 가 정확한 목록 — 본 절은 *우선순위가 높은 다음 후보* 만 추림. + +| 항목 | 후속 | +|---|---| +| 중첩 표 안 inline 컨트롤의 `(section_idx, para_idx)` ↔ `char_offset` 의미축 mismatch (외부 paragraph vs 셀 내부 paragraph) | v0.3.0 부터 있던 Provenance 모델 한계. v0.4.0+ Provenance 정정 spec 별도 | +| `HwpField.cached_value` 추출 (위치는 v0.3.1 에서 채웠으나 *값* 미추출) | `field_ranges` 매핑 필요. 별도 spec | +| `Block.order: int` 필드 — controls 의 시각 순서 보존 | v0.4.0+ 검토. 현 모델은 IR `paragraphs` 배열 등장 순서 = controls 등장 순서로 묵시적 처리 | + +## 5. 참조 + +### 짝 페어 + +- spec: [docs/roadmap/v0.3.1/ir-marker-char-offset.md](../../roadmap/v0.3.1/ir-marker-char-offset.md) +- ADR: [docs/design/v0.3.1/ir-marker-char-offset-research.md](../../design/v0.3.1/ir-marker-char-offset-research.md) + +### 상류 + +- enabling PR: (Task #390 — `pub fn Paragraph::control_text_positions`) +- enabling commit: `cee3c1e` (v0.7.8 GA) +- 자체 등록 이슈 초안 (옵션 A 채택의 출처): [docs/upstream/issue-find-control-text-positions.md](../../upstream/issue-find-control-text-positions.md) — 상류 머지로 RESOLVED 전환 또는 archive 대상 diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 611c79b..ac60b25 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,11 +4,12 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe 본 문서는 Living — 자유 갱신. -## 현재 상태 (2026-04-30) +## 현재 상태 (2026-05-03) - **v0.1.0 / v0.1.1** — Frozen, PyPI 배포 완료 - **v0.2.0** — Frozen, Document IR v1 GA (2026-04-25) - **v0.3.0** — Frozen, Phase 2 (IR 확장 + `rhwp-py` CLI) GA (2026-04-28) +- **v0.3.1** — Frozen, inline 컨트롤 마커 char offset 출고 GA (2026-05-03) - **v0.4.0+** — 미착수, Phase 3 이후 ## 활성 spec 인덱스 @@ -21,7 +22,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.2.0 | Frozen | [v0.2.0/ir.md](v0.2.0/ir.md) | [design/v0.2.0/ir-research.md](../design/v0.2.0/ir-research.md) | | v0.3.0 (IR 확장) | Frozen | [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) | Frozen | [v0.3.0/cli.md](v0.3.0/cli.md) | [design/v0.3.0/cli-research.md](../design/v0.3.0/cli-research.md) | -| v0.3.1 (IR marker char offset) | Draft | [v0.3.1/ir-marker-char-offset.md](v0.3.1/ir-marker-char-offset.md) | [design/v0.3.1/ir-marker-char-offset-research.md](../design/v0.3.1/ir-marker-char-offset-research.md) | +| v0.3.1 (IR marker char offset) | Frozen | [v0.3.1/ir-marker-char-offset.md](v0.3.1/ir-marker-char-offset.md) | [design/v0.3.1/ir-marker-char-offset-research.md](../design/v0.3.1/ir-marker-char-offset-research.md) | | v0.7.0 (MCP server) | Draft | [v0.7.0/mcp.md](v0.7.0/mcp.md) | [design/v0.7.0/mcp-research.md](../design/v0.7.0/mcp-research.md) | ## 미착수 작업 계획 @@ -116,6 +117,7 @@ SemVer 0.x.y 단계에서 minor 는 단조 증가 — v0.9 다음은 v0.10 (v1.0 | 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 | [implementation/v0.3.0/stages/](../implementation/v0.3.0/stages/) (S1~S4) + [aparse-cleanup.md](../implementation/v0.3.0/aparse-cleanup.md) | — | +| v0.3.1 | [implementation/v0.3.1/migration.md](../implementation/v0.3.1/migration.md) | — | ## 원칙 diff --git a/docs/roadmap/v0.3.1/ir-marker-char-offset.md b/docs/roadmap/v0.3.1/ir-marker-char-offset.md index e96dde2..bfee09e 100644 --- a/docs/roadmap/v0.3.1/ir-marker-char-offset.md +++ b/docs/roadmap/v0.3.1/ir-marker-char-offset.md @@ -1,8 +1,8 @@ --- -status: Draft +status: Frozen description: "v0.3.1 — inline 컨트롤 마커 character offset 출고. 상류 v0.7.8 'Paragraph::control_text_positions()' 활용 (schema 변경 없음)" -target: v0.3.1 -last_updated: 2026-05-02 +ga: v0.3.1 +last_updated: 2026-05-03 --- # v0.3.1 — inline 컨트롤 마커의 character offset 출고 From bdaaf4d61ac955c1cfd7ff7c0758ddb5b1991c75 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 3 May 2026 11:11:16 +0900 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20trace=20report=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(v0.3.1=20=EB=A7=A4=ED=95=91=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - AC-10 매핑을 test_ir_schema_export.py::test_real_hwp_document_validates_against_schema 로 정정 (spec § AC-10 가 명시한 권위 위치) - stale entry test_real_sample_ir_passes_jsonschema_validation 제거 (실제 테스트 부재) - test_submodule_pin_matches_changelog_record 추가 (AC-13) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/traces/coverage.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index 03200f4..af19bed 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -402,24 +402,24 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_footnote_marker_populates_or_falls_back` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_has_none_fallback_paragraphs` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_json_roundtrip` | -| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_passes_jsonschema_validation` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_picture_marker_populates_or_falls_back` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_table_marker_populates_or_falls_back` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_zero_width_invariant` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_rust_uses_assert_eq_not_debug_assert` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_schema_provenance_char_start_anyof_integer_or_null` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_schema_version_remains_1_1` | +| v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_submodule_pin_matches_changelog_record` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_table_char_offset_populated_zero_width` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_toc_char_offset_populated_zero_width` | | v0.3.1/ir-marker-char-offset | — | `tests/test_v0_3_1_marker_char_offset.py::test_zero_width_invariant_holds_across_distinct_offsets` | | v0.3.1/ir-marker-char-offset | AC-1 | `tests/test_v0_3_1_marker_char_offset.py::test_footnote_marker_char_offset_populated_zero_width` | | v0.3.1/ir-marker-char-offset | AC-1 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_footnote_marker_populates_or_falls_back` | -| v0.3.1/ir-marker-char-offset | AC-10 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_json_roundtrip` | -| v0.3.1/ir-marker-char-offset | AC-10 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_ir_passes_jsonschema_validation` | +| v0.3.1/ir-marker-char-offset | AC-10 | `tests/test_ir_schema_export.py::test_real_hwp_document_validates_against_schema` | | v0.3.1/ir-marker-char-offset | AC-11 | `tests/test_v0_3_1_marker_char_offset.py::test_real_sample_zero_width_invariant` | | v0.3.1/ir-marker-char-offset | AC-11 | `tests/test_v0_3_1_marker_char_offset.py::test_zero_width_invariant_holds_across_distinct_offsets` | | v0.3.1/ir-marker-char-offset | AC-12 | `tests/test_v0_3_1_marker_char_offset.py::test_rust_uses_assert_eq_not_debug_assert` | | v0.3.1/ir-marker-char-offset | AC-13 | `tests/test_v0_3_1_marker_char_offset.py::test_changelog_records_pin_bump` | +| v0.3.1/ir-marker-char-offset | AC-13 | `tests/test_v0_3_1_marker_char_offset.py::test_submodule_pin_matches_changelog_record` | | v0.3.1/ir-marker-char-offset | AC-14 | `tests/test_v0_3_1_marker_char_offset.py::test_no_synthetic_fixture_directory` | | v0.3.1/ir-marker-char-offset | AC-2 | `tests/test_v0_3_1_marker_char_offset.py::test_endnote_marker_char_offset_populated_zero_width` | | v0.3.1/ir-marker-char-offset | AC-3 | `tests/test_v0_3_1_marker_char_offset.py::test_picture_char_offset_populated_zero_width` | From b183bf35183366b27b502fe14370ff4b12703da0 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 3 May 2026 11:23:18 +0900 Subject: [PATCH 10/11] =?UTF-8?q?chore:=20tests/*.py=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=EC=8B=9C=20spec=20trace=20=EC=9E=90=EB=8F=99=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20hook=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - .claude/hooks/regen-spec-trace.py 신규 — PostToolUse 에서 tests/*.py 편집 감지 시 scripts/generate_spec_trace.py 자동 실행 (CI 와 동일한 --no-project + ad-hoc typer 패턴) - .claude/settings.json PostToolUse 에 hook 등록 - 기존 docs-lint hook 은 docs/*.md 단일 파일만 감시 → tests/*.py 의 @pytest.mark.spec marker 변경이 로컬에서 trace 재생성을 trigger 하지 않아 CI --check 까지 가서야 staleness 발견되던 격차 해소 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/hooks/regen-spec-trace.py | 61 +++++++++++++++++++++++++++++++ .claude/settings.json | 4 ++ 2 files changed, 65 insertions(+) create mode 100755 .claude/hooks/regen-spec-trace.py diff --git a/.claude/hooks/regen-spec-trace.py b/.claude/hooks/regen-spec-trace.py new file mode 100755 index 0000000..f181b98 --- /dev/null +++ b/.claude/hooks/regen-spec-trace.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""tests/*.py PostToolUse hook — spec trace report 자동 재생성. + +stdin 으로 받은 hook event 의 ``tool_input.file_path`` 가 ``tests/**/*.py`` 면 +``scripts/generate_spec_trace.py`` (write mode) 실행하여 ``docs/traces/coverage.md`` +를 자동 갱신. 그 외 즉시 종료. + +배경: trace 는 ``@pytest.mark.spec(...)`` marker 변경으로 invalidate 되지만 기존 +docs-lint hook 은 ``docs/*.md`` 만 감시. ``tests/*.py`` 마커 추가/이름변경/제거가 +로컬에서 재생성을 trigger 하지 않아 CI ``--check`` 까지 가서야 staleness 발견. + +위반(generator failure) 시 exit 2 + stderr — 모델 컨텍스트 주입. +정상 갱신 또는 적용 안 되는 파일은 exit 0. +""" + +import json +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +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) + +try: + rel = Path(file_path).resolve().relative_to(REPO) +except ValueError: + sys.exit(0) + +rel_str = str(rel).replace("\\", "/") +if not (rel_str.startswith("tests/") and rel.suffix == ".py"): + sys.exit(0) + +# * trace 재생성 — CI 와 동일한 무프로젝트 + ad-hoc typer install 패턴. +# project venv 의 extras 설치 상태와 무관하게 동작. +result = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "typer>=0.12", + "python", + str(REPO / "scripts" / "generate_spec_trace.py"), + ], + cwd=str(REPO), + capture_output=True, + text=True, +) +if result.returncode != 0: + sys.stderr.write("\nregen-spec-trace: generator failed\n") + sys.stderr.write(result.stderr) + sys.exit(2) diff --git a/.claude/settings.json b/.claude/settings.json index 15106e9..e53808f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,6 +11,10 @@ { "type": "command", "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/docs-lint.py" + }, + { + "type": "command", + "command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/regen-spec-trace.py" } ] } From 5a73267f41b2efd53de8ba95d9512b4ca665e411 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 3 May 2026 11:37:06 +0900 Subject: [PATCH 11/11] =?UTF-8?q?chore:=20.gitignore=20=EC=97=90=20mutants?= =?UTF-8?q?/=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - mutmut (mutation testing) 작업 디렉토리 leakage 방지 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9c5df84..cfe014a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ htmlcov/ .coverage .coverage.* coverage.xml +# ^ mutmut (mutation testing) 작업 디렉토리 — source copy + 변이 산출물 +mutants/ # * MAC OS .DS_Store