From 39979d06a1fa46d911f4429f541fede2162a74ea Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:34:24 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(docs):=20=ED=8F=90=EA=B8=B0=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=EC=9D=98=20=EC=97=86=EB=8A=94=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=202=EA=B0=9C=EC=99=80=20=EB=82=A1=EC=9D=80?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=C2=B7=EC=98=88=EC=A0=9C=EB=A5=BC=206.x?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4=20(ARC-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doc.style → doc.styles.get, doc.set_check_box → doc.fields.set_check_box (전에는 없는 doc.styles.style, doc.fields.check_box(...).checked) - 이동 대상 79개가 실제 속성으로 풀리는지 확인하는 테스트 추가 - llms.txt: .hwp는 BadZipFile이 아니라 열린다 - api_reference: HwpxDocument.save()는 없다 - examples/build_release_checklist.py를 공개 6.x API로 다시 쓰고 실행 테스트 추가 - usage.md의 5.x 평면 이름을 6.x 이름으로 (예제 장부·지문 갱신) Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 5 + docs/_extra/llms.txt | 8 +- docs/api_reference.md | 3 +- docs/migration-6.0.md | 4 +- docs/python-example-ledger.json | 4 +- docs/usage.md | 118 +++++++-------- examples/build_release_checklist.py | 186 ++++++------------------ src/hwpx/_document/_legacy.py | 4 +- src/hwpx/_document/ns/fields.py | 2 +- src/hwpx/objects/checkbox.py | 2 +- tests/data/document_legacy_shims.json | 10 +- tests/test_documentation_code_fences.py | 2 +- tests/test_engine_surface_6_0.py | 40 +++++ tests/test_release_checklist_example.py | 37 +++++ 14 files changed, 204 insertions(+), 221 deletions(-) create mode 100644 tests/test_release_checklist_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b755a6..8799645c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,11 @@ 복사해서, 그 칸의 그림·도형이 같은 번호로 겹쳤다. - 객체 모델 `set_column_widths`·`equalize_column_widths`가 칸을 안쪽 표보다 좁게 만들면 바이트 편집처럼 거부하고 표를 그대로 둔다(`TableStructureError`). +- 폐기 경고와 `docs/migration-6.0.md`가 없는 이름으로 안내하던 두 곳을 바로잡았다. `doc.style(…)`의 이동 대상은 + `doc.styles.get`, `doc.set_check_box(…)`의 이동 대상은 `doc.fields.set_check_box`다(전에는 없는 + `doc.styles.style`·`doc.fields.check_box(...).checked`를 가리켰다). 이동 대상 79개가 실제 속성으로 풀리는지 + 테스트가 확인한다. `llms.txt`는 `.hwp`가 열린다고 적고, `examples/build_release_checklist.py`는 6.x 이름으로 + 끝까지 돈다(전에는 없는 `document.save()`를 불렀다). ### 수정 diff --git a/docs/_extra/llms.txt b/docs/_extra/llms.txt index fa93e256..946d4da1 100644 --- a/docs/_extra/llms.txt +++ b/docs/_extra/llms.txt @@ -83,8 +83,12 @@ Use the API paths below rather than inferring names from python-docx. - Wrong: `Document()` / `doc.paragraphs[0].runs` — those are python-docx idioms. Use `HwpxDocument.new()` / `document.text.runs()`. (`add_paragraph` and `add_heading` do exist.) -- `.hwp` (HWP 5.x binary) is a different format: opening raises `BadZipFile` - with guidance. Encrypted HWPX is rejected fail-closed. +- `.hwp` (HWP 5.x binary) opens too: `HwpxDocument.open("file.hwp")` converts it + into the same document model. `document.conversion_report` counts what the + conversion could not carry over (`None` for a document opened from HWPX), and + a `Hwp5ConversionWarning` is raised once when something was skipped. + Password-protected, distribution and DRM `.hwp` files raise `Hwp5Error`. + Encrypted HWPX is rejected fail-closed. - All `add_*` calls append (not idempotent); check existence first by traversal when you need "add once". - Report content verification, untouched-part preservation, package/open-safety diff --git a/docs/api_reference.md b/docs/api_reference.md index ee5c924a..38c78d6a 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -206,8 +206,7 @@ - `conversion_report -> Hwp5ConversionReport | None` - `.hwp`를 열 때 문서 모델로 옮기지 못한 내용의 개수(`unconverted`·`dropped`, 읽기 전용). `.hwp`로 열지 않은 문서에서는 `None`입니다. -- `save(path_or_stream=None) -> str | PathLike[str] | BinaryIO | bytes` - - 하위 호환용 래퍼입니다. 내부에서 `save_to_path()`/`save_to_stream()`/`to_bytes()`를 호출하며 `DeprecationWarning`을 발생시킵니다. +- `save()`는 없습니다. 4.0에서 없앴고, 부르면 `AttributeError`가 납니다. 경로에는 `save_to_path()`, 스트림에는 `save_to_stream()`, 바이트에는 `to_bytes()`를 씁니다. *** diff --git a/docs/migration-6.0.md b/docs/migration-6.0.md index 80252085..cec54691 100644 --- a/docs/migration-6.0.md +++ b/docs/migration-6.0.md @@ -94,7 +94,7 @@ python -W error::DeprecationWarning -m pytest | `doc.paragraph_property(…)` | `doc.styles.paragraph_property` | | `doc.set_list_format(…)` | `doc.styles.apply_list_format` | | `doc.set_paragraph_format(…)` | `doc.styles.apply_paragraph_format` | -| `doc.style(…)` | `doc.styles.style` | +| `doc.style(…)` | `doc.styles.get` | ### `doc.page` — 쪽 기하 (11) @@ -150,7 +150,7 @@ python -W error::DeprecationWarning -m pytest | `doc.fill_form_field(…)` | `doc.fields.fill` | | `doc.list_check_boxes(…)` | `doc.fields.check_boxes` | | `doc.list_form_fields(…)` | `doc.fields.all` | -| `doc.set_check_box(…)` | `doc.fields.check_box(...).checked` | +| `doc.set_check_box(…)` | `doc.fields.set_check_box` (또는 `doc.fields.check_boxes[i].checked = …`) | ### `doc.shapes` — 인라인 개체 (7) diff --git a/docs/python-example-ledger.json b/docs/python-example-ledger.json index e3130994..02752179 100644 --- a/docs/python-example-ledger.json +++ b/docs/python-example-ledger.json @@ -14,14 +14,14 @@ "docs/golden-api.md" ], "fenceCount": 117, - "sourceSha256": "a164785f35a43a2f224d2b1b906b71f113de67c0c7e05dea6e3c3a4fa3cb989d", + "sourceSha256": "fa9c605364efe898f0b70e1160608d3c6d0d5d6c470e0ad3bcc0e40b28a0438e", "manualSha256": { "README.md": "d63726c59b063948126eaaa05022b13c3264c88019466ee4bb67db6d8ba27fc3", "README_EN.md": "185f69b6cde4093acdde7bfb2505aa2e9e31b2bb8f46763dc93a854702742082", "docs/quickstart.md": "44d8ca82ee70c0e3dfdf087713a4e4ce935e64cd3f6a546311f95b054a285a18", "docs/recipes-traversal.md": "b63f42b1a6ed23812688599474dcf83300509b86a94b5daceae679bfdbde9343", "docs/mutation-semantics.md": "6de1471f64c1cd8db2e661c413d92bfd27221f5d930708942ac47c6aff2d608b", - "docs/usage.md": "ec70af303f2efaed9fe31f633af01817a4f2f1c6f31174dce4c21fadfd3b6948", + "docs/usage.md": "acb7aeb64bdc91d9c8c56a819a588c915d1e34eb57077b3cf49907384e135874", "docs/examples.md": "f03f0ecee4faef94dda05c92dd25672dfbde3a7447f823603b611bea59f6432e", "docs/faq.md": "cd82e2f1e740cff3f50e620c060ed8ae0aa130271fa373eca3a07a70fb9ca604", "docs/index.md": "111754d7637414e0078ba0c7bbcf19d1a4550a68202acf3dd2c89c0748ea0050", diff --git a/docs/usage.md b/docs/usage.md index e938f282..c6a2c5b6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -76,7 +76,7 @@ document.save_to_path("new-document.hwpx") from hwpx import HwpxDocument document = HwpxDocument.new() -document.replace_text_in_runs("TODO", "DONE", text_color="#FF0000") +document.text.replace("TODO", "DONE", text_color="#FF0000") document.save_to_path("my-document-updated.hwpx") ``` @@ -88,7 +88,7 @@ document.save_to_path("my-document-updated.hwpx") from hwpx import HwpxDocument document = HwpxDocument.new() -for memo in document.memos: +for memo in document.notes.memos: print(memo.id, memo.text) ``` @@ -260,7 +260,7 @@ section.properties.set_footer_text("© Company", page_type="ODD") from hwpx import HwpxDocument document = HwpxDocument.new() -print([header.part_name for header in document.headers]) +print([header.part_name for header in document.parts.headers]) ``` ### 예제 18: 메모 모양 ID 확인 @@ -271,7 +271,7 @@ print([header.part_name for header in document.headers]) from hwpx import HwpxDocument document = HwpxDocument.new() -print(sorted(document.memo_shapes.keys())) +print(sorted(document.styles.memo_shapes.keys())) ``` ### 예제 19: 특정 메모 모양 살펴보기 @@ -282,7 +282,7 @@ print(sorted(document.memo_shapes.keys())) from hwpx import HwpxDocument document = HwpxDocument.new() -shape = document.memo_shape("0") +shape = document.styles.memo_shape("0") if shape: print(shape.memo_type, shape.line_color) ``` @@ -295,7 +295,7 @@ if shape: from hwpx import HwpxDocument document = HwpxDocument.new() -memo = document.add_memo("검토 메모", section_index=0, memo_shape_id_ref="0") +memo = document.notes.add_memo("검토 메모", section_index=0, memo_shape_id_ref="0") print(memo.text) ``` @@ -328,9 +328,9 @@ print(memo.field_id) from hwpx import HwpxDocument document = HwpxDocument.new() -for memo in list(document.memos): +for memo in list(document.notes.memos): if memo.text.startswith("임시"): - document.remove_memo(memo) + document.notes.remove_memo(memo) ``` ### 예제 23: 새 문자 서식 확보 @@ -341,7 +341,7 @@ for memo in list(document.memos): from hwpx import HwpxDocument document = HwpxDocument.new() -style_id = document.ensure_run_style(bold=True, underline=True) +style_id = document.styles.ensure_run(bold=True, underline=True) print("style id:", style_id) ``` @@ -379,8 +379,8 @@ if paragraph.runs: from hwpx import HwpxDocument document = HwpxDocument.new() -style_id = document.ensure_run_style(bold=True) -matches = document.find_runs_by_style(char_pr_id_ref=style_id) +style_id = document.styles.ensure_run(bold=True) +matches = document.text.find_runs(char_pr_id_ref=style_id) print("bold runs:", len(matches)) ``` @@ -392,7 +392,7 @@ print("bold runs:", len(matches)) from hwpx import HwpxDocument document = HwpxDocument.new() -changed = document.replace_text_in_runs("TODO", "DONE", limit=1) +changed = document.text.replace("TODO", "DONE", limit=1) print("replaced:", changed) ``` @@ -404,7 +404,7 @@ print("replaced:", changed) from hwpx import HwpxDocument document = HwpxDocument.new() -all_text = "".join(run.text for run in document.iter_runs()) +all_text = "".join(run.text for run in document.text.runs()) print(all_text[:80]) ``` @@ -456,7 +456,7 @@ section = document.sections[0] table = document.add_table(rows=3, cols=3, section=section, width=54000) ``` -`add_table()`은 문서에 등록된 테두리 채우기가 없으면 헤더 참조 목록에 기본 실선 `borderFill`을 자동으로 추가하고 표 전체에 그 ID를 연결합니다. 생성된 항목은 `document.border_fills` 또는 `document.border_fill("0")`으로 확인할 수 있습니다. +`add_table()`은 문서에 등록된 테두리 채우기가 없으면 헤더 참조 목록에 기본 실선 `borderFill`을 자동으로 추가하고 표 전체에 그 ID를 연결합니다. 생성된 항목은 `document.styles.border_fills` 또는 `document.styles.border_fill("0")`으로 확인할 수 있습니다. ### 예제 33: 표 헤더 병합 @@ -521,9 +521,9 @@ from hwpx import HwpxDocument document = HwpxDocument.new() section = document.sections[0] -line = document.add_line(0, 0, 14400, 0, line_color="#333333", section=section) -rectangle = document.add_rectangle(14400, 7200, fill_color="#FFD9CC", section=section) -ellipse = document.add_ellipse(9000, 4500, section=section) +line = document.shapes.add_line(0, 0, 14400, 0, line_color="#333333", section=section) +rectangle = document.shapes.add_rectangle(14400, 7200, fill_color="#FFD9CC", section=section) +ellipse = document.shapes.add_ellipse(9000, 4500, section=section) ``` ### 예제 37: 도형 크기 변경 @@ -534,14 +534,14 @@ ellipse = document.add_ellipse(9000, 4500, section=section) from hwpx import HwpxDocument document = HwpxDocument.new() -shape = document.add_rectangle(14400, 7200, section=document.sections[0]) +shape = document.shapes.add_rectangle(14400, 7200, section=document.sections[0]) shape.resize(9000, 4500) print(shape.shape_type, shape.width, shape.height) ``` ### 예제 38: 컨트롤 객체 추가 -`add_control()`은 저수준 탈출구입니다. 건네준 요소와 속성만 쓰고 한컴이 요구하는 +`doc.shapes.add_control()`은 저수준 탈출구입니다. 건네준 요소와 속성만 쓰고 한컴이 요구하는 나머지 하위 요소는 만들지 않으므로, 호출자가 채우지 않은 채 저장한 문서는 한컴에서 열리지 않습니다. 그래서 호출 시 `UserWarning`이 나며, 경고 메시지가 대신 쓸 전용 헬퍼를 알려 줍니다. @@ -552,7 +552,7 @@ print(shape.shape_type, shape.width, shape.height) from hwpx import HwpxDocument document = HwpxDocument.new() -control = document.add_control(section=document.sections[0], control_type="LINE", attributes={"id": "line1"}) +control = document.shapes.add_control(section=document.sections[0], control_type="LINE", attributes={"id": "line1"}) ``` ### 예제 39: 컨트롤 속성 변경 @@ -565,7 +565,7 @@ control = document.add_control(section=document.sections[0], control_type="LINE" from hwpx import HwpxDocument document = HwpxDocument.new() -control = document.add_control(section=document.sections[0], control_type="LINE") +control = document.shapes.add_control(section=document.sections[0], control_type="LINE") control.set_attribute("dirty", "false") ``` @@ -671,7 +671,7 @@ document.package.set_part("META-INF/notes.txt", "Generated with python-hwpx") from hwpx import HwpxDocument document = HwpxDocument.new() -for style_id in sorted(document.char_properties.keys()): +for style_id in sorted(document.styles.char_properties.keys()): print("style:", style_id) ``` @@ -683,8 +683,8 @@ for style_id in sorted(document.char_properties.keys()): from hwpx import HwpxDocument document = HwpxDocument.new() -style_id = document.ensure_run_style(bold=True) -style = document.char_property(style_id) +style_id = document.styles.ensure_run(bold=True) +style = document.styles.char_property(style_id) if style: print(style.text_color(), style.underline_type()) ``` @@ -747,8 +747,8 @@ section.properties.set_start_numbering(page=1, table=1) from hwpx import HwpxDocument document = HwpxDocument.new() -if document.headers: - begin = document.headers[0].begin_numbering +if document.parts.headers: + begin = document.parts.headers[0].begin_numbering print(begin.page, begin.table) ``` @@ -760,8 +760,8 @@ if document.headers: from hwpx import HwpxDocument document = HwpxDocument.new() -if document.headers: - document.headers[0].set_begin_numbering(page=1, table=1) +if document.parts.headers: + document.parts.headers[0].set_begin_numbering(page=1, table=1) ``` ### 예제 56: 헤더 글머리표 정의 나열 @@ -772,7 +772,7 @@ if document.headers: from hwpx import HwpxDocument document = HwpxDocument.new() -for bullet_id, bullet in sorted(document.bullets.items()): +for bullet_id, bullet in sorted(document.styles.bullets.items()): print(bullet_id, bullet.char, bullet.para_head.align) ``` @@ -784,8 +784,8 @@ for bullet_id, bullet in sorted(document.bullets.items()): from hwpx import HwpxDocument document = HwpxDocument.new() -para_pr = document.paragraph_property("1") -style = document.style("0") +para_pr = document.styles.paragraph_property("1") +style = document.styles.get("0") if para_pr and style: print(para_pr.align.horizontal, para_pr.tab_pr_id_ref, style.name) ``` @@ -798,9 +798,9 @@ if para_pr and style: from hwpx import HwpxDocument document = HwpxDocument.new() -change = document.track_change("1") +change = document.tracking.change("1") if change: - author = document.track_change_author(change.author_id) + author = document.tracking.author(change.author_id) if author: print(change.change_type, author.name, author.color) ``` @@ -815,8 +815,8 @@ from hwpx import HwpxDocument HM = "{http://www.hancom.co.kr/hwpml/2011/master-page}" document = HwpxDocument.new() -if document.master_pages: - master = document.master_pages[0] +if document.parts.master_pages: + master = document.parts.master_pages[0] item = master.element.find(f"{HM}masterPageItem") if item is not None: item.set("name", "보고서 바탕쪽") @@ -834,7 +834,7 @@ from hwpx import HwpxDocument HHS = "{http://www.hancom.co.kr/hwpml/2011/history}" document = HwpxDocument.new() -for history in document.histories: +for history in document.parts.histories: comment = history.element.find(f"{HHS}historyEntry/{HHS}comment") if comment is not None: comment.text = "QA 점검 완료" @@ -850,9 +850,9 @@ document.save_to_path("my-document.hwpx") from hwpx import HwpxDocument document = HwpxDocument.new() -if document.version: - document.version.element.set("appVersion", "15.0.0.100 WIN32") - document.version.mark_dirty() +if document.parts.version: + document.parts.version.element.set("appVersion", "15.0.0.100 WIN32") + document.parts.version.mark_dirty() document.save_to_path("my-document.hwpx") ``` @@ -866,7 +866,7 @@ from hwpx import HwpxDocument document = HwpxDocument.new() # 마지막 단락 삭제 if len(document.sections[0].paragraphs) > 1: - document.remove_paragraph(document.paragraphs[-1]) + document.paragraphs[-1].remove() document.save_to_path("my-document.hwpx") ``` @@ -974,25 +974,25 @@ table.set_cell_text(1, 1, "Actual") table.set_cell_text(1, 2, "Forecast") # 개체도 문서 또는 문단 수준에서 추가할 수 있습니다. -rectangle = document.add_rectangle(9000, 4500, fill_color="#FFD9CC", section=section) +rectangle = document.shapes.add_rectangle(9000, 4500, fill_color="#FFD9CC", section=section) # add_control은 저수준 탈출구입니다 — 필수 하위 요소를 만들지 않아 UserWarning이 # 나고, 호출자가 채우지 않으면 한컴이 열지 못하는 문서가 됩니다. -control = document.add_control( +control = document.shapes.add_control( section=section, control_type="LINE", attributes={"id": "ctrl1"}, ) ``` -`HwpxDocument.sections`, `HwpxDocument.paragraphs`, `HwpxDocument.headers` 속성은 각각 구역, 모든 문단, 헤더 파트를 리스트로 반환합니다. 섹션 속성(`section.properties`)을 사용하면 페이지 크기, 여백, 바탕쪽 연결과 같은 레이아웃 설정도 쉽게 변경할 수 있습니다. 매니페스트에 등록된 바탕쪽(`document.master_pages`), 문서 이력(`document.histories`), 버전(`document.version`) 파트도 동일한 방식으로 노출되므로 XML 트리를 직접 수정한 뒤 `mark_dirty()`만 호출하면 저장 시 함께 반영됩니다. +`HwpxDocument.sections`, `HwpxDocument.paragraphs`, `doc.parts.headers` 속성은 각각 구역, 모든 문단, 헤더 파트를 리스트로 반환합니다. 섹션 속성(`section.properties`)을 사용하면 페이지 크기, 여백, 바탕쪽 연결과 같은 레이아웃 설정도 쉽게 변경할 수 있습니다. 매니페스트에 등록된 바탕쪽(`document.parts.master_pages`), 문서 이력(`document.parts.histories`), 버전(`document.parts.version`) 파트도 동일한 방식으로 노출되므로 XML 트리를 직접 수정한 뒤 `mark_dirty()`만 호출하면 저장 시 함께 반영됩니다. ```python options = section.properties options.set_page_size(width=72000, height=43200, orientation="LANDSCAPE") options.set_page_margins(left=2000, right=2000, header=1500, footer=1500) -document.headers[0].set_begin_numbering(page=1) +document.parts.headers[0].set_begin_numbering(page=1) ``` ## Markdown으로 문서 만들기 @@ -1027,7 +1027,7 @@ doc.save_to_path("report.hwpx") ```python # 인스턴스로 삭제 paragraph = document.paragraphs[-1] -document.remove_paragraph(paragraph) +paragraph.remove() # 섹션 내 인덱스로 삭제 section = document.sections[0] @@ -1067,23 +1067,23 @@ document.remove_section(1) ## 메모 다루기 -문서에 첨부된 메모는 섹션의 `` 요소와 헤더의 `memoProperties` 정의를 통해 연결됩니다. `HwpxDocument.memos` 속성은 모든 섹션에 포함된 메모 객체를 반환하며, `add_memo()`/`remove_memo()`를 사용하면 새 메모를 생성하거나 삭제할 수 있습니다. +문서에 첨부된 메모는 섹션의 `` 요소와 헤더의 `memoProperties` 정의를 통해 연결됩니다. `doc.notes.memos` 속성은 모든 섹션에 포함된 메모 객체를 반환하며, `doc.notes.add_memo()`/`doc.notes.remove_memo()`를 사용하면 새 메모를 생성하거나 삭제할 수 있습니다. ```python # 메모 모양 정의는 header.ref_list.memo_properties 또는 document.memo_shapes로 조회할 수 있습니다. -default_shape = next(iter(document.memo_shapes)) # 첫 번째 모양 ID +default_shape = next(iter(document.styles.memo_shapes)) # 첫 번째 모양 ID -memo = document.add_memo( +memo = document.notes.add_memo( "검토 의견을 정리했습니다.", section=section, memo_shape_id_ref=default_shape, ) memo.text = "표 1은 최신 수치로 업데이트가 필요합니다." -for existing in document.memos: +for existing in document.notes.memos: print(existing.id, existing.memo_shape_id_ref, existing.text) -document.remove_memo(memo) +document.notes.remove_memo(memo) ``` > **주의:** 한글 편집기에서 메모 풍선을 표시하려면 본문 문단에 대응되는 MEMO 필드 컨트롤(`hp:fieldBegin`/`hp:fieldEnd`)이 있어야 합니다. @@ -1091,9 +1091,9 @@ document.remove_memo(memo) ```python todo = document.add_paragraph("TODO: QA 서명", section=section, char_pr_id_ref=10) -document.add_memo_with_anchor( +document.notes.add_memo( "배포 전 QA 서명을 확인하세요.", - paragraph=todo, + anchor=todo, memo_shape_id_ref="0", memo_id="release-memo-qa", char_pr_id_ref="10", @@ -1106,16 +1106,16 @@ document.add_memo_with_anchor( ## 스타일 기반 텍스트 변환 -런(`HwpxOxmlRun`)은 `charPrIDRef`를 통해 헤더의 문자 서식(`charPr`)과 연결됩니다. `HwpxDocument.find_runs_by_style()`는 색상, 밑줄 종류, 문자 속성 ID 등의 조건으로 런을 필터링하고, `replace_text_in_runs()`는 선택된 런 내부의 부분 문자열만 치환하거나 삭제합니다. +런(`HwpxOxmlRun`)은 `charPrIDRef`를 통해 헤더의 문자 서식(`charPr`)과 연결됩니다. `doc.text.find_runs()`는 색상, 밑줄 종류, 문자 속성 ID 등의 조건으로 런을 필터링하고, `doc.text.replace()`는 선택된 런 내부의 부분 문자열만 치환하거나 삭제합니다. ```python # 빨간색 텍스트에만 TODO 태그가 남아 있는지 검사합니다. -for run in document.find_runs_by_style(text_color="#FF0000"): +for run in document.text.find_runs(text_color="#FF0000"): if "TODO" in run.text: print("검토 필요:", run.text) # 빨간색 텍스트에서 TODO를 DONE으로 교체하고, 최대 두 번만 수행합니다. -document.replace_text_in_runs( +document.text.replace( "TODO", "DONE", text_color="#FF0000", @@ -1123,10 +1123,10 @@ document.replace_text_in_runs( ) # 밑줄이 그어진 텍스트에서 임시 주석을 제거합니다. -document.replace_text_in_runs( +document.text.replace( "(draft)", "", - underline_type="SOLID", + underline_type="BOTTOM", ) ``` @@ -1160,14 +1160,14 @@ paragraph.apply_model(model) ## 런 서식 지정 -헤더의 `` 정의는 여러 런이 공유하는 문자 서식을 담고 있습니다. `HwpxDocument.ensure_run_style()`은 굵게/기울임/밑줄 조합에 맞는 `charPr` 항목을 찾아 ID를 반환하고, 필요한 경우 새 항목을 생성합니다. 문단 객체는 `add_run()` 메서드를 통해 해당 서식을 즉시 사용하는 런을 만들 수 있습니다. +헤더의 `` 정의는 여러 런이 공유하는 문자 서식을 담고 있습니다. `doc.styles.ensure_run()`은 굵게/기울임/밑줄 조합에 맞는 `charPr` 항목을 찾아 ID를 반환하고, 필요한 경우 새 항목을 생성합니다. 문단 객체는 `add_run()` 메서드를 통해 해당 서식을 즉시 사용하는 런을 만들 수 있습니다. ```python section = document.sections[0] paragraph = section.paragraphs[0] # 굵은 밑줄 서식을 확보하고, 동일한 서식을 가진 런을 추가합니다. -style_id = document.ensure_run_style(bold=True, underline=True) +style_id = document.styles.ensure_run(bold=True, underline=True) run = paragraph.add_run("강조된 텍스트", bold=True, underline=True) # 반환된 런은 즉시 서식 토글 속성을 제공합니다. diff --git a/examples/build_release_checklist.py b/examples/build_release_checklist.py index 4f73e34c..2f129787 100644 --- a/examples/build_release_checklist.py +++ b/examples/build_release_checklist.py @@ -4,179 +4,77 @@ from __future__ import annotations -from copy import deepcopy from pathlib import Path -from typing import TYPE_CHECKING -import xml.etree.ElementTree as ET from hwpx.document import HwpxDocument -if TYPE_CHECKING: - from hwpx.oxml import HwpxOxmlHeader - -HH_NS = "http://www.hancom.co.kr/hwpml/2011/head" -HH = f"{{{HH_NS}}}" HPX_SOURCE = Path(__file__).with_name("FormattingShowcase.hwpx") OUTPUT_PATH = Path(__file__).with_name("ReleaseChecklist.hwpx") -def ensure_memo_shapes(header: "HwpxOxmlHeader") -> None: - """Add a pair of memo shape definitions if they are missing.""" - - ref_list = header.element.find(f"{HH}refList") - if ref_list is None: - ref_list = ET.SubElement(header.element, f"{HH}refList") - - memo_props = ref_list.find(f"{HH}memoProperties") - if memo_props is None: - memo_props = ET.SubElement(ref_list, f"{HH}memoProperties", {"itemCnt": "0"}) - - desired_shapes = { - "0": { - "width": "14000", - "lineWidth": "0.6mm", - "lineType": "SOLID", - "lineColor": "#4F81BD", - "fillColor": "#EEF4FB", - "activeColor": "#DDE8F7", - "memoType": "NORMAL", - }, - "5": { - "width": "16000", - "lineWidth": "0.6mm", - "lineType": "DOT", - "lineColor": "#C0504D", - "fillColor": "#FDE9E9", - "activeColor": "#F6D8D8", - "memoType": "NORMAL", - }, - } - - updated = False - for memo_id, attrs in desired_shapes.items(): - existing = memo_props.find(f"{HH}memoPr[@id='{memo_id}']") - if existing is not None: - continue - ET.SubElement(memo_props, f"{HH}memoPr", {"id": memo_id, **attrs}) - updated = True - - if updated: - count = len(list(memo_props.findall(f"{HH}memoPr"))) - memo_props.set("itemCnt", str(count)) - header.mark_dirty() - - -def ensure_underlined_char_property(header: "HwpxOxmlHeader") -> str: - """Clone an existing character property and make it underlined.""" - - ref_list = header.element.find(f"{HH}refList") - if ref_list is None: - raise RuntimeError("header is missing ") - - char_props = ref_list.find(f"{HH}charProperties") - if char_props is None: - raise RuntimeError("header does not expose charProperties") - - underline_id = "50" - existing = char_props.find(f"{HH}charPr[@id='{underline_id}']") - if existing is not None: - return underline_id - - base = char_props.find(f"{HH}charPr[@id='10']") - if base is None: - raise RuntimeError("cannot find base charPr with id=10 to clone") - - underlined = deepcopy(base) - underlined.set("id", underline_id) - underlined.set("textColor", "#1F4E79") - - underline = underlined.find(f"{HH}underline") - if underline is None: - underline = ET.SubElement(underlined, f"{HH}underline") - underline.set("type", "SOLID") - underline.set("color", "#1F4E79") - - char_props.append(underlined) - char_props.set("itemCnt", str(len(list(char_props)))) - header.mark_dirty() - - return underline_id - - - -def build_checklist(document: HwpxDocument, underline_char_id: str) -> None: +def build_checklist(document: HwpxDocument) -> None: """Populate the document with paragraphs, memos, and style edits.""" - section = document.sections[0] - - _heading = document.add_paragraph( - "릴리스 최종 점검", - section=section, - para_pr_id_ref=1, - style_id_ref=1, + # Two memo shapes and two run styles. ``ensure_*`` reuses a definition + # that already exists and returns its id either way. + blue_memo = document.styles.ensure_memo_shape( + width=14000, + line_width="0.6mm", + line_type="SOLID", + line_color="#4F81BD", + fill_color="#EEF4FB", + active_color="#DDE8F7", ) - _intro = document.add_paragraph( - "메모와 스타일 편집 기능이 적용된 QA 확인용 문서입니다.", - section=section, - char_pr_id_ref=0, + red_memo = document.styles.ensure_memo_shape( + width=16000, + line_width="0.6mm", + line_type="DOT", + line_color="#C0504D", + fill_color="#FDE9E9", + active_color="#F6D8D8", ) - - todo = document.add_paragraph( - "TODO: 통합 테스트 결과 정리", - section=section, - char_pr_id_ref=10, + todo_style = document.styles.ensure_run(color="#C00000") + signoff_style = document.styles.ensure_run( + underline=True, underline_color="#1F4E79", color="#1F4E79" ) + + document.add_paragraph("릴리스 최종 점검") + document.add_paragraph("메모와 스타일 편집 기능이 적용된 QA 확인용 문서입니다.") + todo = document.add_paragraph("TODO: 통합 테스트 결과 정리", char_pr_id_ref=todo_style) signoff = document.add_paragraph( - "Draft: 문서 배포 전 QA 서명을 수집하세요.", - section=section, - char_pr_id_ref=underline_char_id, + "Draft: 문서 배포 전 QA 서명을 수집하세요.", char_pr_id_ref=signoff_style ) - document.add_memo_with_anchor( + document.notes.add_memo( "데이터 차트 수치를 2024년 4분기 기준으로 갱신하세요.", - paragraph=todo, - memo_shape_id_ref="0", + anchor=todo, + memo_shape_id_ref=blue_memo, memo_id="release-memo-1", - char_pr_id_ref="10", - attributes={"author": "PM", "visible": "always"}, - field_id="7001", + attributes={"author": "PM"}, created="2024-12-02 09:00:00", number=1, - anchor_char_pr_id_ref="10", ) - - document.add_memo_with_anchor( + document.notes.add_memo( "QA 서명은 12월 15일까지 완료되어야 합니다.", - paragraph=signoff, - memo_shape_id_ref="5", + anchor=signoff, + memo_shape_id_ref=red_memo, memo_id="release-memo-2", - char_pr_id_ref=underline_char_id, - attributes={"author": "QA Lead", "priority": "high"}, - field_id="7002", + attributes={"author": "QA Lead"}, created="2024-12-02 09:30:00", number=2, - anchor_char_pr_id_ref=underline_char_id, ) - # Apply style-aware replacements to verify the transformation APIs. - document.replace_text_in_runs("TODO", "DONE", text_color="#C00000") - document.replace_text_in_runs("Draft: ", "", underline_type="SOLID") - - -def main() -> None: - document = HwpxDocument.open(HPX_SOURCE) - header = document.headers[0] - - ensure_memo_shapes(header) - underline_char_id = ensure_underlined_char_property(header) - - # Refresh cached style information after mutating the header. - document.oxml.invalidate_char_property_cache() + # Style-aware replacements: only runs with the given formatting change. + document.text.replace("TODO", "DONE", text_color="#C00000") + document.text.replace("Draft: ", "", underline_type="BOTTOM") - build_checklist(document, underline_char_id) - document.save(OUTPUT_PATH) - print(f"Generated {OUTPUT_PATH.relative_to(Path.cwd())}") +def main(output_path: Path = OUTPUT_PATH) -> Path: + with HwpxDocument.open(HPX_SOURCE) as document: + build_checklist(document) + document.save_to_path(output_path) + print(f"Generated {output_path}") + return output_path if __name__ == "__main__": diff --git a/src/hwpx/_document/_legacy.py b/src/hwpx/_document/_legacy.py index d6825efa..787d6505 100644 --- a/src/hwpx/_document/_legacy.py +++ b/src/hwpx/_document/_legacy.py @@ -1361,7 +1361,7 @@ def replace_text_in_runs( break return replacements - @_moved("doc.fields.check_box(...).checked") + @_moved("doc.fields.set_check_box") def set_check_box( self, checked: bool, @@ -1721,7 +1721,7 @@ def set_paragraph_format( border_width=border_width, ) - @_moved("doc.styles.style") + @_moved("doc.styles.get") def style(self, style_id_ref: int | str | None) -> Style | None: """Return the style definition referenced by *style_id_ref*.""" diff --git a/src/hwpx/_document/ns/fields.py b/src/hwpx/_document/ns/fields.py index 3a248873..45cd4d80 100644 --- a/src/hwpx/_document/ns/fields.py +++ b/src/hwpx/_document/ns/fields.py @@ -15,7 +15,7 @@ id 별칭은 `field_id` 하나로, 위치 5키는 `location` 하나로 접혔다. `CheckBox.checked` 가 쓰기 가능한 속성이므로 `set_check_box` 는 사실상 -`doc.fields.check_box(...).checked = False` 로 대체된다. 5.x 이름은 shim 으로 +`doc.fields.check_boxes[i].checked = False` 로 대체된다. 5.x 이름은 shim 으로 살아 있고, 이 네임스페이스에도 같은 이름을 남겨 이주 중 양쪽이 겹치게 했다. """ diff --git a/src/hwpx/objects/checkbox.py b/src/hwpx/objects/checkbox.py index c9e5b4d3..fb321bf8 100644 --- a/src/hwpx/objects/checkbox.py +++ b/src/hwpx/objects/checkbox.py @@ -5,7 +5,7 @@ returned in 5.x (design §2.4 #1/#4, §2.5). ``checked`` is settable — writing it mutates the underlying element and marks the owning section dirty, which is why ``set_check_box`` stops being a separate verb once the namespace wires -``doc.fields.check_box(...).checked = False`` (design table row 81). +``doc.fields.check_boxes[i].checked = False`` (design table row 81). """ from __future__ import annotations diff --git a/tests/data/document_legacy_shims.json b/tests/data/document_legacy_shims.json index dcdc8617..05f4f585 100644 --- a/tests/data/document_legacy_shims.json +++ b/tests/data/document_legacy_shims.json @@ -81,7 +81,7 @@ "kind": "method", "removedIn": "7.0", "replacement": "doc.notes.add_memo(anchor=...)", - "signature": "(self, text: 'str' = '', *, paragraph: 'HwpxOxmlParagraph | None' = None, section: 'HwpxOxmlSection | None' = None, section_index: 'int | None' = None, paragraph_text: 'str | None' = None, memo_shape_id_ref: 'str | int | None' = None, memo_id: 'str | None' = None, char_pr_id_ref: 'str | int | None' = None, attributes: 'dict[str, str] | None' = None, field_id: 'str | None' = None, author: 'str | None' = None, created: 'datetime | str | None' = None, number: 'int' = 1, anchor_char_pr_id_ref: 'str | int | None' = None) -> 'tuple[HwpxOxmlMemo, HwpxOxmlParagraph, str]'" + "signature": "(self, text: 'str' = '', *, paragraph: 'HwpxOxmlParagraph | None' = None, section: 'HwpxOxmlSection | None' = None, section_index: 'int | None' = None, paragraph_text: 'str | None' = None, memo_shape_id_ref: 'str | int | None' = None, memo_id: 'str | None' = None, char_pr_id_ref: 'str | int | None' = None, attributes: 'dict[str, str] | None' = None, field_id: 'str | None' = None, author: 'str | None' = None, created: 'datetime | str | None' = None, number: 'int | None' = None, anchor_char_pr_id_ref: 'str | int | None' = None) -> 'tuple[HwpxOxmlMemo, HwpxOxmlParagraph, str]'" }, "add_rectangle": { "kind": "method", @@ -123,7 +123,7 @@ "kind": "method", "removedIn": "7.0", "replacement": "doc.notes.attach", - "signature": "(self, paragraph: 'HwpxOxmlParagraph', memo: 'HwpxOxmlMemo', *, field_id: 'str | None' = None, author: 'str | None' = None, created: 'datetime | str | None' = None, number: 'int' = 1, char_pr_id_ref: 'str | int | None' = None) -> 'str'" + "signature": "(self, paragraph: 'HwpxOxmlParagraph', memo: 'HwpxOxmlMemo', *, field_id: 'str | None' = None, author: 'str | None' = None, created: 'datetime | str | None' = None, number: 'int | None' = None, char_pr_id_ref: 'str | int | None' = None) -> 'str'" }, "border_fill": { "kind": "method", @@ -162,7 +162,7 @@ "kind": "method", "removedIn": "7.0", "replacement": "doc.styles.ensure_border_fill", - "signature": "(self, *, border_color: 'str' = '#BFBFBF', border_width: 'str' = '0.12 mm', fill_color: 'str | None' = None, active_borders: 'Sequence[str] | None' = None, border_type: 'str' = 'SOLID') -> 'str'" + "signature": "(self, *, border_color: 'str' = '#BFBFBF', border_width: 'str' = '0.12 mm', fill_color: 'str | None' = None, fill_image: \"'str | BinaryItem | Mapping[str, object] | None'\" = None, fill_gradient: 'Mapping[str, object] | None' = None, active_borders: 'Sequence[str] | None' = None, border_type: 'str' = 'SOLID') -> 'str'" }, "ensure_numbering": { "kind": "method", @@ -353,7 +353,7 @@ "set_check_box": { "kind": "method", "removedIn": "7.0", - "replacement": "doc.fields.check_box(...).checked", + "replacement": "doc.fields.set_check_box", "signature": "(self, checked: 'bool', *, index: 'int | None' = None, name: 'str | None' = None) -> 'dict[str, Any]'" }, "set_columns": { @@ -431,7 +431,7 @@ "style": { "kind": "method", "removedIn": "7.0", - "replacement": "doc.styles.style", + "replacement": "doc.styles.get", "signature": "(self, style_id_ref: 'int | str | None') -> 'Style | None'" }, "track_change": { diff --git a/tests/test_documentation_code_fences.py b/tests/test_documentation_code_fences.py index b2dc721a..b869598f 100644 --- a/tests/test_documentation_code_fences.py +++ b/tests/test_documentation_code_fences.py @@ -42,7 +42,7 @@ ) EXPECTED_FENCE_COUNT = 117 EXPECTED_FENCE_SHA256 = ( - "a164785f35a43a2f224d2b1b906b71f113de67c0c7e05dea6e3c3a4fa3cb989d" + "fa9c605364efe898f0b70e1160608d3c6d0d5d6c470e0ad3bcc0e40b28a0438e" ) ALLOWED_IMPORT_ROOTS = frozenset(sys.stdlib_module_names) | {"hwpx"} LEDGER = Path("docs/python-example-ledger.json") diff --git a/tests/test_engine_surface_6_0.py b/tests/test_engine_surface_6_0.py index 0b7a0992..8b00b1bc 100644 --- a/tests/test_engine_surface_6_0.py +++ b/tests/test_engine_surface_6_0.py @@ -134,6 +134,46 @@ def test_moved_surface_warns_with_its_destination(name: str) -> None: assert func.__hwpx_removed_in__ == LEGACY_REMOVED_IN +def _moved_targets(replacement: str) -> list[str]: + """경고 문구의 이동 대상에서 점 경로만 뽑는다. + + ``doc.page.set_header(text=...)`` 는 ``doc.page.set_header``, + ``a / b`` 는 둘 다, ``paragraph.remove()`` 는 문단 메서드다. + """ + + return [part.strip().split("(")[0] for part in replacement.split(" / ")] + + +@pytest.mark.parametrize("name", sorted(vars(_LegacyFacade))) +def test_moved_destination_exists(name: str) -> None: + """이동 대상 문자열이 실제 속성으로 풀려야 한다(ARC-08). + + 락 파일은 문구만 고정한다. 없는 이름을 가리키는 경고를 따라 고친 코드는 + ``AttributeError`` 가 난다. + """ + + if name.startswith("__"): + pytest.skip("dunder") + static = inspect.getattr_static(_LegacyFacade, name) + func = static.fget if isinstance(static, property) else static + replacement = getattr(func, "__hwpx_moved_to__", None) + if replacement is None: + pytest.skip("not a moved name") + with HwpxDocument.new() as document: + roots = {"doc": document, "paragraph": document.add_paragraph("x")} + for target in _moved_targets(replacement): + head, *rest = target.split(".") + assert head in roots, f"{name}: 모르는 뿌리 {target!r}" + assert rest, f"{name}: 속성 없는 대상 {target!r}" + owner = roots[head] + for attribute in rest[:-1]: + owner = getattr(owner, attribute) + # 값을 읽지 않고 이름만 본다: 일부 속성은 읽을 때 문서를 훑는다. + assert hasattr(type(owner), rest[-1]) or hasattr(owner, rest[-1]), ( + f"{name}: {target} 가 없습니다" + ) + + def test_a_moved_call_warns_and_still_works() -> None: document = HwpxDocument.new() with pytest.warns(DeprecationWarning) as record: diff --git a/tests/test_release_checklist_example.py b/tests/test_release_checklist_example.py new file mode 100644 index 00000000..523977cd --- /dev/null +++ b/tests/test_release_checklist_example.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`examples/build_release_checklist.py`가 6.x 이름만으로 끝까지 도는지 본다.""" + +from __future__ import annotations + +import importlib.util +import warnings +from pathlib import Path + +from hwpx import HwpxDocument + +ROOT = Path(__file__).resolve().parents[1] + + +def _load(): + spec = importlib.util.spec_from_file_location( + "release_checklist_example", ROOT / "examples/build_release_checklist.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_example_runs_without_moved_names(tmp_path: Path) -> None: + example = _load() + output = tmp_path / "ReleaseChecklist.hwpx" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + assert example.main(output) == output + + with HwpxDocument.open(output) as document: + texts = [paragraph.text for paragraph in document.paragraphs] + memos = document.notes.memos + assert "DONE: 통합 테스트 결과 정리" in texts + assert "문서 배포 전 QA 서명을 수집하세요." in texts + assert len(memos) == 2 From bc5b5ca2732872428edf7af2cad1d4c51f76699e Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:36:56 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(errors):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=207=EC=A2=85=EC=9D=B4=20HwpxError=EB=8F=84?= =?UTF-8?q?=20=EC=83=81=EC=86=8D=20(ARC-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EquationConversionError, UnsupportedLatexError, MathMLUnavailableError, DocumentIngestError, UnsupportedDocumentFormat, RecoverError, WordBoxExtractorRequired. 기존 ValueError·RuntimeError 부모는 유지한다. 코드 7개와 도메인 4개(equation, ingest, recover, toc)를 레지스트리에 올리고 docs/error-codes.md를 다시 만들었다. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 5 ++ docs/error-codes.md | 31 +++++++++++- docs/stable-api.md | 7 +++ src/hwpx/equation/authoring.py | 2 + src/hwpx/equation/eqedit.py | 6 ++- src/hwpx/equation/mathml.py | 6 ++- src/hwpx/errors.py | 12 +++++ src/hwpx/ingest/base.py | 8 +++- src/hwpx/tools/recover.py | 6 ++- src/hwpx/tools/toc_fidelity.py | 5 +- ...st_public_exceptions_inherit_hwpx_error.py | 48 +++++++++++++++++++ 11 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 tests/test_public_exceptions_inherit_hwpx_error.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8799645c..3d39e738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,11 @@ `doc.styles.style`·`doc.fields.check_box(...).checked`를 가리켰다). 이동 대상 79개가 실제 속성으로 풀리는지 테스트가 확인한다. `llms.txt`는 `.hwp`가 열린다고 적고, `examples/build_release_checklist.py`는 6.x 이름으로 끝까지 돈다(전에는 없는 `document.save()`를 불렀다). +- 공개 예외 7종(`EquationConversionError`, `UnsupportedLatexError`, `MathMLUnavailableError`, `DocumentIngestError`, + `UnsupportedDocumentFormat`, `RecoverError`, `WordBoxExtractorRequired`)이 `HwpxError`도 상속한다. 기존 부모 + (`ValueError`·`RuntimeError`)는 그대로라 기존 `except`는 전처럼 잡는다. 각자 `code`가 생겼다 + (`equation-conversion`, `equation-latex-unsupported`, `equation-mathml-unavailable`, `ingest-failed`, + `ingest-format-unsupported`, `recover-unsafe`, `toc-word-box-extractor-required`). ### 수정 diff --git a/docs/error-codes.md b/docs/error-codes.md index 4c2e4772..7884a1ed 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 경계 | 영수증 스키마 버전 | -| 개수 | 200 | 11 | +| 개수 | 207 | 11 | 통합하지 않는 이유: quality 코드는 `hwpx.mutation-report/v1` 과 `VisualCompleteReport` 에 이미 실려 나간 값이다. 이름을 바꾸면 영수증을 읽는 @@ -45,7 +45,7 @@ except HwpxError as exc: `<도메인>-<조건>`, 전부 소문자 kebab-case. 도메인은 6.0 네임스페이스와 패키지 수준 관심사에서 온다: -`capability`, `cell`, `contract`, `document`, `field`, `header`, `heading`, `hwp5`, `hwpx`, `master`, `media`, `note`, `open`, `package`, `page`, `paragraph`, `parts`, `plan`, `preservation`, `quality`, `ref`, `save`, `section`, `shape`, `story`, `style`, `table`, `text`, `track` +`capability`, `cell`, `contract`, `document`, `equation`, `field`, `header`, `heading`, `hwp5`, `hwpx`, `ingest`, `master`, `media`, `note`, `open`, `package`, `page`, `paragraph`, `parts`, `plan`, `preservation`, `quality`, `recover`, `ref`, `save`, `section`, `shape`, `story`, `style`, `table`, `text`, `toc`, `track` 유예 2건 — `unknown-contract-document`, `unknown-contract-schema` — 은 5.6.0 에 이미 나간 이름이라 문법에 맞지 않아도 바꾸지 않는다(7.0 정리). @@ -84,6 +84,14 @@ except HwpxError as exc: | `document-validation-failed` | 저장 전 문서 검증이 실패했다. | | `document-version-root-invalid` | version.xml 루트가 HCFVersion 이 아니다. | +### `equation-*` + +| 코드 | 뜻 | +|---|---| +| `equation-conversion` | 한글 수식 스크립트와 LaTeX 사이의 변환에 실패했다. | +| `equation-latex-unsupported` | LaTeX 조각이 검증된 명령 집합 밖의 명령을 쓴다. | +| `equation-mathml-unavailable` | MathML 변환에 필요한 latex2mathml이 설치돼 있지 않다. | + ### `field-*` | 코드 | 뜻 | @@ -145,6 +153,13 @@ except HwpxError as exc: | `hwpx-type-error` | 인자 타입이 이 연산이 받는 것이 아니다(분류되지 않음). | | `hwpx-value-error` | 인자 값이 이 연산에 쓸 수 없다(분류되지 않음). | +### `ingest-*` + +| 코드 | 뜻 | +|---|---| +| `ingest-failed` | 문서를 Markdown으로 들여오지 못했다(시도한 변환기는 attempts). | +| `ingest-format-unsupported` | 이 입력을 받는 변환기가 등록돼 있지 않다. | + ### `master-*` | 코드 | 뜻 | @@ -254,6 +269,12 @@ except HwpxError as exc: |---|---| | `quality-gate-failed` | 품질 게이트가 저장을 막았다(quality 코드는 context 에). | +### `recover-*` + +| 코드 | 뜻 | +|---|---| +| `recover-unsafe` | 로컬 헤더 항목이 있지만 안전하게 복구할 수 없다. | + ### `save-*` | 코드 | 뜻 | @@ -380,6 +401,12 @@ except HwpxError as exc: | `text-outline-budget` | outline 의 글자·항목 예산이 1보다 작다. | | `text-search-empty` | 바꿀 대상 문자열이 비어 있다. | +### `toc-*` + +| 코드 | 뜻 | +|---|---| +| `toc-word-box-extractor-required` | PDF에서 글자 상자를 읽을 방법 없이 차례 쪽 대조를 불렀다. | + ### `track-*` | 코드 | 뜻 | diff --git a/docs/stable-api.md b/docs/stable-api.md index 54a1e8ea..ebd8d27d 100644 --- a/docs/stable-api.md +++ b/docs/stable-api.md @@ -178,6 +178,13 @@ MCP `analyze_form_fill`/`apply_form_fill`/`verify_form_fill`). | `hwpx.table_patch.TableStructureError` | `table-structure` | `HwpxError`, `ValueError` | 표 구조 편집 거부(fail-closed)·미지원 | | `hwpx.table_patch.RenderCheckRequired` | `render-check-required` | `HwpxError`, `RuntimeError` | `verify_fill(require=True)`인데 실한컴 오라클 미렌더 | | `Hwp5Error` | `hwp5-damaged`(기본)·`hwp5-password`·`hwp5-distribution`·`hwp5-drm`·`hwp5-not-hwp5`·`hwp5-version-unsupported`·`hwp5-limit-exceeded`·`hwp5-write-unsupported` | `HwpxError`, `ValueError` | `.hwp` 열기(`HwpxDocument.open`)와 HWP 5.0 쓰기(`save_to_path("x.hwp")`, `format="hwp"`) | +| `hwpx.equation.EquationConversionError` | `equation-conversion` | `HwpxError`, `ValueError` | 한글 수식 스크립트 ↔ LaTeX 변환 실패 | +| `hwpx.equation.authoring.UnsupportedLatexError` | `equation-latex-unsupported` | `EquationConversionError` | 검증된 집합 밖의 LaTeX 명령 | +| `hwpx.equation.mathml.MathMLUnavailableError` | `equation-mathml-unavailable` | `HwpxError`, `RuntimeError` | `latex2mathml` 없이 MathML 변환 요청 | +| `hwpx.ingest.DocumentIngestError` | `ingest-failed` | `HwpxError`, `RuntimeError` | ingest 변환 실패(`attempts`에 시도 기록) | +| `hwpx.ingest.UnsupportedDocumentFormat` | `ingest-format-unsupported` | `DocumentIngestError` | 입력을 받는 변환기가 없음 | +| `hwpx.tools.recover.RecoverError` | `recover-unsafe` | `HwpxError`, `ValueError` | 로컬 헤더 항목을 안전하게 복구할 수 없음 | +| `hwpx.tools.toc_fidelity.WordBoxExtractorRequired` | `toc-word-box-extractor-required` | `HwpxError`, `RuntimeError` | PDF 글자 상자 추출기 없이 차례 쪽 대조 호출 | ### HWP 5.0 (`.hwp`) diff --git a/src/hwpx/equation/authoring.py b/src/hwpx/equation/authoring.py index 5adfd354..72759855 100644 --- a/src/hwpx/equation/authoring.py +++ b/src/hwpx/equation/authoring.py @@ -36,6 +36,8 @@ class UnsupportedLatexError(EquationConversionError): """Raised when a LaTeX fragment uses commands outside the verified set.""" + default_code = "equation-latex-unsupported" + def _invert(*maps: dict[str, str]) -> dict[str, str]: """LaTeX command → canonical EqEdit token (first-seen wins per map order).""" diff --git a/src/hwpx/equation/eqedit.py b/src/hwpx/equation/eqedit.py index 7f051c94..420add7a 100644 --- a/src/hwpx/equation/eqedit.py +++ b/src/hwpx/equation/eqedit.py @@ -35,6 +35,8 @@ from typing import Any +from ..errors import HwpxError + from .tokens import ( ACCENTS, BIG_OPERATORS, @@ -74,9 +76,11 @@ _SYMBOL_OPS_SORTED = sorted(SYMBOL_OPERATORS, key=len, reverse=True) -class EquationConversionError(ValueError): +class EquationConversionError(HwpxError, ValueError): """Raised when an EqEdit script cannot be converted to LaTeX.""" + default_code = "equation-conversion" + def _lex_quoted_literal(source: str, i: int, tokens: list[str]) -> int: """Consume a quoted EqEdit text run; unterminated quotes swallow the rest.""" diff --git a/src/hwpx/equation/mathml.py b/src/hwpx/equation/mathml.py index 43b1c791..a689936f 100644 --- a/src/hwpx/equation/mathml.py +++ b/src/hwpx/equation/mathml.py @@ -12,6 +12,8 @@ import re from typing import Callable +from ..errors import HwpxError + from .tokens import EQEDIT_MATHML_OPERATOR_COMMANDS # ``False`` marks a resolved-but-unavailable converter; ``None`` means "not yet @@ -21,9 +23,11 @@ _TEXT_COMMAND = "\\text{" -class MathMLUnavailableError(RuntimeError): +class MathMLUnavailableError(HwpxError, RuntimeError): """Raised when ``latex2mathml`` is not installed.""" + default_code = "equation-mathml-unavailable" + def _load_converter() -> Callable[[str], str] | bool: global _converter diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index 56bcfbc2..72806ed1 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -150,11 +150,13 @@ class Hwp5ConversionWarning(UserWarning): "cell", "contract", "document", + "equation", "field", "header", "heading", "hwp5", "hwpx", + "ingest", "master", "media", "note", @@ -166,6 +168,7 @@ class Hwp5ConversionWarning(UserWarning): "plan", "preservation", "quality", + "recover", "ref", "save", "section", @@ -174,6 +177,7 @@ class Hwp5ConversionWarning(UserWarning): "style", "table", "text", + "toc", "track", } ) @@ -420,6 +424,14 @@ class Hwp5ConversionWarning(UserWarning): "hwp5-version-unsupported": "FileHeader의 주 버전이 5가 아니다.", "hwp5-limit-exceeded": "HWP 5.0 입력이 파싱 상한(스트림 크기·레코드 수·디렉터리 수)을 넘는다.", "hwp5-write-unsupported": "HWP 5.0 작성기가 표현할 수 없는 내용이다.", + # -- 수식·ingest·복구·차례 (기존 builtin 부모를 유지한 채 편입) ------ + "equation-conversion": "한글 수식 스크립트와 LaTeX 사이의 변환에 실패했다.", + "equation-latex-unsupported": "LaTeX 조각이 검증된 명령 집합 밖의 명령을 쓴다.", + "equation-mathml-unavailable": "MathML 변환에 필요한 latex2mathml이 설치돼 있지 않다.", + "ingest-failed": "문서를 Markdown으로 들여오지 못했다(시도한 변환기는 attempts).", + "ingest-format-unsupported": "이 입력을 받는 변환기가 등록돼 있지 않다.", + "recover-unsafe": "로컬 헤더 항목이 있지만 안전하게 복구할 수 없다.", + "toc-word-box-extractor-required": "PDF에서 글자 상자를 읽을 방법 없이 차례 쪽 대조를 불렀다.", # -- 계획 실행기 ----------------------------------------------------- "plan-invalid": "편집 계획이 v1 계약을 위반한다.", } diff --git a/src/hwpx/ingest/base.py b/src/hwpx/ingest/base.py index 237b1805..fa9d1a87 100644 --- a/src/hwpx/ingest/base.py +++ b/src/hwpx/ingest/base.py @@ -10,6 +10,8 @@ import re from typing import Any, BinaryIO, Iterable, Protocol +from ..errors import HwpxError + @dataclass(frozen=True, kw_only=True) class DocumentSourceInfo: @@ -83,9 +85,11 @@ def as_dict(self) -> dict[str, Any]: } -class DocumentIngestError(RuntimeError): +class DocumentIngestError(HwpxError, RuntimeError): """Base class for ingest failures with converter-attempt diagnostics.""" + default_code = "ingest-failed" + def __init__(self, message: str, *, attempts: Iterable[ConversionAttempt] = ()) -> None: super().__init__(message) self.attempts = list(attempts) @@ -101,6 +105,8 @@ def as_dict(self) -> dict[str, Any]: class UnsupportedDocumentFormat(DocumentIngestError): """Raised when no registered converter accepts the source.""" + default_code = "ingest-format-unsupported" + class DocumentConverter(Protocol): """Converter contract for a specific source family.""" diff --git a/src/hwpx/tools/recover.py b/src/hwpx/tools/recover.py index c442fbc4..ee10b18c 100644 --- a/src/hwpx/tools/recover.py +++ b/src/hwpx/tools/recover.py @@ -5,6 +5,8 @@ import zlib from pathlib import Path +from ..errors import HwpxError + __all__ = [ "RecoverError", "recover_entries", @@ -18,9 +20,11 @@ _FLAG_DATA_DESCRIPTOR = 0x08 -class RecoverError(ValueError): +class RecoverError(HwpxError, ValueError): """Raised when a local-header entry is present but unsafe to recover.""" + default_code = "recover-unsafe" + def _decompress_deflate(payload: bytes, *, max_entry_size: int) -> bytes: decompressor = zlib.decompressobj(-zlib.MAX_WBITS) diff --git a/src/hwpx/tools/toc_fidelity.py b/src/hwpx/tools/toc_fidelity.py index 4ca028b9..5b6591a0 100644 --- a/src/hwpx/tools/toc_fidelity.py +++ b/src/hwpx/tools/toc_fidelity.py @@ -28,6 +28,7 @@ from typing import Any from hwpx.document import HwpxDocument +from hwpx.errors import HwpxError _HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" _TARGET_RE = re.compile(r"\?#(\d+)") @@ -255,9 +256,11 @@ def _normalize(text: str) -> str: _OUTLINE_PREFIX_RE = re.compile(r"^\d+(?:\.\d+)*\.?") -class WordBoxExtractorRequired(RuntimeError): +class WordBoxExtractorRequired(HwpxError, RuntimeError): """``heading_rendered_pages`` was called without a way to read the PDF.""" + default_code = "toc-word-box-extractor-required" + def heading_rendered_pages( pdf_path: str, diff --git a/tests/test_public_exceptions_inherit_hwpx_error.py b/tests/test_public_exceptions_inherit_hwpx_error.py new file mode 100644 index 00000000..e3076b5f --- /dev/null +++ b/tests/test_public_exceptions_inherit_hwpx_error.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +"""공개 예외가 기존 부모를 지키면서 ``HwpxError`` 로도 잡히는지 본다(ARC-14).""" + +from __future__ import annotations + +import pytest + +from hwpx.equation.authoring import UnsupportedLatexError, latex_to_eqedit +from hwpx.equation.eqedit import EquationConversionError +from hwpx.equation.mathml import MathMLUnavailableError +from hwpx.errors import ERROR_CODES, HwpxError +from hwpx.ingest.base import DocumentIngestError, UnsupportedDocumentFormat +from hwpx.tools.recover import RecoverError +from hwpx.tools.toc_fidelity import WordBoxExtractorRequired + +CASES = [ + (EquationConversionError, ValueError), + (UnsupportedLatexError, ValueError), + (MathMLUnavailableError, RuntimeError), + (DocumentIngestError, RuntimeError), + (UnsupportedDocumentFormat, RuntimeError), + (RecoverError, ValueError), + (WordBoxExtractorRequired, RuntimeError), +] + + +@pytest.mark.parametrize(("error", "builtin_parent"), CASES) +def test_public_exception_keeps_its_parent_and_joins_hwpx_error(error, builtin_parent) -> None: + assert issubclass(error, HwpxError) + assert issubclass(error, builtin_parent) + raised = error("메시지") + assert str(raised) == "메시지" + assert raised.code == error.default_code + assert raised.code in ERROR_CODES + assert raised.to_dict()["message"] == "메시지" + + +def test_ingest_error_keeps_attempts() -> None: + raised = UnsupportedDocumentFormat("없음", attempts=()) + assert raised.attempts == [] + assert raised.as_dict()["error"] == "UnsupportedDocumentFormat" + + +def test_unsupported_latex_is_caught_as_hwpx_error() -> None: + with pytest.raises(HwpxError) as caught: + latex_to_eqedit(r"\thiscommanddoesnotexist{x}") + assert isinstance(caught.value, ValueError) + assert caught.value.code == "equation-latex-unsupported" From 1b97d306b901dc6d626099edec8002f4e830068f Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:44:41 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix(open):=20=EC=86=90=EC=83=81=EB=90=9C=20?= =?UTF-8?q?HWPX=EC=9D=98=20=EB=82=A0=20=EC=98=88=EC=99=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=98=A4=EB=A5=98=ED=98=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AC=B6=EC=9D=8C=20(ENG-31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HwpxDocument.open / HwpxPackage.open이 손상 종류마다 BadZipFile, zlib.error, lxml XMLSyntaxError를 그대로 올리던 것을 HwpxStructureError 하위 클래스로 감싼다. 원래 예외는 __cause__에 둔다. - HwpxArchiveError(HwpxStructureError, BadZipFile) - HwpxCompressedDataError(HwpxStructureError, zlib.error) - HwpxXmlSyntaxError(HwpxStructureError, XMLSyntaxError), part_name 포함 각 클래스가 전에 나던 예외도 상속하므로 기존 except는 그대로 잡는다 (docs/stable-api.md가 적어 둔 BadZipFile 계약 유지). open의 독스트링, stable-api, api_reference를 실제와 맞췄다. opc/security.py는 건드리지 않았다. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 5 + docs/api_reference.md | 5 +- docs/stable-api.md | 21 +++- src/hwpx/document.py | 25 ++++- src/hwpx/errors.py | 8 +- src/hwpx/opc/package.py | 132 ++++++++++++++++++++------ tests/test_open_damaged_input.py | 158 +++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 36 deletions(-) create mode 100644 tests/test_open_damaged_input.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d39e738..f4fffa5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,11 @@ (`ValueError`·`RuntimeError`)는 그대로라 기존 `except`는 전처럼 잡는다. 각자 `code`가 생겼다 (`equation-conversion`, `equation-latex-unsupported`, `equation-mathml-unavailable`, `ingest-failed`, `ingest-format-unsupported`, `recover-unsafe`, `toc-word-box-extractor-required`). +- 손상된 HWPX를 열 때 나던 날 예외를 `HwpxStructureError` 하위 클래스로 묶었다. ZIP이 아니거나 잘린 입력은 + `HwpxArchiveError`, 압축 데이터 손상은 `HwpxCompressedDataError`, 깨진 XML 파트(암호 걸린 HWPX 포함)는 + `HwpxXmlSyntaxError`(`part_name`에 파트 이름)다. 각 클래스는 전에 나던 `zipfile.BadZipFile`·`zlib.error`· + `lxml.etree.XMLSyntaxError`도 상속하므로 기존 `except`는 그대로 잡고, 원래 예외는 `__cause__`에 있다. + `HwpxDocument.open`의 독스트링과 `docs/stable-api.md`가 이 표를 적는다. ### 수정 diff --git a/docs/api_reference.md b/docs/api_reference.md index 38c78d6a..973bce10 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -572,7 +572,10 @@ ### 예외 - `HwpxPackageError`: 패키지 조작 오류에 대한 기본 예외입니다. -- `HwpxStructureError`: HWPX 패키지의 필수 구조 구성 요소가 없거나 일치하지 않을 때 발생합니다. +- `HwpxStructureError`: HWPX 패키지의 필수 구조 구성 요소가 없거나 일치하지 않을 때 발생합니다. 아래 셋의 부모입니다. +- `HwpxArchiveError`: ZIP이 아니거나 잘린 입력. `zipfile.BadZipFile`도 상속합니다. +- `HwpxCompressedDataError`: 압축 데이터가 손상된 입력. `zlib.error`도 상속합니다. +- `HwpxXmlSyntaxError`: XML 파트가 깨진 입력. `lxml.etree.XMLSyntaxError`도 상속하고 `part_name`에 파트 이름을 담습니다. ### 데이터 클래스 diff --git a/docs/stable-api.md b/docs/stable-api.md index ebd8d27d..0535bcb4 100644 --- a/docs/stable-api.md +++ b/docs/stable-api.md @@ -153,8 +153,25 @@ MCP `analyze_form_fill`/`apply_form_fill`/`verify_form_fill`). 아래 표에 있는 예외 — 쓰기 보존 계약, 저장 경로 게이트, 표 구조 편집, 이동된 표면 안내 — 는 `hwpx.errors.HwpxError`(최상위 `hwpx.HwpxError`로도 import) 베이스를 상속합니다. 모든 호출을 감싸는 포괄 계약은 아닙니다: 없는 파일은 -`FileNotFoundError`, HWPX 패키지가 아닌 입력은 `zipfile.BadZipFile`처럼 파이썬이 -원래 내는 예외가 그대로 올라옵니다([지원 매트릭스](support-matrix.md) 참조). +`FileNotFoundError`처럼 파이썬이 원래 내는 예외가 그대로 올라옵니다 +([지원 매트릭스](support-matrix.md) 참조). + +손상된 HWPX를 열 때(`HwpxDocument.open`, `HwpxPackage.open`)의 예외는 모두 +`hwpx.opc.package.HwpxStructureError`의 하위 클래스이면서, 파이썬·lxml이 원래 내던 +예외이기도 합니다. 그래서 `except zipfile.BadZipFile` 같은 기존 처리는 그대로 잡고, +`except HwpxStructureError` 하나로 "손상된 파일"을 모두 잡을 수도 있습니다. 원래 +예외는 `__cause__`에 있습니다. + +| 입력 | 예외 | 함께 상속 | +|---|---|---| +| ZIP이 아님·잘린 파일·빈 파일 | `HwpxArchiveError` | `zipfile.BadZipFile` | +| 압축 데이터 손상 | `HwpxCompressedDataError` | `zlib.error` | +| 깨진 XML 파트, 암호가 걸린 HWPX | `HwpxXmlSyntaxError`(`part_name`) | `lxml.etree.XMLSyntaxError` | +| `mimetype`·`container.xml`·rootfile 없음 | `HwpxStructureError` | — | + +`HwpxPackageError`·`HwpxStructureError` 계열은 `HwpxError`를 상속하지 않습니다 +(`code`·`context`·`suggestion` 필드가 없습니다). 크기·압축률 상한을 넘는 입력은 +`HwpxSecurityError`(`ValueError`)로 거부합니다. 구조화된 예외는 사람용 문장(`str(exc)`)을 그대로 두고, 세 가지 **기계가 읽는** 필드를 얹습니다: diff --git a/src/hwpx/document.py b/src/hwpx/document.py index 4db42a4a..ca4e1908 100644 --- a/src/hwpx/document.py +++ b/src/hwpx/document.py @@ -30,6 +30,7 @@ ) from .opc.package import ( HwpxPackage, + structure_errors, ) from .oxml.namespaces import register_owpml_namespaces from .oxml.table_sizes import new_table_width @@ -128,9 +129,26 @@ def open( ) -> "HwpxDocument": """Open *source* and return a :class:`HwpxDocument` instance. + *source*는 경로, 바이트, 또는 읽을 수 있는 이진 스트림이다. 내용이 HWP 5.0 + 복합 파일(``.hwp``)로 시작하면 같은 문서 모델로 바꿔서 연다. + Raises: - HwpxStructureError: 필수 파일이나 구조가 올바르지 않은 HWPX를 열 때 발생합니다. - HwpxPackageError: 패키지를 여는 과정에서 일반적인 I/O/포맷 오류가 발생하면 전달됩니다. + FileNotFoundError: 경로에 파일이 없다. 그 밖의 ``OSError``(권한 등)도 + 파이썬이 낸 그대로 올라온다. + HwpxStructureError: 손상됐거나 필수 구성이 없는 HWPX. 손상 종류는 하위 + 클래스로 가린다. 원래 예외는 ``__cause__``에 있다. + + - ``HwpxArchiveError``: ZIP이 아니거나 잘린 파일, 빈 파일. + ``zipfile.BadZipFile``이기도 하다. + - ``HwpxCompressedDataError``: 압축 데이터 손상. + ``zlib.error``이기도 하다. + - ``HwpxXmlSyntaxError``: 깨진 XML 파트(암호가 걸린 HWPX 포함). + ``lxml.etree.XMLSyntaxError``이기도 하다. + - ``HwpxStructureError`` 자체: ``mimetype``·``container.xml``· + rootfile 같은 필수 구성이 없다. + HwpxSecurityError: 항목 수·크기·압축률이 안전 상한을 넘는다 + (``ValueError`` 계열). + Hwp5Error: ``.hwp`` 입력이 손상됐거나 암호·배포용·DRM 문서다. """ internal_resources: list[Any] = [] open_source = source @@ -143,7 +161,8 @@ def open( # HwpxPackage/ZipFile accepts os.PathLike at runtime; its narrower # compatibility annotation intentionally remains frozen. package = HwpxPackage.open(cast(Any, open_source)) - root = HwpxOxmlDocument.from_package(package) + with structure_errors(): + root = HwpxOxmlDocument.from_package(package) return cls(package, root, managed_resources=tuple(internal_resources)) @classmethod diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index 72806ed1..849ca7c4 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -5,9 +5,11 @@ moved-surface guidance, and the failures documented on an individual API: those raise a :class:`HwpxError` (or a subclass). It is not a blanket wrapper around every call. Operating-system and container-level failures keep the exception -Python already raises — ``FileNotFoundError`` for a missing path, -:class:`zipfile.BadZipFile` for a payload that is not an HWPX package — and the -support matrix documents them in that form. +Python already raises — ``FileNotFoundError`` for a missing path. A damaged +package raises a :class:`hwpx.opc.package.HwpxStructureError` subclass that is +also the exception Python or lxml raised (:class:`zipfile.BadZipFile`, +``zlib.error``, ``lxml.etree.XMLSyntaxError``); that family does not derive +from :class:`HwpxError`. The base carries three machine-readable fields on top of the human-readable message, so a caller can branch on a stable ``code``, read the measured diff --git a/src/hwpx/opc/package.py b/src/hwpx/opc/package.py index 98bbf432..d3a91439 100644 --- a/src/hwpx/opc/package.py +++ b/src/hwpx/opc/package.py @@ -8,6 +8,8 @@ import io import os import tempfile +import zlib +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, BinaryIO, Iterable, Iterator, Mapping, MutableMapping, Sequence @@ -38,7 +40,16 @@ serialize_xml, ) -__all__ = ["HwpxPackage", "HwpxPackageError", "HwpxStructureError", "RootFile", "VersionInfo"] +__all__ = [ + "HwpxArchiveError", + "HwpxCompressedDataError", + "HwpxPackage", + "HwpxPackageError", + "HwpxStructureError", + "HwpxXmlSyntaxError", + "RootFile", + "VersionInfo", +] logger = logging.getLogger(__name__) _UNCHECKED_SAVE_TOKEN = object() @@ -101,6 +112,68 @@ class HwpxStructureError(HwpxPackageError): """Raised when the underlying HWPX package violates the required structure.""" +class HwpxArchiveError(HwpxStructureError, BadZipFile): + """The input is not a readable ZIP archive (empty, truncated, or not a ZIP). + + It is still a :class:`zipfile.BadZipFile`, so callers that catch that keep + working; the ``zipfile`` error is the ``__cause__``. + """ + + +class HwpxCompressedDataError(HwpxStructureError, zlib.error): + """A ZIP member's compressed data is damaged. + + It is still a :class:`zlib.error`; the original is the ``__cause__``. + """ + + +class HwpxXmlSyntaxError(HwpxStructureError, etree.XMLSyntaxError): + """An XML part of the package is not well-formed. + + It is still an :class:`lxml.etree.XMLSyntaxError` (``position``, ``lineno`` + and ``code`` are kept); the original is the ``__cause__``. ``part_name`` is + the part that failed when the caller knew it, else ``None``. + """ + + part_name: str | None = None + + @classmethod + def wrap( + cls, + exc: etree.XMLSyntaxError, + *, + part_name: str | None = None, + message: str | None = None, + ) -> "HwpxXmlSyntaxError": + line, column = exc.position or (1, 1) + if message is None: + where = f"'{part_name}' 파트" if part_name else "패키지" + message = f"{where}의 XML이 올바르지 않습니다: {exc.msg}" + wrapped = cls(message, exc.code, line, column, exc.filename) + wrapped.part_name = part_name + return wrapped + + +@contextmanager +def structure_errors() -> Iterator[None]: + """Turn the raw exceptions a damaged package raises into structure errors. + + ``zlib.error`` and ``lxml.etree.XMLSyntaxError`` leave as + :class:`HwpxCompressedDataError` and :class:`HwpxXmlSyntaxError`, with the + original as ``__cause__``. Errors that are already package errors pass + through untouched. + """ + + try: + yield + except HwpxPackageError: + raise + except zlib.error as exc: + raise HwpxCompressedDataError(f"압축 데이터가 손상됐습니다: {exc}") from exc + except etree.XMLSyntaxError as exc: + raise HwpxXmlSyntaxError.wrap(exc) from exc + + def _summarize_validation_issues(issues: Sequence[object], *, limit: int = 5) -> str: selected = [str(issue) for issue in issues[:limit]] remaining = len(issues) - len(selected) @@ -476,25 +549,30 @@ def open(cls, pkg_file: str | Path | bytes | bytearray | BinaryIO) -> HwpxPackag zip_order = [info.filename for info in infos] except BadZipFile as exc: if cls._leading_bytes(stream, len(cls.OLE2_MAGIC)) == cls.OLE2_MAGIC: - raise BadZipFile( + raise HwpxArchiveError( "HwpxPackage는 HWPX 패키지만 엽니다. HWP 5.0(.hwp) 문서는 " "HwpxDocument.open으로 열거나, 한컴오피스에서 HWPX로 변환한 뒤 사용하세요." ) from exc - raise + # The message stays the one zipfile wrote, so callers that show or + # match it see no change; only the type gains the structure base. + raise HwpxArchiveError(str(exc)) from exc + except zlib.error as exc: + raise HwpxCompressedDataError(f"압축 데이터가 손상됐습니다: {exc}") from exc logger.debug("HWPX 패키지 파일 목록 %d개를 로드했습니다.", len(files)) if cls.MIMETYPE_PATH not in files: raise HwpxStructureError("HWPX package is missing the mandatory 'mimetype' file.") mimetype = files[cls.MIMETYPE_PATH].decode("utf-8") - rootfiles = cls._parse_container(files.get(cls.CONTAINER_PATH)) - version_info = cls._parse_version(files.get(cls.VERSION_PATH)) - package = cls( - files, - rootfiles, - version_info, - mimetype, - zip_infos=zip_infos, - zip_order=zip_order, - ) + with structure_errors(): + rootfiles = cls._parse_container(files.get(cls.CONTAINER_PATH)) + version_info = cls._parse_version(files.get(cls.VERSION_PATH)) + package = cls( + files, + rootfiles, + version_info, + mimetype, + zip_infos=zip_infos, + zip_order=zip_order, + ) return package @staticmethod @@ -635,20 +713,20 @@ def get_xml(self, part_name: str) -> etree._Element: try: return parse_xml(data) except etree.XMLSyntaxError as exc: - if self._normalize_path(part_name) not in self._encrypted_parts: - raise - # Same shape as the HWP v5 guidance in open(): the exception type - # callers already catch stays, the message names the next step, and - # the original lxml error rides along as __cause__. - line, column = exc.position or (1, 1) - raise etree.XMLSyntaxError( - "암호가 걸린 HWPX 문서는 지원하지 않습니다" - f"('{self._normalize_path(part_name)}' 파트가 암호화돼 있습니다). " - "한컴오피스에서 문서 암호를 해제한 뒤 다시 저장해 사용하세요.", - exc.code, - line, - column, - exc.filename, + # The exception type callers already catch (XMLSyntaxError) stays; + # it also becomes a structure error, names the part, and carries + # the original lxml error as __cause__. + normalized = self._normalize_path(part_name) + if normalized not in self._encrypted_parts: + raise HwpxXmlSyntaxError.wrap(exc, part_name=normalized) from exc + raise HwpxXmlSyntaxError.wrap( + exc, + part_name=normalized, + message=( + "암호가 걸린 HWPX 문서는 지원하지 않습니다" + f"('{normalized}' 파트가 암호화돼 있습니다). " + "한컴오피스에서 문서 암호를 해제한 뒤 다시 저장해 사용하세요." + ), ) from exc def set_xml(self, part_name: str, element: etree._Element) -> None: diff --git a/tests/test_open_damaged_input.py b/tests/test_open_damaged_input.py new file mode 100644 index 00000000..9d3f1775 --- /dev/null +++ b/tests/test_open_damaged_input.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: Apache-2.0 +"""손상된 HWPX를 열 때의 예외 계열(ENG-31). + +손상 종류마다 다른 날 예외(``BadZipFile``·``zlib.error``·``XMLSyntaxError``)가 +나던 것을 ``HwpxStructureError`` 하위 클래스로 묶었다. 기존 ``except`` 가 깨지지 +않도록 각 클래스는 원래 예외도 상속하고, 원래 예외는 ``__cause__`` 에 남는다. +""" + +from __future__ import annotations + +import io +import zipfile +import zlib +from typing import Callable + +import pytest +from lxml import etree + +from hwpx import HwpxDocument +from hwpx.opc.package import ( + HwpxArchiveError, + HwpxCompressedDataError, + HwpxPackage, + HwpxPackageError, + HwpxStructureError, + HwpxXmlSyntaxError, +) + + +@pytest.fixture(scope="module") +def good() -> bytes: + with HwpxDocument.new() as document: + document.add_paragraph("가나다 " * 400) + return document.to_bytes() + + +def _rebuild(data: bytes, mutate: Callable[[str, bytes], bytes | None]) -> bytes: + out = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w") as target: + for info in source.infolist(): + payload = mutate(info.filename, source.read(info.filename)) + if payload is not None: + target.writestr(info, payload, compress_type=info.compress_type) + return out.getvalue() + + +def _break_part(data: bytes, part: str, payload: bytes | None = None) -> bytes: + return _rebuild( + data, + lambda name, body: (payload if payload is not None else body[: len(body) // 2]) + if name == part + else body, + ) + + +def _corrupt_deflate(data: bytes) -> bytes: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + info = archive.getinfo("Contents/section0.xml") + assert info.compress_type == zipfile.ZIP_DEFLATED + start = info.header_offset + 30 + len(info.filename.encode()) + len(info.extra) + damaged = bytearray(data) + damaged[start : start + 12] = b"\xff" * 12 + return bytes(damaged) + + +@pytest.mark.parametrize("opener", [HwpxDocument.open, HwpxPackage.open]) +@pytest.mark.parametrize( + "payload", + [b"", b"not a zip at all " * 20], + ids=["empty", "not-zip"], +) +def test_not_a_zip_is_a_structure_error_and_still_bad_zip_file(opener, payload) -> None: + with pytest.raises(HwpxArchiveError) as caught: + opener(payload) + assert isinstance(caught.value, HwpxStructureError) + assert isinstance(caught.value, zipfile.BadZipFile) + assert type(caught.value.__cause__) is zipfile.BadZipFile + # 문구는 zipfile이 쓴 그대로다. + assert str(caught.value) == str(caught.value.__cause__) + + +@pytest.mark.parametrize("opener", [HwpxDocument.open, HwpxPackage.open]) +def test_truncated_zip_is_an_archive_error(opener, good: bytes) -> None: + with pytest.raises(zipfile.BadZipFile) as caught: + opener(good[: len(good) // 2]) + assert isinstance(caught.value, HwpxArchiveError) + + +@pytest.mark.parametrize("opener", [HwpxDocument.open, HwpxPackage.open]) +def test_damaged_compressed_data_is_a_structure_error(opener, good: bytes) -> None: + with pytest.raises(HwpxCompressedDataError) as caught: + opener(_corrupt_deflate(good)) + assert isinstance(caught.value, HwpxStructureError) + assert isinstance(caught.value, zlib.error) + assert type(caught.value.__cause__) is zlib.error + + +@pytest.mark.parametrize( + ("part", "payload"), + [ + ("Contents/header.xml", None), + ("Contents/section0.xml", None), + ("Contents/content.hpf", b""), + ], +) +def test_broken_xml_part_names_the_part(good: bytes, part: str, payload: bytes | None) -> None: + with pytest.raises(HwpxXmlSyntaxError) as caught: + HwpxDocument.open(_break_part(good, part, payload)) + error = caught.value + assert isinstance(error, HwpxStructureError) + assert isinstance(error, etree.XMLSyntaxError) + assert type(error.__cause__) is etree.XMLSyntaxError + assert error.part_name == part + assert part in str(error) + assert error.position == error.__cause__.position + assert error.code == error.__cause__.code + + +@pytest.mark.parametrize("opener", [HwpxDocument.open, HwpxPackage.open]) +@pytest.mark.parametrize("part", ["META-INF/container.xml", "version.xml"]) +def test_broken_package_level_xml_is_a_structure_error(opener, good: bytes, part: str) -> None: + with pytest.raises(HwpxXmlSyntaxError) as caught: + opener(_break_part(good, part, b"")) + assert isinstance(caught.value, etree.XMLSyntaxError) + assert type(caught.value.__cause__) is etree.XMLSyntaxError + + +def test_one_except_clause_catches_every_kind_of_damage(good: bytes) -> None: + damaged = [ + b"", + good[: len(good) // 2], + _corrupt_deflate(good), + _break_part(good, "Contents/header.xml"), + _rebuild(good, lambda name, body: None if name == "mimetype" else body), + ] + for payload in damaged: + with pytest.raises(HwpxStructureError): + HwpxDocument.open(payload) + + +def test_missing_mimetype_stays_a_plain_structure_error(good: bytes) -> None: + payload = _rebuild(good, lambda name, body: None if name == "mimetype" else body) + with pytest.raises(HwpxStructureError) as caught: + HwpxDocument.open(payload) + assert type(caught.value) is HwpxStructureError + + +def test_get_xml_after_open_wraps_the_same_way(good: bytes) -> None: + package = HwpxPackage.open(_break_part(good, "Contents/section0.xml")) + with pytest.raises(HwpxPackageError) as caught: + package.get_xml("Contents/section0.xml") + assert isinstance(caught.value, HwpxXmlSyntaxError) + assert caught.value.part_name == "Contents/section0.xml" + + +def test_missing_path_is_still_file_not_found(tmp_path) -> None: + with pytest.raises(FileNotFoundError): + HwpxDocument.open(tmp_path / "없는-파일.hwpx") From f8df6ff49c56395f24ee193c41cf86c00975919f Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:53:15 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EC=83=88=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0,=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=8A=A4=EB=83=85=EC=88=8F=20=ED=8C=8C=ED=8A=B8,?= =?UTF-8?q?=20=EB=B3=91=ED=95=A9=20=ED=91=9C=20HTML,=20tables.all=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20(ENG-36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HwpxDocument.new(): 템플릿의 지은이(synthetic-fixture-author)와 2025-09-17 고정 날짜 대신 빈 지은이와 만든 시각(UTC)을 쓴다. Skeleton.hwpx와 blank_document_bytes()는 그대로 두고 templates.new_document_bytes()가 content.hpf의 메타데이터만 바꾼다. - doc.parts.histories: historylastdoc.hml(HWPML 스냅숏)의 to_model()이 죽지 않고 빈 History를 돌려준다. is_version_log 추가. - doc.text.html(): 병합 칸에 colspan·rowspan. - doc.tables.all 독스트링을 실제 범위(본문 바로 아래 표)로. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 8 ++ src/hwpx/_document/ns/tables.py | 8 +- src/hwpx/document.py | 8 +- src/hwpx/oxml/simple_parts.py | 24 ++++- src/hwpx/templates.py | 66 ++++++++++++++ src/hwpx/tools/exporter.py | 37 +++++++- tests/test_engine_small_defects_eng36.py | 110 +++++++++++++++++++++++ 7 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 tests/test_engine_small_defects_eng36.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fffa5f..6f691b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,14 @@ `HwpxXmlSyntaxError`(`part_name`에 파트 이름)다. 각 클래스는 전에 나던 `zipfile.BadZipFile`·`zlib.error`· `lxml.etree.XMLSyntaxError`도 상속하므로 기존 `except`는 그대로 잡고, 원래 예외는 `__cause__`에 있다. `HwpxDocument.open`의 독스트링과 `docs/stable-api.md`가 이 표를 적는다. +- `HwpxDocument.new()`로 만든 문서가 동봉 템플릿의 지은이(`synthetic-fixture-author`)와 만든 날(2025-09-17)을 물려받지 + 않는다. 지은이·마지막 저장한 이·`date`는 비고, `CreatedDate`·`ModifiedDate`는 만든 시각(UTC)이다 + (`SOURCE_DATE_EPOCH`가 있으면 그 시각). 지은이는 `doc.package.set_document_metadata(creator=...)`로 넣는다. +- 버전 이력이 있는 한/글 문서에서 `doc.parts.histories`의 `historylastdoc.hml`(마지막 문서의 HWPML 스냅숏)이 + `to_model()`에서 `HwpxValueError`를 내지 않고 항목 없는 `History`를 돌려준다. 버전 기록인지는 새 속성 + `is_version_log`로 가린다. +- `doc.text.html()`이 병합된 표 칸에 `colspan`·`rowspan`을 쓴다. 전에는 병합 표의 뒤 칸이 왼쪽으로 밀렸다. +- `doc.tables.all`의 설명을 실제 범위(본문 문단에 바로 놓인 표)로 고쳤다. 동작은 그대로다. ### 수정 diff --git a/src/hwpx/_document/ns/tables.py b/src/hwpx/_document/ns/tables.py index c199e070..042849ff 100644 --- a/src/hwpx/_document/ns/tables.py +++ b/src/hwpx/_document/ns/tables.py @@ -35,7 +35,13 @@ class TablesNamespace(_Namespace): @property def all(self) -> list["Table"]: - """문서의 모든 표를 문서 순서로.""" + """본문 문단에 바로 놓인 표를 문서 순서로. + + 표 칸 안의 표, 머리말·꼬리말·각주·미주·글상자 안의 표는 들어 있지 않다. + 칸 안의 표는 그 칸에서(``cell.tables``), 글까지 모두 + 훑으려면 ``doc.text.blocks()``를 쓴다. ``len(doc.tables)``와 + ``for table in doc.tables``도 같은 범위다. + """ tables: list["Table"] = [] for paragraph in self._doc.paragraphs: diff --git a/src/hwpx/document.py b/src/hwpx/document.py index ca4e1908..fd4e8ddd 100644 --- a/src/hwpx/document.py +++ b/src/hwpx/document.py @@ -36,7 +36,7 @@ from .oxml.table_sizes import new_table_width from .mutation_report import Fallback, Mode, MutationReport from .quality import QualityPolicy, SavePipeline, VisualCompleteReport -from .templates import blank_document_bytes +from .templates import new_document_bytes from ._document import fields as _fields from ._document import media as _media @@ -217,9 +217,13 @@ def new(cls, *, inherit_style: bool = True) -> "HwpxDocument": itself. A paragraph added after a heading or a list item then does not pick up its outline or list numbering. The default keeps the inheriting behaviour. + + The document's metadata starts clean: no author, no last-saved-by, and + the creation and modification dates set to now (UTC). Set the author + with ``document.package.set_document_metadata(creator=...)``. """ - document = cls.open(blank_document_bytes()) + document = cls.open(new_document_bytes()) document._inherit_style_default = bool(inherit_style) return document diff --git a/src/hwpx/oxml/simple_parts.py b/src/hwpx/oxml/simple_parts.py index ac51c39e..53300250 100644 --- a/src/hwpx/oxml/simple_parts.py +++ b/src/hwpx/oxml/simple_parts.py @@ -81,9 +81,31 @@ class HwpxOxmlHistory(_HwpxOxmlSimplePart): read=none 해소가 목표라 쓰기 경로는 열지 않는다. """ + @property + def is_version_log(self) -> bool: + """Whether this part is a version log (root ``hhs:history``). + + 한/글은 버전 이력이 있는 문서에 ``DocHistory/versionlog*.xml``(버전 + 기록)과 함께 ``DocHistory/historylastdoc.hml``을 둔다. 뒤의 것은 마지막 + 문서의 HWPML 스냅숏(루트 ``HWPML``)이고 버전 기록이 아니다. + """ + + from .utils import local_name + + return local_name(self._element) == "history" + def to_model(self) -> "History": - from .history_part import parse_history + """Return the version log, or an empty one for the HWPML snapshot part. + + ``historylastdoc.hml``에는 ``historyEntry``가 없으므로 항목 없는 + :class:`History`(``version=None``)를 돌려준다. 원본 XML은 ``element``로 + 읽는다. 버전 기록인지는 :attr:`is_version_log`로 가린다. + """ + + from .history_part import History, parse_history + if not self.is_version_log: + return History(version=None, entries=[]) return parse_history(self._element) diff --git a/src/hwpx/templates.py b/src/hwpx/templates.py index 95387b96..9ddd8c82 100644 --- a/src/hwpx/templates.py +++ b/src/hwpx/templates.py @@ -3,9 +3,14 @@ from __future__ import annotations +import io +import os +import re +from datetime import datetime, timezone from functools import lru_cache from importlib import resources from pathlib import Path +from zipfile import ZipFile, ZipInfo @lru_cache(maxsize=None) @@ -32,3 +37,64 @@ def blank_document_bytes() -> bytes: raise FileNotFoundError( "Could not locate Skeleton.hwpx; ensure the template asset is packaged." ) + + +_CONTENT_HPF = "Contents/content.hpf" +_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +# The fields the bundled skeleton carries from the machine it was saved on. +_EMPTIED_META = ("creator", "lastsaveby", "date") +_STAMPED_META = ("CreatedDate", "ModifiedDate") + + +def _creation_time() -> datetime: + """Now in UTC, or ``SOURCE_DATE_EPOCH`` when a reproducible build sets it.""" + + epoch = os.environ.get("SOURCE_DATE_EPOCH", "").strip() + if epoch: + try: + return datetime.fromtimestamp(int(epoch), tz=timezone.utc) + except (ValueError, OverflowError, OSError): + pass + return datetime.now(timezone.utc) + + +def _meta_pattern(name: str) -> "re.Pattern[bytes]": + return re.compile( + rb'|>[^<]*)' + ) + + +@lru_cache(maxsize=None) +def _blank_document_members() -> tuple[tuple[ZipInfo, bytes], ...]: + with ZipFile(io.BytesIO(blank_document_bytes())) as archive: + return tuple((info, archive.read(info.filename)) for info in archive.infolist()) + + +def new_document_bytes(created: datetime | None = None) -> bytes: + """Return the blank document with its own metadata instead of the skeleton's. + + The bundled skeleton was saved once and carries that save's author and + dates. A document made now gets an empty ``creator`` and ``lastsaveby``, + an empty free-form ``date``, and *created* (default: now, UTC) as both + ``CreatedDate`` and ``ModifiedDate``. Everything else is the skeleton, + byte for byte. + """ + + moment = (created or _creation_time()).astimezone(timezone.utc) + stamp = moment.strftime(_TIMESTAMP_FORMAT).encode("ascii") + out = io.BytesIO() + with ZipFile(out, "w") as archive: + for info, payload in _blank_document_members(): + if info.filename == _CONTENT_HPF: + for name in _EMPTIED_META: + payload = _meta_pattern(name).sub( + b'', payload + ) + for name in _STAMPED_META: + payload = _meta_pattern(name).sub( + b'' + + stamp + b"", + payload, + ) + archive.writestr(info, payload, compress_type=info.compress_type) + return out.getvalue() diff --git a/src/hwpx/tools/exporter.py b/src/hwpx/tools/exporter.py index 548de21c..d9bff44c 100644 --- a/src/hwpx/tools/exporter.py +++ b/src/hwpx/tools/exporter.py @@ -447,6 +447,31 @@ def _table_cells_text( return rows +def _span(value: str | None) -> int: + """A ``hp:cellSpan`` count as a positive int; anything unreadable is 1.""" + + try: + return max(1, int(value or "1")) + except ValueError: + return 1 + + +def _table_cell_spans(tbl: ET.Element) -> list[list[tuple[int, int]]]: + """Return ``(colspan, rowspan)`` per cell, in the order of :func:`_table_cells_text`.""" + + spans: list[list[tuple[int, int]]] = [] + for tr in tbl.findall(f"{_HP}tr"): + row: list[tuple[int, int]] = [] + for tc in tr.findall(f"{_HP}tc"): + span = tc.find(f"{_HP}cellSpan") + if span is None: + row.append((1, 1)) + else: + row.append((_span(span.get("colSpan")), _span(span.get("rowSpan")))) + spans.append(row) + return spans + + def _cell_text( tc: ET.Element, *, @@ -679,11 +704,17 @@ def write_block(block: ET.Element) -> None: if labels is not None: labels.count(block) if rows: + # A merged cell is one hp:tc with a span; the cells it covers are + # absent, so without the span attributes later cells shift left. + spans = _table_cell_spans(block) body_parts.append('') - for row in rows: + for row, row_spans in zip(rows, spans): body_parts.append(" ") - for cell in row: - body_parts.append(f" ") + for cell, (colspan, rowspan) in zip(row, row_spans): + attrs = (f' colspan="{colspan}"' if colspan > 1 else "") + ( + f' rowspan="{rowspan}"' if rowspan > 1 else "" + ) + body_parts.append(f" {_escape_html(cell)}") body_parts.append(" ") body_parts.append("
{_escape_html(cell)}
") diff --git a/tests/test_engine_small_defects_eng36.py b/tests/test_engine_small_defects_eng36.py new file mode 100644 index 00000000..4753df3a --- /dev/null +++ b/tests/test_engine_small_defects_eng36.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ENG-36의 작은 결함들: 새 문서 메타데이터, 이력 스냅숏 파트, 병합 표 HTML.""" + +from __future__ import annotations + +import io +import re +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +from hwpx import HwpxDocument +from hwpx.templates import blank_document_bytes, new_document_bytes +from hwpx.tools.package_validator import validate_editor_open_safety + +ROOT = Path(__file__).resolve().parents[1] +VERSION_HISTORY = ROOT / "tests" / "fixtures" / "gui_probes" / "version_history.hwpx" + + +def _content_hpf(data: bytes) -> str: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + return archive.read("Contents/content.hpf").decode("utf-8") + + +def test_new_document_does_not_carry_the_skeleton_author_or_dates() -> None: + before = datetime.now(timezone.utc).replace(microsecond=0) + with HwpxDocument.new() as document: + metadata = document.package.document_metadata() + saved = document.to_bytes() + after = datetime.now(timezone.utc) + + assert metadata is not None + assert metadata.creator is None + assert metadata.lastsaveby is None + assert metadata.date is None + assert metadata.created_date == metadata.modified_date + created = datetime.strptime(metadata.created_date, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + assert before <= created <= after + + hpf = _content_hpf(saved) + assert "synthetic-fixture-author" not in hpf + assert "2025-09-17" not in hpf + assert validate_editor_open_safety(saved).ok + + +def test_new_document_bytes_changes_only_the_metadata(monkeypatch) -> None: + monkeypatch.setenv("SOURCE_DATE_EPOCH", "1700000000") + fresh = new_document_bytes() + assert fresh == new_document_bytes() # reproducible under SOURCE_DATE_EPOCH + assert "2023-11-14T22:13:20Z" in _content_hpf(fresh) + + with zipfile.ZipFile(io.BytesIO(blank_document_bytes())) as skeleton, zipfile.ZipFile( + io.BytesIO(fresh) + ) as made: + assert [i.filename for i in skeleton.infolist()] == [i.filename for i in made.infolist()] + assert [i.compress_type for i in skeleton.infolist()] == [ + i.compress_type for i in made.infolist() + ] + changed = [ + info.filename + for info in skeleton.infolist() + if skeleton.read(info.filename) != made.read(info.filename) + ] + assert changed == ["Contents/content.hpf"] + + +def test_history_snapshot_part_does_not_break_to_model() -> None: + with HwpxDocument.open(VERSION_HISTORY) as document: + histories = document.parts.histories + by_name = {history.part_name: history for history in histories} + snapshot = by_name["DocHistory/historylastdoc.hml"] + assert not snapshot.is_version_log + model = snapshot.to_model() + assert model.version is None and model.entries == [] + logs = [history for history in histories if history.is_version_log] + assert len(logs) == 2 + assert [len(log.to_model().entries) for log in logs] == [1, 1] + + +def test_html_export_keeps_merged_cells_in_their_columns() -> None: + with HwpxDocument.new() as document: + table = document.add_table(rows=3, cols=3) + for row in range(3): + for col in range(3): + table.cell(row, col).text = f"r{row}c{col}" + table.merge_cells(0, 0, 0, 1) + table.merge_cells(1, 0, 2, 0) + html = document.text.html() + + rows = re.findall(r"(.*?)", html, flags=re.S) + cells = [re.findall(r"]*)>", row) for row in rows] + assert cells == [ + [' colspan="2"', ""], + [' rowspan="2"', "", ""], + ["", ""], + ] + # 병합이 없는 표는 전과 같은 마크업이다. + with HwpxDocument.new() as document: + document.add_table(rows=1, cols=2) + assert "colspan" not in document.text.html() and "rowspan" not in document.text.html() + + +def test_tables_all_is_body_level_only_as_documented() -> None: + with HwpxDocument.new() as document: + outer = document.add_table(rows=1, cols=1) + outer.cell(0, 0).add_table(rows=1, cols=1) + assert len(document.tables.all) == 1 + assert len(outer.cell(0, 0).tables) == 1 From f18e7a788f62193e7b4ed4baba2e1039d485a6d5 Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:55:23 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(ingest):=20Markdown=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=EC=9D=B4=20=EB=B9=A0=EB=9C=A8=EB=A6=B0=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20warnings=C2=B7lossiness=EB=A1=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20(ENG-21,=20ENG-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HwpxMarkdownConverter가 lossiness="low", warnings=[]를 고정으로 붙이던 것을, Markdown에 글이 실리지 않은 요소를 결과와 대조해 종류별로 세도록 바꿨다(metadata["left_out"], warnings, lossiness low/medium/high). 미리 정한 목록이 아니라 결과 대조라, 내보내기가 어떤 종류를 싣게 되면 보고에서도 빠진다. 변경 추적으로 지운 글 표시는 따로 경고한다. README의 "읽기·추출" 문구를 실제 범위로 고쳤다: 각주·미주는 notes=True·rich Markdown·TextExtractor에서, 머리말·꼬리말·메모는 TextExtractor에서만, 수식·글자 겹침·양식 개체 캡션은 어느 경로에도 없다. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 4 + README.md | 5 +- README_EN.md | 5 +- src/hwpx/ingest/hwpx_converter.py | 155 +++++++++++++++++++++++++++++- tests/test_ingest.py | 59 ++++++++++++ 5 files changed, 224 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f691b43..0080cfa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,10 @@ `is_version_log`로 가린다. - `doc.text.html()`이 병합된 표 칸에 `colspan`·`rowspan`을 쓴다. 전에는 병합 표의 뒤 칸이 왼쪽으로 밀렸다. - `doc.tables.all`의 설명을 실제 범위(본문 문단에 바로 놓인 표)로 고쳤다. 동작은 그대로다. +- `hwpx.ingest`의 HWPX → Markdown 변환이 손실을 항상 `"low"`로 보고하지 않는다. Markdown에 글이 실리지 않은 요소 + (머리말·꼬리말·메모·각주·미주·글상자·수식·덧말·글자 겹침·양식 개체 캡션·글맵시·바탕쪽)를 결과와 대조해 종류별로 + 세어 `warnings`와 `metadata["left_out"]`에 싣고, 빠진 것이 있으면 `lossiness`가 `"medium"`(빠진 글이 실린 글보다 + 많으면 `"high"`)이다. 변경 추적으로 지운 글 표시가 있으면 따로 경고한다. 빠진 것이 없으면 전처럼 `"low"`다. ### 수정 diff --git a/README.md b/README.md index f025df0f..625e722d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,10 @@ doc.save_to_path("계획.hwpx") ## 할 수 있는 것 -- 읽기·추출: 텍스트·HTML·Markdown으로 내보냅니다. 서식, 표 안의 표, 각주를 살립니다. +- 읽기·추출: 텍스트·HTML·Markdown으로 내보냅니다. 서식과 표 안의 표를 살립니다. 각주·미주는 `notes=True`를 주거나 + `markdown(rich=True)`·`TextExtractor`를 쓸 때 나오고, 머리말·꼬리말·메모는 `TextExtractor`에서만 나옵니다. + 수식·글자 겹침·양식 개체 캡션은 아직 어느 경로에서도 나오지 않습니다. Markdown 변환(`hwpx.ingest`)은 빠진 + 종류와 개수를 `warnings`와 `lossiness`로 알립니다. - 편집: 문단·표·그림·머리말/꼬리말·메모·각주, 줄 간격·여백·쪽 번호 같은 서식을 고칩니다. - 양식 채우기: 라벨로 칸을 찾아 값만 넣고 손대지 않은 부분은 바이트 그대로 둡니다. FormFit은 값이 칸에 들어가도록 줄을 바꾸거나 글자를 줄일지 한/글의 글자 폭으로 재서 고릅니다. diff --git a/README_EN.md b/README_EN.md index aefb8fbc..020c77e7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -52,7 +52,10 @@ For higher-level workflows (document authoring, form filling, exam typesetting), ## What it does -- Read and extract: text, HTML and Markdown export that keeps formatting, nested tables and footnotes. +- Read and extract: text, HTML and Markdown export that keeps formatting and nested tables. Footnotes and endnotes + come out with `notes=True`, `markdown(rich=True)` or `TextExtractor`; headers, footers and memos only from + `TextExtractor`. Equations, composed characters and form-object captions are not extracted by any path yet. + The Markdown converter (`hwpx.ingest`) reports what it left out in `warnings` and `lossiness`. - Edit: paragraphs, tables, pictures, headers and footers, memos, footnotes; line spacing, margins, page numbers. - Fill forms: find a cell by its label and change only the value; parts you did not touch keep their bytes. FormFit decides whether a value should wrap or shrink to fit its cell, measuring with Hangul's glyph widths. diff --git a/src/hwpx/ingest/hwpx_converter.py b/src/hwpx/ingest/hwpx_converter.py index dbb64bc3..3ef9bc8e 100644 --- a/src/hwpx/ingest/hwpx_converter.py +++ b/src/hwpx/ingest/hwpx_converter.py @@ -3,7 +3,8 @@ from __future__ import annotations -from typing import Any, BinaryIO +import re +from typing import Any, BinaryIO, Iterable, Iterator from zipfile import BadZipFile, ZipFile from hwpx.document import HwpxDocument @@ -50,6 +51,7 @@ def convert( sections = _sections_payload(doc) tables = _tables_payload(doc) paragraph_count = len(list(doc.paragraphs)) + left_out = _left_out_content(doc, markdown) finally: doc.close() @@ -64,13 +66,162 @@ def convert( "paragraph_count": paragraph_count, "table_count": len(tables), "converter": self.name, + "left_out": {kind: entry["count"] for kind, entry in left_out.items()}, }, sections=sections, tables=tables, - lossiness="low", + warnings=_left_out_warnings(left_out), + lossiness=_lossiness(left_out, markdown), ) +_HP_NS = "http://www.hancom.co.kr/hwpml/2011/paragraph" +_HP = f"{{{_HP_NS}}}" + +#: 글을 담는 요소 → 종류. 종류 이름은 ``metadata["left_out"]``의 키이자 계약이다. +_TEXT_CONTAINERS: dict[str, str] = { + "header": "header", + "footer": "footer", + "memo": "memo", + "footNote": "footnote", + "endNote": "endnote", + "drawText": "text_box", +} +#: 글을 속성에 담는 요소 → (종류, 속성 이름). +_ATTRIBUTE_TEXT: dict[str, tuple[str, str]] = { + "compose": ("composed_character", "composeText"), + "checkBtn": ("form_caption", "caption"), + "radioBtn": ("form_caption", "caption"), + "btn": ("form_caption", "caption"), + "textart": ("text_art", "text"), +} +_KIND_LABELS: dict[str, str] = { + "header": "머리말", + "footer": "꼬리말", + "memo": "메모", + "footnote": "각주", + "endnote": "미주", + "text_box": "글상자·도형 안 글", + "equation": "수식", + "ruby": "덧말", + "composed_character": "글자 겹침", + "form_caption": "양식 개체 캡션", + "text_art": "글맵시", + "master_page": "바탕쪽", +} +_MARKDOWN_NOISE = re.compile(r"[\\\s]+") + + +def _squash(text: str) -> str: + """Markdown 이스케이프(백슬래시)와 공백을 지운 비교용 글.""" + + return _MARKDOWN_NOISE.sub("", text) + + +def _own_text(paragraph: Any) -> str: + """문단 자신의 run에 든 글(문단 안 개체의 글은 뺀다).""" + + return "".join( + "".join(text.itertext()) for text in paragraph.iterfind(f"{_HP}run/{_HP}t") + ) + + +def _paragraph_texts(container: Any) -> Iterator[str]: + for paragraph in container.iter(f"{_HP}p"): + text = _own_text(paragraph) + if text.strip(): + yield text + + +def _candidates(root: Any) -> Iterator[tuple[str, list[str]]]: + """``(종류, 그 요소가 담은 글 조각들)``을 문서 순서로 낸다.""" + + for element in root.iter(): + tag = element.tag + if not isinstance(tag, str) or not tag.startswith(_HP): + continue + name = tag[len(_HP) :] + if name in _TEXT_CONTAINERS: + yield _TEXT_CONTAINERS[name], list(_paragraph_texts(element)) + elif name in _ATTRIBUTE_TEXT: + kind, attribute = _ATTRIBUTE_TEXT[name] + yield kind, [element.get(attribute) or ""] + elif name == "equation": + script = element.find(f"{_HP}script") + yield "equation", ["".join(script.itertext()) if script is not None else ""] + elif name == "dutmal": + yield "ruby", [ + "".join(child.itertext()) + for child in element + if child.tag in (f"{_HP}mainText", f"{_HP}subText") + ] + + +def _left_out_content(doc: HwpxDocument, markdown: str) -> dict[str, dict[str, int]]: + """Markdown에 글이 실리지 않은 요소를 종류별로 센다. + + 미리 정한 목록이 아니라 결과와 대조해서 센다. 요소가 담은 글 조각이 + Markdown(이스케이프·공백 무시)에 없으면 그 요소를 빠진 것으로 본다. 그래서 + 내보내기가 나중에 어떤 종류를 싣게 되면 이 보고에서도 저절로 빠진다. + 글이 같은 조각이 본문 다른 곳에 있으면 실린 것으로 보므로, 세는 값은 + 하한이다. + """ + + haystack = _squash(markdown) + found: dict[str, dict[str, int]] = {} + seen: set[tuple[str, tuple[str, ...]]] = set() + + def record(kind: str, pieces: Iterable[str]) -> None: + missing = [piece for piece in pieces if _squash(piece) and _squash(piece) not in haystack] + if not missing: + return + # 머리말·꼬리말은 구역 설정과 본문 컨트롤에 같은 내용이 두 번 저장된다. + key = (kind, tuple(missing)) + if kind in ("header", "footer") and key in seen: + return + seen.add(key) + entry = found.setdefault(kind, {"count": 0, "chars": 0}) + entry["count"] += 1 + entry["chars"] += sum(len(_squash(piece)) for piece in missing) + + for section in doc.sections: + for kind, pieces in _candidates(section.element): + record(kind, pieces) + for master_page in doc.parts.master_pages: + record("master_page", _paragraph_texts(master_page.element)) + + deleted = sum( + 1 for section in doc.sections for _ in section.element.iter(f"{_HP}deleteBegin") + ) + if deleted: + found["tracked_deletion"] = {"count": deleted, "chars": 0} + return found + + +def _left_out_warnings(left_out: dict[str, dict[str, int]]) -> list[str]: + warnings = [ + f"Markdown에 없는 내용: {_KIND_LABELS[kind]} {entry['count']}곳({entry['chars']}자)" + for kind, entry in left_out.items() + if kind != "tracked_deletion" + ] + if "tracked_deletion" in left_out: + warnings.append( + f"변경 추적으로 지운 글 표시가 {left_out['tracked_deletion']['count']}곳 있습니다. " + "지운 글이 살아 있는 글과 구분 없이 Markdown에 섞여 나올 수 있습니다." + ) + return warnings + + +def _lossiness(left_out: dict[str, dict[str, int]], markdown: str) -> str: + """``low``: 빠진 것이 없다. ``medium``: 빠진 것이 있다. ``high``: 빠진 글이 + Markdown에 실린 글보다 많다.""" + + if not left_out: + return "low" + missing_chars = sum(entry["chars"] for entry in left_out.values()) + return "high" if missing_chars > len(_squash(markdown)) else "medium" + + def _looks_like_hwpx_package(file_stream: BinaryIO) -> bool: cur_pos = file_stream.tell() try: diff --git a/tests/test_ingest.py b/tests/test_ingest.py index b59d39d3..c447acf0 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -63,3 +63,62 @@ def test_document_ingestor_reports_unsupported_format(tmp_path: Path) -> None: def test_normalize_markdown_trims_trailing_space_and_extra_blank_lines() -> None: assert normalize_markdown("a \n\n\n\nb\n") == "a\n\nb" + + +def _rich_hwpx() -> bytes: + with HwpxDocument.new() as doc: + doc.add_paragraph("본문 문장입니다") + doc.page.set_header(text="머리말 ZQ글") + doc.page.set_footer(text="꼬리말 ZQ글") + anchor = doc.add_paragraph("메모 달린 문단") + doc.notes.add_memo("메모 ZQ내용", anchor=anchor) + doc.shapes.add_equation("x = {-b +- sqrt {b^2 - 4ac}} over {2a}") + doc.shapes.add_composed_character("가나") + doc.fields.add_check_box(caption="체크ZQ캡션") + return doc.to_bytes() + + +def test_ingest_reports_what_the_markdown_leaves_out() -> None: + """빠뜨린 요소가 있으면 ``low``가 아니고, 종류와 개수를 알린다(ENG-21). + + 보고는 결과와 대조해 만든다. 여기서 보는 것은 "Markdown에 글이 없는 종류는 + 반드시 보고된다"는 한 방향이다. 내보내기가 어떤 종류를 싣게 되면 그 종류는 + 보고에서 빠지고 이 시험은 그대로 통과한다. + """ + + result = DocumentIngestor.default().convert(_rich_hwpx()) + markers = { + "header": "머리말 ZQ글", + "footer": "꼬리말 ZQ글", + "memo": "메모 ZQ내용", + "equation": "sqrt", + "form_caption": "체크ZQ캡션", + } + left_out = result.metadata["left_out"] + absent = {kind for kind, marker in markers.items() if marker not in result.markdown} + assert absent, "이 문서에서 Markdown이 모두 싣는다면 시험 문서를 바꿔야 한다" + assert absent <= set(left_out) + for kind in set(markers) - absent: + assert kind not in left_out + assert result.lossiness in {"medium", "high"} + assert len(result.warnings) == len(left_out) + assert all("Markdown에 없는 내용" in warning for warning in result.warnings) + assert result.as_dict()["warnings"] == result.warnings + + +def test_ingest_counts_a_header_once_and_stays_low_when_nothing_is_left_out( + tmp_path: Path, +) -> None: + plain = DocumentIngestor.default().convert(_sample_hwpx(tmp_path / "plain.hwpx")) + assert plain.lossiness == "low" + assert plain.warnings == [] + assert plain.metadata["left_out"] == {} + + with HwpxDocument.new() as doc: + doc.add_paragraph("본문 " * 200) + doc.page.set_header(text="한 번만 세는 머리말") + data = doc.to_bytes() + result = DocumentIngestor.default().convert(data) + if "한 번만 세는 머리말" not in result.markdown: + assert result.metadata["left_out"] == {"header": 1} + assert result.lossiness == "medium" From ee10dda8d2941c36ce0e0e98429b55861a3f6b59 Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:58:09 +0900 Subject: [PATCH 6/8] =?UTF-8?q?chore(dev):=20dev=20extra=EB=A5=BC=20CONTRI?= =?UTF-8?q?BUTING=C2=B7CI=EC=99=80=20=EB=A7=9E=EC=B6=A4=20(ARC-12=20(6))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING은 `.[dev]` 설치 뒤 ruff와 pytest를 돌리라고 하는데 dev에 ruff·pillow·openpyxl·pyyaml·pytest-cov·numpy가 없었다. dev를 CI가 쓰는 test + typecheck의 상위 집합(+ twine)으로 만들고, CONTRIBUTING과 installation.md에 CI extra를 함께 적었다. 테스트가 세 곳의 일치를 본다. Co-Authored-By: Claude Fable 5.2 --- CONTRIBUTING.md | 5 +++- docs/installation.md | 3 ++- pyproject.toml | 12 +++++++++ tests/test_dev_extra_matches_ci.py | 41 ++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/test_dev_extra_matches_ci.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 746068e5..1a55a318 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,11 @@ 3. **개발 도구를 설치**합니다. 배포 메타데이터가 `pyproject.toml`에 정의되어 있어 편집 가능한 설치로 개발 의존성을 한 번에 구성할 수 있습니다: ```bash - python -m pip install -e .[dev] + python -m pip install -e ".[dev]" ``` + `dev`에는 CI가 설치하는 `test`·`typecheck`(pytest, ruff, mypy, pyright, 테스트가 + 쓰는 pillow·openpyxl·pyyaml 등)와 배포 점검용 twine이 들어 있습니다. CI와 똑같이 + 맞추려면 `python -m pip install -e ".[test,typecheck]"`를 써도 됩니다. 4. **임포트 확인**. `pip install -e`를 실행하면 `hwpx` 패키지가 가상 환경에 설치되어 추가적인 `PYTHONPATH` 설정이 필요 없습니다. 인터프리터에서 `import hwpx`가 정상적으로 동작하는지 확인하세요. diff --git a/docs/installation.md b/docs/installation.md index 4cbe0f43..34f0a0e4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -68,8 +68,9 @@ wheel은 순수 파이썬(`py3-none-any`)이며 유일한 의존성은 `lxml`입 ``` 3. 편집 가능한 설치와 개발 도구를 한 번에 구성합니다. ```bash - python -m pip install -e .[dev] + python -m pip install -e ".[dev]" ``` + `dev`는 CI가 쓰는 `test`·`typecheck` extra를 모두 담고 있습니다. `pip install -e`를 사용하면 추가적인 `PYTHONPATH` 수정 없이도 `hwpx` 패키지를 바로 가져올 수 있습니다. 저장소 예제(`examples/FormattingShowcase.hwpx`)는 개발용 설치를 진행한 경우에만 사용할 수 있습니다. diff --git a/pyproject.toml b/pyproject.toml index cc5d24cf..ac48e8fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,11 +45,23 @@ xlsx = [ preview = [ "latex2mathml>=3.77", ] +# Everything a contributor needs to run the gates in CONTRIBUTING.md: the CI +# extras (`test` + `typecheck`, what .github/workflows/tests.yml installs) plus +# twine for checking a built distribution. tests/test_dev_extra_matches_ci.py +# keeps this a superset of those two. dev = [ "build>=1.0", "twine>=4.0", + "numpy>=1.26", + "openpyxl>=3.1", + "pillow>=10.0", "pytest>=7.4", + "pytest-cov>=5.0", + "pyyaml>=6.0,<7", + "ruff>=0.12", "latex2mathml>=3.77", + "mypy>=1.10", + "pyright>=1.1.390", ] test = [ "build>=1.0", diff --git a/tests/test_dev_extra_matches_ci.py b/tests/test_dev_extra_matches_ci.py new file mode 100644 index 00000000..106a7599 --- /dev/null +++ b/tests/test_dev_extra_matches_ci.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``dev`` extra, CONTRIBUTING, CI가 같은 개발 의존성을 말하는지 본다(ARC-12 (6)). + +CONTRIBUTING은 ``.[dev]`` 뒤에 ruff와 pytest를 돌리라고 하는데, 전에는 ``dev``에 +ruff·pillow·openpyxl·pyyaml이 없어 그대로 따라 하면 실패했다. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - Python 3.10 + import tomli as tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +def _extras() -> dict[str, list[str]]: + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return project["project"]["optional-dependencies"] + + +def test_dev_extra_contains_everything_ci_installs() -> None: + extras = _extras() + ci = set(extras["test"]) | set(extras["typecheck"]) + assert ci <= set(extras["dev"]), sorted(ci - set(extras["dev"])) + + +def test_ci_installs_the_extras_dev_is_built_from() -> None: + workflow = (ROOT / ".github" / "workflows" / "tests.yml").read_text(encoding="utf-8") + assert re.search(r"pip install -e \.\[test,typecheck\]", workflow) + + +def test_contributing_names_the_dev_extra_and_the_ci_extras() -> None: + text = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") + assert 'pip install -e ".[dev]"' in text + assert 'pip install -e ".[test,typecheck]"' in text From 1f1cea29451513afb0ee34dd2be5666be8a4c520 Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:52:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(ledger):=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=97=90=EC=84=9C=20hwp5=20=EB=B0=A9?= =?UTF-8?q?=EC=B6=9C=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EB=B9=BC=EA=B3=A0=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=ED=91=9C=C2=B7=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20(ENG-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/coverage_ledger.py: src/hwpx/hwp5/(.hwp → HWPX 변환기)에 나오는 HWPX 태그는 방출이므로 읽기로 세지 않는다. 읽기 266 → 256 (hp:hiddenComment 등 10개). 쓰기 판정은 전과 같다(api 209 그대로). - capabilities.py: 차례·상호참조, 제목 차례 표시, 색인 표시, 날짜·교정 부호·파일 이름 필드, 메일머지 표시 필드는 doc.refs·doc.fields에 없다. namespace를 None으로, 진입점을 실제 모듈·문단 클래스로 고쳤다. - support-matrix.md: 같은 자리 표, arc·polygon·curve·connectLine 행의 자기모순(저작 API 없음 ↔ add_curve·add_connector), 차트 행의 add_chart(chart_xml) 설명. 계약 문서 사본·장부·표면 인벤토리 재생성. 읽지도 쓰지도 못하는 요소(hp:btn·hp:radioBtn)의 by-capability-area 표시는 그대로다: tests/test_coverage_ledger.py가 그 값을 고정하고 있고 그 파일은 이 갈래에서 고치지 않는다. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 5 ++++ docs/coverage-ledger.json | 25 ++++++++-------- docs/coverage-ledger.md | 26 ++++++++--------- docs/editor-surface-inventory.md | 10 +++---- docs/support-matrix.md | 14 ++++----- scripts/coverage_ledger.py | 29 ++++++++++++++++++- src/hwpx/capabilities.py | 28 ++++++++++++------ src/hwpx/data/contract_docs/support-matrix.md | 14 ++++----- 8 files changed, 97 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0080cfa6..3d4ad7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,11 @@ (머리말·꼬리말·메모·각주·미주·글상자·수식·덧말·글자 겹침·양식 개체 캡션·글맵시·바탕쪽)를 결과와 대조해 종류별로 세어 `warnings`와 `metadata["left_out"]`에 싣고, 빠진 것이 있으면 `lossiness`가 `"medium"`(빠진 글이 실린 글보다 많으면 `"high"`)이다. 변경 추적으로 지운 글 표시가 있으면 따로 경고한다. 빠진 것이 없으면 전처럼 `"low"`다. +- `describe_capabilities()`의 자리 안내를 실제와 맞췄다. 차례·상호참조(`hwpx.tools.toc_author`), 제목 차례 표시·색인 + 표시·날짜/교정 부호/파일 이름 필드·메일머지 표시 필드(`HwpxOxmlParagraph.add_*`)는 `doc.refs`·`doc.fields`에 없으므로 + `namespace`가 `None`이고 진입점이 그 모듈·문단 클래스다. 지원 매트릭스의 같은 표와 arc·polygon·curve·connectLine 행, + 차트 행(`add_chart(chart_xml)`)도 고쳤다. +- 커버리지 장부가 `.hwp` 변환기(`hwp5/`)가 써내는 태그를 읽기로 세지 않는다(읽기 266 → 256). ### 수정 diff --git a/docs/coverage-ledger.json b/docs/coverage-ledger.json index 8dfc2744..475b8923 100644 --- a/docs/coverage-ledger.json +++ b/docs/coverage-ledger.json @@ -28,7 +28,8 @@ "docs/openrate/report-v22.json" ], "skeleton": "src/hwpx/data/Skeleton.hwpx", - "sourceScanned": "src/hwpx/**/*.py" + "sourceScanned": "src/hwpx/**/*.py", + "readScanExcludes": "src/hwpx/hwp5/**/*.py" }, "corpusTotalFiles": 237, "corpusUnknownFiles": { @@ -58,7 +59,7 @@ "schemaDeclared": 307, "corpusOnly": 38, "corpusObserved": 229, - "codeRead": 266, + "codeRead": 256, "codeWriteApi": 209, "codeWriteFrozenTemplate": 10, "codeWriteNone": 126, @@ -228,7 +229,7 @@ "schemaSource": null, "corpusFrequency": 0.0084, "corpusFileCount": 2, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -3246,7 +3247,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0042, "corpusFileCount": 1, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -3428,7 +3429,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0042, "corpusFileCount": 1, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -3554,7 +3555,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0084, "corpusFileCount": 2, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -3827,7 +3828,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0042, "corpusFileCount": 1, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -4464,7 +4465,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0, "corpusFileCount": 0, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -4672,7 +4673,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0042, "corpusFileCount": 1, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -4740,7 +4741,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0042, "corpusFileCount": 1, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -5215,7 +5216,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0084, "corpusFileCount": 2, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, @@ -5469,7 +5470,7 @@ "schemaSource": "ParaList XML schema.xml", "corpusFrequency": 0.0042, "corpusFileCount": 1, - "codeRead": true, + "codeRead": false, "codeWrite": "none", "capabilityArea": null, "capabilityStatus": null, diff --git a/docs/coverage-ledger.md b/docs/coverage-ledger.md index 3547fcc2..4cf38088 100644 --- a/docs/coverage-ledger.md +++ b/docs/coverage-ledger.md @@ -18,7 +18,7 @@ **5) 속성 축.** 요소별 관측 속성 **이름** 집합을 census가 함께 기록한다(`observedAttributes` 컬럼, 값 빈도까지는 이번 사이클 범위 밖 — 생성기 독스트링에 명시). **6) openrate 코퍼스 환류(v4~v21).** `docs/openrate/report-v{4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21}.json`의 스트라타별 실한컴 수용(`render_checked>0`·`render_failed==0`, 구세대 스키마는 `opened==requested>0`도 함께)을 `verificationBasis`로 환류한다(`by-openrate-corpus`/`by-capability-area+openrate-corpus`) — 2026-08-04 감사 R4가 지목한 v4 배선(원 이름 `by-v4-corpus`)을 2026-08 사이클 6.5 트레인⑰에서 v8까지, 사이클 6.6 트레인⑳에서 v9까지, 사이클 6.7 트레인㉔에서 v10까지 확장하며 버전-중립 이름으로 바꿨다. 두 경로로 매핑한다: 이미 등록된 capabilityArea와 1:1 대응하는 스트라타는 `_OPENRATE_STRATUM_TO_CAPABILITY_AREA`(차트·체크박스·수식·각주 2종·테두리채우기·하이라이트·메모·그룹컨테이너·문서옵션호환성), capabilityArea가 아직 없거나 있어도 혼합 지원 영역이라 요소만 지목해야 하는 신규 능력(글꼴·탭·도형텍스트·캡션·쪽번호제어·문자서식·필드파라미터·arc/polygon·인라인 원자 3종)은 `_OPENRATE_STRATUM_TO_ELEMENTS`로 요소를 직접 지목한다 — 둘 다 근거는 각 생성기 스크립트 독스트링이 실제로 부른다고 명시하는 것뿐(무근거 매핑 금지, `fieldBegin`을 일부러 안 매핑한 것과 같은 원칙). -**전 vs 후 (감사가 하한을 인용한 것과 같은 슬라이스 — corpusFileCount>0인 요소만)**: 감사 인용 하한은 관측 228건 중 write=none **70** · read=none **56** · frozen-template **28**([감사 판정문](2026-08-04-completeness-audit-verdict.md) 요약표). 이 원장 재생성 기준으로는 관측 229건 중 write=none **21** · read=none **2** · frozen-template **10**. **주의**: 두 population이 다르다(모집단을 재정의했다 — 위 4항목) — 이 비교는 "같은 잣대로 다시 잰 정확한 델타"가 아니라 분류기 수리가 방향대로 움직였는지의 참고 신호다. 분류기 수리 자체의 정확도 증거는 위 1~3항의 요소별 재현 로그가 1차 근거다. +**전 vs 후 (감사가 하한을 인용한 것과 같은 슬라이스 — corpusFileCount>0인 요소만)**: 감사 인용 하한은 관측 228건 중 write=none **70** · read=none **56** · frozen-template **28**([감사 판정문](2026-08-04-completeness-audit-verdict.md) 요약표). 이 원장 재생성 기준으로는 관측 229건 중 write=none **21** · read=none **11** · frozen-template **10**. **주의**: 두 population이 다르다(모집단을 재정의했다 — 위 4항목) — 이 비교는 "같은 잣대로 다시 잰 정확한 델타"가 아니라 분류기 수리가 방향대로 움직였는지의 참고 신호다. 분류기 수리 자체의 정확도 증거는 위 1~3항의 요소별 재현 로그가 1차 근거다. ## 전체 통계 @@ -28,7 +28,7 @@ | 스키마 선언 | 307 | 89.0% | | 코퍼스에만 있음(스키마 미대응) | 38 | 11.0% | | 실코퍼스에서 관측(빈도>0) | 229 | 66.4% | -| 코드 읽기 | 266 | 77.1% | +| 코드 읽기 | 256 | 74.2% | | 코드 쓰기(api) | 209 | 60.6% | | 쓰기 frozen-template | 10 | 2.9% | | 쓰기 none | 126 | 36.5% | @@ -63,24 +63,24 @@ | `hh:metaTag` | 0.0422 | 10 | True | frozen-template | — | | `hh:forbiddenWord` | 0.0127 | 3 | True | none | — | | `hh:forbiddenWordList` | 0.0127 | 3 | True | none | — | -| `hc:extent` | 0.0084 | 2 | True | none | — | -| `hp:endPt` | 0.0084 | 2 | True | none | — | +| `hc:extent` | 0.0084 | 2 | False | none | — | +| `hp:endPt` | 0.0084 | 2 | False | none | — | | `hp:ole` | 0.0084 | 2 | True | none | — | -| `hp:startPt` | 0.0084 | 2 | True | none | — | +| `hp:startPt` | 0.0084 | 2 | False | none | — | | `hp:btn` | 0.0042 | 1 | False | none | 체크박스 양식개체 | | `hp:comboBox` | 0.0042 | 1 | True | none | — | -| `hp:controlPoints` | 0.0042 | 1 | True | none | — | +| `hp:controlPoints` | 0.0042 | 1 | False | none | — | | `hp:edit` | 0.0042 | 1 | True | none | — | -| `hp:effect` | 0.0042 | 1 | True | none | — | -| `hp:hiddenComment` | 0.0042 | 1 | True | none | — | +| `hp:effect` | 0.0042 | 1 | False | none | — | +| `hp:hiddenComment` | 0.0042 | 1 | False | none | — | | `hp:listItem` | 0.0042 | 1 | True | none | — | | `hp:metaTag` | 0.0042 | 1 | True | none | — | -| `hp:point` | 0.0042 | 1 | True | none | — | -| `hp:presentation` | 0.0042 | 1 | True | none | — | +| `hp:point` | 0.0042 | 1 | False | none | — | +| `hp:presentation` | 0.0042 | 1 | False | none | — | | `hp:radioBtn` | 0.0042 | 1 | False | none | 체크박스 양식개체 | | `hp:text` | 0.0042 | 1 | True | none | — | | `hp:textart` | 0.0042 | 1 | True | none | — | -| `hp:textartPr` | 0.0042 | 1 | True | none | — | +| `hp:textartPr` | 0.0042 | 1 | False | none | — | | `hp:video` | 0.0042 | 1 | True | none | — | ## 네임스페이스별 표 @@ -88,11 +88,11 @@ | 네임스페이스 | 요소 수 | 스키마 선언 | 코퍼스 관측 | 읽기 | 쓰기 api | frozen-template | 쓰기 none | |---|---|---|---|---|---|---|---| | `ha` | 2 | 0 | 2 | 2 | 0 | 2 | 0 | -| `hc` | 31 | 7 | 29 | 31 | 23 | 5 | 3 | +| `hc` | 31 | 7 | 29 | 30 | 23 | 5 | 3 | | `hh` | 126 | 125 | 64 | 72 | 63 | 3 | 60 | | `hhs` | 10 | 10 | 0 | 10 | 0 | 0 | 10 | | `hm` | 2 | 2 | 0 | 2 | 0 | 0 | 2 | -| `hp` | 171 | 161 | 132 | 146 | 121 | 0 | 50 | +| `hp` | 171 | 161 | 132 | 137 | 121 | 0 | 50 | | `hs` | 1 | 1 | 1 | 1 | 1 | 0 | 0 | | `hv` | 2 | 1 | 1 | 2 | 1 | 0 | 1 | diff --git a/docs/editor-surface-inventory.md b/docs/editor-surface-inventory.md index 5e1964e3..76ad6610 100644 --- a/docs/editor-surface-inventory.md +++ b/docs/editor-surface-inventory.md @@ -78,9 +78,9 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 · | 기능 | 엔진 상태 | 근거 | 실한컴 검증 | |---|---|---|---| -| 날짜/시간·교정 부호·파일 이름 필드 | 저작 api | 지원 매트릭스 「날짜/시간·교정 부호·파일 이름 필드」(`Create(experimental)·Render-verified`) · capabilities 영역 `date-time-proofreading-mark-fields` · 위치 `doc.fields` | Render-verified(experimental 저작 포함) | +| 날짜/시간·교정 부호·파일 이름 필드 | 저작 api | 지원 매트릭스 「날짜/시간·교정 부호·파일 이름 필드」(`Create(experimental)·Render-verified`) · capabilities 영역 `date-time-proofreading-mark-fields` · 위치 문단 메서드 — `paragraph.add_date_field` · `add_proofreading_mark` · `add_path_field` | Render-verified(experimental 저작 포함) | | 누름틀(form field) 생성 | 저작 api | 지원 매트릭스 「누름틀(form field) 생성」(`Parse·Edit·Create(experimental)·Render-verified`) · capabilities 영역 `form-field-create` · 위치 `doc.fields` | Render-verified(experimental 저작 포함) | -| 메일머지 표시 필드 | 저작 api | 지원 매트릭스 「메일머지 표시 필드」(`Create(experimental)`) · capabilities 영역 `mail-merge-field` · 위치 `doc.fields` | 미실측 | +| 메일머지 표시 필드 | 저작 api | 지원 매트릭스 「메일머지 표시 필드」(`Create(experimental)`) · capabilities 영역 `mail-merge-field` · 위치 문단 메서드 — `paragraph.add_mail_merge_field` | 미실측 | | 양식 채움(byte-splice) | 저작 api | 지원 매트릭스 「양식 채움(byte-splice)」(`Preserve·Edit·Render-verified`) · capabilities 영역 `form-fill` · 위치 `doc.tables` | Render-verified | | 체크박스 양식개체 | 저작 api | 지원 매트릭스 「체크박스 양식개체」(`Create·Render-verified`) · capabilities 영역 `check-box` · 위치 `doc.fields` | Render-verified | @@ -89,9 +89,9 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 · | 기능 | 엔진 상태 | 근거 | 실한컴 검증 | |---|---|---|---| | 각주/미주 | 저작 api | 지원 매트릭스 「각주/미주」(`Edit·Create·Render-verified`) · capabilities 영역 `footnote-endnote` · 위치 `doc.notes` | Render-verified | -| 네이티브 목차(TOC)/상호참조 | 저작 api | 지원 매트릭스 「네이티브 목차(TOC)/상호참조」(`Create·Render-verified`) · capabilities 영역 `toc-crossref` · 위치 `doc.refs` | Render-verified | -| 색인 표시 | 저작 api | 지원 매트릭스 「색인 표시」(`Create(experimental)`) · capabilities 영역 `index-mark` · 위치 `doc.refs` | 미실측 | -| 차례 숨기기·제목 차례 표시 | 저작 api | 지원 매트릭스 「차례 숨기기·제목 차례 표시」(`Create(experimental)·Render-verified`) · capabilities 영역 `title-mark` · 위치 `doc.refs` | Render-verified(experimental 저작 포함) | +| 네이티브 목차(TOC)/상호참조 | 저작 api | 지원 매트릭스 「네이티브 목차(TOC)/상호참조」(`Create·Render-verified`) · capabilities 영역 `toc-crossref` · 위치 모듈 — `hwpx.tools.toc_author` (`add_native_toc`·`add_page_crossref`) | Render-verified | +| 색인 표시 | 저작 api | 지원 매트릭스 「색인 표시」(`Create(experimental)`) · capabilities 영역 `index-mark` · 위치 문단 메서드 — `paragraph.add_index_mark` | 미실측 | +| 차례 숨기기·제목 차례 표시 | 저작 api | 지원 매트릭스 「차례 숨기기·제목 차례 표시」(`Create(experimental)·Render-verified`) · capabilities 영역 `title-mark` · 위치 문단 메서드 — `paragraph.add_title_mark` | Render-verified(experimental 저작 포함) | | 하이퍼링크·책갈피 | 저작 api | 지원 매트릭스 「하이퍼링크·책갈피」(`Create·Render-verified`) · capabilities 영역 `hyperlink-bookmark` · 위치 `doc.refs` | Render-verified | ### 검토 diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 0ec88489..b8b6c91f 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -28,10 +28,10 @@ | 편집 계획 실행(edit plan) | Preserve·Edit·Render-verified | `hwpx.plan.apply_edit_plan`(experimental, 5.6.0+): 바이트-스플라이스 op 7종을 계획 1파일로 합성 — 정적 선검증→전 체인 인메모리 실행→최종 open-safety 검증→단 1회 원자 쓰기. 중간 실패 시 output·source 바이트 불변(테스트가 바이트 비교로 증명), step별+원본→최종 `hwpx.mutation-report/v1` 실측 사영. 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-editplan 스트라텀(paragraph_patch/fill_cells/apply_table_ops(중첩)/recolor_runs_by_color/strip_runs_by_color 각 1건 — v3/v4의 옛 `edit-plan` 버킷이 이미 다룬 paragraph_patch+fill_cells 조합 이외의 3개 op가 이번 신규 실증분), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 이 모듈은 기존 op를 체이닝만 할 뿐 대응하는 단일 capabilityArea가 없다(formfield류 저수준 공유 요소와 같은 이유) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 | | 도형 저작(선·사각형·타원) | Parse·Preserve·Edit·Create·Render-verified | 전용 `add_line`·`add_rectangle`·`add_ellipse`. 기존 floating 도형은 `Shape.set_position`으로 오프셋만 편집 가능(글자처럼 취급·위치 요소 없음은 typed 거부; 아래 기존 렌더 실적이 새 이동 경로의 검증을 뜻하지 않음). 5.0.0에서 기하 네임스페이스(`hc:`)·선 bounding box·`resize()`의 기하 반영을 수정한 뒤 실한컴 12.30.0(build 6446)에서 **개봉 7/7**(선 수평·대각·수직, 사각형, 타원, resize 후 혼합, 그림). 렌더로 사각형 144×72pt+`#CCE5FF`, 타원 100×60pt+`#FFD9CC`, 대각선이 요청 박스를 실제 span, `resize(14400,7200)` 후 새 크기·DASH 반영까지 확인 | | 저수준 도형·컨트롤 탈출구 | Edit | `add_shape`·`add_control`은 건네받은 요소와 속성만 쓰고 OWPML 필수 하위 요소(`offset`·`orgSz`·`curSz`·`sz`·`pos`·유형별 기하)는 만들지 않는다. 그대로 저장한 문서는 실한컴 12.30.0이 **거부**한다(음성 대조로 확인). 생성 시점의 `UserWarning`은 여전히 하나뿐이지만(휘발성 — 호출 스택에만 남고 파일엔 안 남음), `validate_package`·`validate_editor_open_safety`는 이 위험을 **오류**로 보고하고(빈 `hp:ctrl`은 한컴이 멈춘다), 공개 저장 경로는 이런 문서를 쓰지 않는다(6.6 트레인㉒의 `warning`에서 격상). `hp:container` 부재는 오탐 안 됨(부재는 이 5종을 원래 안 가짐, DEV-016). 도형은 위의 전용 헬퍼를 쓸 것(전용 헬퍼는 경고하지 않는다) | -| arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 아래 서술대로 여전히 정직 보류(저작 API 자체가 없음, Render-verified 대상 아님). 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) | +| arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 전용 저작 API(`add_curve`·`add_connector`, 아래 서술)가 있지만 openrate 스트라텀 실측이 없어 이 행의 Render-verified에 들지 않는다. 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) | | 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/`.text_box`/`.picture`/`.group`으로 부재를 그룹 로컬 좌표에 배치한다. 한/글이 저장한 묶음처럼 부재는 `id`·`zOrder` 0, `numberingType="NONE"`, `textWrap="TOP_AND_BOTTOM"`이고 `groupLevel`은 깊이(1·2·3…)다. 묶음 안 묶음은 꼬리 없는 부재이고, 모든 깊이의 도형은 가장 바깥 묶음의 좌표에 놓인다(한/글은 각 도형을 그 좌표의 자기 `transMatrix`에 그린다). 글상자 부재의 글은 묶음이 구역에 들어간 뒤 쓰여 문단 id가 구역에서 겹치지 않는다. **`arc`/`connectLine` 부재와 이미 배치된 도형의 재그룹은 범위 밖**. `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) | | 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). `add_picture`의 `brightness`·`contrast`(-100~100)·`effect`(`REAL_PIC`/`GRAY_SCALE`/`BLACK_WHITE`)·`alpha`(투명도 0~255)와 `line_color`·`line_width`(실선 테두리)는 한/글이 그대로 저장하고 그린다(범위 밖은 `shape-picture-image-value`·`shape-picture-border-value`로 거부). `crop`(네 변에서 잘라 낼 HWPUNIT, 축척 유지)도 한/글이 그대로 그린다(`shape-picture-crop-value`). 그림자(`PictureShadow`)·빛남(`PictureGlow`)도 같다(`shape-picture-effect-value`; 한/글은 그림을 효과까지 합친 크기로 배치한다). 부드러운 가장자리(`soft_edge`)·반사(`PictureReflection`)도 같다. 뒤집기·회전은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(`ContainerMember.picture`로 그림 부재를 넣는다). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 | -| 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart`(5.3.0+): 데이터·계열·축 레이블에서 차트 개체를 생성한다. 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 | +| 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart(chart_xml)`(5.3.0+): 호출자가 넘긴 ECMA-376 `c:chartSpace` XML을 차트 개체로 넣는다. 데이터·계열·축 레이블에서 ChartML을 만드는 일은 core가 하지 않는다(automation의 `build_chart_ml` 같은 생성기가 한다). 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 | | 수식 | Parse·Create(experimental)·Render-verified | `add_equation`(EqEdit script 삽입, 5.2.0+)과 `hwpx.equation.latex_to_eqedit`(렌더 검증 토큰셋만 변환, 밖은 `UnsupportedLatexError`로 typed 거부). 저작 어휘 전 토큰을 실한컴 렌더 오라클 픽셀 실측(60수식 배터리)으로 확정했고, 기존 수식 개체는 파싱·patch 보존됨(미리보기 MathML 렌더는 뷰어/플러그인 계층) | | 문단 첫 글자 장식(드롭캡) | Create(experimental)·Render-verified | `doc.shapes.add_drop_cap`(6.12 트레인㊸, 갭②) — 편집기 메뉴 표면 역매핑(트레인㊷)이 찾은 신규 갭. **어휘가 문단 속성이 아니라 도형 공통속성이라는 게 첫 발견**: `dropcapstyle`(`hc:DropCapStyleType`, `None`/`DoubleLine`/`TripleLine`/`Margin`)은 `hh:paraPr`가 아니라 `AbstractShapeObjectType`(rect/picture/table/chart 등 모든 삽입 개체가 공유하는 공통 속성군, `ParaList XML schema.xml:2006`)에 산다 — 이 라이브러리의 기존 도형 빌더 전부가 이 속성을 `"None"`으로 고정 방출해 온 이유가 이거다. 실코퍼스 67파일 전수에서 `dropcapstyle="TripleLine"` 실사용은 `error__20230809__test.hwpx` 단 1건뿐(나머지 전부 `"None"`) — 그 1건을 역설계: 드롭캡은 문단 첫 글자를 잘라내는 게 아니라 **투명한 `hp:rect`(선 `style="NONE"`·채움/그림자 `alpha="0"`, `lock="1"`)를 run에 끼워 넣고, 그 안 `drawText/subList/p/run/t`에 키운 문자를 별도로 담는** 구조다. 배치는 일반 도형 빌더의 "흐르지 않는 개체" 기본값(`horzRelTo="COLUMN"`·`flowWithText="0"`)과 달리 `horzRelTo="PARA"`·`flowWithText="1"`, `curSz`는 `orgSz`와 다르게 `"0"`/`"0"`(리사이즈 안 됨 센티널로 추정), `outMargin right="850"`(다른 셋은 0). 정체불명 `hp:parameterset`(`name="539"`, 값 `"2"`)은 DEV-011 선례대로 의미 추정 없이 그대로 보존. **`TripleLine`과 `DoubleLine`을 쓴다**: 한/글은 같은 rect+drawText 구조에 parameterset 값 1인 `DoubleLine`(`TripleLine`은 2)을 읽고, 그리고, 그대로 저장하며, 상자 크기가 같으면 두 스타일의 화면이 같다(상자 옆으로 가는 줄 수는 스타일이 아니라 상자 높이가 정한다). `Margin`은 상자를 여백 쪽 어디에 두는지 근거가 없어 typed 거부로 남긴다(curve·connectLine과 같은 원칙). width/height도 실측된 폰트-크기→박스-크기 변환 공식이 없어 호출자가 직접 지정(자동 계산 시도 안 함). 원장 라우팅은 없음(`hp:rect`가 이미 "도형 저작" 영역에 등록돼 있어 재등록하면 그 카운트를 덮어씀, `hp:fieldBegin`류보다 더 확실한 공유 요소 사례). 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-drop-cap 스트라텀(문자 5종 로테이션 + width/height 변주 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정) — 그때는 `TripleLine`만(`DoubleLine`은 뒤에 한/글이 다시 저장한 파일로 확인, `Margin`은 typed 거부) | | 변경추적(redline) | Edit·Create·Render-verified | `doc.tracking.insert`/`.delete`/`.replace`(`add_tracked_insert`·`add_tracked_delete`·`add_tracked_replace`의 현재 이름); 실 Windows 한컴 COM `IsTrackChange=1`·검토 리본 수락/거부 스파이크. **렌더 관측 두 갈래(정직 병기, 미해소)**: corpus-metrics의 실사용 코퍼스(Windows COM `SaveAs("PDF")`, 기존 문서에 이미 담긴 실이력 변경추적)에서는 43건이 `render_unavailable`(한컴이 PDF export 자체를 거부)로 집계됐다 — 반면 `docs/openrate/report-v14.json` authored-redline 스트라텀(macOS Hancom GUI 오라클, `doc.tracking.insert`/`.delete`/`.replace`로 신규 저작한 단순 삽입/삭제/치환 각 1~2건, 단일 저자)은 5/5 render_checked·0 render_failed로 성공했다(2026-08-08 측정). 두 관측이 진짜 모순인지(예: Windows COM vs macOS GUI 경로 차이, 또는 실이력의 복잡도가 export 거부를 유발) 추가 조사 없이는 확정할 수 없다 — 신규 저작·단순 변경추적은 렌더 검증됨, 복잡한/기존 이력 변경추적의 export 거부 위험은 여전히 알려진 한계로 남긴다 | @@ -89,9 +89,9 @@ | 변경추적(redline) | `doc.tracking` | | 메모(코멘트) | `doc.notes` | | 각주/미주 | `doc.notes` | -| 네이티브 목차(TOC)/상호참조 | `doc.refs` | -| 차례 숨기기·제목 차례 표시 | `doc.refs` | -| 색인 표시 | `doc.refs` | +| 네이티브 목차(TOC)/상호참조 | 모듈 — `hwpx.tools.toc_author` (`add_native_toc`·`add_page_crossref`) | +| 차례 숨기기·제목 차례 표시 | 문단 메서드 — `paragraph.add_title_mark` | +| 색인 표시 | 문단 메서드 — `paragraph.add_index_mark` | | 암호화 HWPX | 미지원 | | HWP 5.x 바이너리 | 루트 — `HwpxDocument.open` · `doc.save_to_path` · `doc.save_to_stream(format=)` · `doc.to_bytes(format=)` · `doc.conversion_report` | | 누름틀(form field) 생성 | `doc.fields` | @@ -100,7 +100,7 @@ | 테두리 채우기(이미지·그라데이션) | `doc.styles` | | 문서 정보(메타데이터) | `doc.parts` | | 바탕쪽 | `doc.parts`/`doc.page` | -| 날짜/시간·교정 부호·파일 이름 필드 | `doc.fields` | +| 날짜/시간·교정 부호·파일 이름 필드 | 문단 메서드 — `paragraph.add_date_field` · `add_proofreading_mark` · `add_path_field` | | 문서 옵션·호환성 | `doc.parts` | | 라이선스 표시(CCL) | `doc.parts` | | 페이지 레이아웃(용지·여백·머리말/꼬리말·쪽번호·단·줄번호·격자·요소 숨김) | `doc.page` | @@ -112,7 +112,7 @@ | id 기반 글 읽기·일괄 바꿈(text blocks) | `doc.text` | | 하이퍼링크·책갈피 | `doc.refs` | | 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` | -| 메일머지 표시 필드 | `doc.fields` | +| 메일머지 표시 필드 | 문단 메서드 — `paragraph.add_mail_merge_field` | | 문서 끼워 넣기(문서 병합) | 모듈 — `hwpx.tools.document_merge` | | Markdown 가져오기(Markdown → 문서) | 모듈 — `hwpx.tools.markdown_import` | | 덧말·글자 겹치기 | `doc.shapes` | diff --git a/scripts/coverage_ledger.py b/scripts/coverage_ledger.py index e61808dc..43f0aa3f 100644 --- a/scripts/coverage_ledger.py +++ b/scripts/coverage_ledger.py @@ -359,6 +359,15 @@ def to_offset(line: int, col: int) -> int: return "".join(chars) +def _is_hwp5_emitter(path: Path) -> bool: + """``src/hwpx/hwp5/`` 아래 파일인가(.hwp → HWPX 변환기, HWPX XML을 써낸다).""" + + try: + return path.relative_to(SRC_DIR).parts[:1] == ("hwp5",) + except ValueError: + return False + + def _read_source_corpus(files: list[tuple[Path, str]]) -> str: """파일 목록을 주석·독스트링을 지운 뒤 하나의 탐색용 텍스트로 합친다.""" @@ -1715,7 +1724,14 @@ def build_ledger( census = load_corpus(census_path) corpus_counts = census.frequencies total_real_files = census.total_real_files - source_files = _read_source_files() + all_source_files = _read_source_files() + # ``hwp5/``는 .hwp를 읽어 HWPX XML을 **써내는** 변환기다. 거기 나오는 HWPX + # 태그는 방출이지 HWPX를 읽는 코드가 아니다. 한 덩어리로 훑으면 방출 줄 + # 근처에 쓰기 표지가 없을 때 "읽기"로 잡혀, 엔진이 읽지 못하는 요소(예: + # hp:hiddenComment)가 codeRead=True가 됐다(ENG-33). 그래서 읽기 판정은 + # ``hwp5/`` 밖의 소스로만 하고, ``hwp5/``는 쓰기 신호로만 본다. + source_files = [item for item in all_source_files if not _is_hwp5_emitter(item[0])] + hwp5_files = [item for item in all_source_files if _is_hwp5_emitter(item[0])] # 평문 마커/디스패치 스캔은 주석·독스트링을 지운 텍스트에서 돈다(§3-C2 # 위양성 수리). ast 기반 리졸버(루프 태그·인자 태그)는 원본 텍스트를 # 그대로 쓴다 — 문자열 리터럴 안에서는 진짜 ast 노드가 나올 수 없어 @@ -1725,6 +1741,10 @@ def build_ledger( loop_resolved = _resolve_loop_tag_tables(source_files) arg_tag_resolved = _resolve_argument_tag_literals(source_files) indirect_resolved = _merge_indirect_resolved(loop_resolved, arg_tag_resolved) + hwp5_text = _read_source_corpus(hwp5_files) + hwp5_indirect = _merge_indirect_resolved( + _resolve_loop_tag_tables(hwp5_files), _resolve_argument_tag_literals(hwp5_files) + ) skeleton_text = _read_skeleton_text() status_by_area = _parse_support_matrix_status( SUPPORT_MATRIX_PATH.read_text(encoding="utf-8") @@ -1748,6 +1768,12 @@ def build_ledger( code_read, code_write_api = classify_code_usage( prefix, name, family, source_text, dispatch_windows, indirect_resolved ) + if not code_write_api: + # 방출기의 쓰기 신호는 전과 같은 기준(여는 태그 리터럴·쓰기 표지)으로 + # 센다. 쓰기 표지 없이 태그만 나온 줄은 읽기로도 쓰기로도 세지 않는다. + _emitted_read, code_write_api = classify_code_usage( + prefix, name, family, hwp5_text, [], hwp5_indirect + ) if code_write_api: write_mode = "api" elif is_in_skeleton(prefix, name, skeleton_text): @@ -1821,6 +1847,7 @@ def build_ledger( ], "skeleton": "src/hwpx/data/Skeleton.hwpx", "sourceScanned": "src/hwpx/**/*.py", + "readScanExcludes": "src/hwpx/hwp5/**/*.py", }, "corpusTotalFiles": total_real_files, "corpusUnknownFiles": census.unknown_files, diff --git a/src/hwpx/capabilities.py b/src/hwpx/capabilities.py index 8c970cd0..42e680e7 100644 --- a/src/hwpx/capabilities.py +++ b/src/hwpx/capabilities.py @@ -222,7 +222,9 @@ }, { "area": "toc-crossref", - "namespace": "doc.refs", + "namespace": None, + # 자리: doc.refs에는 차례·상호참조 메서드가 없다(add_bookmark·add_hyperlink + # 뿐). 진입점은 아래 모듈 함수라 namespace는 None이다(ENG-33). "matrix_row": "네이티브 목차(TOC)/상호참조", "entry_points": ("hwpx.tools.toc_author:add_native_toc",), # 6.8 트레인㉚: add_bookmark/add_hyperlink는 여기 있었으나(오귀속), @@ -235,7 +237,9 @@ }, { "area": "title-mark", - "namespace": "doc.refs", + "namespace": None, + # 자리: HwpxOxmlParagraph.add_title_mark. doc.refs에는 없어 namespace는 + # None이고 진입점은 문단 클래스다(ENG-33). "matrix_row": "차례 숨기기·제목 차례 표시", # 6.15 트레인 — DEV-044(hp:titleMark, 스키마는 구조 선언·의미는 # 미문서화). 6.13/6.14는 캐럿 문단 타겟팅을 실측할 수단이 없어 @@ -249,7 +253,7 @@ # 다른 진입 경로(hwpx.tools.toc_author 모듈 함수 vs 문단 메서드)라 # 별도 영역으로 분리 — toc-crossref의 "혼합 지원 영역 오염" 방지 # 원칙과 같은 이유. - "entry_points": ("hwpx.document:HwpxDocument",), + "entry_points": ("hwpx.oxml.paragraph:HwpxOxmlParagraph",), "authoring_methods": (), }, { @@ -442,7 +446,9 @@ }, { "area": "date-time-proofreading-mark-fields", - "namespace": "doc.fields", + "namespace": None, + # 자리: HwpxOxmlParagraph.add_date_field·add_proofreading_mark· + # add_path_field. doc.fields에는 없어 namespace는 None이다(ENG-33). "matrix_row": "날짜/시간·교정 부호·파일 이름 필드", # 6.13 트레인㊻ — 팀장 실한컴 macOS GUI 프로브①③(2026-08-11)의 # gold를 직접 역설계. add_date_field/add_proofreading_mark는 @@ -456,12 +462,14 @@ # 안 만들고 이 영역에 합류(행 제목도 갱신) — 실 코퍼스(트레인㊺가 # 이미 확보, markdown_export/99_all_in_one_stress.hwpx)만으로 # 계약이 나와 GUI 프로브 불필요했다. - "entry_points": ("hwpx.document:HwpxDocument",), + "entry_points": ("hwpx.oxml.paragraph:HwpxOxmlParagraph",), "authoring_methods": (), }, { "area": "index-mark", - "namespace": "doc.refs", + "namespace": None, + # 자리: HwpxOxmlParagraph.add_index_mark. doc.refs에는 없어 namespace는 + # None이다(ENG-33). "matrix_row": "색인 표시", # P4 — 팀장 실한컴 GUI 프로브 gold 2건(1키/2키) 역설계. # HwpxOxmlParagraph.add_index_mark만 있다(HwpxDocument 루트에는 @@ -471,12 +479,14 @@ # hp:titleMark) 편집기 메뉴도 다르고, 한 영역에 묶으면 매트릭스 # 등급 하나로 두 기능의 검증 상태를 뭉개게 된다(toc-crossref의 # "혼합 지원 영역 오염" 방지 원칙). - "entry_points": ("hwpx.document:HwpxDocument",), + "entry_points": ("hwpx.oxml.paragraph:HwpxOxmlParagraph",), "authoring_methods": (), }, { "area": "mail-merge-field", - "namespace": "doc.fields", + "namespace": None, + # 자리: HwpxOxmlParagraph.add_mail_merge_field. doc.fields에는 없어 + # namespace는 None이다(ENG-33). "matrix_row": "메일머지 표시 필드", # P4 — 팀장 실한컴 GUI 프로브 gold 역설계. 기존 "mail-merge" # 영역에 합치지 않고 새로 만든 이유: 그 영역은 namespace=None · @@ -491,7 +501,7 @@ # field_marks.py·같은 hp:fieldBegin 계열이지만 그쪽에 합류시키지 # 않은 것도 같은 이유다 — 그 행은 "입력 > 필드" 계열 3종이고 # 메일머지 표시는 "도구 > 메일 머지" 메뉴다. - "entry_points": ("hwpx.document:HwpxDocument",), + "entry_points": ("hwpx.oxml.paragraph:HwpxOxmlParagraph",), "authoring_methods": (), }, { diff --git a/src/hwpx/data/contract_docs/support-matrix.md b/src/hwpx/data/contract_docs/support-matrix.md index 0ec88489..b8b6c91f 100644 --- a/src/hwpx/data/contract_docs/support-matrix.md +++ b/src/hwpx/data/contract_docs/support-matrix.md @@ -28,10 +28,10 @@ | 편집 계획 실행(edit plan) | Preserve·Edit·Render-verified | `hwpx.plan.apply_edit_plan`(experimental, 5.6.0+): 바이트-스플라이스 op 7종을 계획 1파일로 합성 — 정적 선검증→전 체인 인메모리 실행→최종 open-safety 검증→단 1회 원자 쓰기. 중간 실패 시 output·source 바이트 불변(테스트가 바이트 비교로 증명), step별+원본→최종 `hwpx.mutation-report/v1` 실측 사영. 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-editplan 스트라텀(paragraph_patch/fill_cells/apply_table_ops(중첩)/recolor_runs_by_color/strip_runs_by_color 각 1건 — v3/v4의 옛 `edit-plan` 버킷이 이미 다룬 paragraph_patch+fill_cells 조합 이외의 3개 op가 이번 신규 실증분), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 이 모듈은 기존 op를 체이닝만 할 뿐 대응하는 단일 capabilityArea가 없다(formfield류 저수준 공유 요소와 같은 이유) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 | | 도형 저작(선·사각형·타원) | Parse·Preserve·Edit·Create·Render-verified | 전용 `add_line`·`add_rectangle`·`add_ellipse`. 기존 floating 도형은 `Shape.set_position`으로 오프셋만 편집 가능(글자처럼 취급·위치 요소 없음은 typed 거부; 아래 기존 렌더 실적이 새 이동 경로의 검증을 뜻하지 않음). 5.0.0에서 기하 네임스페이스(`hc:`)·선 bounding box·`resize()`의 기하 반영을 수정한 뒤 실한컴 12.30.0(build 6446)에서 **개봉 7/7**(선 수평·대각·수직, 사각형, 타원, resize 후 혼합, 그림). 렌더로 사각형 144×72pt+`#CCE5FF`, 타원 100×60pt+`#FFD9CC`, 대각선이 요청 박스를 실제 span, `resize(14400,7200)` 후 새 크기·DASH 반영까지 확인 | | 저수준 도형·컨트롤 탈출구 | Edit | `add_shape`·`add_control`은 건네받은 요소와 속성만 쓰고 OWPML 필수 하위 요소(`offset`·`orgSz`·`curSz`·`sz`·`pos`·유형별 기하)는 만들지 않는다. 그대로 저장한 문서는 실한컴 12.30.0이 **거부**한다(음성 대조로 확인). 생성 시점의 `UserWarning`은 여전히 하나뿐이지만(휘발성 — 호출 스택에만 남고 파일엔 안 남음), `validate_package`·`validate_editor_open_safety`는 이 위험을 **오류**로 보고하고(빈 `hp:ctrl`은 한컴이 멈춘다), 공개 저장 경로는 이런 문서를 쓰지 않는다(6.6 트레인㉒의 `warning`에서 격상). `hp:container` 부재는 오탐 안 됨(부재는 이 5종을 원래 안 가짐, DEV-016). 도형은 위의 전용 헬퍼를 쓸 것(전용 헬퍼는 경고하지 않는다) | -| arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 아래 서술대로 여전히 정직 보류(저작 API 자체가 없음, Render-verified 대상 아님). 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) | +| arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 전용 저작 API(`add_curve`·`add_connector`, 아래 서술)가 있지만 openrate 스트라텀 실측이 없어 이 행의 Render-verified에 들지 않는다. 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) | | 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/`.text_box`/`.picture`/`.group`으로 부재를 그룹 로컬 좌표에 배치한다. 한/글이 저장한 묶음처럼 부재는 `id`·`zOrder` 0, `numberingType="NONE"`, `textWrap="TOP_AND_BOTTOM"`이고 `groupLevel`은 깊이(1·2·3…)다. 묶음 안 묶음은 꼬리 없는 부재이고, 모든 깊이의 도형은 가장 바깥 묶음의 좌표에 놓인다(한/글은 각 도형을 그 좌표의 자기 `transMatrix`에 그린다). 글상자 부재의 글은 묶음이 구역에 들어간 뒤 쓰여 문단 id가 구역에서 겹치지 않는다. **`arc`/`connectLine` 부재와 이미 배치된 도형의 재그룹은 범위 밖**. `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) | | 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). `add_picture`의 `brightness`·`contrast`(-100~100)·`effect`(`REAL_PIC`/`GRAY_SCALE`/`BLACK_WHITE`)·`alpha`(투명도 0~255)와 `line_color`·`line_width`(실선 테두리)는 한/글이 그대로 저장하고 그린다(범위 밖은 `shape-picture-image-value`·`shape-picture-border-value`로 거부). `crop`(네 변에서 잘라 낼 HWPUNIT, 축척 유지)도 한/글이 그대로 그린다(`shape-picture-crop-value`). 그림자(`PictureShadow`)·빛남(`PictureGlow`)도 같다(`shape-picture-effect-value`; 한/글은 그림을 효과까지 합친 크기로 배치한다). 부드러운 가장자리(`soft_edge`)·반사(`PictureReflection`)도 같다. 뒤집기·회전은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(`ContainerMember.picture`로 그림 부재를 넣는다). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 | -| 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart`(5.3.0+): 데이터·계열·축 레이블에서 차트 개체를 생성한다. 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 | +| 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart(chart_xml)`(5.3.0+): 호출자가 넘긴 ECMA-376 `c:chartSpace` XML을 차트 개체로 넣는다. 데이터·계열·축 레이블에서 ChartML을 만드는 일은 core가 하지 않는다(automation의 `build_chart_ml` 같은 생성기가 한다). 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 | | 수식 | Parse·Create(experimental)·Render-verified | `add_equation`(EqEdit script 삽입, 5.2.0+)과 `hwpx.equation.latex_to_eqedit`(렌더 검증 토큰셋만 변환, 밖은 `UnsupportedLatexError`로 typed 거부). 저작 어휘 전 토큰을 실한컴 렌더 오라클 픽셀 실측(60수식 배터리)으로 확정했고, 기존 수식 개체는 파싱·patch 보존됨(미리보기 MathML 렌더는 뷰어/플러그인 계층) | | 문단 첫 글자 장식(드롭캡) | Create(experimental)·Render-verified | `doc.shapes.add_drop_cap`(6.12 트레인㊸, 갭②) — 편집기 메뉴 표면 역매핑(트레인㊷)이 찾은 신규 갭. **어휘가 문단 속성이 아니라 도형 공통속성이라는 게 첫 발견**: `dropcapstyle`(`hc:DropCapStyleType`, `None`/`DoubleLine`/`TripleLine`/`Margin`)은 `hh:paraPr`가 아니라 `AbstractShapeObjectType`(rect/picture/table/chart 등 모든 삽입 개체가 공유하는 공통 속성군, `ParaList XML schema.xml:2006`)에 산다 — 이 라이브러리의 기존 도형 빌더 전부가 이 속성을 `"None"`으로 고정 방출해 온 이유가 이거다. 실코퍼스 67파일 전수에서 `dropcapstyle="TripleLine"` 실사용은 `error__20230809__test.hwpx` 단 1건뿐(나머지 전부 `"None"`) — 그 1건을 역설계: 드롭캡은 문단 첫 글자를 잘라내는 게 아니라 **투명한 `hp:rect`(선 `style="NONE"`·채움/그림자 `alpha="0"`, `lock="1"`)를 run에 끼워 넣고, 그 안 `drawText/subList/p/run/t`에 키운 문자를 별도로 담는** 구조다. 배치는 일반 도형 빌더의 "흐르지 않는 개체" 기본값(`horzRelTo="COLUMN"`·`flowWithText="0"`)과 달리 `horzRelTo="PARA"`·`flowWithText="1"`, `curSz`는 `orgSz`와 다르게 `"0"`/`"0"`(리사이즈 안 됨 센티널로 추정), `outMargin right="850"`(다른 셋은 0). 정체불명 `hp:parameterset`(`name="539"`, 값 `"2"`)은 DEV-011 선례대로 의미 추정 없이 그대로 보존. **`TripleLine`과 `DoubleLine`을 쓴다**: 한/글은 같은 rect+drawText 구조에 parameterset 값 1인 `DoubleLine`(`TripleLine`은 2)을 읽고, 그리고, 그대로 저장하며, 상자 크기가 같으면 두 스타일의 화면이 같다(상자 옆으로 가는 줄 수는 스타일이 아니라 상자 높이가 정한다). `Margin`은 상자를 여백 쪽 어디에 두는지 근거가 없어 typed 거부로 남긴다(curve·connectLine과 같은 원칙). width/height도 실측된 폰트-크기→박스-크기 변환 공식이 없어 호출자가 직접 지정(자동 계산 시도 안 함). 원장 라우팅은 없음(`hp:rect`가 이미 "도형 저작" 영역에 등록돼 있어 재등록하면 그 카운트를 덮어씀, `hp:fieldBegin`류보다 더 확실한 공유 요소 사례). 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-drop-cap 스트라텀(문자 5종 로테이션 + width/height 변주 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정) — 그때는 `TripleLine`만(`DoubleLine`은 뒤에 한/글이 다시 저장한 파일로 확인, `Margin`은 typed 거부) | | 변경추적(redline) | Edit·Create·Render-verified | `doc.tracking.insert`/`.delete`/`.replace`(`add_tracked_insert`·`add_tracked_delete`·`add_tracked_replace`의 현재 이름); 실 Windows 한컴 COM `IsTrackChange=1`·검토 리본 수락/거부 스파이크. **렌더 관측 두 갈래(정직 병기, 미해소)**: corpus-metrics의 실사용 코퍼스(Windows COM `SaveAs("PDF")`, 기존 문서에 이미 담긴 실이력 변경추적)에서는 43건이 `render_unavailable`(한컴이 PDF export 자체를 거부)로 집계됐다 — 반면 `docs/openrate/report-v14.json` authored-redline 스트라텀(macOS Hancom GUI 오라클, `doc.tracking.insert`/`.delete`/`.replace`로 신규 저작한 단순 삽입/삭제/치환 각 1~2건, 단일 저자)은 5/5 render_checked·0 render_failed로 성공했다(2026-08-08 측정). 두 관측이 진짜 모순인지(예: Windows COM vs macOS GUI 경로 차이, 또는 실이력의 복잡도가 export 거부를 유발) 추가 조사 없이는 확정할 수 없다 — 신규 저작·단순 변경추적은 렌더 검증됨, 복잡한/기존 이력 변경추적의 export 거부 위험은 여전히 알려진 한계로 남긴다 | @@ -89,9 +89,9 @@ | 변경추적(redline) | `doc.tracking` | | 메모(코멘트) | `doc.notes` | | 각주/미주 | `doc.notes` | -| 네이티브 목차(TOC)/상호참조 | `doc.refs` | -| 차례 숨기기·제목 차례 표시 | `doc.refs` | -| 색인 표시 | `doc.refs` | +| 네이티브 목차(TOC)/상호참조 | 모듈 — `hwpx.tools.toc_author` (`add_native_toc`·`add_page_crossref`) | +| 차례 숨기기·제목 차례 표시 | 문단 메서드 — `paragraph.add_title_mark` | +| 색인 표시 | 문단 메서드 — `paragraph.add_index_mark` | | 암호화 HWPX | 미지원 | | HWP 5.x 바이너리 | 루트 — `HwpxDocument.open` · `doc.save_to_path` · `doc.save_to_stream(format=)` · `doc.to_bytes(format=)` · `doc.conversion_report` | | 누름틀(form field) 생성 | `doc.fields` | @@ -100,7 +100,7 @@ | 테두리 채우기(이미지·그라데이션) | `doc.styles` | | 문서 정보(메타데이터) | `doc.parts` | | 바탕쪽 | `doc.parts`/`doc.page` | -| 날짜/시간·교정 부호·파일 이름 필드 | `doc.fields` | +| 날짜/시간·교정 부호·파일 이름 필드 | 문단 메서드 — `paragraph.add_date_field` · `add_proofreading_mark` · `add_path_field` | | 문서 옵션·호환성 | `doc.parts` | | 라이선스 표시(CCL) | `doc.parts` | | 페이지 레이아웃(용지·여백·머리말/꼬리말·쪽번호·단·줄번호·격자·요소 숨김) | `doc.page` | @@ -112,7 +112,7 @@ | id 기반 글 읽기·일괄 바꿈(text blocks) | `doc.text` | | 하이퍼링크·책갈피 | `doc.refs` | | 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` | -| 메일머지 표시 필드 | `doc.fields` | +| 메일머지 표시 필드 | 문단 메서드 — `paragraph.add_mail_merge_field` | | 문서 끼워 넣기(문서 병합) | 모듈 — `hwpx.tools.document_merge` | | Markdown 가져오기(Markdown → 문서) | 모듈 — `hwpx.tools.markdown_import` | | 덧말·글자 겹치기 | `doc.shapes` | From 5f2a412a9f638a5fedc3a8d24ce3cc20da7c15ba Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:18:33 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test:=20=EC=95=9E=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EB=93=A4=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EB=9D=BD=C2=B7?= =?UTF-8?q?=EC=9E=A5=EB=B6=80=C2=B7=EA=B8=B0=EB=8C=80=EA=B0=92=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20(ENG-36,=20ENG-33,=20ENG-21=20=ED=9B=84=EC=86=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 테스트에서 새로 깨진 4건을 바로잡는다. - test_document_metadata: 새 문서는 템플릿의 date를 물려받지 않는다(ENG-36) - model_surface.json·stable-api.md: History에 is_version_log 추가(ENG-36) - python-example-ledger.json: README 문구 변경에 따른 해시(ENG-21) - test_ns_refs: toc-crossref의 namespace는 None(ENG-33) Co-Authored-By: Claude Fable 5.2 --- docs/python-example-ledger.json | 4 ++-- docs/stable-api.md | 2 +- tests/data/model_surface.json | 2 +- tests/test_document_metadata.py | 11 +++++++---- tests/test_ns_refs.py | 7 +++++-- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/python-example-ledger.json b/docs/python-example-ledger.json index 02752179..0a9b5180 100644 --- a/docs/python-example-ledger.json +++ b/docs/python-example-ledger.json @@ -16,8 +16,8 @@ "fenceCount": 117, "sourceSha256": "fa9c605364efe898f0b70e1160608d3c6d0d5d6c470e0ad3bcc0e40b28a0438e", "manualSha256": { - "README.md": "d63726c59b063948126eaaa05022b13c3264c88019466ee4bb67db6d8ba27fc3", - "README_EN.md": "185f69b6cde4093acdde7bfb2505aa2e9e31b2bb8f46763dc93a854702742082", + "README.md": "c62824dec6c7985ef11514138f4d303ad2205de7f732a4639051c37142eb9ca1", + "README_EN.md": "7f7458a51abaeb58284cd29790d50f6729a94bcd28e5b5fa330ffd675b7cd82a", "docs/quickstart.md": "44d8ca82ee70c0e3dfdf087713a4e4ce935e64cd3f6a546311f95b054a285a18", "docs/recipes-traversal.md": "b63f42b1a6ed23812688599474dcf83300509b86a94b5daceae679bfdbde9343", "docs/mutation-semantics.md": "6de1471f64c1cd8db2e661c413d92bfd27221f5d930708942ac47c6aff2d608b", diff --git a/docs/stable-api.md b/docs/stable-api.md index 0535bcb4..e9bd8427 100644 --- a/docs/stable-api.md +++ b/docs/stable-api.md @@ -52,7 +52,7 @@ stable 로 올리면 24개 모듈 수백 멤버가 major 에서만 바뀔 수 멤버**. - 목록 **안**의 멤버 → stable. major 경계에서만 바뀐다. -- 목록 **밖**의 멤버(156개 — `apply_model`·`mark_dirty`·`remove_stale_layout_caches` +- 목록 **밖**의 멤버(157개 — `apply_model`·`mark_dirty`·`remove_stale_layout_caches` 등) → 구현 세부. minor 에서 바뀔 수 있다. `hwpx.oxml.*` import 경로는 그대로 살아 있다. 옮기지도, deprecate 하지도 않는다. diff --git a/tests/data/model_surface.json b/tests/data/model_surface.json index 1750dcc5..982eb355 100644 --- a/tests/data/model_surface.json +++ b/tests/data/model_surface.json @@ -83,7 +83,7 @@ }, "History": { "implementation": "hwpx.oxml.simple_parts.HwpxOxmlHistory", - "internalCount": 9, + "internalCount": 10, "stable": [] }, "InlineObject": { diff --git a/tests/test_document_metadata.py b/tests/test_document_metadata.py index 61bea096..4ebcc025 100644 --- a/tests/test_document_metadata.py +++ b/tests/test_document_metadata.py @@ -29,11 +29,14 @@ def test_metadata_read_on_a_fresh_document() -> None: assert metadata is not None assert metadata.language == "ko" - # date/lastsaveby/created_date/modified_date come from the skeleton's - # own frozen snapshot -- present, but not asserted on exact value here - # (that's the skeleton's own business, not this feature's contract). - assert metadata.date is not None + # A new document does not inherit the skeleton's author or dates (ENG-36): + # creator, lastsaveby and the free-form date are empty, and the two ISO + # dates are the moment the document was made. + assert metadata.creator is None + assert metadata.lastsaveby is None + assert metadata.date is None assert metadata.created_date is not None + assert metadata.modified_date == metadata.created_date def test_set_document_metadata_updates_only_the_given_fields() -> None: diff --git a/tests/test_ns_refs.py b/tests/test_ns_refs.py index 20af57fe..7cab1d56 100644 --- a/tests/test_ns_refs.py +++ b/tests/test_ns_refs.py @@ -48,7 +48,7 @@ def test_bad_sections_are_typed_errors(name, args, kwargs, code, document) -> No assert excinfo.value.code == code -def test_this_is_where_toc_and_cross_reference_will_live() -> None: +def test_toc_and_cross_reference_live_in_their_module_not_in_doc_refs() -> None: """경계 근거: 능력 레지스트리가 TOC/상호참조와 하이퍼링크/책갈피를 같은 네임스페이스(doc.refs) 밑에 두되, 서로 다른 캐파빌리티 영역으로 분리해 등록해 뒀다 (6.8 트레인㉚ -- 하이퍼링크/책갈피는 toc-crossref 영역에 @@ -60,7 +60,10 @@ def test_this_is_where_toc_and_cross_reference_will_live() -> None: toc_area = next(row for row in _CAPABILITY_AREAS if row["area"] == "toc-crossref") assert toc_area["authoring_methods"] == () assert toc_area["entry_points"] == ("hwpx.tools.toc_author:add_native_toc",) - assert toc_area["namespace"] == "doc.refs" + # 차례·상호참조 메서드는 doc.refs에 없다(모듈 함수 hwpx.tools.toc_author). + # 레지스트리는 실제 자리를 적으므로 namespace는 None이다(ENG-33). + assert toc_area["namespace"] is None + assert not hasattr(HwpxDocument.new().refs, "add_native_toc") link_area = next(row for row in _CAPABILITY_AREAS if row["area"] == "hyperlink-bookmark") assert set(link_area["authoring_methods"]) == {"add_bookmark", "add_hyperlink"}