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 +7 -12
- hwpx/capabilities.py +2 -2
- hwpx/data/contract_docs/mutation-semantics.md +13 -7
- hwpx/data/contract_docs/recipes-traversal.md +13 -6
- hwpx/data/contract_docs/support-matrix.md +2 -2
- hwpx/equation/authoring.py +5 -0
- hwpx/equation/mathml.py +66 -0
- hwpx/equation/render.py +2 -2
- hwpx/equation/tokens.py +11 -0
- hwpx/form_fit/measure.py +22 -2
- hwpx/opc/xml_utils.py +29 -11
- hwpx/oxml/_paragraph_text_edit.py +42 -0
- hwpx/oxml/objects.py +27 -0
- hwpx/oxml/paragraph.py +37 -0
- hwpx/oxml/section_format.py +19 -12
- hwpx/oxml/section_story.py +79 -1
- hwpx/oxml/table.py +100 -1
- hwpx/table_patch.py +24 -4
- hwpx/tools/table_navigation.py +29 -19
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/METADATA +29 -17
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/RECORD +26 -25
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/WHEEL +0 -0
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-6.3.0.dist-info → python_hwpx-6.5.0.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
| `
|
|
21
|
-
| `
|
|
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
|
-
(
|
|
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
|
-
- `
|
|
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.
|
|
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.
|
|
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.
|
|
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 측정) |
|
hwpx/equation/authoring.py
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
37
|
+
"""Normalize namespace declarations, preserving ordinary URI-valued data.
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
hwpx/oxml/section_format.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
1191
|
-
return HwpxOxmlSectionHeaderFooter(element, self,
|
|
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)
|
hwpx/oxml/section_story.py
CHANGED
|
@@ -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(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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)
|
hwpx/tools/table_navigation.py
CHANGED
|
@@ -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
|
|
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 =
|
|
344
|
-
target_col =
|
|
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
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
+
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
|
-
|
|
141
|
+
values = {
|
|
140
142
|
"성명 > right": "홍길동",
|
|
141
143
|
"소속 > right": "플랫폼팀",
|
|
142
|
-
}
|
|
143
|
-
doc.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
43
|
-
hwpx/data/contract_docs/recipes-traversal.md,sha256=
|
|
44
|
-
hwpx/data/contract_docs/support-matrix.md,sha256=
|
|
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=
|
|
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=
|
|
49
|
-
hwpx/equation/render.py,sha256=
|
|
50
|
-
hwpx/equation/tokens.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
99
|
-
hwpx/oxml/paragraph.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
157
|
-
python_hwpx-6.
|
|
158
|
-
python_hwpx-6.
|
|
159
|
-
python_hwpx-6.
|
|
160
|
-
python_hwpx-6.
|
|
161
|
-
python_hwpx-6.
|
|
162
|
-
python_hwpx-6.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|