From 42666221a4dadb2a523ee709459112a4d5560726 Mon Sep 17 00:00:00 2001 From: kokyuhyun <38392618+airmang@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:44:08 +0900 Subject: [PATCH 1/2] feat(markdown): write a Markdown subset into HWPX and add a clean new-document mode `hwpx.tools.markdown_import.from_markdown(markdown, style=...)` builds a new document from the Markdown models write most reliably: # to #### headings, paragraphs, -/*/1. lists nested by indentation (applied as Hancom lists), pipe tables with a bold, shaded header row, and **bold**/*italic* inline. Anything else is written as plain text; unknown Markdown never raises. `doc.text.append_markdown(...)` adds the same to an existing document. `MarkdownStyle` is a genre-neutral, overridable look; genre presets belong to the application layer. `HwpxDocument.new(inherit_style=False)` makes a clean document in which `add_paragraph` starts from the base shapes instead of copying the last paragraph, so a heading's outline or a list's numbering does not follow into the next paragraph. `add_paragraph(inherit_style=None)` now follows the document; the default document still inherits. Closes #669 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 + docs/editor-surface-inventory.md | 3 +- docs/python-example-ledger.json | 33 +- docs/support-matrix.md | 2 + docs/usage.md | 25 + scripts/editor_surface_inventory.py | 2 + src/hwpx/_document/ns/text.py | 17 + src/hwpx/capabilities.py | 17 + src/hwpx/data/contract_docs/support-matrix.md | 2 + src/hwpx/document.py | 23 +- src/hwpx/tools/markdown_import.py | 461 ++++++++++++++++++ tests/data/document_facade_surface.json | 4 +- tests/test_documentation_code_fences.py | 4 +- tests/test_markdown_import.py | 208 ++++++++ tests/test_namespace_hygiene.py | 2 + 15 files changed, 789 insertions(+), 26 deletions(-) create mode 100644 src/hwpx/tools/markdown_import.py create mode 100644 tests/test_markdown_import.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 471cabb9..649e2e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +### 추가 + +- `from_markdown(markdown, style=...)`(`hwpx.tools.markdown_import`): 흔히 쓰는 Markdown 부분 집합으로 새 문서를 만든다. + `#`~`####` 제목, 문단, `-`/`*`/`1.` 목록(들여쓰기로 수준을 나누고 한/글 목록으로 적용), `|` 표(구분선 위 행은 + 굵게·음영 머리글), `**굵게**`·`*기울임*`을 쓰고, 그 밖의 Markdown은 평문으로 쓰며 오류를 내지 않는다. 이미 있는 + 문서에는 `doc.text.append_markdown(markdown)`으로 덧붙인다. 모양은 장르 중립 `MarkdownStyle` + (같은 모듈, 기본 `#` 16pt·`##` 12pt 굵게)로 바꾼다. 공문·가정통신문 같은 장르 프리셋은 응용 + 계층이 이 스타일로 넘긴다. +- `HwpxDocument.new(inherit_style=False)`: 깨끗한 새 문서. `add_paragraph`가 앞 문단의 문단·글자 모양을 물려받지 + 않고 바탕글에서 시작하므로, 제목이나 목록 다음 문단에 개요·목록 번호가 따라붙지 않는다. `add_paragraph`의 + `inherit_style` 기본값은 이제 `None`(문서를 따름)이고, 기본 문서에서는 전처럼 물려받는다. + ### 고침 - 줄 캐시(`write_line_caches`)가 머리에 정의되지 않은 글머리표·번호를 가리키는 문단 모양(예: 글머리표 idRef 0)의 diff --git a/docs/editor-surface-inventory.md b/docs/editor-surface-inventory.md index 25c8ca67..6c28892d 100644 --- a/docs/editor-surface-inventory.md +++ b/docs/editor-surface-inventory.md @@ -38,7 +38,7 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 · -자동 생성 시점 교차 확인: 원장(요소 축) 132건 render-verified(요소 345개 중) · 캐파빌리티 영역(기능 축) 41개 등록됨. +자동 생성 시점 교차 확인: 원장(요소 축) 132건 render-verified(요소 345개 중) · 캐파빌리티 영역(기능 축) 42개 등록됨. ### 서식 @@ -122,6 +122,7 @@ support-matrix.md` · `docs/coverage-ledger.json`)과 OWPML 스키마 · | 기능 | 엔진 상태 | 근거 | 실한컴 검증 | |---|---|---|---| +| Markdown 가져오기(Markdown → 문서) | 저작 api | 지원 매트릭스 「Markdown 가져오기(Markdown → 문서)」(`Create(experimental)`) · capabilities 영역 `markdown-import` · 위치 모듈 — `hwpx.tools.markdown_import` | 미실측 | | 메일머지(placeholder 템플릿 배치 생성) | 저작 api | 지원 매트릭스 「메일머지(placeholder 템플릿 배치 생성)」(`Edit·Render-verified`) · capabilities 영역 `mail-merge` · 위치 모듈 — `hwpx.tools.mail_merge` | Render-verified | | 문서 끼워 넣기(문서 병합) | 저작 api | 지원 매트릭스 「문서 끼워 넣기(문서 병합)」(`Create·Render-verified`) · capabilities 영역 `document-merge` · 위치 모듈 — `hwpx.tools.document_merge` | Render-verified | | 편집 계획 실행(edit plan) | 저작 api | 지원 매트릭스 「편집 계획 실행(edit plan)」(`Preserve·Edit·Render-verified`) · capabilities 영역 `edit-plan` · 위치 모듈 — `hwpx.plan` | Render-verified | diff --git a/docs/python-example-ledger.json b/docs/python-example-ledger.json index c1a0200c..e3130994 100644 --- a/docs/python-example-ledger.json +++ b/docs/python-example-ledger.json @@ -13,15 +13,15 @@ "docs/safe-write-contract.md", "docs/golden-api.md" ], - "fenceCount": 116, - "sourceSha256": "5db7dce9e8f65361d4cc9a4911e5b9148dc5e9e75cabec7bbcdce4b1861e8a80", + "fenceCount": 117, + "sourceSha256": "a164785f35a43a2f224d2b1b906b71f113de67c0c7e05dea6e3c3a4fa3cb989d", "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": "df93e611c465f9da290af12612e4c5b524a88406307d953ed08fa354763f8dc5", + "docs/usage.md": "ec70af303f2efaed9fe31f633af01817a4f2f1c6f31174dce4c21fadfd3b6948", "docs/examples.md": "f03f0ecee4faef94dda05c92dd25672dfbde3a7447f823603b611bea59f6432e", "docs/faq.md": "cd82e2f1e740cff3f50e620c060ed8ae0aa130271fa373eca3a07a70fb9ca604", "docs/index.md": "111754d7637414e0078ba0c7bbcf19d1a4550a68202acf3dd2c89c0748ea0050", @@ -100,6 +100,7 @@ "docs/usage.md#65", "docs/usage.md#66", "docs/usage.md#67", + "docs/usage.md#72", "docs/examples.md#1", "docs/examples.md#6", "docs/golden-api.md#1" @@ -121,8 +122,8 @@ "docs/usage.md#8", "docs/usage.md#68", "docs/usage.md#70", - "docs/usage.md#80", "docs/usage.md#81", + "docs/usage.md#82", "docs/examples.md#2", "docs/examples.md#3", "docs/examples.md#4", @@ -146,7 +147,6 @@ "docs/usage.md#34", "docs/usage.md#69", "docs/usage.md#71", - "docs/usage.md#72", "docs/usage.md#73", "docs/usage.md#74", "docs/usage.md#75", @@ -154,8 +154,9 @@ "docs/usage.md#77", "docs/usage.md#78", "docs/usage.md#79", - "docs/usage.md#82", + "docs/usage.md#80", "docs/usage.md#83", + "docs/usage.md#84", "docs/faq.md#2", "docs/faq.md#4", "docs/faq.md#5" @@ -187,36 +188,36 @@ "document", "section" ], - "docs/usage.md#72": [ - "document" - ], "docs/usage.md#73": [ "document" ], "docs/usage.md#74": [ - "document", - "section" + "document" ], "docs/usage.md#75": [ "document", "section" ], "docs/usage.md#76": [ - "document" + "document", + "section" ], "docs/usage.md#77": [ - "section" + "document" ], "docs/usage.md#78": [ - "document" + "section" ], "docs/usage.md#79": [ "document" ], - "docs/usage.md#82": [ - "finder" + "docs/usage.md#80": [ + "document" ], "docs/usage.md#83": [ + "finder" + ], + "docs/usage.md#84": [ "document" ], "docs/faq.md#2": [ diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 186391c1..01496b52 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -61,6 +61,7 @@ | 메일머지(placeholder 템플릿 배치 생성) | Edit·Render-verified | `hwpx.tools.mail_merge.merge_template_rows`(6.8 트레인㉛ 등재, 코드는 이전 사이클부터 존재 — 편집기 표면 인벤토리 트레인㉙·계층 판정 트레인㉚이 찾을 때까지 캐파빌리티·매트릭스 어디에도 등재가 없었다). `{{field}}`/`${field}`/`<>` 세 플레이스홀더 문법을 지원하는 템플릿 1개 + 행 데이터(csv/json/시퀀스)로 문서 N개를 배치 생성 — 기존 `hp:t` 텍스트를 치환하는 것이라 새 요소를 만들지 않는다(Create가 아니라 Edit). `strict`/`fit_policy`/`value_sanitizer` 등 운영 옵션 다수. 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-mailmerge 스트라텀(세 플레이스홀더 문법 각각 겨냥하는 전용 템플릿으로 회전), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — 이 영역의 첫 독립 실한컴 판정 | | 메일머지 표시 필드 | Create(experimental) | `HwpxOxmlParagraph.add_mail_merge_field(name, cached_text=)`(`oxml/field_marks.py`, P4). 팀장 실한컴 GUI 프로브 gold(`tests/fixtures/gui_probes/mailmerge_display_fields.hwpx`, "도구 > 메일 머지 > 메일 머지 표시 달기"로 필드 2개 삽입) 역설계 — 위 「메일머지(placeholder 템플릿 배치 생성)」가 **기존 `hp:t` 텍스트를 치환**하는 Edit인 것과 달리 이쪽은 `hp:fieldBegin type="MAILMERGE"` 자체를 새로 만드는 Create다(그래서 별도 영역). DATE/PATH와 같은 단일 run 안 `ctrl(begin)`/`t`/`ctrl(end)` 배치에 더해 **끝에 빈 `hp:t`가 하나 더** 붙고(gold 2/2), `hp:fieldBegin`에 `metaTag` 속성이 **없다**(DATE/교정 부호/PATH gold는 셋 다 `metaTag=""`) — 필드 종류마다 실측이 갈리는 유일한 속성. `hp:parameters`(`cnt="5"`): `Fiexde=1`(**오타 철자가 실물·한컴 공식 문서 공통의 정본이라 정정 금지** — `trackchageConfig`·memo의 `NOMAL`과 같은 부류) · `Prop=8`(DATE/PATH와 같은 캐시 필드류 공유값) · `Command`/`FieldValue`가 둘 다 필드 이름 그대로 · `FieldType="USER_DEFINE"`. 캐시 텍스트 기본값 `{{이름}}`은 `hwpx.tools.mail_merge`가 인식하는 플레이스홀더 문법이라 이 필드로 저작한 템플릿이 그 배치 생성기에 그대로 물린다. gold의 두 필드는 `id`가 다른데 `fieldid`는 같은 값을 공유하나, 표본이 문서 하나뿐이라 채번 규칙을 역산할 근거가 없어 DATE/PATH와 같은 독립 난수를 쓴다. 실한컴 렌더 검증: `docs/openrate/report-v22.json` authored-mailmerge-field 스트라텀(단일/2필드/커스텀 캐시/**한글 필드명(경계)**/표 셀 안 필드, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-18 측정) | | 문서 끼워 넣기(문서 병합) | Create·Render-verified | `hwpx.tools.document_merge.append_document`/`.insert_document`(6.9 트레인㉝ v1, 6.10 트레인㊱ v2 — 편집기 표면 인벤토리 트레인㉙의 macOS 메뉴 전수 스캔이 찾은 신규 갭, "입력→문서 끼워 넣기…"). 다른 HWPX 문서의 본문을 연 문서에 끼워 넣는다 — 핵심은 헤더가 소유한 8개 공유 자원(charPr/paraPr/style/borderFill/tabPr/numbering·bullet/memoPr/fontfaces)을 원본에서 새 id로 복제해 재매핑하는 것이지 단순 복사가 아니다(그대로 복사하면 대상 문서의 기존 id를 조용히 가로챈다). 한컴 편집기 실측(팀장, 2026-08-07): "입력→문서 끼워 넣기…" 패널의 4개 고유 옵션(글자 모양 유지·스타일 유지·쪽 모양 유지·문단 모양 유지, 기본값 전부 해제=흡수)이 정책 축의 실측 근거. v2는 이 4축을 한컴과 같은 이름의 매개변수(`keep_character_shape`/`keep_style`/`keep_paragraph_shape`/`keep_page_shape`)로 노출한다 — 기본값은 v1의 기출하·실검증 동작과 동일(글자·스타일·문단 모양=유지, 쪽 모양=흡수), 반대 방향은 매핑 실측이 없어 typed 오류로 정직 거부(과설계 안 함). v2는 MEMO 병합도 지원한다(DEV-042): `hp:memogroup`은 절 레벨 형제라 원본의 모든 절에서 참조된 `hp:memo`를 찾아 복제, 새 메모 id 채번 후 body 문단 복제본과 같은 리스트로 묶어 기존 축1 재매핑(charPr/paraPr/style/memoShapeIDRef)에 그대로 태운다. 실측으로 찾은 결함 8건을 수정(v1 6건 + v2 2건): 스타일 자신의 기본 charPr/paraPr 누락, `HwpxOxmlDocument.char_properties` 캐시 미무효화, `linkListIDRef`/`linkListNextIDRef` 과잉거부(팀장 독립 검증 — 벤더드 코퍼스 5,891/5,891건이 전부 "0" 관용값인데 존재만으로 거부해 표가 든 모든 문서, 우리 자신의 `add_table` 산출물 포함, 병합이 불가능했다), 저장 경로 dirty-tracking 미설정(가장 심각 — `target_header.mark_dirty()`를 호출하지 않아 헤더에 새로 추가한 요소가 저장 시 조용히 누락되는데, 그림이 있는 병합만 우연히 `add_image`의 부수효과로 가려져 있었다), `hp:secPr` 제거가 통째 run을 지워 표·텍스트까지 함께 삭제(실코퍼스 표 문서에서 secPr·표·텍스트가 한 run에 공존, 수정 후 secPr만 개별 제거), 새 id 채번 순서가 프로세스마다 비결정적(`set` 순회를 `sorted()`로 교체), `check_id_integrity` 자신의 `memoShapeIDRef="0"` 오탐(fresh 문서의 secPr 스켈레톤 기본값을 memo_shapes 테이블이 채워지는 순간부터 댕글링으로 오판 — 병합과 무관한 사전 존재 결함, `id_integrity.py`의 `_ALLOWED_SENTINELS`로 수정), `hp:fieldEnd`의 `fieldid` 미재생성(원본 문서의 raw uuid4().hex 값이 복사된 내용에 그대로 남음 — v14 생성기 자체 결정성 검사로 발견, `begin_fieldid_map`으로 수정). `hp:connectLine`(스마트 연결선)·`chartIDRef`·실체인 `linkListIDRef`가 있으면 fail-closed 거부. 참조 무결성은 재발명하지 않고 기존 `hwpx.tools.id_integrity.check_id_integrity`로 검증(설계 문서: `docs/2026-08-08-document-merge-contract.md`). 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-docmerge 스트라텀(v1, 10건 중 4건이 표 포함 — 위 결함 셋이 전부 표 병합에서 터졌던 형태를 정면으로 겨냥), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정). v2(4축 매개변수+MEMO 병합)는 `docs/openrate/report-v14.json` authored-docmerge2 스트라텀(단일 메모/센티널 기본값/대상 기존 memogroup 합류/표 동반 4갈래, 4축 기본값 명시통과 호출), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정)로 검증 완료. 6.15: 클론 경로 감사(속성-클론 중첩 참조 앨리어싱 수리, `b81713e`)의 후속으로 v20(`cross-real-merge`, 우리 자신이 생성한 문서가 아니라 실한컴이 저장한 문서끼리 병합 — v13의 성긴 헤더로는 이 앨리어싱 결함이 애초에 안 보였을 것이라는 지적)을 실행: 실 문서 3종 쌍방향 병합·중간 삽입 3건·필드 보유 소스 2종·3단 연쇄 병합 12건, macOS Hancom GUI 오라클 12/12 render_checked·0 render_failed(2026-08-12 측정) — 앨리어싱 수리 후 재현이 사라졌음을 실 소스로 재확인. 세 버전 모두 같은 스트라텀 그룹(document_merge)이라 원장 라우팅은 여전히 없음(위 산문 근거만) | +| Markdown 가져오기(Markdown → 문서) | Create(experimental) | `hwpx.tools.markdown_import.from_markdown(markdown, style=)`으로 새 문서를, `doc.text.append_markdown(markdown)`으로 기존 문서 끝에 쓴다. `#`~`####` 제목(굵게·크기, 개요 번호 없음), 문단(줄 끝 공백 둘·`\`은 줄 바꿈), `-`/`*`/`+`·`1.`/`1)` 목록(들여쓰기로 수준, `doc.styles.apply_list_format`의 한/글 목록), `\|` 표(구분선 위 행은 굵게·음영 머리글, 짧은 행은 빈 칸으로 채움, 칸 안 `
`은 줄 바꿈), `**굵게**`·`*기울임*`·`` `코드` ``(글자만)·`[글](주소)`(글만)를 쓴다. 인용·HTML·코드 블록은 평문으로 쓰고 수평선은 버리며, 모르는 Markdown으로 오류를 내지 않는다. 새 문단은 앞 문단의 모양을 물려받지 않아(`HwpxDocument.new(inherit_style=False)`) 제목·목록 번호가 뒤 문단에 따라붙지 않는다. 모양은 장르 중립 `MarkdownStyle`(기본 `#` 16pt·`##` 12pt·`###` 11pt·`####` 10pt 굵게, 머리글 음영 `#D9D9D9`) — 장르 프리셋은 응용 계층 몫. 새 요소를 만들지 않고 기존 저작 primitive만 쓰므로 원장 라우팅은 없다. 저장·재열기와 편집기 열기 안전성 검사는 테스트로 확인했고, 실한컴 렌더 판정은 아직 없다 | | 덧말·글자 겹치기 | Parse·Create·Render-verified | `doc.shapes.add_dutmal`/`.add_composed_character`(6.9 트레인㉞). `hp:compose`(글자 겹치기/원문자·합자)는 읽기 모델이 이전 사이클(commit `6f88e2e`)부터 있었으나 저작 API·캐파빌리티 등재가 없었다 — 이번에 `add_composed_character`로 채우고 같은 `body.ComposedCharacter`/`_composed_character_to_xml` 경로를 저작에도 재사용(파싱·저작 한 코드 경로). `hp:dutmal`(덧말)은 이번에 처음 리버스엔지니어링 — 실코퍼스 표본 정확히 1건(`reader_writer__SimpleDutmal.hwpx`, hwpxlib 왕복 픽스처), macOS 편집기 메뉴 전수 스캔(트레인㉙)이 1급 메뉴 항목임은 확인했으나 그 이상의 빈도 근거는 없다 — 정직 고지. 그 1건이 스키마의 두 주장을 직접 반증한다(`option`은 스키마상 `fixed="4"`인데 실측 `"0"`, `szRatio`는 `xs:positiveInteger`인데 실측 `"0"` — DEV-041). 저작 API는 이 실측값을 기본값으로 채택하고 스키마 주장을 검증 규칙으로 강제하지 않는다. 둘 다 `hp:t`의 자식이 아니라 `hp:run` 직속(스키마 문서 순서와 실제 위치가 다름, 실코퍼스로 확인). 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-dutmal-compose 스트라텀(dutmal 기본값을 실측값 그대로 두고 검증 — 스키마 주장이 아니라 우리 채택을 오라클이 판정), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — `hp:compose`/`hp:dutmal`/`hp:mainText`/`hp:subText` 4개 요소 전부 원장에 `by-openrate-corpus` 반영(코드-쓰기 분류기가 `_qualified_tag(tag, "literal")`류 관용구를 못 잡던 갭도 이번에 수동 override로 같이 메움) | ## 6.0 표면 위치 @@ -111,6 +112,7 @@ | 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` | | 메일머지 표시 필드 | `doc.fields` | | 문서 끼워 넣기(문서 병합) | 모듈 — `hwpx.tools.document_merge` | +| Markdown 가져오기(Markdown → 문서) | 모듈 — `hwpx.tools.markdown_import` | | 덧말·글자 겹치기 | `doc.shapes` | 5.x 의 옛 이름은 6.x 동안 계속 답하되 `DeprecationWarning` 을 내고 7.0 에서 diff --git a/docs/usage.md b/docs/usage.md index 6fe18e4c..e938f282 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -995,6 +995,31 @@ options.set_page_margins(left=2000, right=2000, header=1500, footer=1500) document.headers[0].set_begin_numbering(page=1) ``` +## Markdown으로 문서 만들기 + +언어 모델이 가장 안정적으로 쓰는 형식이 Markdown이다. `from_markdown`은 흔히 쓰는 부분 집합 +(`#`~`####` 제목, 문단, `-`/`1.` 목록, `|` 표, `**굵게**`·`*기울임*`)으로 새 문서를 만들고, +모르는 Markdown은 평문으로 쓴다. 이미 있는 문서에는 `doc.text.append_markdown`으로 덧붙인다. + + +```python +from hwpx.tools.markdown_import import MarkdownStyle, from_markdown + +doc = from_markdown( + "# 운영 보고\n\n이번 학기 **핵심 성과**를 정리한다.\n\n" + "- 참여 학생 120명\n- 만족도 4.6점\n\n" + "| 구분 | 목표 | 실적 |\n|---|---|---|\n| 1학기 | 10 | 12 |\n", + style=MarkdownStyle(heading_sizes=(18, 13, 11, 10)), +) +doc.text.append_markdown("## 맺음\n\n다음 학기에도 이어 간다.") +doc.save_to_path("report.hwpx") +``` + +새 문단은 앞 문단의 모양을 물려받지 않아 제목·목록 번호가 뒤 문단에 따라붙지 않는다. 직접 +`add_paragraph`로 문서를 쌓을 때도 같은 동작을 원하면 `HwpxDocument.new(inherit_style=False)`로 +문서를 만든다. 공문·가정통신문 같은 장르별 모양은 이 라이브러리가 아니라 응용 계층이 +`MarkdownStyle`로 정한다. + ## 단락 삭제 문단 객체의 `remove()` 메서드를 호출하거나, 섹션/문서 수준에서 인덱스 또는 인스턴스로 단락을 삭제할 수 있습니다. diff --git a/scripts/editor_surface_inventory.py b/scripts/editor_surface_inventory.py index c04f1097..672efd4e 100644 --- a/scripts/editor_surface_inventory.py +++ b/scripts/editor_surface_inventory.py @@ -119,6 +119,8 @@ "mail-merge": "자동화", # 6.9 트레인㉝ — 트레인㉙의 macOS 메뉴 전수 스캔이 찾은 문서 병합 갭 등재. "document-merge": "자동화", + # Markdown 가져오기 — 문서 병합처럼 기존 문서/원고를 문서로 옮기는 자동화. + "markdown-import": "자동화", # 6.9 트레인㉞ — 덧말·글자 겹치기 저작. "dutmal-compose": "개체", # 6.13 트레인㊻ — 팀장 실한컴 GUI 프로브①③ gold 기반 날짜/시간·교정 diff --git a/src/hwpx/_document/ns/text.py b/src/hwpx/_document/ns/text.py index 739656d0..24ccb9a1 100644 --- a/src/hwpx/_document/ns/text.py +++ b/src/hwpx/_document/ns/text.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from ...objects.highlight import Highlight + from ...tools.markdown_import import MarkdownStyle from ...oxml import HwpxOxmlParagraph, HwpxOxmlRun __all__ = ["TextNamespace"] @@ -122,6 +123,22 @@ def html(self, **kwargs: object) -> str: return _persistence.export_html(self._doc, **kwargs) + # -- 가져오기 ---------------------------------------------------------- + + def append_markdown(self, markdown: str, *, style: "MarkdownStyle | None" = None) -> None: + """*markdown* 을 마지막 구역 끝에 문단·목록·표로 덧붙인다. + + ``hwpx.tools.markdown_import.from_markdown`` 과 같은 부분 집합(제목 ``#``~``####``, + 문단, ``-``/``1.`` 목록, ``|`` 표, ``**굵게**``/``*기울임*``)을 쓴다. + 새 문단은 앞 문단의 서식을 물려받지 않는다. 모르는 Markdown 은 평문으로 + 쓰고 오류를 내지 않는다. *style* 은 + :class:`hwpx.tools.markdown_import.MarkdownStyle`. + """ + + from ...tools.markdown_import import append_markdown + + append_markdown(self._doc, markdown, style=style) + # -- 순회·검색 --------------------------------------------------------- def runs(self) -> Iterator["HwpxOxmlRun"]: diff --git a/src/hwpx/capabilities.py b/src/hwpx/capabilities.py index b4aabcf7..ecc0f2c2 100644 --- a/src/hwpx/capabilities.py +++ b/src/hwpx/capabilities.py @@ -377,6 +377,23 @@ ), "authoring_methods": (), }, + { + "area": "markdown-import", + "namespace": None, + "matrix_row": "Markdown 가져오기(Markdown → 문서)", + # 흔한 Markdown 부분 집합(제목·문단·목록·표·굵게/기울임)을 기존 + # 저작 primitive(add_paragraph·add_run·add_table·apply_list_format)로 + # 쓴다. 새 요소를 만들지 않으므로 coverage_ledger 등록은 없다. + # 진입점은 모듈 함수(from_markdown)이고 기존 문서에는 + # doc.text.append_markdown 으로 덧붙인다. 6.x 루트 예산에 빈 칸이 + # 없어 루트 생성자가 아니라 모듈에 산다(mail-merge와 같은 이유로 + # namespace=None). + "entry_points": ( + "hwpx.tools.markdown_import:from_markdown", + "hwpx.tools.markdown_import:append_markdown", + ), + "authoring_methods": (), + }, { "area": "dutmal-compose", "namespace": "doc.shapes", diff --git a/src/hwpx/data/contract_docs/support-matrix.md b/src/hwpx/data/contract_docs/support-matrix.md index 186391c1..01496b52 100644 --- a/src/hwpx/data/contract_docs/support-matrix.md +++ b/src/hwpx/data/contract_docs/support-matrix.md @@ -61,6 +61,7 @@ | 메일머지(placeholder 템플릿 배치 생성) | Edit·Render-verified | `hwpx.tools.mail_merge.merge_template_rows`(6.8 트레인㉛ 등재, 코드는 이전 사이클부터 존재 — 편집기 표면 인벤토리 트레인㉙·계층 판정 트레인㉚이 찾을 때까지 캐파빌리티·매트릭스 어디에도 등재가 없었다). `{{field}}`/`${field}`/`<>` 세 플레이스홀더 문법을 지원하는 템플릿 1개 + 행 데이터(csv/json/시퀀스)로 문서 N개를 배치 생성 — 기존 `hp:t` 텍스트를 치환하는 것이라 새 요소를 만들지 않는다(Create가 아니라 Edit). `strict`/`fit_policy`/`value_sanitizer` 등 운영 옵션 다수. 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-mailmerge 스트라텀(세 플레이스홀더 문법 각각 겨냥하는 전용 템플릿으로 회전), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — 이 영역의 첫 독립 실한컴 판정 | | 메일머지 표시 필드 | Create(experimental) | `HwpxOxmlParagraph.add_mail_merge_field(name, cached_text=)`(`oxml/field_marks.py`, P4). 팀장 실한컴 GUI 프로브 gold(`tests/fixtures/gui_probes/mailmerge_display_fields.hwpx`, "도구 > 메일 머지 > 메일 머지 표시 달기"로 필드 2개 삽입) 역설계 — 위 「메일머지(placeholder 템플릿 배치 생성)」가 **기존 `hp:t` 텍스트를 치환**하는 Edit인 것과 달리 이쪽은 `hp:fieldBegin type="MAILMERGE"` 자체를 새로 만드는 Create다(그래서 별도 영역). DATE/PATH와 같은 단일 run 안 `ctrl(begin)`/`t`/`ctrl(end)` 배치에 더해 **끝에 빈 `hp:t`가 하나 더** 붙고(gold 2/2), `hp:fieldBegin`에 `metaTag` 속성이 **없다**(DATE/교정 부호/PATH gold는 셋 다 `metaTag=""`) — 필드 종류마다 실측이 갈리는 유일한 속성. `hp:parameters`(`cnt="5"`): `Fiexde=1`(**오타 철자가 실물·한컴 공식 문서 공통의 정본이라 정정 금지** — `trackchageConfig`·memo의 `NOMAL`과 같은 부류) · `Prop=8`(DATE/PATH와 같은 캐시 필드류 공유값) · `Command`/`FieldValue`가 둘 다 필드 이름 그대로 · `FieldType="USER_DEFINE"`. 캐시 텍스트 기본값 `{{이름}}`은 `hwpx.tools.mail_merge`가 인식하는 플레이스홀더 문법이라 이 필드로 저작한 템플릿이 그 배치 생성기에 그대로 물린다. gold의 두 필드는 `id`가 다른데 `fieldid`는 같은 값을 공유하나, 표본이 문서 하나뿐이라 채번 규칙을 역산할 근거가 없어 DATE/PATH와 같은 독립 난수를 쓴다. 실한컴 렌더 검증: `docs/openrate/report-v22.json` authored-mailmerge-field 스트라텀(단일/2필드/커스텀 캐시/**한글 필드명(경계)**/표 셀 안 필드, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-18 측정) | | 문서 끼워 넣기(문서 병합) | Create·Render-verified | `hwpx.tools.document_merge.append_document`/`.insert_document`(6.9 트레인㉝ v1, 6.10 트레인㊱ v2 — 편집기 표면 인벤토리 트레인㉙의 macOS 메뉴 전수 스캔이 찾은 신규 갭, "입력→문서 끼워 넣기…"). 다른 HWPX 문서의 본문을 연 문서에 끼워 넣는다 — 핵심은 헤더가 소유한 8개 공유 자원(charPr/paraPr/style/borderFill/tabPr/numbering·bullet/memoPr/fontfaces)을 원본에서 새 id로 복제해 재매핑하는 것이지 단순 복사가 아니다(그대로 복사하면 대상 문서의 기존 id를 조용히 가로챈다). 한컴 편집기 실측(팀장, 2026-08-07): "입력→문서 끼워 넣기…" 패널의 4개 고유 옵션(글자 모양 유지·스타일 유지·쪽 모양 유지·문단 모양 유지, 기본값 전부 해제=흡수)이 정책 축의 실측 근거. v2는 이 4축을 한컴과 같은 이름의 매개변수(`keep_character_shape`/`keep_style`/`keep_paragraph_shape`/`keep_page_shape`)로 노출한다 — 기본값은 v1의 기출하·실검증 동작과 동일(글자·스타일·문단 모양=유지, 쪽 모양=흡수), 반대 방향은 매핑 실측이 없어 typed 오류로 정직 거부(과설계 안 함). v2는 MEMO 병합도 지원한다(DEV-042): `hp:memogroup`은 절 레벨 형제라 원본의 모든 절에서 참조된 `hp:memo`를 찾아 복제, 새 메모 id 채번 후 body 문단 복제본과 같은 리스트로 묶어 기존 축1 재매핑(charPr/paraPr/style/memoShapeIDRef)에 그대로 태운다. 실측으로 찾은 결함 8건을 수정(v1 6건 + v2 2건): 스타일 자신의 기본 charPr/paraPr 누락, `HwpxOxmlDocument.char_properties` 캐시 미무효화, `linkListIDRef`/`linkListNextIDRef` 과잉거부(팀장 독립 검증 — 벤더드 코퍼스 5,891/5,891건이 전부 "0" 관용값인데 존재만으로 거부해 표가 든 모든 문서, 우리 자신의 `add_table` 산출물 포함, 병합이 불가능했다), 저장 경로 dirty-tracking 미설정(가장 심각 — `target_header.mark_dirty()`를 호출하지 않아 헤더에 새로 추가한 요소가 저장 시 조용히 누락되는데, 그림이 있는 병합만 우연히 `add_image`의 부수효과로 가려져 있었다), `hp:secPr` 제거가 통째 run을 지워 표·텍스트까지 함께 삭제(실코퍼스 표 문서에서 secPr·표·텍스트가 한 run에 공존, 수정 후 secPr만 개별 제거), 새 id 채번 순서가 프로세스마다 비결정적(`set` 순회를 `sorted()`로 교체), `check_id_integrity` 자신의 `memoShapeIDRef="0"` 오탐(fresh 문서의 secPr 스켈레톤 기본값을 memo_shapes 테이블이 채워지는 순간부터 댕글링으로 오판 — 병합과 무관한 사전 존재 결함, `id_integrity.py`의 `_ALLOWED_SENTINELS`로 수정), `hp:fieldEnd`의 `fieldid` 미재생성(원본 문서의 raw uuid4().hex 값이 복사된 내용에 그대로 남음 — v14 생성기 자체 결정성 검사로 발견, `begin_fieldid_map`으로 수정). `hp:connectLine`(스마트 연결선)·`chartIDRef`·실체인 `linkListIDRef`가 있으면 fail-closed 거부. 참조 무결성은 재발명하지 않고 기존 `hwpx.tools.id_integrity.check_id_integrity`로 검증(설계 문서: `docs/2026-08-08-document-merge-contract.md`). 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-docmerge 스트라텀(v1, 10건 중 4건이 표 포함 — 위 결함 셋이 전부 표 병합에서 터졌던 형태를 정면으로 겨냥), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정). v2(4축 매개변수+MEMO 병합)는 `docs/openrate/report-v14.json` authored-docmerge2 스트라텀(단일 메모/센티널 기본값/대상 기존 memogroup 합류/표 동반 4갈래, 4축 기본값 명시통과 호출), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정)로 검증 완료. 6.15: 클론 경로 감사(속성-클론 중첩 참조 앨리어싱 수리, `b81713e`)의 후속으로 v20(`cross-real-merge`, 우리 자신이 생성한 문서가 아니라 실한컴이 저장한 문서끼리 병합 — v13의 성긴 헤더로는 이 앨리어싱 결함이 애초에 안 보였을 것이라는 지적)을 실행: 실 문서 3종 쌍방향 병합·중간 삽입 3건·필드 보유 소스 2종·3단 연쇄 병합 12건, macOS Hancom GUI 오라클 12/12 render_checked·0 render_failed(2026-08-12 측정) — 앨리어싱 수리 후 재현이 사라졌음을 실 소스로 재확인. 세 버전 모두 같은 스트라텀 그룹(document_merge)이라 원장 라우팅은 여전히 없음(위 산문 근거만) | +| Markdown 가져오기(Markdown → 문서) | Create(experimental) | `hwpx.tools.markdown_import.from_markdown(markdown, style=)`으로 새 문서를, `doc.text.append_markdown(markdown)`으로 기존 문서 끝에 쓴다. `#`~`####` 제목(굵게·크기, 개요 번호 없음), 문단(줄 끝 공백 둘·`\`은 줄 바꿈), `-`/`*`/`+`·`1.`/`1)` 목록(들여쓰기로 수준, `doc.styles.apply_list_format`의 한/글 목록), `\|` 표(구분선 위 행은 굵게·음영 머리글, 짧은 행은 빈 칸으로 채움, 칸 안 `
`은 줄 바꿈), `**굵게**`·`*기울임*`·`` `코드` ``(글자만)·`[글](주소)`(글만)를 쓴다. 인용·HTML·코드 블록은 평문으로 쓰고 수평선은 버리며, 모르는 Markdown으로 오류를 내지 않는다. 새 문단은 앞 문단의 모양을 물려받지 않아(`HwpxDocument.new(inherit_style=False)`) 제목·목록 번호가 뒤 문단에 따라붙지 않는다. 모양은 장르 중립 `MarkdownStyle`(기본 `#` 16pt·`##` 12pt·`###` 11pt·`####` 10pt 굵게, 머리글 음영 `#D9D9D9`) — 장르 프리셋은 응용 계층 몫. 새 요소를 만들지 않고 기존 저작 primitive만 쓰므로 원장 라우팅은 없다. 저장·재열기와 편집기 열기 안전성 검사는 테스트로 확인했고, 실한컴 렌더 판정은 아직 없다 | | 덧말·글자 겹치기 | Parse·Create·Render-verified | `doc.shapes.add_dutmal`/`.add_composed_character`(6.9 트레인㉞). `hp:compose`(글자 겹치기/원문자·합자)는 읽기 모델이 이전 사이클(commit `6f88e2e`)부터 있었으나 저작 API·캐파빌리티 등재가 없었다 — 이번에 `add_composed_character`로 채우고 같은 `body.ComposedCharacter`/`_composed_character_to_xml` 경로를 저작에도 재사용(파싱·저작 한 코드 경로). `hp:dutmal`(덧말)은 이번에 처음 리버스엔지니어링 — 실코퍼스 표본 정확히 1건(`reader_writer__SimpleDutmal.hwpx`, hwpxlib 왕복 픽스처), macOS 편집기 메뉴 전수 스캔(트레인㉙)이 1급 메뉴 항목임은 확인했으나 그 이상의 빈도 근거는 없다 — 정직 고지. 그 1건이 스키마의 두 주장을 직접 반증한다(`option`은 스키마상 `fixed="4"`인데 실측 `"0"`, `szRatio`는 `xs:positiveInteger`인데 실측 `"0"` — DEV-041). 저작 API는 이 실측값을 기본값으로 채택하고 스키마 주장을 검증 규칙으로 강제하지 않는다. 둘 다 `hp:t`의 자식이 아니라 `hp:run` 직속(스키마 문서 순서와 실제 위치가 다름, 실코퍼스로 확인). 실한컴 렌더 검증: `docs/openrate/report-v13.json` authored-dutmal-compose 스트라텀(dutmal 기본값을 실측값 그대로 두고 검증 — 스키마 주장이 아니라 우리 채택을 오라클이 판정), macOS Hancom GUI 오라클 10/10 render_checked·0 render_failed(2026-08-08 측정) — `hp:compose`/`hp:dutmal`/`hp:mainText`/`hp:subText` 4개 요소 전부 원장에 `by-openrate-corpus` 반영(코드-쓰기 분류기가 `_qualified_tag(tag, "literal")`류 관용구를 못 잡던 갭도 이번에 수동 override로 같이 메움) | ## 6.0 표면 위치 @@ -111,6 +112,7 @@ | 메일머지(placeholder 템플릿 배치 생성) | 모듈 — `hwpx.tools.mail_merge` | | 메일머지 표시 필드 | `doc.fields` | | 문서 끼워 넣기(문서 병합) | 모듈 — `hwpx.tools.document_merge` | +| Markdown 가져오기(Markdown → 문서) | 모듈 — `hwpx.tools.markdown_import` | | 덧말·글자 겹치기 | `doc.shapes` | 5.x 의 옛 이름은 6.x 동안 계속 답하되 `DeprecationWarning` 을 내고 7.0 에서 diff --git a/src/hwpx/document.py b/src/hwpx/document.py index 3f7084a6..4db42a4a 100644 --- a/src/hwpx/document.py +++ b/src/hwpx/document.py @@ -99,6 +99,8 @@ def __init__( # is resolved lazily and only when a policy actually renders, so normal # (transparent) saves never probe Hancom. self._save_pipeline = SavePipeline() + # What add_paragraph(inherit_style=None) means for this document (new(inherit_style=...)). + self._inherit_style_default = True def __repr__(self) -> str: """Return a compact and safe summary of the document state.""" @@ -183,15 +185,24 @@ def conversion_report(self) -> Hwp5ConversionReport | None: return self._hwp5_report @classmethod - def new(cls) -> "HwpxDocument": + def new(cls, *, inherit_style: bool = True) -> "HwpxDocument": """Return a new blank document based on the default skeleton template. Its only paragraph is empty and holds the section settings, and :meth:`add_paragraph` appends after it, so the document starts with an empty line. Write the first line with ``document.paragraphs[0].text = ...``. + + ``inherit_style=False`` makes a clean document: :meth:`add_paragraph` + then starts every paragraph from the base shapes (바탕글) instead of + copying the last paragraph's, unless a call passes ``inherit_style`` + 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. """ - return cls.open(blank_document_bytes()) + document = cls.open(blank_document_bytes()) + document._inherit_style_default = bool(inherit_style) + return document @classmethod def from_package(cls, package: HwpxPackage) -> "HwpxDocument": @@ -411,12 +422,14 @@ def add_paragraph( char_pr_id_ref: str | int | None = None, run_attributes: dict[str, str] | None = None, include_run: bool = True, - inherit_style: bool = True, + inherit_style: bool | None = None, **extra_attrs: str, ) -> HwpxOxmlParagraph: """Append a paragraph to the document and return it. - When *inherit_style* is ``True`` (the default) and no explicit + *inherit_style* ``None`` (the default) follows the document: ``True`` + unless it was made with ``HwpxDocument.new(inherit_style=False)``. + When it is ``True`` and no explicit style references are given, the new paragraph inherits ``paraPrIDRef``, ``styleIDRef`` and ``charPrIDRef`` from the last paragraph in the target section so that consecutive @@ -456,7 +469,7 @@ def add_paragraph( char_pr_id_ref=char_pr_id_ref, run_attributes=run_attributes, include_run=include_run, - inherit_style=inherit_style, + inherit_style=self._inherit_style_default if inherit_style is None else inherit_style, **cast(Any, extra_attrs), ) diff --git a/src/hwpx/tools/markdown_import.py b/src/hwpx/tools/markdown_import.py new file mode 100644 index 00000000..843187d0 --- /dev/null +++ b/src/hwpx/tools/markdown_import.py @@ -0,0 +1,461 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Markdown → HWPX: write a small, common Markdown subset into a document. + +``markdown_export`` goes one way (HWPX → Markdown); this module goes the other. +It is a genre-neutral writer: it knows headings, paragraphs, lists, pipe tables +and bold/italic, and nothing about reports, official letters or notices. Genre +presets (공문·가정통신문 …) belong to the application layer, which can pass its +own :class:`MarkdownStyle`. + +Supported subset: + +- ``#`` ~ ``####`` headings (``#####``/``######`` are drawn like ``####``) +- paragraphs (consecutive lines join with a space; two trailing spaces or a + trailing ``\\`` is a line break) +- ``-``/``*``/``+`` bullets and ``1.``/``1)`` numbered lists, nested by + indentation, as real Hancom lists (:meth:`doc.styles.apply_list_format`) +- pipe tables; a row followed by a ``|---|`` separator is the header row + (bold, shaded); ``
`` in a cell is a line break +- inline ``**bold**``/``__bold__``, ``*italic*``, ``***both***``, + ``` `code` ``` (kept as text), ``[text](url)`` (the text), ``\\`` escapes +- fenced code blocks become one paragraph per block, lines kept + +Anything else — block quotes, rules, HTML — is written as plain text or dropped +(rules). Unknown Markdown never raises. + +New paragraphs never inherit the previous paragraph's style, so a heading or a +list item does not hand its outline or list numbering to the paragraph after it. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..document import HwpxDocument + from ..oxml import HwpxOxmlParagraph + +__all__ = ["MarkdownStyle", "append_markdown", "from_markdown"] + + +@dataclass(frozen=True) +class MarkdownStyle: + """How :func:`append_markdown` draws each Markdown construct. + + The default is a neutral document look: ``#`` 16pt bold, ``##`` 12pt bold, + ``###`` 11pt bold, ``####`` 10pt bold, body text at the document's base + size, and a light grey header row in tables. Sizes are points; ``None`` + keeps the document's base character shape. + """ + + heading_sizes: tuple[float, float, float, float] = (16.0, 12.0, 11.0, 10.0) + heading_bold: bool = True + heading_space_before_pt: float | None = 6.0 + heading_space_after_pt: float | None = 3.0 + body_size: float | None = None + table_header_shading: str | None = "#D9D9D9" + table_header_bold: bool = True + bullet_char: str | None = None + + def __post_init__(self) -> None: + if len(self.heading_sizes) != 4 or any(size <= 0 for size in self.heading_sizes): + raise ValueError("heading_sizes must be four positive point sizes (# to ####)") + if self.body_size is not None and self.body_size <= 0: + raise ValueError("body_size must be positive") + + +# -------------------------------------------------------------------------- +# Inline: text → [(text, bold, italic)] + +_ESCAPABLE = set("\\`*_{}[]()#+-.!|>~") +_LINK = re.compile(r"\[([^\]]*)\]\(([^)\s]*)(?:\s+\"[^\"]*\")?\)") +_BR = re.compile(r"", re.IGNORECASE) + +Segment = tuple[str, bool, bool] + + +def parse_inline(text: str) -> list[Segment]: + """Split *text* into ``(text, bold, italic)`` segments. + + A marker opens only when its closer appears later; an unmatched marker is + kept as text. + """ + + segments: list[Segment] = [] + buffer: list[str] = [] + bold = italic = False + + def flush() -> None: + if buffer: + chunk = "".join(buffer) + if segments and segments[-1][1:] == (bold, italic): + segments[-1] = (segments[-1][0] + chunk, bold, italic) + else: + segments.append((chunk, bold, italic)) + buffer.clear() + + i, n = 0, len(text) + while i < n: + char = text[i] + if char == "\\" and i + 1 < n and text[i + 1] in _ESCAPABLE: + buffer.append(text[i + 1]) + i += 2 + continue + if char == "`": + end = text.find("`", i + 1) + if end > i: + buffer.append(text[i + 1 : end]) + i = end + 1 + continue + if char == "[": + match = _LINK.match(text, i) + if match: + buffer.append(match.group(1)) + i = match.end() + continue + marker = text[i : i + 2] + if marker in ("**", "__"): + opens = not bold and i + 2 < n and not text[i + 2].isspace() and text.find(marker, i + 2) > i + 2 + if bold or opens: + flush() + bold = not bold + i += 2 + continue + if char == "*": + opens = not italic and i + 1 < n and not text[i + 1].isspace() and text.find("*", i + 1) > i + 1 + if italic or opens: + flush() + italic = not italic + i += 1 + continue + buffer.append(char) + i += 1 + flush() + return segments + + +# -------------------------------------------------------------------------- +# Blocks + + +@dataclass +class _Heading: + level: int + text: str + + +@dataclass +class _Paragraph: + text: str + + +@dataclass +class _ListItem: + kind: str # "bullet" | "number" + level: int # 1-based + text: str + number: int | None = None + + +@dataclass +class _List: + items: list[_ListItem] + + +@dataclass +class _Table: + rows: list[list[str]] + header: bool + + +_HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$") +_LIST_ITEM = re.compile(r"^(\s*)([-*+]|(\d{1,9})[.)])\s+(.*)$") +_RULE = re.compile(r"^\s{0,3}([-*_])(\s*\1){2,}\s*$") +_FENCE = re.compile(r"^\s{0,3}(```|~~~)") +_TABLE_SEPARATOR = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$") +_QUOTE = re.compile(r"^\s{0,3}>\s?") + + +def _indent_width(prefix: str) -> int: + return sum(4 if char == "\t" else 1 for char in prefix) + + +def _split_row(line: str) -> list[str]: + body = line.strip() + body = body.removeprefix("|") + if body.endswith("|") and not body.endswith("\\|"): + body = body[:-1] + cells: list[str] = [] + current: list[str] = [] + i = 0 + while i < len(body): + if body[i] == "\\" and i + 1 < len(body) and body[i + 1] == "|": + current.append("|") + i += 2 + continue + if body[i] == "|": + cells.append("".join(current).strip()) + current = [] + else: + current.append(body[i]) + i += 1 + cells.append("".join(current).strip()) + return cells + + +def _join_lines(lines: list[str]) -> str: + text = "" + for index, line in enumerate(lines): + hard_break = line.endswith((" ", "\\")) + piece = line.rstrip() + piece = piece.removesuffix("\\") + piece = piece.strip() + text += piece + if index < len(lines) - 1: + text += "\n" if hard_break else " " + return text + + +def parse_blocks(markdown: str) -> Iterator[object]: + """Yield the blocks of *markdown* in order.""" + + lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n") + paragraph: list[str] = [] + i = 0 + + def end_paragraph() -> Iterator[object]: + if paragraph: + yield _Paragraph(_join_lines(paragraph)) + paragraph.clear() + + while i < len(lines): + line = lines[i] + if not line.strip(): + yield from end_paragraph() + i += 1 + continue + fence = _FENCE.match(line) + if fence: + yield from end_paragraph() + body: list[str] = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith(fence.group(1)): + body.append(lines[i]) + i += 1 + i += 1 # the closing fence (or the end) + yield _Paragraph("\n".join(body)) + continue + heading = _HEADING.match(line) + if heading: + yield from end_paragraph() + yield _Heading(min(len(heading.group(1)), 4), heading.group(2)) + i += 1 + continue + if _RULE.match(line): # a rule wins over a "- - -" list item, as in CommonMark + yield from end_paragraph() + i += 1 + continue + if line.lstrip().startswith("|"): + yield from end_paragraph() + rows: list[list[str]] = [] + header = False + while i < len(lines) and lines[i].lstrip().startswith("|"): + if _TABLE_SEPARATOR.match(lines[i]): + header = header or len(rows) == 1 + else: + rows.append(_split_row(lines[i])) + i += 1 + yield _Table(rows, header) + continue + item = _LIST_ITEM.match(line) + if item: + # A list item interrupts a paragraph: models often write "다음과 같다:" then "- …". + yield from end_paragraph() + items: list[_ListItem] = [] + indents: list[int] = [] + while i < len(lines): + current = lines[i] + match = _LIST_ITEM.match(current) + if match: + width = _indent_width(match.group(1)) + while indents and width < indents[-1]: + indents.pop() + if not indents or width > indents[-1]: + indents.append(width) + number = match.group(3) + items.append( + _ListItem( + kind="number" if number is not None else "bullet", + level=min(len(indents), 10), + text=match.group(4).strip(), + number=int(number) if number is not None else None, + ) + ) + i += 1 + continue + if current.strip() and current[:1].isspace() and items: + items[-1].text += " " + current.strip() # a continuation line + i += 1 + continue + break + yield _List(items) + continue + quote = _QUOTE.match(line) + paragraph.append(line[quote.end() :] if quote else line) + i += 1 + yield from end_paragraph() + + +# -------------------------------------------------------------------------- +# Writing + + +class _Writer: + def __init__(self, document: HwpxDocument, style: MarkdownStyle, *, reuse_first: bool) -> None: + self.doc = document + self.style = style + self._char_ids: dict[tuple[bool, bool, float | None], str] = {} + self._reuse = document.paragraphs[0] if reuse_first else None + + def char_id(self, bold: bool, italic: bool, size: float | None) -> str: + key = (bold, italic, size) + if key not in self._char_ids: + self._char_ids[key] = self.doc.styles.ensure_run(bold=bold, italic=italic, size=size) + return self._char_ids[key] + + def write_runs( + self, paragraph: HwpxOxmlParagraph, text: str, *, bold: bool = False, size: float | None = None + ) -> None: + segments = parse_inline(_BR.sub("\n", text)) or [("", False, False)] + for chunk, seg_bold, seg_italic in segments: + paragraph.add_run(chunk, char_pr_id_ref=self.char_id(bold or seg_bold, seg_italic, size)) + + def stop_reusing(self) -> None: + self._reuse = None + + def new_paragraph(self) -> HwpxOxmlParagraph: + if self._reuse is not None: + paragraph, self._reuse = self._reuse, None + return paragraph + return self.doc.add_paragraph("", include_run=False, inherit_style=False) + + def heading(self, block: _Heading) -> None: + paragraph = self.new_paragraph() + self.write_runs( + paragraph, + block.text, + bold=self.style.heading_bold, + size=self.style.heading_sizes[block.level - 1], + ) + self.doc.styles.apply_paragraph_format( + paragraphs=[paragraph], + spacing_before_pt=self.style.heading_space_before_pt, + spacing_after_pt=self.style.heading_space_after_pt, + keep_with_next=True, + ) + + def paragraph(self, block: _Paragraph) -> None: + self.write_runs(self.new_paragraph(), block.text, size=self.style.body_size) + + def list(self, block: _List) -> None: + groups: dict[tuple[str, int], list[int]] = {} + starts: dict[tuple[str, int], int | None] = {} + for item in block.items: + paragraph = self.new_paragraph() + self.write_runs(paragraph, item.text, size=self.style.body_size) + index = self._index_of(paragraph) + key = (item.kind, item.level) + if key not in groups: + groups[key] = [] + starts[key] = item.number + groups[key].append(index) + numbered = False + for (kind, level), indexes in sorted(groups.items(), key=lambda entry: entry[1][0]): + if kind == "number": + start = starts[(kind, level)] + self.doc.styles.apply_list_format( + paragraph_indexes=indexes, + kind="number", + level=level, + start=None if numbered or start in (None, 1) else start, + continue_list=numbered, + ) + numbered = True + else: + self.doc.styles.apply_list_format( + paragraph_indexes=indexes, kind="bullet", level=level, bullet_char=self.style.bullet_char + ) + + def _index_of(self, paragraph: HwpxOxmlParagraph) -> int: + element = paragraph.element + for index, candidate in enumerate(self.doc.paragraphs): + if candidate.element is element: + return index + raise AssertionError("paragraph is not in the body") # pragma: no cover + + def table(self, block: _Table) -> None: + if not block.rows: + return + cols = max(len(row) for row in block.rows) + table = self.doc.add_table(len(block.rows), cols) + for row_index, row in enumerate(block.rows): + is_header = block.header and row_index == 0 + for col_index in range(cols): + text = row[col_index] if col_index < len(row) else "" + cell = table.cell(row_index, col_index) + self.write_runs( + cell.paragraphs[0], + text, + bold=is_header and self.style.table_header_bold, + size=self.style.body_size, + ) + if is_header and self.style.table_header_shading: + table.set_cell_shading(row_index, col_index, self.style.table_header_shading) + + +def append_markdown( + document: HwpxDocument, + markdown: str, + *, + style: MarkdownStyle | None = None, + reuse_first_paragraph: bool = False, +) -> None: + """Append *markdown* to the end of *document*'s last section. + + ``reuse_first_paragraph=True`` writes the first heading, paragraph or list + item into the document's first paragraph instead of a new one — what + :func:`from_markdown` does with a blank document's empty first line. A table never takes that paragraph (it holds the section settings). + """ + + writer = _Writer(document, style or MarkdownStyle(), reuse_first=reuse_first_paragraph) + for block in parse_blocks(markdown): + if isinstance(block, _Table): + writer.table(block) + writer.stop_reusing() + elif isinstance(block, _Heading): + writer.heading(block) + elif isinstance(block, _List): + writer.list(block) + elif isinstance(block, _Paragraph): + writer.paragraph(block) + + +def from_markdown(markdown: str, *, style: MarkdownStyle | None = None) -> HwpxDocument: + """Return a new clean document holding *markdown*. + + The document is made with ``HwpxDocument.new(inherit_style=False)`` and the + first heading, paragraph or list item takes its empty first line. To add + Markdown to a document you already have, use ``doc.text.append_markdown``. + + The 6.x root of :class:`HwpxDocument` has no free slot for another + constructor, so this lives here rather than as ``HwpxDocument.from_markdown``. + """ + + from ..document import HwpxDocument + + document = HwpxDocument.new(inherit_style=False) + append_markdown(document, markdown, style=style, reuse_first_paragraph=True) + return document diff --git a/tests/data/document_facade_surface.json b/tests/data/document_facade_surface.json index 3942354b..d8f76ebc 100644 --- a/tests/data/document_facade_surface.json +++ b/tests/data/document_facade_surface.json @@ -21,7 +21,7 @@ }, "add_paragraph": { "kind": "method", - "signature": "(self, text: 'str' = '', *, section: 'HwpxOxmlSection | None' = None, section_index: 'int | None' = None, para_pr_id_ref: 'str | int | None' = None, style: 'int | str | Style | None' = None, style_id_ref: 'str | int | None' = None, char_pr_id_ref: 'str | int | None' = None, run_attributes: 'dict[str, str] | None' = None, include_run: 'bool' = True, inherit_style: 'bool' = True, **extra_attrs: 'str') -> 'HwpxOxmlParagraph'" + "signature": "(self, text: 'str' = '', *, section: 'HwpxOxmlSection | None' = None, section_index: 'int | None' = None, para_pr_id_ref: 'str | int | None' = None, style: 'int | str | Style | None' = None, style_id_ref: 'str | int | None' = None, char_pr_id_ref: 'str | int | None' = None, run_attributes: 'dict[str, str] | None' = None, include_run: 'bool' = True, inherit_style: 'bool | None' = None, **extra_attrs: 'str') -> 'HwpxOxmlParagraph'" }, "add_picture": { "kind": "method", @@ -54,7 +54,7 @@ }, "new": { "kind": "classmethod", - "signature": "(cls) -> \"'HwpxDocument'\"" + "signature": "(cls, *, inherit_style: 'bool' = True) -> \"'HwpxDocument'\"" }, "notes": { "kind": "property" diff --git a/tests/test_documentation_code_fences.py b/tests/test_documentation_code_fences.py index 26d2eafc..b2dc721a 100644 --- a/tests/test_documentation_code_fences.py +++ b/tests/test_documentation_code_fences.py @@ -40,9 +40,9 @@ r"^```(?:python|py)(?:[ \t]+[^\n]*)?\n(.*?)^```[ \t]*$", re.MULTILINE | re.DOTALL, ) -EXPECTED_FENCE_COUNT = 116 +EXPECTED_FENCE_COUNT = 117 EXPECTED_FENCE_SHA256 = ( - "5db7dce9e8f65361d4cc9a4911e5b9148dc5e9e75cabec7bbcdce4b1861e8a80" + "a164785f35a43a2f224d2b1b906b71f113de67c0c7e05dea6e3c3a4fa3cb989d" ) ALLOWED_IMPORT_ROOTS = frozenset(sys.stdlib_module_names) | {"hwpx"} LEDGER = Path("docs/python-example-ledger.json") diff --git a/tests/test_markdown_import.py b/tests/test_markdown_import.py new file mode 100644 index 00000000..d804374b --- /dev/null +++ b/tests/test_markdown_import.py @@ -0,0 +1,208 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Markdown → HWPX (``hwpx.tools.markdown_import``) and the clean new-document mode.""" + +from __future__ import annotations + +import pytest + +from hwpx.document import HwpxDocument +from hwpx.tools.markdown_import import MarkdownStyle, from_markdown, parse_inline +from hwpx.tools.package_validator import validate_editor_open_safety + + +class _Look: + def __init__(self, run_style) -> None: + self.bold = "bold" in run_style.child_attributes + self.italic = "italic" in run_style.child_attributes + self.height = int(run_style.attributes["height"]) + + +def _look(doc, run) -> _Look: + return _Look(doc.styles.char_property(run.char_pr_id_ref)) + + +def _char(doc, paragraph, text) -> _Look: + for run in paragraph.runs: + if run.text == text: + return _look(doc, run) + raise AssertionError(f"no run {text!r}") + + +def _has_heading(doc, paragraph) -> str | None: + para_pr = doc.styles.paragraph_property(paragraph.para_pr_id_ref) + heading = getattr(para_pr, "heading", None) + return getattr(heading, "type", None) if heading is not None else None + + +# -- inline --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text,expected", + [ + ("plain", [("plain", False, False)]), + ("a **b** c", [("a ", False, False), ("b", True, False), (" c", False, False)]), + ("__b__", [("b", True, False)]), + ("*i* x", [("i", False, True), (" x", False, False)]), + ("***bi***", [("bi", True, True)]), + ("2 * 3 = 6", [("2 * 3 = 6", False, False)]), + ("**unclosed", [("**unclosed", False, False)]), + (r"\*literal\*", [("*literal*", False, False)]), + ("see [docs](https://example.com) now", [("see docs now", False, False)]), + ("`a*b*c`", [("a*b*c", False, False)]), + ], +) +def test_parse_inline(text, expected) -> None: + assert parse_inline(text) == expected + + +# -- blocks --------------------------------------------------------------- + +SAMPLE = """# 운영 보고 + +이 보고서는 **핵심 성과**와 *과제*를 정리한다. +둘째 줄은 이어진다. + +## 추진 배경 + +다음과 같다: +- 첫째 항목 +- 둘째 항목 + - 하위 항목 +1. 번호 하나 +2. 번호 둘 + +| 구분 | 목표 | 실적 | +|---|:---:|---:| +| 1학기 | 10 | **12** | +| 2학기 | 20 | + +### 맺음 + +끝 문단. +""" + + +def test_from_markdown_writes_each_construct() -> None: + doc = from_markdown(SAMPLE) + texts = [p.text for p in doc.paragraphs] + assert texts[0] == "운영 보고" # the blank document's first line is reused + assert "이 보고서는 핵심 성과와 과제를 정리한다. 둘째 줄은 이어진다." in texts + assert texts.index("다음과 같다:") + 1 == texts.index("첫째 항목") # a list interrupts a paragraph + + title = doc.paragraphs[0] + assert _char(doc, title, "운영 보고").bold + sub = doc.paragraphs[texts.index("추진 배경")] + assert _char(doc, sub, "추진 배경").bold + + body = doc.paragraphs[texts.index("이 보고서는 핵심 성과와 과제를 정리한다. 둘째 줄은 이어진다.")] + assert _char(doc, body, "핵심 성과").bold + assert _char(doc, body, "과제").italic + assert not _char(doc, body, "이 보고서는 ").bold + + for name in ("첫째 항목", "둘째 항목", "하위 항목"): + assert _has_heading(doc, doc.paragraphs[texts.index(name)]) == "BULLET" + for name in ("번호 하나", "번호 둘"): + assert _has_heading(doc, doc.paragraphs[texts.index(name)]) == "NUMBER" + first = doc.paragraphs[texts.index("첫째 항목")] + nested = doc.paragraphs[texts.index("하위 항목")] + assert first.para_pr_id_ref != nested.para_pr_id_ref # level 2 is its own shape + + (table,) = [t for p in doc.paragraphs for t in p.tables] + assert (table.row_count, table.column_count) == (3, 3) + assert table.cell(0, 0).text == "구분" + assert table.cell(2, 2).text == "" # a short row is padded + header_run = next(r for r in table.cell(0, 1).paragraphs[0].runs if r.text) + assert _look(doc, header_run).bold + assert table.cell(0, 0).element.get("borderFillIDRef") != table.cell(1, 0).element.get("borderFillIDRef") + bold_cell = next(r for r in table.cell(1, 2).paragraphs[0].runs if r.text) + assert _look(doc, bold_cell).bold + + +def test_no_outline_or_list_numbering_leaks_into_following_paragraphs() -> None: + doc = from_markdown("# 제목\n\n본문\n\n1. 하나\n2. 둘\n\n뒤 문단\n") + texts = [p.text for p in doc.paragraphs] + for name in ("본문", "뒤 문단"): + paragraph = doc.paragraphs[texts.index(name)] + assert _has_heading(doc, paragraph) in (None, "NONE") + assert paragraph.style_id_ref == "0" + + +def test_save_reopen_and_editor_open_safety(tmp_path) -> None: + doc = from_markdown(SAMPLE) + path = tmp_path / "md.hwpx" + doc.save_to_path(path) + reopened = HwpxDocument.open(path) + assert [p.text for p in reopened.paragraphs] == [p.text for p in doc.paragraphs] + assert "| 1학기 | 10 | 12 |" in reopened.text.markdown() + assert validate_editor_open_safety(path).to_dict()["ok"] is True + + +def test_unknown_markdown_never_raises() -> None: + doc = from_markdown("> 인용\n\n---\n\n
html
\n\n```\ncode kept\n```\n\n- - -\n\n####### 7\n") + texts = [p.text for p in doc.paragraphs] + assert "인용" in texts + assert "
html
" in texts + assert "code kept" in texts + assert "####### 7" in texts + + +def test_empty_and_table_first_markdown() -> None: + assert [p.text for p in from_markdown("").paragraphs] == [""] + doc = from_markdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n뒤\n") + assert doc.paragraphs[0].text == "" # the section paragraph is not a table anchor + assert doc.paragraphs[-1].text == "뒤" + + +def test_hard_line_break_and_br_in_cells() -> None: + doc = from_markdown("첫 줄 \n둘째 줄\n\n| a
b |\n") + assert doc.paragraphs[0].text == "첫 줄\n둘째 줄" + (table,) = [t for p in doc.paragraphs for t in p.tables] + assert "a" in table.cell(0, 0).text and "b" in table.cell(0, 0).text + + +def test_numbered_list_start_is_kept() -> None: + doc = from_markdown("3. 셋\n4. 넷\n") + numbering_id = doc.styles.paragraph_property(doc.paragraphs[0].para_pr_id_ref).heading.id_ref + numbering = doc.styles.numbering(numbering_id) + assert numbering is not None + assert "3" in {child.attributes.get("start") for child in numbering.children if child.name == "paraHead"} + + +def test_style_overrides_sizes() -> None: + doc = from_markdown("# 큰 제목\n\n본문", style=MarkdownStyle(heading_sizes=(20, 14, 12, 10), body_size=11)) + assert _char(doc, doc.paragraphs[0], "큰 제목").height == 2000 + assert _char(doc, doc.paragraphs[1], "본문").height == 1100 + with pytest.raises(ValueError): + MarkdownStyle(heading_sizes=(16, 12)) # type: ignore[arg-type] + + +def test_append_markdown_adds_to_an_existing_document() -> None: + doc = HwpxDocument.new() + doc.paragraphs[0].text = "기존" + doc.add_heading("개요 제목", 1) + doc.text.append_markdown("새 문단\n\n- 항목") + texts = [p.text for p in doc.paragraphs] + assert texts[:2] == ["기존", "개요 제목"] + added = doc.paragraphs[texts.index("새 문단")] + assert _has_heading(doc, added) in (None, "NONE") # not the heading's outline + + +# -- clean new-document mode ---------------------------------------------- + + +def test_default_document_still_inherits() -> None: + doc = HwpxDocument.new() + heading = doc.add_heading("제목", 1) + paragraph = doc.add_paragraph("본문") + assert paragraph.para_pr_id_ref == heading.para_pr_id_ref + + +def test_clean_document_does_not_inherit_unless_asked() -> None: + doc = HwpxDocument.new(inherit_style=False) + heading = doc.add_heading("제목", 1) + paragraph = doc.add_paragraph("본문") + assert paragraph.para_pr_id_ref != heading.para_pr_id_ref + assert paragraph.style_id_ref == "0" + explicit = doc.add_paragraph("물려받음", inherit_style=True) + assert explicit.para_pr_id_ref == paragraph.para_pr_id_ref # inherits from the previous paragraph diff --git a/tests/test_namespace_hygiene.py b/tests/test_namespace_hygiene.py index d6aa5fc8..9fa82126 100644 --- a/tests/test_namespace_hygiene.py +++ b/tests/test_namespace_hygiene.py @@ -158,6 +158,8 @@ def test_every_capability_authoring_method_resolves_on_the_document() -> None: "mail-merge": None, # 루트도 아니고 doc.* 네임스페이스도 아님(모듈 함수) # 6.9 트레인㉝ — 편집기 표면 인벤토리(트레인㉙)가 찾은 "문서 끼워 넣기" 갭. "document-merge": None, # mail-merge와 같은 이유, 순수 hwpx.tools 모듈 함수 + # Markdown 가져오기 — 진입점은 모듈 함수(기존 문서는 doc.text.append_markdown). + "markdown-import": None, # 6.9 트레인㉞ — 덧말·글자 겹치기 저작. "dutmal-compose": "shapes", # 6.12 트레인㊸ — 편집기 메뉴 표면 역매핑(트레인㊷)이 찾은 "문서 정보" 갭. From adc60bf320b9f82acf25796d62adce6c1306c233 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:18:39 +0900 Subject: [PATCH 2/2] fix(markdown): typed style errors and an ownership entry for the import module MarkdownStyle now raises HwpxValueError (text-markdown-style-invalid), keeping the untyped-error census flat, and tools/markdown_import.py gets its explicit ownership exception, so the boundary ratchet classifies it. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/module-ownership.json | 6 ++++++ docs/error-codes.md | 3 ++- src/hwpx/errors.py | 1 + src/hwpx/tools/markdown_import.py | 16 ++++++++++++++-- tests/test_product_boundary.py | 3 ++- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/architecture/module-ownership.json b/docs/architecture/module-ownership.json index e13f7758..b91fadc3 100644 --- a/docs/architecture/module-ownership.json +++ b/docs/architecture/module-ownership.json @@ -746,6 +746,12 @@ "disposition": "core", "approvedBy": "owner-authorized issue565 additive implementation", "rationale": "QName-aware bounded byte-span selection of text after a table within its own paragraph; reusable format behavior. The caller supplies the predicate, so this module contains no genre, institution, workflow or caption policy. Secure XML parsing and existing OPC markup tokenization validate namespace scopes without reserializing source bytes." + }, + { + "path": "src/hwpx/tools/markdown_import.py", + "disposition": "core", + "approvedBy": "markdown-import-for-small-models", + "rationale": "from_markdown/append_markdown: writes a common Markdown subset (headings, paragraphs, nested lists, pipe tables, bold/italic) into HWPX through the document's own paragraph, list and table authoring. MarkdownStyle is genre-neutral sizing only; genre presets (official letters, notices) stay with the application layer that passes a style in." } ], "removed": [ diff --git a/docs/error-codes.md b/docs/error-codes.md index ba90f390..fbae61b7 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -30,7 +30,7 @@ except HwpxError as exc: | 형태 | `style-not-found` | `VISUAL_COMPLETE_FAILED` | | 쓰임 | 예외 분기 | **발행된 영수증 스키마의 필드값** | | 관리 | major 경계 | 영수증 스키마 버전 | -| 개수 | 196 | 11 | +| 개수 | 198 | 11 | 통합하지 않는 이유: quality 코드는 `hwpx.mutation-report/v1` 과 `VisualCompleteReport` 에 이미 실려 나간 값이다. 이름을 바꾸면 영수증을 읽는 @@ -375,6 +375,7 @@ except HwpxError as exc: | `text-highlight-match-crosses-markup` | 찾은 구간이 인라인 마크업을 가로질러 안전하게 감쌀 수 없다. | | `text-highlight-match-empty` | 형광펜으로 감쌀 문자열이 비어 있다. | | `text-highlight-match-not-found` | 문단에서 형광펜으로 감쌀 문자열을 찾지 못했다. | +| `text-markdown-style-invalid` | MarkdownStyle 의 제목·본문 글자 크기가 양수가 아니거나 제목 크기가 네 개가 아니다. | | `text-search-empty` | 바꿀 대상 문자열이 비어 있다. | ### `track-*` diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index e788dbf3..a31ddc35 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -401,6 +401,7 @@ class Hwp5ConversionWarning(UserWarning): "text-highlight-match-not-found": "문단에서 형광펜으로 감쌀 문자열을 찾지 못했다.", "text-highlight-match-crosses-markup": "찾은 구간이 인라인 마크업을 가로질러 안전하게 감쌀 수 없다.", "text-highlight-color-invalid": "형광펜 색이 #RRGGBB 6자리 16진 형식이 아니다.", + "text-markdown-style-invalid": "MarkdownStyle 의 제목·본문 글자 크기가 양수가 아니거나 제목 크기가 네 개가 아니다.", # -- 저장·패키지 ----------------------------------------------------- "save-failed": "저장 경로가 아무것도 쓰기 전에 fail-closed 했다.", "save-package-contract-violated": "package.save(None) 이 bytes 를 돌려주지 않았다.", diff --git a/src/hwpx/tools/markdown_import.py b/src/hwpx/tools/markdown_import.py index 843187d0..6a2b6396 100644 --- a/src/hwpx/tools/markdown_import.py +++ b/src/hwpx/tools/markdown_import.py @@ -61,10 +61,22 @@ class MarkdownStyle: bullet_char: str | None = None def __post_init__(self) -> None: + from ..errors import HwpxValueError + if len(self.heading_sizes) != 4 or any(size <= 0 for size in self.heading_sizes): - raise ValueError("heading_sizes must be four positive point sizes (# to ####)") + raise HwpxValueError( + "heading_sizes must be four positive point sizes (# to ####)", + code="text-markdown-style-invalid", + context={"heading_sizes": tuple(self.heading_sizes)}, + suggestion="Give four positive point sizes, one per heading level # to ####.", + ) if self.body_size is not None and self.body_size <= 0: - raise ValueError("body_size must be positive") + raise HwpxValueError( + "body_size must be positive", + code="text-markdown-style-invalid", + context={"body_size": self.body_size}, + suggestion="Give a positive point size, or None to keep the document's body size.", + ) # -------------------------------------------------------------------------- diff --git a/tests/test_product_boundary.py b/tests/test_product_boundary.py index 1822bdf9..ec8c3c89 100644 --- a/tests/test_product_boundary.py +++ b/tests/test_product_boundary.py @@ -323,7 +323,8 @@ def test_real_tree_gate_runs_from_a_gitless_source_copy(tmp_path: Path) -> None: # (oxml/table_structure.py), outside table.py because that owner file sits at its 1600-line cap. # +1: the experimental line layout cache writer (layout/line_cache.py). # +1: QName-aware trailing-table-text byte spans; caller owns selection policy. - assert report["classifiedFiles"] == 184 + # +1: Markdown subset import (tools/markdown_import.py); genre presets stay in automation. + assert report["classifiedFiles"] == 185 def test_gitless_cli_reproduces_literal_dynamic_import_failure_without_mutating_source(