python-hwpx 6.3.0__py3-none-any.whl → 6.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hwpx/_document/fields.py CHANGED
@@ -689,20 +689,15 @@ def _insert_form_field_text_run(
689
689
  match: dict[str, Any],
690
690
  value: str,
691
691
  ) -> None:
692
- paragraph = match["_paragraph"]
692
+ # A field may begin and end within one run. A run index is not a
693
+ # paragraph-child insertion point, and inserting before the end run can
694
+ # put the value before fieldBegin. Insert the text immediately after the
695
+ # actual begin control, retaining its surrounding run and character style.
693
696
  runs: list[Any] = match["_runs"]
694
- begin_run_index = int(match["_begin_run_index"])
695
- end_run_index = match.get("_end_run_index")
696
- begin_run = runs[begin_run_index]
697
- char_ref = begin_run.get("charPrIDRef") or paragraph.char_pr_id_ref or "0"
698
- run = paragraph.element.makeelement(f"{_HP}run", {"charPrIDRef": str(char_ref)})
699
- text_node = run.makeelement(f"{_HP}t", {})
697
+ begin_run = runs[int(match["_begin_run_index"])]
698
+ text_node = begin_run.makeelement(f"{_HP}t", {})
700
699
  text_node.text = _sanitize_field_text(value)
701
- run.append(text_node)
702
- if end_run_index is None:
703
- paragraph.element.insert(begin_run_index + 1, run)
704
- else:
705
- paragraph.element.insert(int(end_run_index), run)
700
+ begin_run.insert(int(match["_begin_child_index"]) + 1, text_node)
706
701
 
707
702
 
708
703
  def fill_form_field(
hwpx/capabilities.py CHANGED
@@ -74,7 +74,7 @@ _CAPABILITY_AREAS: tuple[dict[str, Any], ...] = (
74
74
  "area": "paragraph-table-authoring",
75
75
  "namespace": None,
76
76
  "matrix_row": "문단·표 저작/편집",
77
- "entry_points": ("hwpx.document:HwpxDocument",),
77
+ "entry_points": ("hwpx.document:HwpxDocument", "hwpx.model:Table"),
78
78
  "authoring_methods": ("add_heading", "add_paragraph", "add_section"),
79
79
  },
80
80
  {
@@ -116,7 +116,7 @@ _CAPABILITY_AREAS: tuple[dict[str, Any], ...] = (
116
116
  "area": "shape-authoring",
117
117
  "namespace": "doc.shapes",
118
118
  "matrix_row": "도형 저작(선·사각형·타원)",
119
- "entry_points": ("hwpx.document:HwpxDocument",),
119
+ "entry_points": ("hwpx.document:HwpxDocument", "hwpx.model:Shape"),
120
120
  "authoring_methods": ("add_line", "add_rectangle", "add_ellipse"),
121
121
  },
122
122
  {
@@ -14,12 +14,12 @@ stable 편집 표면의 계약을 한 곳에 모았다. 아래 표의 실패 모
14
14
  | 호출 | 반환 | 대표 실패 모드 | 다시 실행하면 |
15
15
  |---|---|---|---|
16
16
  | `add_paragraph(text)` | `HwpxOxmlParagraph` | 사실상 없음 | 문단이 하나 더 추가된다(append, 비멱등) |
17
- | `remove_paragraph(p)` | `None` | 섹션의 마지막 단락 삭제 시 `ValueError` | 이미 제거된 문단이면 조용히 무시된다(무해) |
17
+ | `paragraph.remove()` | `None` | 섹션의 마지막 단락 삭제 시 `ValueError` | 이미 제거된 문단이면 조용히 무시된다(무해) |
18
18
  | `add_table(rows, cols)` | `HwpxOxmlTable` | 사실상 없음 | 표가 하나 더 추가된다(비멱등) |
19
19
  | `table.set_cell_text(r, c, text)` | `FitResult \| None` | 범위 밖 좌표는 `IndexError` (`exceed table bounds`) | 같은 값이면 결과 동일(수렴) |
20
- | `replace_text_in_runs(search, repl)` | `int` (치환 수) | 빈 `search`는 `ValueError` | 치환할 것이 없으면 `0` — 1회차 후 수렴 |
21
- | `add_memo_with_anchor(...)` | `(memo, paragraph, field_id)` 튜플 | 아래 캐비앗 참고 | 메모가 하나 더 붙는다(비멱등) |
22
- | `add_footnote(text, paragraph)` | `HwpxOxmlNote` | 사실상 없음 | 각주가 하나 더 붙는다(비멱등) |
20
+ | `document.text.replace(search, repl)` | `int` (치환 수) | 빈 `search`는 `ValueError` | 치환할 것이 없으면 `0` — 1회차 후 수렴 |
21
+ | `document.notes.add_memo(..., anchor=p)` | `Memo` (`paragraph`, `field_id` 속성) | 아래 캐비앗 참고 | 메모가 하나 더 붙는다(비멱등) |
22
+ | `document.notes.add_footnote(text, paragraph)` | `HwpxOxmlNote` | 사실상 없음 | 각주가 하나 더 붙는다(비멱등) |
23
23
 
24
24
  "사실상 없음"은 정상 인자에서 실패 경로가 없다는 뜻이다 — 타입이 어긋난
25
25
  인자는 여느 파이썬 API처럼 `TypeError` 계열로 즉시 드러난다.
@@ -35,19 +35,25 @@ print(report.actual_mode)
35
35
  안전 검증을 통과한 결과만 대상 경로를 교체한다.
36
36
  - 요청한 보존 등급(`mode="patch"` 등)을 지킬 수 없으면
37
37
  `PreservationDowngradeError`로 실패하고 **아무것도 쓰지 않는다**
38
- (fail-closed). 자동 다운그레이드는 없다.
38
+ (`mode="patch", fallback="error"`). 기본 `auto`는 달성 가능한 등급을 선택하며,
39
+ 명시한 `fallback="rebuild"`는 강등을 허용한다.
39
40
  - `return_report=True`는 `MutationReport`를 돌려준다 — 실제 저장 모드,
40
41
  손대지 않은 파트의 바이트 보존 검증 결과까지. 스키마와 전체 규칙은
41
42
  [안전한 쓰기 계약](safe-write-contract.md).
43
+ - `report.ok`는 요청 내용 반영을 검사하지 않는다. 요청 건수와 출력 값을 별도로
44
+ 확인하고 `verification.visual="not_performed"`를 시각 통과로 읽지 않는다.
42
45
  - **직렬화는 결정론적이다**: 같은 문서 상태에서 `to_bytes()`를 두 번 부르면
43
46
  바이트가 동일하다(실측). diff·해시 기반 파이프라인에 안전하다.
44
47
 
45
48
  ## 알아둘 캐비앗 (정직 고지)
46
49
 
47
- - `add_memo_with_anchor(memo_shape_id_ref=...)`는 참조가 실재하는 메모
50
+ - `document.notes.add_memo(memo_shape_id_ref=...)`는 참조가 실재하는 메모
48
51
  모양인지 **검증하지 않고 조용히 수용한다**. 존재하지 않는 ID를 넣으면
49
- 저장은 되지만 편집기 표시가 어긋날 수 있다. `document.memo_shapes`로
52
+ 저장은 되지만 편집기 표시가 어긋날 수 있다. `document.styles.memo_shapes`로
50
53
  실재 ID를 확인하고 쓰는 것을 권장한다.
54
+ - `document.text.replace`는 본문 문단의 개별 런 안에서 치환한다. 여러 런에
55
+ 걸친 검색어와 중첩 셀 문단은 이 경로의 대상이 아니다. `0`을 반환하면 저장
56
+ 성공 여부와 관계없이 요청이 반영되지 않은 것이다.
51
57
  - `add_*` 계열은 전부 append 의미론이다. "없으면 추가"가 필요하면 먼저
52
58
  {doc}`recipes-traversal`의 순회로 존재 여부를 확인하라.
53
59
  - 편집은 저장 전까지 메모리에만 있다. 저장 경로가 곧 커밋이다.
@@ -79,34 +79,41 @@ print("셀 수:", len(cells))
79
79
 
80
80
  ## 런 단위로 서식과 함께 순회하기
81
81
 
82
- 굵기·색 같은 서식은 런(run)에 붙는다. 문서 전체 런을 순회하거나, 특정
82
+ 굵기·색 같은 서식은 런(run)에 붙는다. 본문 문단의 런을 순회하거나, 특정
83
83
  서식의 런만 골라낼 수 있다.
84
84
 
85
85
  ```python
86
86
  from hwpx import HwpxDocument
87
87
 
88
88
  document = HwpxDocument.open("input.hwpx")
89
- for run in document.iter_runs():
89
+ for run in document.text.runs():
90
90
  print(repr(run.text), "charPr:", run.char_pr_id_ref)
91
91
 
92
- red_runs = document.find_runs_by_style(text_color="#FF0000")
92
+ red_runs = document.text.find_runs(text_color="#FF0000")
93
93
  print("빨간 런:", len(red_runs))
94
94
  ```
95
95
 
96
+ ## 병합 셀의 라벨 찾기
97
+
98
+ 라벨 기반 표 탐색에는 `document.tables.find_cell_by_label(label)`을 쓴다.
99
+ 병합 셀 하나는 라벨 한 건으로 세며, 오른쪽/아래쪽 이동은 그 병합 범위 밖의
100
+ 인접 셀로 간다. 서로 다른 셀에 같은 라벨이 있으면 여러 결과를 돌려준다.
101
+ 논리 좌표 여러 개가 같은 병합 셀을 가리키는 경우와 실제 중복 라벨은 구분한다.
102
+
96
103
  ## 메모와 누름틀(필드) 나열하기
97
104
 
98
105
  ```python
99
106
  from hwpx import HwpxDocument
100
107
 
101
108
  document = HwpxDocument.open("input.hwpx")
102
- for memo in document.memos:
109
+ for memo in document.notes.memos:
103
110
  print("메모:", memo.id)
104
111
 
105
- for field in document.list_form_fields():
112
+ for field in document.fields.all:
106
113
  print("누름틀:", field)
107
114
  ```
108
115
 
109
- 문서에 해당 요소가 없으면 빈 리스트가 나온다 — 예외가 아니다.
116
+ 문서에 해당 요소가 없으면 빈 컬렉션이 나온다 — 예외가 아니다.
110
117
 
111
118
  ## 다음 단계
112
119
 
@@ -21,12 +21,12 @@
21
21
 
22
22
  | 능력 영역 | 상태 | 증거 |
23
23
  |---|---|---|
24
- | 문단·표 저작/편집 | Parse·Preserve·Edit·Create·Render-verified | corpus-metrics「오픈 수용률」476/476, 「저작 품질 게이트」실저작 58/58, 「렌더 검증」416건. **6.5+**: `paragraph.add_run(text, expand_special_characters=True)`가 `hp:lineBreak`(줄바꿈)·`hp:nbSpace`(비분리공백)·`hp:fwSpace`(전각공백)를 저작한다 — 실코퍼스 3파일(`error__20230818__test.hwpx`/`error__20251107__test.hwpx`/`error__20250808__...hwpx`) 리버스로 셋 다 **단일 hp:t 안의 mixed content**임을 확인(`hp:tab`이 쓰는 "hp:run 형제" 형태와 다름 — 스키마는 둘 다 허용하나 실 산출물은 이 셋에 대해 전자만 씀). 같은 리버스 과정에서 텍스트 추출기(`TextExtractor`)가 이 세 원자(+`hp:hyphen`)를 `hp:t` 중첩 위치에서 못 읽던 결함도 함께 수리 — 수리 전엔 원자가 조용히 사라지며 앞뒤 텍스트가 구분자 없이 붙어 읽혔다(줄바꿈이 있었다는 사실 자체가 유실). 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-inlineatoms 스트라텀(3원자의 0공백 순서조합 15종 전량), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정). **6.12 트레인㊸ 갭④**: `doc.styles.apply_paragraph_format(column_break=...)`가 `hp:p`의 `columnBreak` 속성(문단 인스턴스별 강제 단 나눔)을 저작한다 — `page_break_before`가 다루는 `hh:breakSetting`(paraPr 공유 스타일)과는 다른 메커니즘이라 대상 paraPr을 새로 안 만들고 문단에 직접 적용된다. 실코퍼스 67파일 전수(14266문단) 측정: 자매 속성 `pageBreak`는 73건 실사용 확인(항상 "0"/"1" 리터럴, "true"/"false" 0건)되지만 `columnBreak`는 0건 — 그래도 같은 요소·같은 리터럴 규약을 재사용하는 것뿐이라 구조적 추측 위험은 없다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-column-break 스트라텀(5문단 문서에서 대상 문단 위치를 0~4로 로테이션, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정). 옛 5.x 표면(`doc.set_paragraph_format`)에는 의도적으로 안 얹었다 — 그 shim은 5.x 시그니처 그대로 얼려두는 게 계약이라(7.0 제거 예정), 신규 옵션은 `doc.styles.apply_paragraph_format`에만 산다 |
24
+ | 문단·표 저작/편집 | Parse·Preserve·Edit·Create·Render-verified | `Table.set_cell_borders`는 네 변 색/종류만 수정하며 기존 선 두께·배경·대각선과 공유 스타일을 보존한다. 전체 스타일 교체용 `set_cell_border_fill`과 구분한다. corpus-metrics「오픈 수용률」476/476, 「저작 품질 게이트」실저작 58/58, 「렌더 검증」416건. **6.5+**: `paragraph.add_run(text, expand_special_characters=True)`가 `hp:lineBreak`(줄바꿈)·`hp:nbSpace`(비분리공백)·`hp:fwSpace`(전각공백)를 저작한다 — 실코퍼스 3파일(`error__20230818__test.hwpx`/`error__20251107__test.hwpx`/`error__20250808__...hwpx`) 리버스로 셋 다 **단일 hp:t 안의 mixed content**임을 확인(`hp:tab`이 쓰는 "hp:run 형제" 형태와 다름 — 스키마는 둘 다 허용하나 실 산출물은 이 셋에 대해 전자만 씀). 같은 리버스 과정에서 텍스트 추출기(`TextExtractor`)가 이 세 원자(+`hp:hyphen`)를 `hp:t` 중첩 위치에서 못 읽던 결함도 함께 수리 — 수리 전엔 원자가 조용히 사라지며 앞뒤 텍스트가 구분자 없이 붙어 읽혔다(줄바꿈이 있었다는 사실 자체가 유실). 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-inlineatoms 스트라텀(3원자의 0공백 순서조합 15종 전량), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정). **6.12 트레인㊸ 갭④**: `doc.styles.apply_paragraph_format(column_break=...)`가 `hp:p`의 `columnBreak` 속성(문단 인스턴스별 강제 단 나눔)을 저작한다 — `page_break_before`가 다루는 `hh:breakSetting`(paraPr 공유 스타일)과는 다른 메커니즘이라 대상 paraPr을 새로 안 만들고 문단에 직접 적용된다. 실코퍼스 67파일 전수(14266문단) 측정: 자매 속성 `pageBreak`는 73건 실사용 확인(항상 "0"/"1" 리터럴, "true"/"false" 0건)되지만 `columnBreak`는 0건 — 그래도 같은 요소·같은 리터럴 규약을 재사용하는 것뿐이라 구조적 추측 위험은 없다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-column-break 스트라텀(5문단 문서에서 대상 문단 위치를 0~4로 로테이션, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정). 옛 5.x 표면(`doc.set_paragraph_format`)에는 의도적으로 안 얹었다 — 그 shim은 5.x 시그니처 그대로 얼려두는 게 계약이라(7.0 제거 예정), 신규 옵션은 `doc.styles.apply_paragraph_format`에만 산다 |
25
25
  | 표 구조 변경(행·열·표 삭제/삽입, 열 오토핏) | Preserve·Edit·Render-verified | `hwpx.table_patch.apply_table_ops`; corpus-metrics「바이트 보존」497/497(patch 경로). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-tablestructure 스트라텀(delete_column/delete_row/insert_row_by_clone/set_column_widths/autofit_columns 각 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장(coverage-ledger) 라우팅은 없음** — `apply_table_ops`가 건드리는 `hp:tbl`/`tr`/`tc`/`cellAddr`/`cellSpan`/`cellSz`/`cellMargin`은 전부 "문단·표 저작/편집"(대형 혼합 영역)에 이미 등록돼 있어, 여기 추가하면 이 스트라텀이 실제로 검증한 적 없는 이웃 요소까지 openrate 근거를 얻는 위양성이 생긴다(v8 polygon/arc 전례와 동일 판단) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만. **6.12 트레인㊸ 갭⑤**가 `split_table`(표 나누기)·`merge_table`(표 붙이기) 두 op를 추가했다 — 스키마에 표 나누기/붙이기 전용 어휘는 없다(`hp:tbl`/`TableType`은 `pageBreak`/`repeatHeader`/`rowCnt`/`colCnt`뿐, `ParaList XML schema.xml:2008`), 순수 구조 편집이라 `_insert_block_by_clone`과 같은 fail-closed 원칙(병합 셀이 경계를 걸치면 거부)을 그대로 따른다. `split_table`은 `split_row` 물리 행 인덱스에서 표를 둘로 나눠(윗칸은 원래 표 id·아랫칸은 새 래핑 문단+새 표 id) 병합 셀이 경계를 걸치면 거부한다(실코퍼스 `public_official_table.hwpx`로 clean·crossing 양쪽 검증). `merge_table`은 그 역연산 — 바로 다음 표와 합치되 `colCnt` 불일치나 두 표 사이 실텍스트 존재 시 거부(내용 무음 소실 방지). **"표 뒤집기"는 이번 트레인에서 의도적으로 보류**: 행/열 순서 반전은 병합 셀·중첩표에서 정의가 흐려지고(예: rowSpan 셀을 반전하면 어느 칸이 내용을 갖는가?) 실코퍼스에 근거로 삼을 예시가 전혀 없다(스키마조차 이런 연산의 존재를 암시하지 않는다) — curve·connectLine과 같은 원칙으로 근거 없는 알고리즘을 추측하지 않는다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-table-split-merge 스트라텀(분할 3종[3/4/5행 표를 각기 다른 위치에서 분할]·병합 1건[2+2행 표 결합]·분할 후 재병합 왕복 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정, 기존 v14의 15/15은 split/merge를 겨냥하지 않았었다) |
26
26
  | 표 생성(병합·중첩 포함) | Create·Render-verified | `add_table`·`merge_cells`(피복 셀 제거+cellSpan — 실한컴 병합 의미론과 동형, 3종 병합 렌더 픽셀 확인)·셀 문단 `add_table`(중첩). 기본값은 5.4.0에서 실한컴에 맞췄다: 셀 안여백 510/510/141/141, 기본 표 폭은 본문 폭, 중첩 표는 부모 셀 사용 폭. **남은 갭**: 선 종류는 raw 전용. **6.7+**: `table.label`/`.set_label()`/`.remove_label()`(`hp:label`, Avery형 라벨시트/명패 인쇄 레이아웃) — 사설 코퍼스 75건·436건 리버스(DEV-023, 스키마와 완전 일치), 항상 표의 마지막 자식으로 배치. **Render-verified(6.8, 트레인㉘)**: openrate v11 실한컴 배치(macOS GUI 오라클) 15/15 render-checked·음성대조 3/3 — 관측 2클러스터(2×9 소형시트·1×2 대형 정사각 명패)와 관측 밖 조합(3×6 미관측 그리드·landscape=NARROWLY·partial 속성) 전부 수용, 수용 경계가 관측 분포보다 넓다. **6.13 트레인㊻**: `equalize_column_widths()`/`equalize_row_heights()`(`HwpxOxmlTable`) — 편집기 메뉴 표면 역매핑(트레인㊷·㊺)이 찾은 부분 대응(균등화 "전용" 헬퍼 부재)을 닫는다. 신규 XML 어휘는 전혀 없음: 전자는 기존 `set_column_widths([1]*column_count)`를 전용 이름으로 노출할 뿐이고, 후자는 그 행 대응(같은 `iter_grid`/rowSpan 로직·`_distribute_size` 재사용, 병합 셀은 자신이 덮는 행/열들의 새 크기 합을 받는다 — `cellSz`만 갱신). 실한컴 렌더 검증: `docs/openrate/report-v17.json` authored-cell-equalize 스트라텀(열만/행만/양축/양축+병합 셀 1개 로테이션, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-11 측정) — 기존 `set_column_widths` 자체는 v14의 authored-tablestructure에서 1건 확인됐을 뿐 이 두 신규 편의 메서드 자체는 이번이 첫 독립 검증 |
27
27
  | 양식 채움(byte-splice) | Preserve·Edit·Render-verified | `hwpx.patch`·`table_patch`·`body_patch`; corpus-metrics「바이트 보존」497/497. wild 공개 양식의 서식 충실은 2차 실측 후 **산출분 pass 82.1%(23/28)·산출+fail 7.6%(5/66)** — 불가능 타깃은 typed 거부 35건(전수 감별 과잉거부 0). 잔존은 페이지 리플 5건(4건 typed 경고 동반·완전 무음 1건, 「구조결함 2차 실측」절), 표 shape 부류는 측정 인공물로 판명·판정기 교정. **6.11 사이클(트레인㊳ 판정, ㊵ 이후 정리 트레인 생성)**: 이 영역 자체의 render-verified 갭(v14 소탕 대상에서 빠뜨림으로 판정)을 v15로 메움 — `docs/openrate/report-v15.json` authored-formfill 스트라텀(합성 2열 양식 → `table_patch.fill_cells`로 빈 값 셀 byte-splice 채움, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — `fill_cells`가 건드리는 `hp:t`는 기존 요소 텍스트 치환일 뿐 새 요소를 안 만든다(find-replace·document-merge와 같은 이유) — 등급 갱신은 이 산문과 report-v15.json 직접 인용으로만 |
28
28
  | 편집 계획 실행(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 직접 인용으로만 |
29
- | 도형 저작(선·사각형·타원) | Parse·Preserve·Edit·Create·Render-verified | 전용 `add_line`·`add_rectangle`·`add_ellipse`. 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 반영까지 확인 |
29
+ | 도형 저작(선·사각형·타원) | 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 반영까지 확인 |
30
30
  | 저수준 도형·컨트롤 탈출구 | Edit | `add_shape`·`add_control`은 건네받은 요소와 속성만 쓰고 OWPML 필수 하위 요소(`offset`·`orgSz`·`curSz`·`sz`·`pos`·유형별 기하)는 만들지 않는다. 그대로 저장한 문서는 실한컴 12.30.0이 **거부**한다(음성 대조로 확인). 생성 시점의 `UserWarning`은 여전히 하나뿐이지만(휘발성 — 호출 스택에만 남고 파일엔 안 남음), **6.6 트레인㉒**부터 `validate_package`·`validate_editor_open_safety`가 이 위험을 저장 이후에도 검증 결과에 명시적 `warning`으로 남긴다(fail-closed 아님 — `ok`는 그대로 `True`, 정직한 신호만 추가). `hp:container` 부재는 오탐 안 됨(부재는 이 5종을 원래 안 가짐, DEV-016). 도형은 위의 전용 헬퍼를 쓸 것(전용 헬퍼는 경고하지 않는다) |
31
31
  | arc·polygon·curve·connectLine | Parse·Preserve·Create(arc·polygon, experimental)·Render-verified(arc·polygon만)·Unsupported-but-preserved(curve·connectLine) | **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는 이번 사이클 정직 보류**: 유일한 실 예시(`SimpleCurve.hwpx`)에서 곡선의 `orgSz`가 꼭짓점(`hp:seg` 앵커점) bbox보다 뚜렷이 크다(폭 16636 vs 앵커점 bbox 15225·높이 21360 vs 20500) — 스플라인이 자기 앵커점 밖으로 부풀어 오르는 실측이며, 한컴의 정확한 곡선 적합 알고리즘 근거 없이 bbox를 추정하면 침묵 오류가 된다. **connectLine도 정직 보류**: 유일한 "Simple" 정본 예시(`SimpleConnectLine.hwpx`)는 자유선이 아니라 `subjectIDRef`로 다른 두 도형(rect·ellipse)을 잇는 "스마트 연결선"이고, `offset`이 음수(부호 없는 32비트로 직렬화된 것을 실측 확인)·`curSz`가 `orgSz`와 다르며·`scaMatrix`가 평행이동까지 얹은 비항등 행렬이라 그 관계식 근거가 없다. 다른 예시(`error__20230818__test.hwpx`, 미부착 `subjectIDRef=0`)는 파일명이 스스로 결함 사례임을 밝히고 `scaMatrix`가 e1=0인 퇴화 행렬이라 정본으로 못 쓴다. `startPt`/`endPt`가 `hp:connectLine`에서는 `hp:` 네임스페이스(`hp:line`의 `hc:startPt`와 다름, 실측 확인)라는 점만 확정 — 도형마다 기하 네임스페이스를 개별 검증해야 한다는 교훈 재확인 |
32
32
  | 그룹 개체(컨테이너) | 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`으로 부재를 그룹 로컬 좌표에 배치 — **`pic`/`arc`/`line`/`connectLine`/중첩 `container` 부재와 이미 배치된 도형의 재그룹은 이번 사이클 범위 밖**(전자는 같은 패턴으로 자연 확장 가능, `pic`은 media 배선이 추가로 필요). **알려진 한계**: `resize()`는 컨테이너 자신의 크기만 갱신하고 부재를 비례 재배치하지 않는다(직속 자식 스캔이 부재 도형 전체를 보지, 그 안의 점 좌표를 안 봄) — 부재별로 개별 `resize()`를 부를 것. 실한컴 렌더 검증: `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 측정) |
@@ -78,6 +78,11 @@ _LATEX_TO_EQEDIT.update(
78
78
  "\\forall": "FORALL",
79
79
  "\\iint": "dint",
80
80
  "\\iiint": "tint",
81
+ # ``triangle`` is accepted when reading (observed in real documents) but
82
+ # only the documented uppercase spelling is emitted when authoring: the
83
+ # lowercase form has not been render-verified, and lowercase ``forall``
84
+ # already turned out to render as literal text.
85
+ "\\triangle": "TRIANGLE",
81
86
  # Common LaTeX aliases sharing a verified target.
82
87
  "\\le": "leq",
83
88
  "\\ge": "geq",
hwpx/equation/mathml.py CHANGED
@@ -9,11 +9,16 @@ code block rather than dropping the equation.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import re
12
13
  from typing import Callable
13
14
 
15
+ from .tokens import EQEDIT_MATHML_OPERATOR_COMMANDS
16
+
14
17
  # ``False`` marks a resolved-but-unavailable converter; ``None`` means "not yet
15
18
  # probed" so the import is attempted lazily on first use.
16
19
  _converter: Callable[[str], str] | bool | None = None
20
+ _LATEX_WORD_COMMAND_RE = re.compile(r"\\[A-Za-z]+")
21
+ _TEXT_COMMAND = "\\text{"
17
22
 
18
23
 
19
24
  class MathMLUnavailableError(RuntimeError):
@@ -62,8 +67,69 @@ def latex_to_mathml(latex: str) -> str:
62
67
  raise ValueError(f"latex2mathml could not render fragment: {exc}") from exc
63
68
 
64
69
 
70
+ def eqedit_latex_to_mathml(latex: str) -> str:
71
+ """Convert EqEdit-derived LaTeX while preserving known token roles.
72
+
73
+ ``latex2mathml`` classifies plain ``\\triangle`` as an identifier. EqEdit's
74
+ token map identifies it as a mathematical symbol, so the MathML-only input
75
+ receives an operator wrapper before conversion. The returned/public LaTeX
76
+ is not rewritten, and serialized MathML is never patched afterward.
77
+ """
78
+
79
+ def annotate_role(match: re.Match[str]) -> str:
80
+ command = match.group(0)
81
+ if command in EQEDIT_MATHML_OPERATOR_COMMANDS:
82
+ return rf"\mathop{{{command}}}"
83
+ return command
84
+
85
+ return latex_to_mathml(
86
+ "".join(
87
+ segment if literal else _LATEX_WORD_COMMAND_RE.sub(annotate_role, segment)
88
+ for segment, literal in _split_text_literals(latex)
89
+ )
90
+ )
91
+
92
+
93
+ def _split_text_literals(latex: str) -> list[tuple[str, bool]]:
94
+ """Split ``latex`` into ``(segment, is_text_literal)`` pieces.
95
+
96
+ Quoted EqEdit literals become ``\\text{...}``; their content is prose, so
97
+ the role annotation must not rewrite a ``\\triangle`` typed inside quotes.
98
+ Braces inside the literal are balanced; an unterminated group is passed
99
+ through untouched so ``latex2mathml`` reports it instead of this splitter.
100
+ """
101
+
102
+ pieces: list[tuple[str, bool]] = []
103
+ cursor = 0
104
+ while True:
105
+ start = latex.find(_TEXT_COMMAND, cursor)
106
+ if start < 0:
107
+ break
108
+ depth = 0
109
+ end = -1
110
+ for index in range(start + len(_TEXT_COMMAND) - 1, len(latex)):
111
+ char = latex[index]
112
+ if char == "{":
113
+ depth += 1
114
+ elif char == "}":
115
+ depth -= 1
116
+ if depth == 0:
117
+ end = index + 1
118
+ break
119
+ if end < 0:
120
+ break
121
+ if start > cursor:
122
+ pieces.append((latex[cursor:start], False))
123
+ pieces.append((latex[start:end], True))
124
+ cursor = end
125
+ if cursor < len(latex):
126
+ pieces.append((latex[cursor:], False))
127
+ return pieces
128
+
129
+
65
130
  __all__ = [
66
131
  "MathMLUnavailableError",
132
+ "eqedit_latex_to_mathml",
67
133
  "latex2mathml_available",
68
134
  "latex_to_mathml",
69
135
  ]
hwpx/equation/render.py CHANGED
@@ -18,7 +18,7 @@ from dataclasses import dataclass
18
18
  import html
19
19
 
20
20
  from .eqedit import EquationConversionError, eqedit_to_latex
21
- from .mathml import MathMLUnavailableError, latex_to_mathml
21
+ from .mathml import MathMLUnavailableError, eqedit_latex_to_mathml
22
22
 
23
23
  # Fidelity labels surfaced in the preview (Constitution IX, honest reporting).
24
24
  LABEL_MATHML = "수식 MathML 렌더"
@@ -63,7 +63,7 @@ def render_equation(script: str) -> EquationRender:
63
63
  return _fallback_block("script", LABEL_SCRIPT, script, latex=None)
64
64
 
65
65
  try:
66
- mathml = latex_to_mathml(latex)
66
+ mathml = eqedit_latex_to_mathml(latex)
67
67
  except MathMLUnavailableError:
68
68
  return _fallback_block("latex", LABEL_LATEX_NO_LIB, latex, latex=latex)
69
69
  except ValueError:
hwpx/equation/tokens.py CHANGED
@@ -116,6 +116,11 @@ OPERATORS: dict[str, str] = {
116
116
  "partial": r"\partial",
117
117
  "nabla": r"\nabla",
118
118
  "angle": r"\angle",
119
+ # Hancom's symbol guide spells this token in uppercase, while legacy
120
+ # documents also contain the exact lowercase form. Mixed-case variants are
121
+ # intentionally not accepted because the reader uses exact token lookup.
122
+ "triangle": r"\triangle",
123
+ "TRIANGLE": r"\triangle",
119
124
  "cdots": r"\cdots",
120
125
  "ldots": r"\ldots",
121
126
  "vdots": r"\vdots",
@@ -254,11 +259,17 @@ DELIMITERS: dict[str, str] = {
254
259
  # Structural keywords handled directly by the parser (not simple substitution).
255
260
  STRUCTURAL = frozenset({"over", "atop", "sqrt", "root", "of", "LEFT", "RIGHT", "left", "right"})
256
261
 
262
+ # LaTeX commands emitted from EqEdit operator tokens that latex2mathml otherwise
263
+ # classifies as identifiers. The render boundary uses this metadata only while
264
+ # producing MathML; the public LaTeX remains the canonical command above.
265
+ EQEDIT_MATHML_OPERATOR_COMMANDS = frozenset({OPERATORS["triangle"]})
266
+
257
267
 
258
268
  __all__ = [
259
269
  "ACCENTS",
260
270
  "BIG_OPERATORS",
261
271
  "DELIMITERS",
272
+ "EQEDIT_MATHML_OPERATOR_COMMANDS",
262
273
  "FUNCTIONS",
263
274
  "GREEK",
264
275
  "MATRIX_ENVIRONMENTS",
hwpx/form_fit/measure.py CHANGED
@@ -386,6 +386,27 @@ def _cell_margin_vertical(cell_element: object) -> tuple[int, int]:
386
386
  return (0, 0)
387
387
 
388
388
 
389
+ def _effective_cell_margins(cell: object) -> tuple[int, int, int, int]:
390
+ """Respect explicit table-margin inheritance instead of inactive cell zeros."""
391
+ element = getattr(cell, "element", None)
392
+ if element is None:
393
+ return (0, 0, 0, 0)
394
+ if element.get("hasMargin") in {"0", "false", "False"}:
395
+ table_element = getattr(getattr(cell, "table", None), "element", None)
396
+ if table_element is not None:
397
+ for child in table_element:
398
+ if _local_name(child.tag) == "inMargin":
399
+ return (
400
+ int(child.get("left", "0") or 0),
401
+ int(child.get("right", "0") or 0),
402
+ int(child.get("top", "0") or 0),
403
+ int(child.get("bottom", "0") or 0),
404
+ )
405
+ left, right = _cell_margin(element)
406
+ top, bottom = _cell_margin_vertical(element)
407
+ return left, right, top, bottom
408
+
409
+
389
410
  def _first_para_line_spacing_ratio(cell: object, document: object) -> float | None:
390
411
  """Per-line em multiple from the cell's first paragraph line spacing (PERCENT).
391
412
 
@@ -478,7 +499,7 @@ def resolve_slot_metrics(
478
499
 
479
500
  raw_width = float(getattr(cell, "width", 0) or 0)
480
501
  element = getattr(cell, "element", None)
481
- left, right = _cell_margin(element) if element is not None else (0, 0)
502
+ left, right, top, bottom = _effective_cell_margins(cell)
482
503
  inner = max(raw_width - left - right, 0.0) * safety
483
504
  inline_width, inline_count = (
484
505
  _inline_object_width(element) if element is not None else (0.0, 0)
@@ -495,7 +516,6 @@ def resolve_slot_metrics(
495
516
  row_span = int(getattr(cell, "span", (1, 1))[0])
496
517
  except Exception: # pragma: no cover - defensive
497
518
  row_span = 1
498
- top, bottom = _cell_margin_vertical(element) if element is not None else (0, 0)
499
519
  inner_h = max(raw_height - top - bottom, 0.0) * safety if raw_height > 0 else 0.0
500
520
  # A cell authored shorter than one line at the tightest pitch is an auto-grow
501
521
  # floor, not a ceiling (Hancom grows the row past it); a merged row-span's
hwpx/opc/xml_utils.py CHANGED
@@ -4,9 +4,10 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  from io import BytesIO
7
+ import re
7
8
  from typing import Mapping
8
9
 
9
- from lxml import etree
10
+ import lxml.etree as etree
10
11
 
11
12
  from .security import guard_xml_bytes, guard_xml_depth
12
13
 
@@ -25,19 +26,36 @@ _HWPML_2016_TO_2011: tuple[tuple[bytes, bytes], ...] = (
25
26
  )
26
27
 
27
28
 
29
+ _XML_MARKUP = re.compile(
30
+ rb"<!--.*?-->|<!\[CDATA\[.*?\]\]>|<\?.*?\?>|<[^!?](?:[^>\"']|\"[^\"]*\"|'[^']*')*>",
31
+ re.S,
32
+ )
33
+ _XML_ATTRIBUTE = re.compile(rb"([\w:.-]+)(\s*=\s*)([\"'])(.*?)\3", re.S)
34
+
35
+
28
36
  def normalize_hwpml_namespaces(data: bytes) -> bytes:
29
- """Replace 2016 HWPML namespace URIs with their 2011 equivalents.
37
+ """Normalize namespace declarations, preserving ordinary URI-valued data.
30
38
 
31
- This is a byte-level transformation applied **before** XML parsing so that
32
- all downstream code can rely on a single, consistent set of namespace
33
- constants (the 2011 family). The replacement is harmless for documents
34
- that already use 2011 namespaces because the 2016 byte sequences simply
35
- won't appear.
39
+ In particular, hp:case/@hp:required-namespace is a feature-selection value,
40
+ not a namespace declaration. Rewriting it can select the wrong switch case.
41
+ Comments, CDATA, processing instructions and quoted non-xmlns values stay
42
+ byte-identical. The parser still uses the canonical 2011 namespace family.
36
43
  """
37
- for old, new in _HWPML_2016_TO_2011:
38
- if old in data:
39
- data = data.replace(old, new)
40
- return data
44
+ mapping = dict(_HWPML_2016_TO_2011)
45
+
46
+ def attribute(match: re.Match[bytes]) -> bytes:
47
+ name, equals, quote, value = match.groups()
48
+ if name == b"xmlns" or name.startswith(b"xmlns:"):
49
+ value = mapping.get(value, value)
50
+ return name + equals + quote + value + quote
51
+
52
+ def markup(match: re.Match[bytes]) -> bytes:
53
+ tag = match.group()
54
+ if tag.startswith((b"<!", b"<?")):
55
+ return tag
56
+ return _XML_ATTRIBUTE.sub(attribute, tag)
57
+
58
+ return _XML_MARKUP.sub(markup, data)
41
59
 
42
60
 
43
61
  def parse_xml(data: bytes) -> etree._Element:
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Small helpers for a style-preserving paragraph text edit."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from ..errors import HwpxValueError
9
+ from ._document_primitives import _is_tab_control_element
10
+ from .namespaces import tag_local_name
11
+
12
+
13
+ def plain_text_nodes_for_edit(runs: list[ET.Element]) -> list[ET.Element]:
14
+ nodes: list[ET.Element] = []
15
+ for run in runs:
16
+ for child in run:
17
+ if tag_local_name(child.tag) == "t":
18
+ if len(child):
19
+ raise HwpxValueError(
20
+ "mixed text markup cannot be edited safely",
21
+ code="paragraph-mixed-text-unsupported",
22
+ )
23
+ nodes.append(child)
24
+ elif tag_local_name(child.tag) == "tab" or _is_tab_control_element(child):
25
+ raise HwpxValueError(
26
+ "tab controls require an explicit run target",
27
+ code="paragraph-tab-target-required",
28
+ )
29
+ return nodes
30
+
31
+
32
+ def edit_node_candidates(
33
+ nodes: list[ET.Element], prefix: int, end: int
34
+ ) -> list[tuple[ET.Element, int]]:
35
+ offset = 0
36
+ candidates: list[tuple[ET.Element, int]] = []
37
+ for node in nodes:
38
+ length = len(node.text or "")
39
+ if offset <= prefix and end <= offset + length:
40
+ candidates.append((node, offset))
41
+ offset += length
42
+ return candidates
hwpx/oxml/objects.py CHANGED
@@ -1303,6 +1303,33 @@ class HwpxOxmlShape:
1303
1303
 
1304
1304
  # --- size access -------------------------------------------------------
1305
1305
 
1306
+ def set_position(self, *, horizontal_offset: int, vertical_offset: int) -> None:
1307
+ """Set an existing floating shape's offsets, in HWP units.
1308
+
1309
+ The existing reference frames, alignment, anchor, geometry and size
1310
+ are preserved. Inline shapes and missing positioning metadata refuse
1311
+ before mutation: changing their offsets would not move the drawing.
1312
+ """
1313
+ from ..errors import HwpxValueError
1314
+
1315
+ for value in (horizontal_offset, vertical_offset):
1316
+ if isinstance(value, bool) or not isinstance(value, int) or not -(2**31) <= value < 2**31:
1317
+ raise HwpxValueError(
1318
+ "shape offsets must be signed 32-bit integer HWP units",
1319
+ code="shape-position-value",
1320
+ suggestion="Pass integer offsets in the shape's existing reference frame.",
1321
+ )
1322
+ position = self.element.find(f"{_HP}pos")
1323
+ if position is None or position.get("treatAsChar") not in {"0", "false", "False"}:
1324
+ raise HwpxValueError(
1325
+ "position editing requires an existing floating shape",
1326
+ code="shape-position-unsupported",
1327
+ suggestion="Inspect the anchor; inline placement is controlled by paragraph flow.",
1328
+ )
1329
+ position.set("horzOffset", str(horizontal_offset))
1330
+ position.set("vertOffset", str(vertical_offset))
1331
+ self.paragraph.section.mark_dirty()
1332
+
1306
1333
  @property
1307
1334
  def width(self) -> int:
1308
1335
  sz = self.element.find(f"{_HP}sz")
hwpx/oxml/paragraph.py CHANGED
@@ -40,6 +40,7 @@ from .note_authoring import (
40
40
  _paragraph_endnotes,
41
41
  _paragraph_footnotes,
42
42
  )
43
+ from ._paragraph_text_edit import edit_node_candidates, plain_text_nodes_for_edit
43
44
  from .objects import (
44
45
  HwpxOxmlInlineObject,
45
46
  _create_picture_element,
@@ -336,6 +337,42 @@ class HwpxOxmlParagraph:
336
337
  _clear_paragraph_layout_cache(self.element)
337
338
  self.section.mark_dirty()
338
339
 
340
+ def set_text_preserving_runs(self, value: str) -> None:
341
+ """Replace one unambiguous text span without changing run boundaries.
342
+
343
+ The edit must fit inside one plain ``hp:t`` node. This covers a
344
+ correction within a styled run while retaining every other run and
345
+ control; cross-style replacements require an explicit run target.
346
+ """
347
+ from ..errors import HwpxValueError
348
+
349
+ nodes = plain_text_nodes_for_edit(self._run_elements())
350
+ old = "".join(node.text or "" for node in nodes)
351
+ if old == value:
352
+ return
353
+ if not nodes:
354
+ raise HwpxValueError("paragraph has no plain text node", code="paragraph-text-missing")
355
+ prefix = 0
356
+ while prefix < min(len(old), len(value)) and old[prefix] == value[prefix]:
357
+ prefix += 1
358
+ suffix = 0
359
+ while (suffix < len(old) - prefix and suffix < len(value) - prefix
360
+ and old[len(old) - suffix - 1] == value[len(value) - suffix - 1]):
361
+ suffix += 1
362
+ end = len(old) - suffix
363
+ replacement = value[prefix:len(value) - suffix if suffix else len(value)]
364
+ candidates = edit_node_candidates(nodes, prefix, end)
365
+ if len(candidates) != 1:
366
+ raise HwpxValueError(
367
+ "text edit crosses run boundaries or has ambiguous insertion style",
368
+ code="paragraph-text-style-ambiguous",
369
+ )
370
+ node, offset = candidates[0]
371
+ current = node.text or ""
372
+ node.text = _sanitize_text(current[:prefix - offset] + replacement + current[end - offset:])
373
+ _clear_paragraph_layout_cache(self.element)
374
+ self.section.mark_dirty()
375
+
339
376
  def clear_text(self) -> None:
340
377
  """Remove all text content while preserving styles and non-text elements.
341
378
 
@@ -22,7 +22,7 @@ from ._document_primitives import (
22
22
  _paragraph_id,
23
23
  )
24
24
  from .numbering import SectionStartNumbering
25
- from .section_story import HwpxOxmlSectionHeaderFooter
25
+ from .section_story import HwpxOxmlSectionHeaderFooter, _section_story_elements
26
26
 
27
27
  if TYPE_CHECKING:
28
28
  from .section import HwpxOxmlSection
@@ -1163,7 +1163,7 @@ class HwpxOxmlSectionProperties:
1163
1163
  @property
1164
1164
  def headers(self) -> list[HwpxOxmlSectionHeaderFooter]:
1165
1165
  wrappers: list[HwpxOxmlSectionHeaderFooter] = []
1166
- for element in self.element.findall(f"{_HP}header"):
1166
+ for element in _section_story_elements(self, "header"):
1167
1167
  apply = self._match_apply_for_element("header", element)
1168
1168
  wrappers.append(HwpxOxmlSectionHeaderFooter(element, self, apply))
1169
1169
  return wrappers
@@ -1171,24 +1171,31 @@ class HwpxOxmlSectionProperties:
1171
1171
  @property
1172
1172
  def footers(self) -> list[HwpxOxmlSectionHeaderFooter]:
1173
1173
  wrappers: list[HwpxOxmlSectionHeaderFooter] = []
1174
- for element in self.element.findall(f"{_HP}footer"):
1174
+ for element in _section_story_elements(self, "footer"):
1175
1175
  apply = self._match_apply_for_element("footer", element)
1176
1176
  wrappers.append(HwpxOxmlSectionHeaderFooter(element, self, apply))
1177
1177
  return wrappers
1178
1178
 
1179
1179
  def get_header(self, page_type: str = "BOTH") -> Optional[HwpxOxmlSectionHeaderFooter]:
1180
- element = self._find_header_footer("header", page_type)
1181
- if element is None:
1182
- return None
1183
- apply = self._match_apply_for_element("header", element)
1184
- return HwpxOxmlSectionHeaderFooter(element, self, apply)
1180
+ return self._get_story("header", page_type)
1185
1181
 
1186
1182
  def get_footer(self, page_type: str = "BOTH") -> Optional[HwpxOxmlSectionHeaderFooter]:
1187
- element = self._find_header_footer("footer", page_type)
1188
- if element is None:
1183
+ return self._get_story("footer", page_type)
1184
+
1185
+ def _get_story(self, kind: str, page_type: str) -> Optional[HwpxOxmlSectionHeaderFooter]:
1186
+ from ..errors import HwpxValueError
1187
+
1188
+ matches = [s for s in _section_story_elements(self, kind)
1189
+ if s.get("applyPageType", "BOTH") == page_type]
1190
+ if len(matches) > 1:
1191
+ raise HwpxValueError(
1192
+ "multiple stories match the page type", code="story-ambiguous",
1193
+ suggestion="Inspect the complete story list and select one native ID.",
1194
+ )
1195
+ if not matches:
1189
1196
  return None
1190
- apply = self._match_apply_for_element("footer", element)
1191
- return HwpxOxmlSectionHeaderFooter(element, self, apply)
1197
+ element = matches[0]
1198
+ return HwpxOxmlSectionHeaderFooter(element, self, self._match_apply_for_element(kind, element))
1192
1199
 
1193
1200
  def set_header_text(self, text: str, page_type: str = "BOTH") -> HwpxOxmlSectionHeaderFooter:
1194
1201
  element = self._ensure_header_footer("header", page_type)
@@ -53,7 +53,7 @@ def _simple_story_text_target(
53
53
  paragraph_children = list(paragraph)
54
54
  runs = _direct_children(paragraph, f"{_HP}run")
55
55
  if len(runs) != 1 or any(
56
- child.tag not in {f"{_HP}run", f"{_HP}lineSegArray"}
56
+ child.tag not in {f"{_HP}run", f"{_HP}lineSegArray", f"{_HP}linesegarray"}
57
57
  for child in paragraph_children
58
58
  ):
59
59
  raise ValueError("header story has rich or control-bearing paragraph content")
@@ -165,6 +165,81 @@ def _find_target_mirror(
165
165
  return targets[0] if targets else None
166
166
 
167
167
 
168
+ def _section_story_elements(properties: Any, kind: str) -> list[ET.Element]:
169
+ """Expose native control-only stories as well as legacy logical stories."""
170
+ logical = properties.element.findall(f"{_HP}{kind}")
171
+ identities = {(s.get("id"), s.get("applyPageType", "BOTH")) for s in logical}
172
+ return logical + [
173
+ story for _, story in _iter_control_stories(properties.section.element, kind)
174
+ if (story.get("id"), story.get("applyPageType", "BOTH")) not in identities
175
+ ]
176
+
177
+
178
+ def _set_control_story_text(element: ET.Element, properties: Any, value: str) -> None:
179
+ """Edit a unique native control in place, without synthesizing a secPr copy."""
180
+ from ..errors import HwpxValueError
181
+
182
+ kind = _story_kind(element)
183
+ native_id, page_type = element.get("id"), element.get("applyPageType")
184
+ conflicts = [
185
+ s for s in _section_story_elements(properties, kind)
186
+ if s.get("id") == native_id or s.get("applyPageType", "BOTH") == page_type
187
+ ]
188
+ if (not native_id or page_type not in {"BOTH", "EVEN", "ODD"}
189
+ or len(conflicts) != 1 or conflicts[0] is not element):
190
+ raise HwpxValueError(
191
+ "native story identity is missing or ambiguous", code="story-ambiguous",
192
+ suggestion="Inspect story IDs and page types before selecting one control.",
193
+ )
194
+ mirror = _find_target_mirror(properties.section.element, kind, native_id, page_type)
195
+ if mirror is not element or properties._apply_elements(kind):
196
+ raise HwpxValueError(
197
+ "native story linkage is inconsistent", code="story-linkage",
198
+ suggestion="Inspect the existing control and section apply metadata.",
199
+ )
200
+ paragraph, text = _native_story_text_target(element)
201
+ changed = text.text != value
202
+ text.text = value
203
+ changed = bool(_clear_paragraph_layout_cache(paragraph)) or changed
204
+ if changed:
205
+ properties.section.mark_dirty()
206
+
207
+
208
+ def _native_story_text_target(element: ET.Element) -> tuple[ET.Element, ET.Element]:
209
+ """Find one visible simple text leaf while retaining empty native paragraphs."""
210
+ from ..errors import HwpxValueError
211
+
212
+ invalid = HwpxValueError(
213
+ "native story must have one visible text leaf without rich controls",
214
+ code="story-rich-content",
215
+ suggestion="Select individual runs for rich content instead of replacing the story.",
216
+ )
217
+ sublists = _direct_children(element, f"{_HP}subList")
218
+ if len(sublists) != 1 or list(element) != sublists:
219
+ raise invalid
220
+ targets: list[tuple[ET.Element, ET.Element]] = []
221
+ for paragraph in sublists[0]:
222
+ if paragraph.tag != f"{_HP}p":
223
+ raise invalid
224
+ targets.extend((paragraph, text) for text in _native_paragraph_texts(paragraph, invalid))
225
+ if len(targets) != 1:
226
+ raise invalid
227
+ return targets[0]
228
+
229
+
230
+ def _native_paragraph_texts(paragraph: ET.Element, invalid: ValueError) -> Iterator[ET.Element]:
231
+ for run in paragraph:
232
+ if run.tag in {f"{_HP}lineSegArray", f"{_HP}linesegarray"}:
233
+ continue
234
+ if run.tag != f"{_HP}run":
235
+ raise invalid
236
+ for text in run:
237
+ if text.tag != f"{_HP}t" or list(text):
238
+ raise invalid
239
+ if text.text:
240
+ yield text
241
+
242
+
168
243
  def _story_structure_signature(
169
244
  element: ET.Element,
170
245
  parts: tuple[ET.Element, ET.Element, ET.Element, ET.Element],
@@ -381,6 +456,9 @@ class HwpxOxmlSectionHeaderFooter:
381
456
 
382
457
  _validate_simple_story_value(value)
383
458
  kind = _story_kind(self.element)
459
+ if not any(self.element is s for s in self._properties.element.findall(f"{_HP}{kind}")):
460
+ _set_control_story_text(self.element, self._properties, value)
461
+ return
384
462
  native_id, page_type = _validate_logical_story_identity(
385
463
  self.element, self._properties, kind
386
464
  )
hwpx/oxml/table.py CHANGED
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
  import re as _re
7
7
  from copy import deepcopy
8
8
  from dataclasses import dataclass
9
- from typing import TYPE_CHECKING, Any, Iterator, Sequence, cast
9
+ from typing import TYPE_CHECKING, Any, Iterator, NoReturn, Sequence, cast
10
10
  import xml.etree.ElementTree as ET
11
11
 
12
12
  from ._document_primitives import (
@@ -42,6 +42,70 @@ if TYPE_CHECKING:
42
42
  from .section import HwpxOxmlSection
43
43
 
44
44
 
45
+ def _set_cell_borders_preserving(table: Any, cell: Any, color: str, line_type: str) -> None:
46
+ from ..errors import HwpxValueError
47
+ from ._document_primitives import _normalize_border_type
48
+ from .namespaces import HH
49
+
50
+ def refuse(message: str) -> NoReturn:
51
+ raise HwpxValueError(
52
+ message, code="cell-border-edit-unsupported",
53
+ suggestion="Select a cell with one existing complete border style and a #RRGGBB color.",
54
+ )
55
+
56
+ if not isinstance(color, str) or _re.fullmatch(r"#[0-9A-Fa-f]{6}", color) is None:
57
+ refuse("border color must be #RRGGBB")
58
+ document = table.paragraph.section.document
59
+ if document is None or not document.headers:
60
+ refuse("cell border edit requires an attached document header")
61
+ header = document.headers[0]
62
+ try:
63
+ line_type = _normalize_border_type(line_type, header._BORDER_LINE_TYPES)
64
+ except (ValueError, TypeError, AttributeError):
65
+ refuse("border line type is unsupported")
66
+ container = header._border_fills_element()
67
+ if container is None:
68
+ refuse("existing border style container is missing")
69
+ reference = cell.element.get("borderFillIDRef") or table.element.get("borderFillIDRef")
70
+ matches = [n for n in container if n.tag == HH + "borderFill" and n.get("id") == reference]
71
+ if len(matches) != 1:
72
+ refuse("existing cell border style is missing or ambiguous")
73
+ candidate = deepcopy(matches[0])
74
+ _color_existing_borders(candidate, color, line_type)
75
+ key = _border_edit_signature(candidate, root=True)
76
+ for existing in container:
77
+ if existing.tag == HH + "borderFill" and _border_edit_signature(existing, root=True) == key:
78
+ table.set_cell_border_fill(cell.address[0], cell.address[1], existing.get("id"))
79
+ return
80
+ candidate.set("id", header._allocate_border_fill_id(container))
81
+ container.append(candidate)
82
+ container.set("itemCnt", str(len(container.findall(HH + "borderFill"))))
83
+ header.mark_dirty()
84
+ cell.element.set("borderFillIDRef", candidate.get("id"))
85
+ table.mark_dirty()
86
+
87
+
88
+ def _color_existing_borders(candidate: Any, color: str, line_type: str) -> None:
89
+ from ..errors import HwpxValueError
90
+ from .namespaces import HH
91
+
92
+ for name in ("leftBorder", "rightBorder", "topBorder", "bottomBorder"):
93
+ sides = candidate.findall(HH + name)
94
+ if len(sides) != 1:
95
+ raise HwpxValueError(
96
+ "existing cell border style requires exactly four sides",
97
+ code="cell-border-edit-unsupported",
98
+ suggestion="Select a cell with one existing complete border style.",
99
+ )
100
+ sides[0].set("color", color.upper())
101
+ sides[0].set("type", line_type)
102
+
103
+
104
+ def _border_edit_signature(node: Any, *, root: bool = False) -> tuple[Any, ...]:
105
+ attributes = tuple(sorted((k, v) for k, v in node.attrib.items() if not (root and k == "id")))
106
+ return node.tag, attributes, node.text, node.tail, tuple(_border_edit_signature(c) for c in node)
107
+
108
+
45
109
  def _wrap_paragraph(
46
110
  element: ET.Element,
47
111
  section: "HwpxOxmlSection",
@@ -400,6 +464,30 @@ class HwpxOxmlTable:
400
464
  ")"
401
465
  )
402
466
 
467
+ @property
468
+ def height(self) -> int:
469
+ """Declared table height in HWP units."""
470
+ size = self.element.find(f"{_HP}sz")
471
+ return int(size.get("height", "0")) if size is not None else 0
472
+
473
+ @property
474
+ def treat_as_char(self) -> bool:
475
+ """Whether Hancom lays out this table as one inline character."""
476
+ position = self.element.find(f"{_HP}pos")
477
+ return position is not None and position.get("treatAsChar", "1") == "1"
478
+
479
+ def set_treat_as_char(self, value: bool) -> None:
480
+ """Choose inline layout or flowing table layout across pages."""
481
+ position = self.element.find(f"{_HP}pos")
482
+ if position is None:
483
+ from ..errors import HwpxValueError
484
+
485
+ raise HwpxValueError("table has no hp:pos", code="table-position-missing")
486
+ desired = "1" if value else "0"
487
+ if position.get("treatAsChar") != desired:
488
+ position.set("treatAsChar", desired)
489
+ self.mark_dirty()
490
+
403
491
  # --- caption (hp:caption) -----------------------------------------
404
492
 
405
493
  @property
@@ -784,6 +872,17 @@ class HwpxOxmlTable:
784
872
  cell.element.set("borderFillIDRef", str(border_fill_id_ref))
785
873
  self.mark_dirty()
786
874
 
875
+ def set_cell_borders(
876
+ self, row_index: int, col_index: int, *, color: str, line_type: str = "SOLID"
877
+ ) -> None:
878
+ """Change four existing border colors/types, preserving fill and widths.
879
+
880
+ Copies and deduplicates the complete existing style; other cells that
881
+ share it remain unchanged. Missing or ambiguous style definitions refuse.
882
+ """
883
+ cell = self.cell(row_index, col_index)
884
+ _set_cell_borders_preserving(self, cell, color, line_type)
885
+
787
886
  def set_cell_shading(self, row_index: int, col_index: int, color: str) -> None:
788
887
  cell = self.cell(row_index, col_index)
789
888
  document = self.paragraph.section.document
hwpx/table_patch.py CHANGED
@@ -1165,7 +1165,9 @@ def _split_cell_vertical(table: str, row: int, col: int, sizes: Sequence[int]) -
1165
1165
  return out
1166
1166
 
1167
1167
 
1168
- def _insert_row_by_clone(table: str, ref_row: int, count: int = 1) -> str:
1168
+ def _insert_row_by_clone(
1169
+ table: str, ref_row: int, count: int = 1, *, used_ids: set[int] | None = None
1170
+ ) -> str:
1169
1171
  """Insert *count* rows after physical row *ref_row* by cloning it (formatting
1170
1172
  preserved, paragraph ids refreshed). Rows below shift; cells spanning across
1171
1173
  the insertion grow their rowSpan."""
@@ -1173,7 +1175,7 @@ def _insert_row_by_clone(table: str, ref_row: int, count: int = 1) -> str:
1173
1175
  if count < 1:
1174
1176
  return table
1175
1177
  prefix, rows, suffix = _parse_table(table)
1176
- if ref_row >= len(rows):
1178
+ if not 0 <= ref_row < len(rows):
1177
1179
  raise TableStructureError(f"ref row {ref_row} out of range")
1178
1180
 
1179
1181
  def shift(tc: str):
@@ -1192,10 +1194,20 @@ def _insert_row_by_clone(table: str, ref_row: int, count: int = 1) -> str:
1192
1194
  ref = rows[ref_row]
1193
1195
  if any((_si(tc, "cellSpan", "rowSpan") or 1) != 1 for tc in _S_TC.findall(ref)):
1194
1196
  raise TableStructureError("clone source row must have rowSpan==1 cells")
1197
+ occupied = set(used_ids or ()) | {int(m.group(2)) for m in _PARA_ID_RE.finditer(table)}
1198
+ next_id = 1
1199
+
1200
+ def fresh_id(match: re.Match[str]) -> str:
1201
+ nonlocal next_id
1202
+ while next_id in occupied:
1203
+ next_id += 1
1204
+ occupied.add(next_id)
1205
+ return match.group(1) + str(next_id) + match.group(3)
1206
+
1195
1207
  clones = []
1196
1208
  for k in range(1, count + 1):
1197
1209
  clone = _map_cells(ref, lambda tc: _ss(tc, "cellAddr", "rowAddr", ref_row + k))
1198
- clone = _refresh_ids(clone, 1000 + k)
1210
+ clone = _PARA_ID_RE.sub(fresh_id, clone)
1199
1211
  clones.append(clone)
1200
1212
  new_rows = shifted[: ref_row + 1] + clones + shifted[ref_row + 1:]
1201
1213
  return _rebuild(prefix, new_rows, suffix, rowcnt=len(new_rows))
@@ -1830,7 +1842,15 @@ def apply_table_ops(
1830
1842
  new_section = section[:ps1] + new_first + section[pe2:]
1831
1843
  dims_after = _table_dims(merged_table)
1832
1844
  elif name in _STRUCT_OPS:
1833
- new_table = _STRUCT_OPS[name](section[ts:te].decode("utf-8"), op)
1845
+ if name == "insert_row_by_clone":
1846
+ used_ids = {int(m.group(2)) for data in sections.values()
1847
+ for m in _PARA_ID_RE.finditer(data.decode("utf-8"))}
1848
+ new_table = _insert_row_by_clone(
1849
+ section[ts:te].decode("utf-8"), op["ref_row"],
1850
+ int(op.get("count", 1)), used_ids=used_ids,
1851
+ )
1852
+ else:
1853
+ new_table = _STRUCT_OPS[name](section[ts:te].decode("utf-8"), op)
1834
1854
  _validate_or_raise(new_table)
1835
1855
  new_section = section[:ts] + new_table.encode("utf-8") + section[te:]
1836
1856
  dims_after = _table_dims(new_table)
@@ -3,9 +3,10 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from dataclasses import dataclass
7
6
  import re
8
- from typing import TYPE_CHECKING, Literal, Mapping, TypedDict
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Literal, TypedDict
9
10
 
10
11
  from ..oxml import HwpxOxmlParagraph, HwpxOxmlTable
11
12
 
@@ -13,8 +14,8 @@ if TYPE_CHECKING:
13
14
  from ..document import HwpxDocument
14
15
 
15
16
  __all__ = [
16
- "SearchDirection",
17
17
  "PathDirection",
18
+ "SearchDirection",
18
19
  "TableCellReference",
19
20
  "TableFillApplied",
20
21
  "TableFillFailed",
@@ -339,14 +340,19 @@ def _move(
339
340
  col_index: int,
340
341
  direction: PathDirection,
341
342
  ) -> tuple[int, int] | None:
343
+ # A direction crosses the current physical cell's edge. Moving one grid
344
+ # slot could otherwise stay inside a merged label and overwrite the label.
345
+ grid = table.get_cell_map()
346
+ current = grid[row_index][col_index]
347
+ anchor_row, anchor_col = current.anchor
342
348
  row_delta, col_delta = _direction_delta(direction)
343
- target_row = row_index + row_delta
344
- target_col = col_index + col_delta
349
+ target_row = anchor_row + (current.row_span if row_delta > 0 else row_delta)
350
+ target_col = anchor_col + (current.col_span if col_delta > 0 else col_delta)
345
351
  if target_row < 0 or target_col < 0:
346
352
  return None
347
353
  if target_row >= table.row_count or target_col >= table.column_count:
348
354
  return None
349
- return (target_row, target_col)
355
+ return grid[target_row][target_col].anchor
350
356
 
351
357
 
352
358
  def _find_label_candidates(
@@ -359,20 +365,24 @@ def _find_label_candidates(
359
365
 
360
366
  candidates: list[_LabelCandidate] = []
361
367
  for table_ref in tables:
362
- for row_index in range(table_ref.table.row_count):
363
- for col_index in range(table_ref.table.column_count):
364
- cell_text = _cell_text(table_ref.table, row_index, col_index)
365
- if _normalize_label_text(cell_text) != normalized_label:
366
- continue
367
- candidates.append(
368
- _LabelCandidate(
369
- table_index=table_ref.table_index,
370
- table=table_ref.table,
371
- row=row_index,
372
- col=col_index,
373
- text=cell_text,
374
- )
368
+ # Count physical cells, not every logical slot occupied by a merge.
369
+ # Distinct cells with the same label remain ambiguous.
370
+ for position in table_ref.table.iter_grid():
371
+ if not position.is_anchor:
372
+ continue
373
+ row_index, col_index = position.anchor
374
+ cell_text = _cell_text(table_ref.table, row_index, col_index)
375
+ if _normalize_label_text(cell_text) != normalized_label:
376
+ continue
377
+ candidates.append(
378
+ _LabelCandidate(
379
+ table_index=table_ref.table_index,
380
+ table=table_ref.table,
381
+ row=row_index,
382
+ col=col_index,
383
+ text=cell_text,
375
384
  )
385
+ )
376
386
  return candidates
377
387
 
378
388
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 6.3.0
3
+ Version: 6.5.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -68,24 +68,26 @@ Dynamic: license-file
68
68
 
69
69
  한컴오피스가 없어도 됩니다. HWPX는 ZIP+XML(OWPML) 포맷이라 순수 파이썬만으로
70
70
  읽고, 고치고, 새로 만들 수 있습니다 — Windows·macOS·Linux·CI, 그리고
71
- **파이썬이 도는 ChatGPT 채팅 안에서도** 그대로 동작합니다. 기존 문서는 손댄
72
- 곳만 바뀌고 나머지는 바이트 그대로 유지되며, 새 문서는 실제 한컴오피스가 여는
73
- 형태로 만들어집니다.
71
+ **파이썬이 도는 ChatGPT 채팅 안에서도** 그대로 동작합니다. 기존 문서의 저장은 요청한 보존 등급과 실제 검증 결과를 영수증으로 남깁니다.
72
+ 새 문서의 한컴 호환성은 아래에 날짜·버전을 명시한 코퍼스로 측정합니다.
74
73
 
75
74
  <p align="center">
76
75
  <img src="https://raw.githubusercontent.com/airmang/python-hwpx/main/docs/assets/chatgpt-formfill.png" width="760" alt="일반 ChatGPT 대화에 hwpx 양식을 올려 지도안 작성을 부탁하고, 양식이 유지된 채 채워진 문서를 돌려받는 화면">
77
76
  </p>
78
77
  <p align="center"><sub>일반 ChatGPT 대화 — 양식 <code>.hwpx</code>를 올리고 말로 부탁하면, 서식을 유지한 채 채워진 문서가 돌아옵니다.</sub></p>
79
78
 
80
- **ChatGPT에서 그대로 따라 하기** — 문서를 올리면서 이렇게 부탁하면 됩니다:
79
+ **ChatGPT에서 그대로 따라 하기** — ChatGPT 채팅의 파이썬 환경은 PyPI 접근이
80
+ 막혀 있으므로 [최신 Release](https://github.com/airmang/python-hwpx/releases/latest)에서
81
+ `python_hwpx-*.whl` 하나를 받아 문서와 **함께 올리고** 이렇게 부탁합니다:
81
82
 
82
83
  ```text
84
+ 첨부한 python_hwpx-*.whl 을 pip 로 설치한 다음(pip install /mnt/data/python_hwpx-*.whl),
83
85
  이 .hwpx 파일을 python-hwpx 라이브러리로 열어서 작업해줘.
84
- (pip install python-hwpx 로 설치하면 돼)
85
86
  양식과 서식은 그대로 두고, ○○만 바꿔서 새 파일로 돌려줘.
86
87
  ```
87
88
 
88
89
  설치부터 결과 파일까지 대화 안에서 끝납니다 — 내 컴퓨터에 파이썬이 없어도 됩니다.
90
+ 자세한 절차와 에이전트용 지시문은 [AI 채팅 환경에서 쓰기](docs/ai-assistants.md)에,
89
91
  AI 도구가 정확한 API를 배우도록 [llms.txt](https://airmang.github.io/python-hwpx/llms.txt)도 제공합니다.
90
92
 
91
93
  | | 저장소 | 역할 |
@@ -136,25 +138,32 @@ doc.save_to_path("계획.hwpx")
136
138
 
137
139
  ```python
138
140
  doc = HwpxDocument.open("신청서.hwpx")
139
- result = doc.fill_by_path({
141
+ values = {
140
142
  "성명 > right": "홍길동",
141
143
  "소속 > right": "플랫폼팀",
142
- })
143
- doc.save_to_path("신청서-작성완료.hwpx")
144
+ }
145
+ result = doc.tables.fill_by_path(values)
146
+ if result["failed_count"] or result["applied_count"] != len(values):
147
+ raise ValueError(result["failed"])
148
+ report = doc.save_to_path("신청서-작성완료.hwpx", mode="patch", fallback="error", return_report=True)
144
149
  ```
145
150
 
146
- 라벨 기준으로 셀을 찾아 채우고, 손대지 않은 영역은 원본 바이트가 그대로 유지됩니다.
151
+ 중복·누락 라벨은 실패로 확인하고 저장을 중단합니다. patch는 미수정 ZIP 파트의
152
+ 바이트 보존을 요구합니다. 수정 파트 내부의 보존과 시각적 배치는 별도 확인이
153
+ 필요합니다. 출력 재개봉과 내용 확인까지 포함한 경로는 [안전한 쓰기 계약](docs/safe-write-contract.md)을 따르세요.
147
154
 
148
155
  ### 저장에는 영수증이 따라옵니다
149
156
 
150
157
  ```python
151
158
  report = doc.save_to_path("결과.hwpx", return_report=True)
152
- print(report.actual_mode) # "patch" — 문서 재조립 없이 저장됨
159
+ print(report.actual_mode) # "patch" 또는 "rebuild" — 실제 저장 등급
153
160
  print(report.preservation.untouched_part_payloads.to_dict())
154
161
  # {"verified": 17, "changed": 0}
155
162
  ```
156
163
 
157
- 요청한 보존 등급을 지킬 수 없으면 아무것도 쓰지 않고 실패합니다(fail-closed).
164
+ `mode="patch", fallback="error"`는 보존 등급 미달 시 출력하지 않습니다.
165
+ 기본 `auto`는 달성 가능한 등급을 선택합니다. `report.ok`는 요청 반영이나 시각
166
+ 검증 완료를 뜻하지 않습니다.
158
167
  전체 규칙: [안전한 쓰기 계약](docs/safe-write-contract.md).
159
168
 
160
169
  *예제 중 **독립 실행 예제** 표시가 없는 블록은 여러분의 기존 문서를 입력으로
@@ -190,17 +199,20 @@ print(report.preservation.untouched_part_payloads.to_dict())
190
199
 
191
200
  | 환경 | 무엇을 하면 되나 |
192
201
  |---|---|
193
- | 일반 ChatGPT 대화 | `.hwpx`를 올리고 `pip install python-hwpx` 후 파이썬으로 편집해 되받기 |
202
+ | 일반 ChatGPT 대화 | `.hwpx`와 [Release wheel](https://github.com/airmang/python-hwpx/releases/latest)을 함께 올리고 오프라인 설치 후 편집해 되받기 — [안내](docs/ai-assistants.md) |
194
203
  | 로컬·서버 파이썬 | `pip install python-hwpx` — 스크립트·배치·CI |
195
204
  | 파이썬 자동화 (MCP 없이) | `pip install python-hwpx-automation` — 저작·양식 채움·검증 워크플로를 그냥 파이썬으로 |
196
205
  | ChatGPT MCP 앱 | [`python-hwpx-automation`](https://github.com/airmang/python-hwpx-automation)의 MCP adapter를 커넥터로 등록 |
197
206
  | Codex 마켓플레이스 플러그인 | [`hwpx-plugins`](https://github.com/airmang/hwpx-plugins) 설치 |
198
207
  | Claude Code · Hermes · OpenClaw | 같은 MCP 서버를 각 클라이언트에 등록 ([`python-hwpx-automation`](https://github.com/airmang/python-hwpx-automation)) |
199
208
 
200
- 실제로 일반 ChatGPT 대화에 `.hwpx`를 올리고 설치를 부탁했을 때 PyPI 설치와
201
- 편집·되받기까지 동작하는 것을 확인했습니다. 파이썬 실행과 네트워크 허용 범위는
202
- 플랜·설정에 따라 다르며, 네트워크가 막힌 환경에서는 wheel 파일을 함께 올리면
203
- 오프라인 설치로 같은 여정이 됩니다.
209
+ ChatGPT 채팅의 파이썬 환경은 현재 PyPI 접근이 막혀 있습니다. 매 릴리스의
210
+ GitHub Release에 첨부되는 `py3-none-any` wheel을 문서와 함께 올리면 오프라인
211
+ 설치로 같은 여정이 됩니다. wheel의 유일한 의존성은 `lxml`이며, 실행 환경에
212
+ 없으면 lxml wheel도 함께 올립니다. 같은 wheel을 GitHub Actions artifact
213
+ `python-hwpx-wheel`로도 유지하므로, artifact를 가져올 수 있는 에이전트는 첨부
214
+ 없이 설치할 수 있습니다(실험 경로, [안내](docs/ai-assistants.md)). 파이썬 실행
215
+ 가능 여부와 업로드 경로는 플랜·설정에 따라 다릅니다.
204
216
 
205
217
  ## 비교
206
218
 
@@ -1,6 +1,6 @@
1
1
  hwpx/__init__.py,sha256=DqoPNdD-4-oP4b_56P5ADABnyRGcDZiHch__S-urI4s,16642
2
2
  hwpx/body_patch.py,sha256=g8RhaFQ9nf3grVI7qVPEwJc06x0JmwXow8hkY84aT18,29165
3
- hwpx/capabilities.py,sha256=1tCpz9-C1yzyvmAVNSuYdvqBy95DgbAu9efhD781HMI,42357
3
+ hwpx/capabilities.py,sha256=7s_dJQ37PhoO0z91Q8jeAOJHEQtz5yzgGspkunBIDWU,42395
4
4
  hwpx/document.py,sha256=rG65RrwgmADeFd4TBolKUMCnBZiecfjphCP7v3dS9T0,28370
5
5
  hwpx/errors.py,sha256=Y10AKVHVzH1uAZQ2GXVyanW7S5603WT5qUfCWq4wBcg,19760
6
6
  hwpx/experimental.py,sha256=Jvc7WAi3eMFRKYubqwtIWi9oU0OjuqNOfottWkaP2RQ,2113
@@ -9,13 +9,13 @@ hwpx/mutation_report.py,sha256=Uw9fxft8kioXpQ0feQwSlq4vOj3-418Oywt7qmDmU_8,19494
9
9
  hwpx/package.py,sha256=0rKjGCJbPQvrVBIy07Jpjsu3fI7HhbqFCGWTiTDsJpo,1141
10
10
  hwpx/patch.py,sha256=k_LoVxHzIBNs4kg2V4yBOgAxjsrim7pksG0rmRmLApE,27264
11
11
  hwpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- hwpx/table_patch.py,sha256=GrQNmkdTS7nBolGOX473NSvq-PglM8qVSAH1zDaPJCQ,95805
12
+ hwpx/table_patch.py,sha256=E3x1D2bSp9fDYGatMqfxyPYLfLrqrJBiv55WPidktss,96647
13
13
  hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
14
14
  hwpx/_document/__init__.py,sha256=REiNqMbuk_TS4NVC27FIo1rUt3Z8nhJrTBGOgNtxBRg,90
15
15
  hwpx/_document/_legacy.py,sha256=kmiiJ-U8GEUrSvTGG7L-3TaEoLGucCPMgEuGfAJoTb4,59780
16
16
  hwpx/_document/_resolve.py,sha256=wl-Hhc-8PnEkG7Mrb6DlEw1fvdwFOImtiyCfTyuXC0A,7113
17
17
  hwpx/_document/_units.py,sha256=qyC8YtnvV2VlSGltJntDaIMF_sHy_XJZCwwe-g080TQ,386
18
- hwpx/_document/fields.py,sha256=5zo4c8IbAFbJYOQTYfc6by6F01IddpGa64KnPwhMFwc,33515
18
+ hwpx/_document/fields.py,sha256=CYxzSf3Kmwi2lK4xS5t9AVsV12XM1MVS6RgDtdetNT0,33423
19
19
  hwpx/_document/headings.py,sha256=UgIPCKr9lIkKUNS5cauleTHRym4ijN_kse2VaXO-OFw,7100
20
20
  hwpx/_document/highlight.py,sha256=pcc7HGZyDuo3jfhsbX_5lR67Y54o4Rssg7RCSl7eV4M,6358
21
21
  hwpx/_document/layout.py,sha256=VY4UloMVHELIM64BhEOdiu0HQ7d8yu2H6KQYQVEkJ1A,32699
@@ -39,19 +39,19 @@ hwpx/_document/ns/text.py,sha256=y7KQqP1eddBCFbjZqSYbfS_DlOZFKQ1D-x_6Lxj41dM,616
39
39
  hwpx/_document/ns/tracking.py,sha256=t3uW2D7llfLtMQygkzJuIm_IQMFOurR1f1IoG-uPnKk,4561
40
40
  hwpx/data/Skeleton.hwpx,sha256=yR-3epkzo-VkWiV3ji0rmemBN-2ZVypdHdKe8MD9TJg,7490
41
41
  hwpx/data/contract_docs/known-traps.md,sha256=InpfsjhmKvMDXrCcyRFngB9SuQt1s3fFftnq1BFaWuI,4792
42
- hwpx/data/contract_docs/mutation-semantics.md,sha256=cA7SYl7nEZOQUAI2gqXWNqUsMeHe7Pyt5Mjl637es1U,3712
43
- hwpx/data/contract_docs/recipes-traversal.md,sha256=l6t0rMdmj9g8RXfw7JsonmZEB_41tg9O7toVXW94JW8,3987
44
- hwpx/data/contract_docs/support-matrix.md,sha256=ohxfZacwzb6a0esZCs32sAYJgcC7eJeGLSYcQW38UgU,65309
42
+ hwpx/data/contract_docs/mutation-semantics.md,sha256=3GjuRgPd7W58y9N1aj7xK10U4DZR7ZUt368G3hA_gfY,4325
43
+ hwpx/data/contract_docs/recipes-traversal.md,sha256=tj9CsabtI4EmiFmUdNz4MwBQm4apS12s6uZ5v9vek0M,4419
44
+ hwpx/data/contract_docs/support-matrix.md,sha256=vODB2JsgEGFRFHkeEQovwy6McxBSEimjlkhIG4N-93Y,65733
45
45
  hwpx/equation/__init__.py,sha256=0wks0_pS_tBokCVMeWUJGE6tbW35Ptc0I5Z_36FY-Jo,1328
46
- hwpx/equation/authoring.py,sha256=s8wJWNEEFJ2oS4xbs4-Hyv7Dlml_mSQrljdy0hbzpjU,16786
46
+ hwpx/equation/authoring.py,sha256=xuLRxkIPPJmiDfSVX-ngcQYYKZRqSuHV1PBVvBvo6vY,17117
47
47
  hwpx/equation/eqedit.py,sha256=FjRNwc_JVC01yprqLFk5N6Nm5qCow2K2x-u3Dw7Wmio,11314
48
- hwpx/equation/mathml.py,sha256=mbmAojF-4e9cMEQQvgHx5wvp_dG3Mb_sPjqH7SpOaxQ,2150
49
- hwpx/equation/render.py,sha256=g698YmVWsfG4V-QNROXmSK0za-npJ4Z7xIe6Y5DZ1g0,2874
50
- hwpx/equation/tokens.py,sha256=RyFyqj3rMllFB5k4d2XO2L8CkiIySb573evJ-fwujhY,7097
48
+ hwpx/equation/mathml.py,sha256=Wbuw9hikBJoxzUdKjMDasvBe8suGGjNUXBTeNrp-GSM,4424
49
+ hwpx/equation/render.py,sha256=P475CF6xhEYveDgw2aiEHhg9TW-3ZTs3MlpyzU5PPvY,2888
50
+ hwpx/equation/tokens.py,sha256=76T57UgeE-08NVPHjN0FD3wvxvIdPksbxgjegvUgf5w,7729
51
51
  hwpx/form_fit/__init__.py,sha256=l1HcFge4u_wL2vogB-VxSMrb-8h7zUT_IgTWMtnH9CM,1779
52
52
  hwpx/form_fit/apply.py,sha256=X2q7K-mLku4AXxm4-xP80QGzhBlpzpqA_Ade8aPXzNY,3450
53
53
  hwpx/form_fit/engine.py,sha256=2W5ePGnpaLDoVPH8RxKdRfVx3r3usy26RBWj0jU1fpE,21553
54
- hwpx/form_fit/measure.py,sha256=ox1_Psitahp4hiZUyM88zLE3WcCEXsD5e5WJhUqLxpI,22312
54
+ hwpx/form_fit/measure.py,sha256=u9ZTgzdYSX7YA9iEwVZXl_YIVNtyckHA-9XR-vojx4I,23153
55
55
  hwpx/form_fit/policy.py,sha256=iXAatjS7ZycOdKgMCL4Q_kYg0qnogFPdRNlr_ecOvjc,3106
56
56
  hwpx/form_fit/report.py,sha256=h1NzQfbj1JYvZtKUi0DckJNWGnUHMZtQ3iCzbUxUjHA,3305
57
57
  hwpx/ingest/__init__.py,sha256=cjDQwMdaqbq2xiD32zmaFAyzZbENga_6MLbgo0sLGZE,636
@@ -70,10 +70,11 @@ hwpx/objects/tracked.py,sha256=uTCm-RQ7c37Yg03nEm9Q6mhcYFGFN2_pBRrwToNPiZ4,2169
70
70
  hwpx/opc/package.py,sha256=GyLnsY_zXIu_nMIvaLmLauWy2l3jiVBQl6GDRGUZl60,45742
71
71
  hwpx/opc/relationships.py,sha256=ID7wUnzv3ooq90Vu-4wQ4r1ROg8xedYcaGDXUF4FMck,7171
72
72
  hwpx/opc/security.py,sha256=0wZitXK0J55wkbsWhyMyyufq0syBu_ZsBkqozrp-2DA,9062
73
- hwpx/opc/xml_utils.py,sha256=L_fHY1-D5I_TfdRkDQV-bn55EnXc6AqEDWItfMpawVs,3840
73
+ hwpx/opc/xml_utils.py,sha256=r4wc0irlf4tOXlOAwXoqReJOY3-EIqd7Uan56XR6dBs,4474
74
74
  hwpx/oxml/__init__.py,sha256=Ogl__NFyPORPxboCHqrsn2IrRdcjaQBzmnYm4pPWXpA,6731
75
75
  hwpx/oxml/_document_impl.py,sha256=41cxktJqXHER37fjA5HIFR8kw7dHMoISTxEv53PRBYo,5367
76
76
  hwpx/oxml/_document_primitives.py,sha256=IcGRT7B4gblNgJ5XDivV_DME-5cU5p88hRSAM1L8_Hk,56887
77
+ hwpx/oxml/_paragraph_text_edit.py,sha256=8IHjDPRh1yXtCYSbL0LER9Z4_wVnXybpCh3qQ8jwmPo,1453
77
78
  hwpx/oxml/body.py,sha256=RRopsZ7rvHDMIYNoEwHPty7DXyaZaFRDqJQSI6gVIls,52196
78
79
  hwpx/oxml/canonical_defaults.py,sha256=WHAK7u_W-PDv3P3N1-1-aWAoiUgBh4x-UybFL4NqW-w,3791
79
80
  hwpx/oxml/color.py,sha256=K2lbU_JdqRxFkQelGD6BbBkH1IspJSQyGlKHNz1Nmf4,2393
@@ -95,18 +96,18 @@ hwpx/oxml/namespaces.py,sha256=SPYOj0N3qm6TCcl2xiEj7hkVdCVHqx0GpkF3kKDKoZg,6838
95
96
  hwpx/oxml/note_authoring.py,sha256=xOoYeX640XkwdftUQeHsdojGHHbiLbobZPi3aahh5ek,7198
96
97
  hwpx/oxml/numbering.py,sha256=9a0ARGW1DkKsTF7mEEXM4FA5loQmzIBuqE4APA8UFIQ,669
97
98
  hwpx/oxml/numbering_kinds.py,sha256=dikaExXr8GZ0gNL4KM5xxvGQOZDCkCdcA5qKDLsstsg,4034
98
- hwpx/oxml/objects.py,sha256=8gUCyH8dMDqiz_TmISKu_ZxIIsboZOc3wJ9A6KENKz4,54118
99
- hwpx/oxml/paragraph.py,sha256=sdoUFlQ7tsZe0mdyTlpzcj4Peh2mkjtrfL1GuqYFFDw,60979
99
+ hwpx/oxml/objects.py,sha256=kL4xy0tBmq8E598sJ67bY2xk5Zwg09apelPqQGnD44w,55570
100
+ hwpx/oxml/paragraph.py,sha256=2TN9XDwBUNA4qFBh7yTPM9M43zpnA-x_grsevh_B7fE,62747
100
101
  hwpx/oxml/parser.py,sha256=pIfyNdW3WdFkCcE8JFY7hn1fobRbMngzfhVZICvWeVs,2937
101
102
  hwpx/oxml/run.py,sha256=khz9hf2_YpQjYSNR5Qq8XKJ4EmGfiJu9LtfCfLgblnU,16375
102
103
  hwpx/oxml/schema.py,sha256=ElR3_IIhhPPZEqJtNKMNCHA-VFVLdhL454W_4spuvlc,1284
103
104
  hwpx/oxml/section.py,sha256=W91Si7-kaUvl2y_VLV5kpsXPGtT5fUwOyIRh6qQLGyM,13395
104
- hwpx/oxml/section_format.py,sha256=aXzX84AiU51qr52g34k5wDAs9033Z_ycX8v_xMqNHH8,46659
105
+ hwpx/oxml/section_format.py,sha256=Sl6D42HwT2DzxeAVgn-k129-gumynL4bAt1miUku3oQ,47001
105
106
  hwpx/oxml/section_layout.py,sha256=TyzddL31pdKSFWYfxVKdIjs3vo8UiG0lERa5-X6bKHI,5535
106
- hwpx/oxml/section_story.py,sha256=zvUV7O4dtogWcM9-zudRAszCHRwfzTBMW2GGI1Q24kg,22410
107
+ hwpx/oxml/section_story.py,sha256=OqCXk78gPL0ILQJqDBnhmjtq_tUru0j23IeXXpoWApA,25896
107
108
  hwpx/oxml/settings.py,sha256=0cI0zODpxwUgcleVR0J6I4nGT3FGSW8CI0qxwZHXaAk,5523
108
109
  hwpx/oxml/simple_parts.py,sha256=XpeVmBWOjv1UVN6sGbskYqwyCIDsNSgzsnnG4QG80Us,3810
109
- hwpx/oxml/table.py,sha256=Sl7x-h1ONpCv-_0zFpzGhAMx2JR-UMxg8M7goOn-yD4,53050
110
+ hwpx/oxml/table.py,sha256=Pgy3vW2sS2SqLEcnrQVaV8uHmmWivE5C86yX-fNrSlA,57561
110
111
  hwpx/oxml/utils.py,sha256=2L9au3SD_MFkFt2DXx3udXBT5caYuYO623lhGhgldVo,2972
111
112
  hwpx/oxml/version_part.py,sha256=kFXKQUKgClrM-LuVvBEaSD4RePV1kk1Kt8ltgttefS8,2627
112
113
  hwpx/plan/__init__.py,sha256=WvlBce8ic63hf8MsA6x-K_1BVrkNu6gaxKVIM7Axxks,1471
@@ -144,7 +145,7 @@ hwpx/tools/repair.py,sha256=JReRCUiPP44FG-1euzV56DC-RU5FnRxVZtHuGmMiVxM,11550
144
145
  hwpx/tools/report_utils.py,sha256=6HYEeQc3ZxTpxbwF11s47uZ-KmV4tsHPE1MV4491KDE,4434
145
146
  hwpx/tools/roundtrip_diff.py,sha256=ao0AdpDJkq89u5hwcrsxTijvSsia9Jaw1OOnh4WAco4,1365
146
147
  hwpx/tools/table_cleanup.py,sha256=0_f6NnvNp3QD4owKd_bRX6FZbeUmoQC7a4_VGzF2SCE,1796
147
- hwpx/tools/table_navigation.py,sha256=rtbrWFKpJhqC3LD0ZXImyHgjmDR2hjHCFy3_S-qNBwA,16479
148
+ hwpx/tools/table_navigation.py,sha256=lhXs2FcCq6irKCztfGQz07wB6WOpnj_NaVoFgIZNOG8,17007
148
149
  hwpx/tools/template_analyzer.py,sha256=iH7egGRYNb8UesDB1TJ4tvW41-mlmv5Ap3VacqJFSlE,21355
149
150
  hwpx/tools/text_extract_cli.py,sha256=BmsDAwNXpDPhEayb9ez2ORtGNzPd_Xxduy4_cLXhnUw,2188
150
151
  hwpx/tools/text_extractor.py,sha256=PZ8rCsCJMDK8wjOgfcakfoTQ5iVviH2IhWF_9l_NzY4,27929
@@ -153,10 +154,10 @@ hwpx/tools/toc_fidelity.py,sha256=rvoKH8QJ65WNVrxS0d-i2AtODvBy-4O8NRudfQ244v4,19
153
154
  hwpx/tools/validator.py,sha256=U856izL9NcJZOiKDYoCpwOSaFNQ2Un8Jn4pzL20A96Q,7100
154
155
  hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
155
156
  hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
156
- python_hwpx-6.3.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
157
- python_hwpx-6.3.0.dist-info/licenses/NOTICE,sha256=he3PBLXnIZK0sNsozc-SAQ2jvWjV61goylWUZ8HPi3s,3837
158
- python_hwpx-6.3.0.dist-info/METADATA,sha256=Ybcxl0xVXkDcdsqWplRYdERcAEZjAxQLqayu3AUnqV8,13652
159
- python_hwpx-6.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
160
- python_hwpx-6.3.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
161
- python_hwpx-6.3.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
162
- python_hwpx-6.3.0.dist-info/RECORD,,
157
+ python_hwpx-6.5.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
158
+ python_hwpx-6.5.0.dist-info/licenses/NOTICE,sha256=he3PBLXnIZK0sNsozc-SAQ2jvWjV61goylWUZ8HPi3s,3837
159
+ python_hwpx-6.5.0.dist-info/METADATA,sha256=MLawxpU4hei8fmJR8XZXiU2Mc-RBjnI3dHxuywHr7tE,14995
160
+ python_hwpx-6.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
161
+ python_hwpx-6.5.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
162
+ python_hwpx-6.5.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
163
+ python_hwpx-6.5.0.dist-info/RECORD,,