From 85ac1525c65f30ab75aefa7bc2cee9c328292314 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:50:55 +0900 Subject: [PATCH] feat(text): id-addressed text blocks, a budgeted outline and batch text changes (#666, #667, #668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc.text.blocks() walks body paragraphs and table-cell paragraphs (nested tables included) in document order with positional ids such as s0p16t0r3c0q0 and a readable location (표1 4행 1열). A merged cell appears once, at its anchor; a table missing cell addresses falls back to physical positions so ids stay unique. Ids are identical after save and reopen. doc.text.outline(max_chars=...) lists only non-empty blocks as {id, where, text} within a character budget: tabs and line breaks become spaces, control characters go, long blocks are cut with an ellipsis. A page always holds one item and next_after resumes paging; to_text() is a one-line-per-item prompt form. doc.text.apply_changes({id: text}) resolves every id before writing, keeps runs and controls when the edit stays inside one run, falls back to the first run's formatting otherwise (reported as rewritten), and estimates with the form-fit measurer which changed cells would need more lines than the cell holds (overflow). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 + docs/ai-assistants.md | 24 ++ docs/architecture/module-ownership.json | 6 + docs/editor-surface-inventory.md | 3 +- docs/error-codes.md | 4 +- docs/support-matrix.md | 2 + scripts/editor_surface_inventory.py | 1 + src/hwpx/_document/ns/text.py | 62 +++ src/hwpx/_document/text_blocks.py | 385 ++++++++++++++++++ src/hwpx/capabilities.py | 10 + src/hwpx/data/contract_docs/support-matrix.md | 2 + src/hwpx/errors.py | 2 + tests/test_namespace_hygiene.py | 3 +- tests/test_product_boundary.py | 4 +- tests/test_text_blocks.py | 223 ++++++++++ 15 files changed, 739 insertions(+), 4 deletions(-) create mode 100644 src/hwpx/_document/text_blocks.py create mode 100644 tests/test_text_blocks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 471cabb9..13d7651d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +### 추가 + +- `doc.text.blocks()`가 본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로, 위치로 정해지는 id(`s0p16`, + `s0p16t0r3c0q0`)와 사람이 읽는 위치(`표1 4행 1열`)와 함께 내놓는다. 병합 칸은 대표 칸에서 한 번만 나오고, id는 + 저장·재열기 뒤에도 같다(#666). +- `doc.text.outline(max_chars=8000)`이 글자가 있는 덩어리만 `{"id", "where", "text"}` 목록으로, 글자 예산 안에서 + 돌려준다. 탭·줄 바꿈은 공백, 제어 문자는 지우고, 긴 덩어리는 앞부분만 남긴다. 예산이 다하면 `next_after`로 다음 + 쪽을 읽고, `to_text()`는 한 줄에 한 항목인 프롬프트 형태다. 작은 모델에 문서 전체 대신 짧은 목록을 넘기는 용도다(#668). +- `doc.text.apply_changes({id: 새 글})`이 여러 덩어리를 한 번에 바꾸고 id마다 `changed`·`unchanged`·`unknown_ids`· + `rewritten`을 보고한다. 한 런 안의 바꿈은 런과 컨트롤을 지키고, 칸이 품는 줄보다 많이 필요할 것으로 추정되는 칸은 + `overflow`로 미리 알린다(form-fit 측정기의 추정, 렌더 아님)(#667). + ### 고침 - 줄 캐시(`write_line_caches`)가 머리에 정의되지 않은 글머리표·번호를 가리키는 문단 모양(예: 글머리표 idRef 0)의 diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md index 9874ee9c..8695b55e 100644 --- a/docs/ai-assistants.md +++ b/docs/ai-assistants.md @@ -87,6 +87,30 @@ environment where PyPI is unavailable: confirm the requested change is present. ``` +## 작은 모델·로컬 LLM — 짧은 목록으로 읽고 id로 바꾸기 + +27B급 로컬 모델은 문서 구조를 통째로 읽으면 문맥이 넘치고, 긴 인수를 담은 도구 호출이 +깨지기 쉽습니다. 모델에게는 id 붙은 짧은 목록만 보여 주고, 바꿀 글만 `{id: 새 글}`로 +받아 코드가 문서에 넣는 방식이 안정적입니다. + +```python +from hwpx import HwpxDocument + +doc = HwpxDocument.open("학습지.hwpx") +page = doc.text.outline(max_chars=8000) # [{"id", "where", "text"}, ...] +prompt = page.to_text() # "[s0p3t0r1c1q0] 표1 2행 2열: ..." 한 줄씩 +# page.complete 가 False 면 doc.text.outline(after=page.next_after) 로 다음 쪽 + +changes = {"s0p3t0r1c1q0": "바뀐 문항 내용"} # 모델이 JSON 으로 돌려준 것 +report = doc.text.apply_changes(changes) +report.unknown_ids # 문서에 없는 id +report.overflow # 칸이 넘칠 것으로 추정되는 곳 — 글을 줄이게 다시 물어보기 +doc.save_to_path("학습지-변형.hwpx") +``` + +id는 위치로 정해져 저장·재열기 뒤에도 같습니다. 문단·표·칸을 넣거나 지우면 그 뒤의 +id가 바뀌므로, 목록을 읽은 뒤 구조를 바꿨다면 목록을 다시 읽습니다. + ## 최소 예제 wheel 설치 뒤 새 문서를 만들어 돌려주는 가장 짧은 코드입니다. diff --git a/docs/architecture/module-ownership.json b/docs/architecture/module-ownership.json index e13f7758..05185231 100644 --- a/docs/architecture/module-ownership.json +++ b/docs/architecture/module-ownership.json @@ -746,6 +746,12 @@ "disposition": "core", "approvedBy": "owner-authorized issue565 additive implementation", "rationale": "QName-aware bounded byte-span selection of text after a table within its own paragraph; reusable format behavior. The caller supplies the predicate, so this module contains no genre, institution, workflow or caption policy. Secure XML parsing and existing OPC markup tokenization validate namespace scopes without reserializing source bytes." + }, + { + "path": "src/hwpx/_document/text_blocks.py", + "disposition": "core", + "approvedBy": "text-blocks-for-small-models", + "rationale": "doc.text.blocks/outline/apply_changes: walks body and table-cell paragraphs (nested tables included) in document order with positional ids, lists the non-empty ones within a character budget, and writes {id: text} back through the paragraph's own run-preserving setter, estimating cell overflow with the form-fit measurer. Genre-neutral format behavior with no workflow, prompt or model policy; the owner of doc.text keeps its namespace module short by delegating here like the other _document owners." } ], "removed": [ diff --git a/docs/editor-surface-inventory.md b/docs/editor-surface-inventory.md index 25c8ca67..a9594fc7 100644 --- a/docs/editor-surface-inventory.md +++ b/docs/editor-surface-inventory.md @@ -38,7 +38,7 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 · -자동 생성 시점 교차 확인: 원장(요소 축) 132건 render-verified(요소 345개 중) · 캐파빌리티 영역(기능 축) 41개 등록됨. +자동 생성 시점 교차 확인: 원장(요소 축) 132건 render-verified(요소 345개 중) · 캐파빌리티 영역(기능 축) 42개 등록됨. ### 서식 @@ -122,6 +122,7 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 · | 기능 | 엔진 상태 | 근거 | 실한컴 검증 | |---|---|---|---| +| id 기반 글 읽기·일괄 바꿈(text blocks) | 저작 api | 지원 매트릭스 「id 기반 글 읽기·일괄 바꿈(text blocks)」(`Parse·Edit`) · capabilities 영역 `text-blocks` · 위치 `doc.text` | 미실측 | | 메일머지(placeholder 템플릿 배치 생성) | 저작 api | 지원 매트릭스 「메일머지(placeholder 템플릿 배치 생성)」(`Edit·Render-verified`) · capabilities 영역 `mail-merge` · 위치 모듈 — `hwpx.tools.mail_merge` | Render-verified | | 문서 끼워 넣기(문서 병합) | 저작 api | 지원 매트릭스 「문서 끼워 넣기(문서 병합)」(`Create·Render-verified`) · capabilities 영역 `document-merge` · 위치 모듈 — `hwpx.tools.document_merge` | Render-verified | | 편집 계획 실행(edit plan) | 저작 api | 지원 매트릭스 「편집 계획 실행(edit plan)」(`Preserve·Edit·Render-verified`) · capabilities 영역 `edit-plan` · 위치 모듈 — `hwpx.plan` | Render-verified | diff --git a/docs/error-codes.md b/docs/error-codes.md index ba90f390..10e3d5ae 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -30,7 +30,7 @@ except HwpxError as exc: | 형태 | `style-not-found` | `VISUAL_COMPLETE_FAILED` | | 쓰임 | 예외 분기 | **발행된 영수증 스키마의 필드값** | | 관리 | major 경계 | 영수증 스키마 버전 | -| 개수 | 196 | 11 | +| 개수 | 199 | 11 | 통합하지 않는 이유: quality 코드는 `hwpx.mutation-report/v1` 과 `VisualCompleteReport` 에 이미 실려 나간 값이다. 이름을 바꾸면 영수증을 읽는 @@ -371,10 +371,12 @@ except HwpxError as exc: | 코드 | 뜻 | |---|---| +| `text-block-unknown` | outline 의 after 에 준 글 덩어리 id 가 문서에 없다. | | `text-highlight-color-invalid` | 형광펜 색이 #RRGGBB 6자리 16진 형식이 아니다. | | `text-highlight-match-crosses-markup` | 찾은 구간이 인라인 마크업을 가로질러 안전하게 감쌀 수 없다. | | `text-highlight-match-empty` | 형광펜으로 감쌀 문자열이 비어 있다. | | `text-highlight-match-not-found` | 문단에서 형광펜으로 감쌀 문자열을 찾지 못했다. | +| `text-outline-budget` | outline 의 글자·항목 예산이 1보다 작다. | | `text-search-empty` | 바꿀 대상 문자열이 비어 있다. | ### `track-*` diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 186391c1..a2c64989 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -57,6 +57,7 @@ | 글꼴 등록 | Create·Render-verified | `doc.styles.ensure_font`(6.8 트레인㉚ 등재, 코드는 2026-08-04 감사 §2 갭#1 이후 사이클에서 구현됐으나 캐파빌리티 미등재 상태였다). 실한컴 렌더 검증: `docs/openrate/report-v5.json` authored-fontface 스트라텀(lang 7블록 전부·substFont on/off 조합), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed — 실제로 참조된 fontRef까지 확인(선언만 하고 고아로 남기지 않음) | | 표 탐색 기반 채움(라벨 매칭 네비게이션) | Edit·Render-verified | `doc.tables.fill_by_path`/`.find_cell_by_label`(6.8 트레인㉚ 등재, 코드는 이전 사이클부터 존재). "양식 채움(byte-splice)" 영역과는 다른 메커니즘(byte-splice가 아니라 라벨 매칭 기반 구조적 셀 채움) — 혼동 주의. 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-tablenavfill 스트라텀(단일-홉 right/down 4건 + multi-hop `합계 > down > right` 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 기존 `hp:tc`/`hp:t` 텍스트를 치환만 할 뿐 새 요소를 안 만든다(document-merge·mail-merge·find-replace와 같은 이유로 CAPABILITY_KEYWORDS 등록 요소 0개) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 | | 찾아바꾸기 | Edit·Render-verified | `doc.text.replace`/`.find_runs`(6.8 트레인㉚ 등재, 코드는 이전 사이클부터 존재). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-findreplace 스트라텀(무제한 치환 + `char_pr_id_ref`로 좁힌 스타일 한정 치환 교대), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 기존 `hp:t` 텍스트만 치환할 뿐 새 요소를 안 만든다(document-merge·mail-merge·table-navigation-fill과 같은 이유) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 | +| id 기반 글 읽기·일괄 바꿈(text blocks) | Parse·Edit | `doc.text.blocks`·`.outline`·`.apply_changes`: 본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로 위치형 id(`s0p16t0r3c0q0`)와 함께 순회하고, 글자 예산 안의 짧은 목록으로 읽고, `{id: 새 글}`을 한 번에 적용한다. 병합 칸은 대표 칸에서 한 번만 나오고 id는 저장·재열기 뒤에도 같다(저장소 픽스처 1,210개·93,688덩어리 실측). 한 런 안의 바꿈은 런·컨트롤을 지키고, 칸 넘침은 form-fit 측정기로 추정해 보고한다 — 렌더가 아니라 추정이므로 Render-verified가 아니다. 기존 `hp:t` 텍스트만 바꾸고 새 요소를 만들지 않아 원장 라우팅은 없다(찾아바꾸기와 같은 이유) | | 하이퍼링크·책갈피 | Create·Render-verified | `doc.refs.add_hyperlink`/`.add_bookmark`(6.8 트레인㉚에서 "네이티브 목차(TOC)/상호참조" 영역에서 분리 — 그 영역의 Render-verified 근거는 TOC 구조·페이지 정합뿐이라 하이퍼링크·책갈피를 한 번도 독립적으로 커버한 적이 없었다, 편집기 표면 인벤토리 트레인㉙ 발견). **6.9 트레인㉜**: `docs/openrate/report-v12.json` authored-hyperlink-bookmark 스트라텀(북마크 생성 후 외부 URL/문서 내 `#앵커` 하이퍼링크 교대), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) — 이 영역의 첫 독립 실한컴 판정. `hp:bookmark`는 요소 직접 등록(`by-openrate-corpus`), `hp:fieldBegin`(하이퍼링크 자신의 컨트롤)은 CLICKHERE/TOC/CROSSREF와 공유하는 저수준 요소라 여전히 요소 등록 없음 — 영역 등급만 실측으로 갱신, 원장 오염 경로는 그대로 차단. 읽기: `doc.refs.bookmarks`가 한/글의 두 저장 형태(`hp:bookmark`·`hp:fieldBegin type="BOOKMARK"`)를 함께 나열한다(#579) | | 메일머지(placeholder 템플릿 배치 생성) | Edit·Render-verified | `hwpx.tools.mail_merge.merge_template_rows`(6.8 트레인㉛ 등재, 코드는 이전 사이클부터 존재 — 편집기 표면 인벤토리 트레인㉙·계층 판정 트레인㉚이 찾을 때까지 캐파빌리티·매트릭스 어디에도 등재가 없었다). `{{field}}`/`${field}`/`<>` 세 플레이스홀더 문법을 지원하는 템플릿 1개 + 행 데이터(csv/json/시퀀스)로 문서 N개를 배치 생성 — 기존 `hp:t` 텍스트를 치환하는 것이라 새 요소를 만들지 않는다(Create가 아니라 Edit). `strict`/`fit_policy`/`value_sanitizer` 등 운영 옵션 다수. 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-mailmerge 스트라텀(세 플레이스홀더 문법 각각 겨냥하는 전용 템플릿으로 회전), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — 이 영역의 첫 독립 실한컴 판정 | | 메일머지 표시 필드 | Create(experimental) | `HwpxOxmlParagraph.add_mail_merge_field(name, cached_text=)`(`oxml/field_marks.py`, P4). 팀장 실한컴 GUI 프로브 gold(`tests/fixtures/gui_probes/mailmerge_display_fields.hwpx`, "도구 > 메일 머지 > 메일 머지 표시 달기"로 필드 2개 삽입) 역설계 — 위 「메일머지(placeholder 템플릿 배치 생성)」가 **기존 `hp:t` 텍스트를 치환**하는 Edit인 것과 달리 이쪽은 `hp:fieldBegin type="MAILMERGE"` 자체를 새로 만드는 Create다(그래서 별도 영역). DATE/PATH와 같은 단일 run 안 `ctrl(begin)`/`t`/`ctrl(end)` 배치에 더해 **끝에 빈 `hp:t`가 하나 더** 붙고(gold 2/2), `hp:fieldBegin`에 `metaTag` 속성이 **없다**(DATE/교정 부호/PATH gold는 셋 다 `metaTag=""`) — 필드 종류마다 실측이 갈리는 유일한 속성. `hp:parameters`(`cnt="5"`): `Fiexde=1`(**오타 철자가 실물·한컴 공식 문서 공통의 정본이라 정정 금지** — `trackchageConfig`·memo의 `NOMAL`과 같은 부류) · `Prop=8`(DATE/PATH와 같은 캐시 필드류 공유값) · `Command`/`FieldValue`가 둘 다 필드 이름 그대로 · `FieldType="USER_DEFINE"`. 캐시 텍스트 기본값 `{{이름}}`은 `hwpx.tools.mail_merge`가 인식하는 플레이스홀더 문법이라 이 필드로 저작한 템플릿이 그 배치 생성기에 그대로 물린다. gold의 두 필드는 `id`가 다른데 `fieldid`는 같은 값을 공유하나, 표본이 문서 하나뿐이라 채번 규칙을 역산할 근거가 없어 DATE/PATH와 같은 독립 난수를 쓴다. 실한컴 렌더 검증: `docs/openrate/report-v22.json` authored-mailmerge-field 스트라텀(단일/2필드/커스텀 캐시/**한글 필드명(경계)**/표 셀 안 필드, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-18 측정) | @@ -107,6 +108,7 @@ | 글꼴 등록 | `doc.styles` | | 표 탐색 기반 채움(라벨 매칭 네비게이션) | `doc.tables` | | 찾아바꾸기 | `doc.text` | +| id 기반 글 읽기·일괄 바꿈(text blocks) | `doc.text` | | 하이퍼링크·책갈피 | `doc.refs` | | 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` | | 메일머지 표시 필드 | `doc.fields` | diff --git a/scripts/editor_surface_inventory.py b/scripts/editor_surface_inventory.py index c04f1097..47549729 100644 --- a/scripts/editor_surface_inventory.py +++ b/scripts/editor_surface_inventory.py @@ -114,6 +114,7 @@ "font-registration": "서식", "table-navigation-fill": "표", "find-replace": "서식", + "text-blocks": "자동화", "hyperlink-bookmark": "참조", # 6.8 트레인㉛ — 트레인㉚이 찾은 mail_merge 측정 갭 등재. "mail-merge": "자동화", diff --git a/src/hwpx/_document/ns/text.py b/src/hwpx/_document/ns/text.py index 739656d0..37d31abc 100644 --- a/src/hwpx/_document/ns/text.py +++ b/src/hwpx/_document/ns/text.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from ...objects.highlight import Highlight from ...oxml import HwpxOxmlParagraph, HwpxOxmlRun + from ..text_blocks import TextBlock, TextChangeReport, TextOutline __all__ = ["TextNamespace"] @@ -154,6 +155,67 @@ def find_runs( ) ] + # -- id 붙은 글 덩어리 --------------------------------------------------- + + def blocks(self) -> Iterator["TextBlock"]: + """본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로, 안정적인 id와 함께 내놓는다. + + id는 위치로 정해진다: ``s0p16`` (0번 구역의 16번 본문 문단, 번호는 0부터), ``s0p16t0r3c0q0`` (그 문단의 0번 표, + 3행 0열 칸의 0번 문단), 칸 안 표는 ``…q0t0r1c1q0`` 처럼 이어진다. 행·열은 칸의 주소라 + 병합 칸은 대표 칸에서 한 번만 나온다. 저장·재열기 뒤에도 같고, 앞쪽에 문단·표·칸을 + 넣거나 지울 때만 바뀐다. 머리말·꼬리말·각주·메모·글상자는 포함하지 않는다. + """ + + from ..text_blocks import iter_text_blocks + + return iter_text_blocks(self._doc) + + def outline( + self, + *, + max_chars: int = 8000, + max_items: int | None = None, + max_block_chars: int = 200, + after: str | None = None, + ) -> "TextOutline": + """글자가 있는 덩어리를 ``{"id", "where", "text"}`` 목록으로, 글자 예산 안에서 돌려준다. + + 빈 문단은 빼고, 탭·줄 바꿈은 공백으로, 제어 문자는 지운다. *max_block_chars* 보다 긴 + 덩어리는 앞부분만 남기고 ``…`` 로 끝낸다(``"truncated": True``). *max_chars* 는 각 항목의 + id·위치·글자를 센다. 예산이나 *max_items* 가 다하면 항목 단위로 멈추고 ``complete`` 가 + ``False``, ``next_after`` 가 마지막 id다 — 다음 쪽은 ``after=next_after`` 로 읽는다. 한 쪽에는 예산보다 큰 항목이라도 적어도 하나가 들어간다. + ``to_text()`` 는 한 줄에 한 항목인 가장 짧은 프롬프트 형태다. + """ + + from ..text_blocks import outline + + return outline( + self._doc, + max_chars=max_chars, + max_items=max_items, + max_block_chars=max_block_chars, + after=after, + ) + + def apply_changes( + self, + changes: "dict[str, str]", + *, + check_overflow: bool = True, + ) -> "TextChangeReport": + """``{id: 새 글}`` 을 한 번에 적용하고 id마다 결과를 보고한다. + + 모든 id를 먼저 찾은 뒤 쓴다. 없는 id는 ``unknown_ids`` 로 보고하고 나머지는 적용한다. + 한 런 안에서 끝나는 바꿈은 런과 컨트롤을 모두 지키고, 서식이 다른 런에 걸치거나 탭·줄 + 바꿈을 넣는 바꿈은 문단 첫 런의 서식으로 쓴다(``rewritten``). *check_overflow* 면 바뀐 칸을 + 전후로 재어, 칸이 품는 줄보다 많이(그리고 전보다 많이) 필요할 것으로 추정되는 칸을 + ``overflow`` 에 넣는다 — 렌더가 아니라 form-fit 측정기의 추정이다. + """ + + from ..text_blocks import apply_text_changes + + return apply_text_changes(self._doc, changes, check_overflow=check_overflow) + # -- 치환 -------------------------------------------------------------- def replace( diff --git a/src/hwpx/_document/text_blocks.py b/src/hwpx/_document/text_blocks.py new file mode 100644 index 00000000..ccc2ee99 --- /dev/null +++ b/src/hwpx/_document/text_blocks.py @@ -0,0 +1,385 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Text blocks with stable ids — read a short list, write changes back by id. + +A *text block* is one paragraph of the body or of a table cell (nested tables +included), in document order. Its id is positional:: + + s0p16 section 0, body paragraph 16 + s0p16t0r3c0q0 … its table 0, the cell at row 3 / column 0, cell paragraph 0 + s0p16t0r3c0q0t0r1c1q0 … a table nested in that cell paragraph + +``r``/``c`` are the cell's own address (``hp:cellAddr``), so a merged cell +appears once, at its anchor. Ids survive save and reopen; they change only +when paragraphs, tables or cells are inserted or removed before them. + +This is the engine side of the "the model reads a short list and writes +changes, code assembles the document" workflow: :func:`outline` makes the +budgeted list, :func:`apply_text_changes` puts the answers back. +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, Iterator, Mapping + +if TYPE_CHECKING: + from ..document import HwpxDocument + from ..oxml import HwpxOxmlParagraph, HwpxOxmlTableCell + +__all__ = [ + "TextBlock", + "TextChangeReport", + "TextOutline", + "apply_text_changes", + "iter_text_blocks", + "outline", +] + +_WHITESPACE_RE = re.compile(r"\s+") + + +@dataclass(frozen=True, slots=True) +class TextBlock: + """One body or table-cell paragraph with its stable id.""" + + id: str + #: Human-readable position, e.g. ``"본문 17문단"`` or ``"표2 4행 1열"``. + location: str + paragraph: "HwpxOxmlParagraph" + #: The table cell holding the paragraph, ``None`` for a body paragraph. + cell: "HwpxOxmlTableCell | None" = None + + @property + def text(self) -> str: + return self.paragraph.text + + @property + def in_table(self) -> bool: + return self.cell is not None + + +def _cells_in_order(table: Any) -> list[tuple[int, int, Any]]: + """Each physical cell once (a merged cell at its anchor), row by row, with its address. + + The address is the cell's ``hp:cellAddr``. A table where a cell has none, or + two cells share one, is addressed by physical position instead (row element, + cell element within it) so its ids stay unique. + """ + + cells = [(row_index, cell_index, cell) for row_index, row in enumerate(table.rows) for cell_index, cell in enumerate(row.cells)] + addresses = [cell.address if cell._addr_element() is not None else None for _r, _c, cell in cells] + if None in addresses or len(set(addresses)) != len(addresses): + return cells + return [(row, col, cell) for (row, col), (_r, _c, cell) in zip(addresses, cells)] # type: ignore[misc] + + +def _walk_cell_tables( + paragraph: "HwpxOxmlParagraph", + prefix: str, + where: str, + table_numbers: list[int] | None, +) -> Iterator[TextBlock]: + for table_index, table in enumerate(paragraph.tables): + if table_numbers is not None: + table_numbers[0] += 1 + label = f"표{table_numbers[0]}" + else: + label = f"{where} 안 표{table_index + 1}" + for row, col, cell in _cells_in_order(table): + cell_prefix = f"{prefix}t{table_index}r{row}c{col}" + cell_where = f"{label} {row + 1}행 {col + 1}열" + for cell_paragraph_index, cell_paragraph in enumerate(cell.paragraphs): + block_id = f"{cell_prefix}q{cell_paragraph_index}" + yield TextBlock(block_id, cell_where, cell_paragraph, cell) + yield from _walk_cell_tables(cell_paragraph, block_id, cell_where, None) + + +def iter_text_blocks(document: "HwpxDocument") -> Iterator[TextBlock]: + """Yield every body paragraph and table-cell paragraph in document order. + + Top-level tables are numbered in document order across sections (``표1``, + ``표2``…); a nested table is named inside its cell (``표2 1행 1열 안 표1``). + Headers, footers, notes, memos and text boxes are not included. + """ + + sections = document.sections + table_numbers = [0] + for section_index, section in enumerate(sections): + section_label = f"{section_index + 1}구역 " if len(sections) > 1 else "" + for paragraph_index, paragraph in enumerate(section.paragraphs): + block_id = f"s{section_index}p{paragraph_index}" + yield TextBlock(block_id, f"{section_label}본문 {paragraph_index + 1}문단", paragraph) + yield from _walk_cell_tables(paragraph, block_id, "", table_numbers) + + +# --------------------------------------------------------------------------- # +# outline: the budgeted short list +# --------------------------------------------------------------------------- # +class TextOutline(list): # type: ignore[type-arg] + """``list[dict]`` of ``{"id", "where", "text"}`` items, plus paging facts. + + It is a plain list for JSON (``json.dumps`` writes the items only); the + attributes say whether the budget cut it short and where to resume. + """ + + #: ``True`` when every non-empty block fit in the budget. + complete: bool + #: Pass as ``after=`` to read the next page; ``None`` when complete. + next_after: str | None + #: Non-empty blocks after the ``after`` cursor, listed or not. + total: int + + def __init__(self, items: list[dict[str, Any]], *, complete: bool, next_after: str | None, total: int): + super().__init__(items) + self.complete = complete + self.next_after = next_after + self.total = total + + def to_text(self) -> str: + """One line per item — ``[id] where: text`` — the cheapest prompt form.""" + + lines = [f"[{item['id']}] {item['where']}: {item['text']}" for item in self] + if not self.complete: + lines.append(f"… {self.total - len(self)} more (after={self.next_after})") + return "\n".join(lines) + + +def _clean(text: str) -> str: + """Tabs and line breaks become spaces; other control characters go.""" + + text = "".join(" " if ch in "\t\n\r" else ch for ch in text if ch in "\t\n\r" or unicodedata.category(ch) != "Cc") + return _WHITESPACE_RE.sub(" ", text).strip() + + +def outline( + document: "HwpxDocument", + *, + max_chars: int = 8000, + max_items: int | None = None, + max_block_chars: int = 200, + after: str | None = None, +) -> TextOutline: + """The non-empty text blocks as a short list that fits *max_chars*. + + Empty paragraphs are left out, tabs and line breaks become spaces and + control characters are dropped, and a block longer than *max_block_chars* + keeps its start and ends with ``…`` (its item gets ``"truncated": True``). + *max_chars* counts each item's id, location and text. When the budget or + *max_items* runs out the outline stops at a whole item: ``complete`` is + ``False`` and ``next_after`` is the last id listed, to pass as *after* for + the next page. A page always holds at least one item, even one larger than + *max_chars*. + """ + + from ..errors import HwpxLookupError, HwpxValueError + + for name, value in (("max_chars", max_chars), ("max_block_chars", max_block_chars), ("max_items", max_items)): + if value is not None and value < 1: + raise HwpxValueError( + f"{name} must be >= 1", + code="text-outline-budget", + context={name: value}, + suggestion="Pass a positive budget, or leave max_items unset for no item cap.", + ) + + items: list[dict[str, Any]] = [] + used = 0 + total = 0 + full = False + seen_cursor = after is None + for block in iter_text_blocks(document): + if not seen_cursor: + seen_cursor = block.id == after + continue + text = _clean(block.text) + if not text: + continue + total += 1 + if full: + continue + item: dict[str, Any] = {"id": block.id, "where": block.location, "text": text} + if len(text) > max_block_chars: + item["text"] = text[: max_block_chars - 1].rstrip() + "…" + item["truncated"] = True + cost = len(block.id) + len(block.location) + len(item["text"]) + # A page always holds one item, so paging moves on even when a single + # item is larger than the whole budget. + if items and (used + cost > max_chars or (max_items is not None and len(items) >= max_items)): + full = True + continue + used += cost + items.append(item) + if after is not None and not seen_cursor: + raise HwpxLookupError( + f"unknown text block id: {after!r}", + code="text-block-unknown", + context={"after": after}, + suggestion="Pass the next_after of the previous page, or an id from doc.text.blocks().", + ) + complete = len(items) == total + return TextOutline( + items, + complete=complete, + next_after=None if complete or not items else items[-1]["id"], + total=total, + ) + + +# --------------------------------------------------------------------------- # +# apply_text_changes: write answers back by id +# --------------------------------------------------------------------------- # +@dataclass(slots=True) +class TextChangeReport: + """What :func:`apply_text_changes` did with each id.""" + + changed: list[str] = field(default_factory=list) + #: Ids the document does not have (nothing was written for them). + unknown_ids: list[str] = field(default_factory=list) + #: Ids whose text was already the requested text. + unchanged: list[str] = field(default_factory=list) + #: Changed ids whose runs could not all be kept: the edit crossed runs of + #: different formatting, or added a tab or line break. The new text took the + #: paragraph's first run's formatting. + rewritten: list[str] = field(default_factory=list) + #: Cells whose new text is estimated to need more lines than the cell holds, + #: so the row would grow: ``{"id", "where", "lines", "lineBudget", + #: "linesBefore", "confidence"}``. An estimate from the form-fit measurer, + #: not a render. + overflow: list[dict[str, Any]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + """``True`` when every id was known and no cell is estimated to overflow.""" + + return not self.unknown_ids and not self.overflow + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "changed": list(self.changed), + "unknownIds": list(self.unknown_ids), + "unchanged": list(self.unchanged), + "rewritten": list(self.rewritten), + "overflow": [dict(entry) for entry in self.overflow], + "warnings": list(self.warnings), + } + + +def _write(paragraph: "HwpxOxmlParagraph", value: str) -> bool: + """Write *value*; return ``True`` when the runs had to be rewritten.""" + + from ..errors import HwpxValueError + + if "\t" not in value and "\n" not in value: + try: + paragraph.set_text_preserving_runs(value) + return False + except HwpxValueError: + pass + paragraph.text = value + return True + + +def _cell_lines(cell: Any, texts: list[str]) -> tuple[int, int | None, str]: + """Estimated lines of *texts* (one per cell paragraph) and the cell's budget.""" + + from ..form_fit.measure import measure, resolve_slot_metrics + + # The same document handle set_cell_text(fit=...) measures with. + document = getattr(getattr(cell.table.paragraph, "section", None), "document", None) + slot = resolve_slot_metrics(cell, document, max_lines=1) + wide = replace(slot, max_lines=100_000) + lines = 0 + confidence = "high" + for text in texts: + for line in text.split("\n"): + if not line: + lines += 1 + continue + measured = measure(line, wide) + lines += max(1, measured.lines) + if measured.confidence != "high": + confidence = "low" + return lines, slot.height_lines(), confidence + + +def apply_text_changes( + document: "HwpxDocument", + changes: Mapping[str, str], + *, + check_overflow: bool = True, +) -> TextChangeReport: + """Replace the text of each block id in *changes* and report per id. + + Every id is resolved before anything is written, so an unknown id never + leaves a half-applied batch behind it: unknown ids are reported and the + rest is applied. A change that stays inside one run keeps every run and + control (:meth:`~hwpx.oxml.HwpxOxmlParagraph.set_text_preserving_runs`); + one that crosses differently formatted runs or adds a tab or line break + is written into the first run (``rewritten``). With *check_overflow*, + each changed cell is measured before and after: a cell estimated to need + more lines than it holds, and more than it needed before, is listed in + ``overflow``. + """ + + report = TextChangeReport() + wanted = {str(key): "" if value is None else str(value) for key, value in changes.items()} + blocks: dict[str, TextBlock] = {} + pending = set(wanted) + if pending: + for block in iter_text_blocks(document): + if block.id in pending: + blocks[block.id] = block + pending.discard(block.id) + if not pending: + break + report.unknown_ids = [key for key in wanted if key not in blocks] + + # Measure the touched cells before writing. + before: dict[int, tuple[Any, str, int, int | None]] = {} + if check_overflow: + for key, block in blocks.items(): + cell = block.cell + if cell is None or id(cell.element) in before or wanted[key] == block.text: + continue + try: + lines, budget, _confidence = _cell_lines(cell, [p.text for p in cell.paragraphs]) + except Exception as error: # measuring must never block the edit + report.warnings.append(f"{key}: overflow not measured ({type(error).__name__})") + continue + # The room is the cell as authored: an auto-growing row measured after + # the write would count the new text into its own budget. + before[id(cell.element)] = (cell, key, lines, budget) + + for key in wanted: + block = blocks.get(key) + if block is None: + continue + if block.text == wanted[key]: + report.unchanged.append(key) + continue + if _write(block.paragraph, wanted[key]): + report.rewritten.append(key) + report.changed.append(key) + + for cell, key, lines_before, budget in before.values(): + try: + lines, _budget_after, confidence = _cell_lines(cell, [p.text for p in cell.paragraphs]) + except Exception as error: # pragma: no cover - measured once already + report.warnings.append(f"{key}: overflow not measured ({type(error).__name__})") + continue + if budget is not None and lines > budget and lines > lines_before: + report.overflow.append( + { + "id": key, + "where": blocks[key].location, + "lines": lines, + "lineBudget": budget, + "linesBefore": lines_before, + "confidence": confidence, + } + ) + return report diff --git a/src/hwpx/capabilities.py b/src/hwpx/capabilities.py index b4aabcf7..21205aeb 100644 --- a/src/hwpx/capabilities.py +++ b/src/hwpx/capabilities.py @@ -342,6 +342,16 @@ "entry_points": ("hwpx.document:HwpxDocument",), "authoring_methods": (), }, + { + # id 붙은 글 덩어리: 짧은 목록으로 읽고(outline) id로 일괄 바꾼다(apply_changes). + # 기존 문단의 글만 바꾸고 새 요소를 만들지 않으므로 원장 라우팅은 없다 + # (find-replace와 같은 원칙). + "area": "text-blocks", + "namespace": "doc.text", + "matrix_row": "id 기반 글 읽기·일괄 바꿈(text blocks)", + "entry_points": ("hwpx.document:HwpxDocument",), + "authoring_methods": (), + }, { "area": "hyperlink-bookmark", "namespace": "doc.refs", diff --git a/src/hwpx/data/contract_docs/support-matrix.md b/src/hwpx/data/contract_docs/support-matrix.md index 186391c1..a2c64989 100644 --- a/src/hwpx/data/contract_docs/support-matrix.md +++ b/src/hwpx/data/contract_docs/support-matrix.md @@ -57,6 +57,7 @@ | 글꼴 등록 | Create·Render-verified | `doc.styles.ensure_font`(6.8 트레인㉚ 등재, 코드는 2026-08-04 감사 §2 갭#1 이후 사이클에서 구현됐으나 캐파빌리티 미등재 상태였다). 실한컴 렌더 검증: `docs/openrate/report-v5.json` authored-fontface 스트라텀(lang 7블록 전부·substFont on/off 조합), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed — 실제로 참조된 fontRef까지 확인(선언만 하고 고아로 남기지 않음) | | 표 탐색 기반 채움(라벨 매칭 네비게이션) | Edit·Render-verified | `doc.tables.fill_by_path`/`.find_cell_by_label`(6.8 트레인㉚ 등재, 코드는 이전 사이클부터 존재). "양식 채움(byte-splice)" 영역과는 다른 메커니즘(byte-splice가 아니라 라벨 매칭 기반 구조적 셀 채움) — 혼동 주의. 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-tablenavfill 스트라텀(단일-홉 right/down 4건 + multi-hop `합계 > down > right` 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 기존 `hp:tc`/`hp:t` 텍스트를 치환만 할 뿐 새 요소를 안 만든다(document-merge·mail-merge·find-replace와 같은 이유로 CAPABILITY_KEYWORDS 등록 요소 0개) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 | | 찾아바꾸기 | Edit·Render-verified | `doc.text.replace`/`.find_runs`(6.8 트레인㉚ 등재, 코드는 이전 사이클부터 존재). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-findreplace 스트라텀(무제한 치환 + `char_pr_id_ref`로 좁힌 스타일 한정 치환 교대), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 기존 `hp:t` 텍스트만 치환할 뿐 새 요소를 안 만든다(document-merge·mail-merge·table-navigation-fill과 같은 이유) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 | +| id 기반 글 읽기·일괄 바꿈(text blocks) | Parse·Edit | `doc.text.blocks`·`.outline`·`.apply_changes`: 본문 문단과 표 칸 문단(칸 안 표 포함)을 문서 순서대로 위치형 id(`s0p16t0r3c0q0`)와 함께 순회하고, 글자 예산 안의 짧은 목록으로 읽고, `{id: 새 글}`을 한 번에 적용한다. 병합 칸은 대표 칸에서 한 번만 나오고 id는 저장·재열기 뒤에도 같다(저장소 픽스처 1,210개·93,688덩어리 실측). 한 런 안의 바꿈은 런·컨트롤을 지키고, 칸 넘침은 form-fit 측정기로 추정해 보고한다 — 렌더가 아니라 추정이므로 Render-verified가 아니다. 기존 `hp:t` 텍스트만 바꾸고 새 요소를 만들지 않아 원장 라우팅은 없다(찾아바꾸기와 같은 이유) | | 하이퍼링크·책갈피 | Create·Render-verified | `doc.refs.add_hyperlink`/`.add_bookmark`(6.8 트레인㉚에서 "네이티브 목차(TOC)/상호참조" 영역에서 분리 — 그 영역의 Render-verified 근거는 TOC 구조·페이지 정합뿐이라 하이퍼링크·책갈피를 한 번도 독립적으로 커버한 적이 없었다, 편집기 표면 인벤토리 트레인㉙ 발견). **6.9 트레인㉜**: `docs/openrate/report-v12.json` authored-hyperlink-bookmark 스트라텀(북마크 생성 후 외부 URL/문서 내 `#앵커` 하이퍼링크 교대), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) — 이 영역의 첫 독립 실한컴 판정. `hp:bookmark`는 요소 직접 등록(`by-openrate-corpus`), `hp:fieldBegin`(하이퍼링크 자신의 컨트롤)은 CLICKHERE/TOC/CROSSREF와 공유하는 저수준 요소라 여전히 요소 등록 없음 — 영역 등급만 실측으로 갱신, 원장 오염 경로는 그대로 차단. 읽기: `doc.refs.bookmarks`가 한/글의 두 저장 형태(`hp:bookmark`·`hp:fieldBegin type="BOOKMARK"`)를 함께 나열한다(#579) | | 메일머지(placeholder 템플릿 배치 생성) | Edit·Render-verified | `hwpx.tools.mail_merge.merge_template_rows`(6.8 트레인㉛ 등재, 코드는 이전 사이클부터 존재 — 편집기 표면 인벤토리 트레인㉙·계층 판정 트레인㉚이 찾을 때까지 캐파빌리티·매트릭스 어디에도 등재가 없었다). `{{field}}`/`${field}`/`<>` 세 플레이스홀더 문법을 지원하는 템플릿 1개 + 행 데이터(csv/json/시퀀스)로 문서 N개를 배치 생성 — 기존 `hp:t` 텍스트를 치환하는 것이라 새 요소를 만들지 않는다(Create가 아니라 Edit). `strict`/`fit_policy`/`value_sanitizer` 등 운영 옵션 다수. 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-mailmerge 스트라텀(세 플레이스홀더 문법 각각 겨냥하는 전용 템플릿으로 회전), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — 이 영역의 첫 독립 실한컴 판정 | | 메일머지 표시 필드 | Create(experimental) | `HwpxOxmlParagraph.add_mail_merge_field(name, cached_text=)`(`oxml/field_marks.py`, P4). 팀장 실한컴 GUI 프로브 gold(`tests/fixtures/gui_probes/mailmerge_display_fields.hwpx`, "도구 > 메일 머지 > 메일 머지 표시 달기"로 필드 2개 삽입) 역설계 — 위 「메일머지(placeholder 템플릿 배치 생성)」가 **기존 `hp:t` 텍스트를 치환**하는 Edit인 것과 달리 이쪽은 `hp:fieldBegin type="MAILMERGE"` 자체를 새로 만드는 Create다(그래서 별도 영역). DATE/PATH와 같은 단일 run 안 `ctrl(begin)`/`t`/`ctrl(end)` 배치에 더해 **끝에 빈 `hp:t`가 하나 더** 붙고(gold 2/2), `hp:fieldBegin`에 `metaTag` 속성이 **없다**(DATE/교정 부호/PATH gold는 셋 다 `metaTag=""`) — 필드 종류마다 실측이 갈리는 유일한 속성. `hp:parameters`(`cnt="5"`): `Fiexde=1`(**오타 철자가 실물·한컴 공식 문서 공통의 정본이라 정정 금지** — `trackchageConfig`·memo의 `NOMAL`과 같은 부류) · `Prop=8`(DATE/PATH와 같은 캐시 필드류 공유값) · `Command`/`FieldValue`가 둘 다 필드 이름 그대로 · `FieldType="USER_DEFINE"`. 캐시 텍스트 기본값 `{{이름}}`은 `hwpx.tools.mail_merge`가 인식하는 플레이스홀더 문법이라 이 필드로 저작한 템플릿이 그 배치 생성기에 그대로 물린다. gold의 두 필드는 `id`가 다른데 `fieldid`는 같은 값을 공유하나, 표본이 문서 하나뿐이라 채번 규칙을 역산할 근거가 없어 DATE/PATH와 같은 독립 난수를 쓴다. 실한컴 렌더 검증: `docs/openrate/report-v22.json` authored-mailmerge-field 스트라텀(단일/2필드/커스텀 캐시/**한글 필드명(경계)**/표 셀 안 필드, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-18 측정) | @@ -107,6 +108,7 @@ | 글꼴 등록 | `doc.styles` | | 표 탐색 기반 채움(라벨 매칭 네비게이션) | `doc.tables` | | 찾아바꾸기 | `doc.text` | +| id 기반 글 읽기·일괄 바꿈(text blocks) | `doc.text` | | 하이퍼링크·책갈피 | `doc.refs` | | 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` | | 메일머지 표시 필드 | `doc.fields` | diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index e788dbf3..9ad53fa1 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -401,6 +401,8 @@ class Hwp5ConversionWarning(UserWarning): "text-highlight-match-not-found": "문단에서 형광펜으로 감쌀 문자열을 찾지 못했다.", "text-highlight-match-crosses-markup": "찾은 구간이 인라인 마크업을 가로질러 안전하게 감쌀 수 없다.", "text-highlight-color-invalid": "형광펜 색이 #RRGGBB 6자리 16진 형식이 아니다.", + "text-outline-budget": "outline 의 글자·항목 예산이 1보다 작다.", + "text-block-unknown": "outline 의 after 에 준 글 덩어리 id 가 문서에 없다.", # -- 저장·패키지 ----------------------------------------------------- "save-failed": "저장 경로가 아무것도 쓰기 전에 fail-closed 했다.", "save-package-contract-violated": "package.save(None) 이 bytes 를 돌려주지 않았다.", diff --git a/tests/test_namespace_hygiene.py b/tests/test_namespace_hygiene.py index d6aa5fc8..661ed5f6 100644 --- a/tests/test_namespace_hygiene.py +++ b/tests/test_namespace_hygiene.py @@ -100,7 +100,7 @@ def test_the_page_namespace_is_free_of_internal_callbacks() -> None: ("refs", ["add_bookmark", "add_hyperlink"]), ("tracking", ["insert", "delete", "replace", "add_change", "change", "author"]), ("styles", ["resolve", "ensure_run", "apply_paragraph_format"]), - ("text", ["plain", "markdown", "html", "runs", "find_runs", "replace", "highlight", "highlights"]), + ("text", ["plain", "markdown", "html", "runs", "find_runs", "replace", "highlight", "highlights", "blocks", "outline", "apply_changes"]), ], ) def test_every_namespace_exposes_its_verbs(namespace: str, verbs: list[str]) -> None: @@ -132,6 +132,7 @@ def test_every_capability_authoring_method_resolves_on_the_document() -> None: "drop-cap": "shapes", "redline": "tracking", "highlight": "text", + "text-blocks": "text", "border-fill-image-gradient": "styles", "memo": "notes", "footnote-endnote": "notes", diff --git a/tests/test_product_boundary.py b/tests/test_product_boundary.py index 1822bdf9..15273ccb 100644 --- a/tests/test_product_boundary.py +++ b/tests/test_product_boundary.py @@ -323,7 +323,9 @@ def test_real_tree_gate_runs_from_a_gitless_source_copy(tmp_path: Path) -> None: # (oxml/table_structure.py), outside table.py because that owner file sits at its 1600-line cap. # +1: the experimental line layout cache writer (layout/line_cache.py). # +1: QName-aware trailing-table-text byte spans; caller owns selection policy. - assert report["classifiedFiles"] == 184 + # +1: id-addressed text blocks, budgeted outline and batch text changes + # (_document/text_blocks.py), the owner behind doc.text.blocks/outline/apply_changes. + assert report["classifiedFiles"] == 185 def test_gitless_cli_reproduces_literal_dynamic_import_failure_without_mutating_source( diff --git a/tests/test_text_blocks.py b/tests/test_text_blocks.py new file mode 100644 index 00000000..61a6d1ed --- /dev/null +++ b/tests/test_text_blocks.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +"""doc.text.blocks / outline / apply_changes — id-addressed text for small models.""" + +from __future__ import annotations + +import io +import json + +import pytest + +from hwpx import HwpxDocument +from hwpx.oxml.namespaces import HP + + +def _sample() -> HwpxDocument: + doc = HwpxDocument.new() + doc.add_paragraph("제목 문단") + doc.add_paragraph("") + table = doc.add_table(3, 3) + for row in range(3): + for col in range(3): + table.set_cell_text(row, col, f"칸{row}{col}") + table.merge_cells(1, 0, 2, 0) + inner = table.cell(0, 1).add_table(1, 2) + inner.set_cell_text(0, 0, "안쪽") + doc.add_paragraph("끝\t문단") + return doc + + +def _reopen(doc: HwpxDocument) -> HwpxDocument: + return HwpxDocument.open(io.BytesIO(doc.to_bytes())) + + +def _by_text(doc: HwpxDocument, text: str): + return next(block for block in doc.text.blocks() if block.text == text) + + +# --------------------------------------------------------------------------- blocks + + +def test_blocks_walk_body_cells_and_nested_tables_in_document_order() -> None: + blocks = list(_sample().text.blocks()) + texts = [block.text for block in blocks if block.text] + assert texts == ["제목 문단", "칸00", "칸01", "안쪽", "칸02", "칸10", "칸20", "칸11", "칸12", "칸21", "칸22", "끝\t문단"] + + title = next(block for block in blocks if block.text == "제목 문단") + assert (title.id, title.location, title.in_table) == ("s0p1", "본문 2문단", False) + + cell = next(block for block in blocks if block.text == "칸22") + assert (cell.id, cell.location, cell.in_table) == ("s0p3t0r2c2q0", "표1 3행 3열", True) + + nested = next(block for block in blocks if block.text == "안쪽") + assert nested.id == "s0p3t0r0c1q1t0r0c0q0" + assert nested.location == "표1 1행 2열 안 표1 1행 1열" + + +def test_a_merged_cell_appears_once_at_its_anchor() -> None: + blocks = list(_sample().text.blocks()) + merged = [block for block in blocks if block.id.startswith("s0p3t0r1c0") or block.id.startswith("s0p3t0r2c0")] + # The merge moved the covered cell's paragraph into the anchor; no r2c0 block exists. + assert [block.id for block in merged] == ["s0p3t0r1c0q0", "s0p3t0r1c0q1"] + assert [block.text for block in merged] == ["칸10", "칸20"] + + +def test_ids_are_unique_and_survive_save_and_reopen() -> None: + doc = _sample() + before = {block.id: block.text for block in doc.text.blocks()} + assert len(before) == len(list(doc.text.blocks())) + after = {block.id: block.text for block in _reopen(doc).text.blocks()} + assert after == before + + +def test_a_table_without_cell_addresses_gets_physical_ids() -> None: + doc = HwpxDocument.new() + table = doc.add_table(2, 2) + for row in range(2): + for col in range(2): + table.set_cell_text(row, col, f"{row}{col}") + for tc in table.element.iter(f"{HP}tc"): + for addr in tc.findall(f"{HP}cellAddr"): + tc.remove(addr) + blocks = [block for block in doc.text.blocks() if block.in_table] + prefix = blocks[0].id.split("t0")[0] + assert [block.id for block in blocks] == [f"{prefix}t0r{row}c{col}q0" for row in range(2) for col in range(2)] + assert [block.text for block in blocks] == ["00", "01", "10", "11"] + + +# --------------------------------------------------------------------------- outline + + +def test_outline_lists_only_non_empty_blocks_with_clean_text() -> None: + items = _sample().text.outline() + assert items.complete is True and items.next_after is None + assert items.total == len(items) == 12 + assert items[0] == {"id": "s0p1", "where": "본문 2문단", "text": "제목 문단"} + assert items[-1]["text"] == "끝 문단" # the tab became a space + # A plain list for JSON: the paging attributes stay out of the payload. + assert json.loads(json.dumps(items, ensure_ascii=False))[0]["id"] == "s0p1" + + +def test_outline_truncates_a_long_block_and_marks_it() -> None: + doc = HwpxDocument.new() + doc.add_paragraph("가" * 500) + (item,) = doc.text.outline(max_block_chars=50) + assert item["truncated"] is True + assert len(item["text"]) == 50 and item["text"].endswith("…") + + +def test_outline_pages_through_the_document_within_the_budget() -> None: + doc = _sample() + full = [item["id"] for item in doc.text.outline()] + collected: list[str] = [] + after = None + for _ in range(20): + page = doc.text.outline(max_chars=40, after=after) + assert page, "a page always holds one item" + cost = sum(len(i["id"]) + len(i["where"]) + len(i["text"]) for i in page) + assert cost <= 40 or len(page) == 1 # a lone item may exceed the budget + collected += [item["id"] for item in page] + if page.complete: + break + after = page.next_after + assert collected == full + + +def test_outline_max_items_and_to_text() -> None: + page = _sample().text.outline(max_items=2) + assert len(page) == 2 and page.complete is False and page.total == 12 + assert page.to_text().splitlines() == [ + "[s0p1] 본문 2문단: 제목 문단", + "[s0p3t0r0c0q0] 표1 1행 1열: 칸00", + "… 10 more (after=s0p3t0r0c0q0)", + ] + + +def test_outline_rejects_an_unknown_cursor_and_bad_budgets() -> None: + doc = _sample() + with pytest.raises(KeyError): + doc.text.outline(after="s9p9") + with pytest.raises(ValueError): + doc.text.outline(max_chars=0) + with pytest.raises(ValueError): + doc.text.outline(max_items=0) + + +# --------------------------------------------------------------------------- apply_changes + + +def test_apply_changes_reports_each_id() -> None: + doc = _sample() + report = doc.text.apply_changes({"s0p1": "새 제목", "s0p3t0r0c0q0": "칸00", "s9p9": "없음"}) + assert report.changed == ["s0p1"] + assert report.unchanged == ["s0p3t0r0c0q0"] + assert report.unknown_ids == ["s9p9"] + assert report.ok is False + assert report.to_dict()["unknownIds"] == ["s9p9"] + reopened = _reopen(doc) + assert next(reopened.text.blocks()).text == "" and _by_text(reopened, "새 제목").id == "s0p1" + + +def test_apply_changes_keeps_runs_when_the_edit_stays_in_one_run() -> None: + doc = HwpxDocument.new() + paragraph = doc.add_paragraph("이름: ") + paragraph.add_run("홍길동", bold=True) + styles_before = [run.char_pr_id_ref for run in paragraph.runs] + block_id = _by_text(doc, "이름: 홍길동").id + + report = doc.text.apply_changes({block_id: "이름: 김철수"}) + + assert report.changed == [block_id] and report.rewritten == [] + assert [run.text for run in paragraph.runs] == ["이름: ", "김철수"] + assert [run.char_pr_id_ref for run in paragraph.runs] == styles_before + + +def test_apply_changes_rewrites_an_edit_across_runs_and_says_so() -> None: + doc = HwpxDocument.new() + paragraph = doc.add_paragraph("앞 ") + paragraph.add_run("굵게", bold=True) + block_id = _by_text(doc, "앞 굵게").id + + report = doc.text.apply_changes({block_id: "완전히 다른 글"}) + + assert report.rewritten == [block_id] + assert paragraph.text == "완전히 다른 글" + + +def test_a_line_break_in_new_text_becomes_a_line_break_element() -> None: + doc = HwpxDocument.new() + paragraph = doc.add_paragraph("한 줄") + block_id = _by_text(doc, "한 줄").id + report = doc.text.apply_changes({block_id: "첫 줄\n둘째 줄"}) + assert report.rewritten == [block_id] + assert paragraph.element.find(f".//{HP}lineBreak") is not None + assert _by_text(_reopen(doc), "첫 줄\n둘째 줄").id == block_id + + +def test_apply_changes_warns_when_a_cell_would_grow() -> None: + doc = _sample() + target = _by_text(doc, "칸22").id + report = doc.text.apply_changes({target: "아주 긴 문장입니다 " * 60}) + assert report.changed == [target] + (entry,) = report.overflow + assert entry["id"] == target and entry["where"] == "표1 3행 3열" + assert entry["lines"] > entry["lineBudget"] >= entry["linesBefore"] + assert report.ok is False + + +def test_a_short_cell_edit_does_not_warn_and_the_check_can_be_skipped() -> None: + doc = _sample() + target = _by_text(doc, "칸22").id + assert doc.text.apply_changes({target: "짧게"}).overflow == [] + long_report = doc.text.apply_changes({target: "아주 긴 문장입니다 " * 60}, check_overflow=False) + assert long_report.overflow == [] and long_report.ok is True + + +def test_an_empty_change_set_touches_nothing() -> None: + doc = _sample() + before = doc.to_bytes() + report = doc.text.apply_changes({}) + assert report.to_dict() == { + "ok": True, "changed": [], "unknownIds": [], "unchanged": [], "rewritten": [], "overflow": [], "warnings": [], + } + assert doc.to_bytes() == before