python-hwpx 1.3__tar.gz → 1.5__tar.gz
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.
- {python-hwpx-1.3/src/python_hwpx.egg-info → python-hwpx-1.5}/PKG-INFO +6 -2
- {python-hwpx-1.3 → python-hwpx-1.5}/README.md +5 -1
- {python-hwpx-1.3 → python-hwpx-1.5}/pyproject.toml +1 -1
- python-hwpx-1.5/src/hwpx/data/Skeleton.hwpx +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/document.py +36 -2
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/__init__.py +6 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/document.py +312 -4
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/package.py +88 -0
- {python-hwpx-1.3 → python-hwpx-1.5/src/python_hwpx.egg-info}/PKG-INFO +6 -2
- {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/SOURCES.txt +1 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_integration_hwpx_compatibility.py +89 -0
- python-hwpx-1.5/tests/test_tables_default_border.py +81 -0
- python-hwpx-1.3/src/hwpx/data/Skeleton.hwpx +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/LICENSE +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/setup.cfg +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/__init__.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/opc/package.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/body.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/common.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/header.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/parser.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/schema.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/utils.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/templates.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/__init__.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/object_finder.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/text_extractor.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/validator.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_document_formatting.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_inline_models.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_memo_and_style_editing.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_oxml_parsing.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_section_headers.py +0 -0
- {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_text_extractor_annotations.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5
|
|
4
4
|
Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License: Non-Commercial License
|
|
@@ -38,7 +38,8 @@ Requires-Dist: pytest>=7.4; extra == "test"
|
|
|
38
38
|
- **문서 편집 API** – `hwpx.document.HwpxDocument`는 문단과 표, 메모, 헤더 속성을 파이썬 객체로 노출하고 새 콘텐츠를 손쉽게 추가합니다. 섹션 머리말·꼬리말을 수정하면 `<hp:headerApply>`/`<hp:footerApply>`와 마스터 페이지 링크도 함께 갱신합니다.
|
|
39
39
|
- **타입이 지정된 본문 모델** – `hwpx.oxml.body`는 표·컨트롤·인라인 도형·변경 추적 태그를 데이터 클래스에 매핑하고, `HwpxOxmlParagraph.model`/`HwpxOxmlRun.model`로 이를 조회·수정한 뒤 XML로 되돌릴 수 있도록 지원합니다.
|
|
40
40
|
- **메모와 필드 앵커** – `add_memo_with_anchor()`로 메모를 생성하면서 MEMO 필드 컨트롤을 자동 삽입해 한/글에서 바로 표시되도록 합니다.
|
|
41
|
-
- **헤더 참조 목록 탐색** – 글머리표, 문단 속성, 스타일, 변경 추적 항목, 작성자 정보를 데이터클래스로 파싱하고 `document.bullets`·`document.styles` 같은 조회 헬퍼로 ID 기반 검색을 단순화했습니다.
|
|
41
|
+
- **헤더 참조 목록 탐색** – 글머리표, 문단 속성, 테두리 채우기, 스타일, 변경 추적 항목, 작성자 정보를 데이터클래스로 파싱하고 `document.border_fills`·`document.bullets`·`document.styles` 같은 조회 헬퍼로 ID 기반 검색을 단순화했습니다.
|
|
42
|
+
- **바탕쪽·이력·버전 파트 제어** – 매니페스트에 포함된 master-page/history/version 파트를 `document.master_pages`, `document.histories`, `document.version`으로 직접 편집하고 저장합니다.
|
|
42
43
|
- **스타일 기반 텍스트 치환** – 런 서식(색상, 밑줄, `charPrIDRef`)으로 필터링해 텍스트를 선택적으로 교체하거나 삭제합니다. 하이라이트
|
|
43
44
|
마커나 태그로 분리된 문자열도 서식을 유지한 채 치환합니다.
|
|
44
45
|
- **텍스트 추출 파이프라인** – `hwpx.tools.text_extractor.TextExtractor`는 하이라이트, 각주, 컨트롤을 원하는 방식으로 표현하며 문단 텍스트를 반환합니다.
|
|
@@ -78,6 +79,7 @@ print("sections:", len(document.sections))
|
|
|
78
79
|
# 2) 문단과 표, 메모 추가
|
|
79
80
|
section = document.sections[0]
|
|
80
81
|
paragraph = document.add_paragraph("자동 생성한 문단", section=section)
|
|
82
|
+
# 표에 사용할 기본 실선 테두리 채우기가 없으면 add_table()이 자동으로 생성합니다.
|
|
81
83
|
table = document.add_table(rows=2, cols=2, section=section)
|
|
82
84
|
table.set_cell_text(0, 0, "항목")
|
|
83
85
|
table.set_cell_text(0, 1, "값")
|
|
@@ -89,6 +91,8 @@ document.add_memo_with_anchor("배포 전 검토", paragraph=paragraph, memo_sha
|
|
|
89
91
|
document.save("output/example.hwpx")
|
|
90
92
|
```
|
|
91
93
|
|
|
94
|
+
`HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
|
|
95
|
+
|
|
92
96
|
더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
|
|
93
97
|
|
|
94
98
|
## 문서
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
- **문서 편집 API** – `hwpx.document.HwpxDocument`는 문단과 표, 메모, 헤더 속성을 파이썬 객체로 노출하고 새 콘텐츠를 손쉽게 추가합니다. 섹션 머리말·꼬리말을 수정하면 `<hp:headerApply>`/`<hp:footerApply>`와 마스터 페이지 링크도 함께 갱신합니다.
|
|
9
9
|
- **타입이 지정된 본문 모델** – `hwpx.oxml.body`는 표·컨트롤·인라인 도형·변경 추적 태그를 데이터 클래스에 매핑하고, `HwpxOxmlParagraph.model`/`HwpxOxmlRun.model`로 이를 조회·수정한 뒤 XML로 되돌릴 수 있도록 지원합니다.
|
|
10
10
|
- **메모와 필드 앵커** – `add_memo_with_anchor()`로 메모를 생성하면서 MEMO 필드 컨트롤을 자동 삽입해 한/글에서 바로 표시되도록 합니다.
|
|
11
|
-
- **헤더 참조 목록 탐색** – 글머리표, 문단 속성, 스타일, 변경 추적 항목, 작성자 정보를 데이터클래스로 파싱하고 `document.bullets`·`document.styles` 같은 조회 헬퍼로 ID 기반 검색을 단순화했습니다.
|
|
11
|
+
- **헤더 참조 목록 탐색** – 글머리표, 문단 속성, 테두리 채우기, 스타일, 변경 추적 항목, 작성자 정보를 데이터클래스로 파싱하고 `document.border_fills`·`document.bullets`·`document.styles` 같은 조회 헬퍼로 ID 기반 검색을 단순화했습니다.
|
|
12
|
+
- **바탕쪽·이력·버전 파트 제어** – 매니페스트에 포함된 master-page/history/version 파트를 `document.master_pages`, `document.histories`, `document.version`으로 직접 편집하고 저장합니다.
|
|
12
13
|
- **스타일 기반 텍스트 치환** – 런 서식(색상, 밑줄, `charPrIDRef`)으로 필터링해 텍스트를 선택적으로 교체하거나 삭제합니다. 하이라이트
|
|
13
14
|
마커나 태그로 분리된 문자열도 서식을 유지한 채 치환합니다.
|
|
14
15
|
- **텍스트 추출 파이프라인** – `hwpx.tools.text_extractor.TextExtractor`는 하이라이트, 각주, 컨트롤을 원하는 방식으로 표현하며 문단 텍스트를 반환합니다.
|
|
@@ -48,6 +49,7 @@ print("sections:", len(document.sections))
|
|
|
48
49
|
# 2) 문단과 표, 메모 추가
|
|
49
50
|
section = document.sections[0]
|
|
50
51
|
paragraph = document.add_paragraph("자동 생성한 문단", section=section)
|
|
52
|
+
# 표에 사용할 기본 실선 테두리 채우기가 없으면 add_table()이 자동으로 생성합니다.
|
|
51
53
|
table = document.add_table(rows=2, cols=2, section=section)
|
|
52
54
|
table.set_cell_text(0, 0, "항목")
|
|
53
55
|
table.set_cell_text(0, 1, "값")
|
|
@@ -59,6 +61,8 @@ document.add_memo_with_anchor("배포 전 검토", paragraph=paragraph, memo_sha
|
|
|
59
61
|
document.save("output/example.hwpx")
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
`HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
|
|
65
|
+
|
|
62
66
|
더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
|
|
63
67
|
|
|
64
68
|
## 문서
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-hwpx"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5"
|
|
8
8
|
description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { text = "Non-Commercial License" }
|
|
Binary file
|
|
@@ -11,15 +11,19 @@ from typing import BinaryIO, Iterator, List, Tuple
|
|
|
11
11
|
|
|
12
12
|
from .oxml import (
|
|
13
13
|
Bullet,
|
|
14
|
+
GenericElement,
|
|
14
15
|
HwpxOxmlDocument,
|
|
15
16
|
HwpxOxmlHeader,
|
|
17
|
+
HwpxOxmlHistory,
|
|
16
18
|
HwpxOxmlInlineObject,
|
|
19
|
+
HwpxOxmlMasterPage,
|
|
17
20
|
HwpxOxmlMemo,
|
|
18
21
|
HwpxOxmlParagraph,
|
|
19
22
|
HwpxOxmlRun,
|
|
20
23
|
HwpxOxmlSection,
|
|
21
24
|
HwpxOxmlSectionHeaderFooter,
|
|
22
25
|
HwpxOxmlTable,
|
|
26
|
+
HwpxOxmlVersion,
|
|
23
27
|
MemoShape,
|
|
24
28
|
ParagraphProperty,
|
|
25
29
|
RunStyle,
|
|
@@ -89,6 +93,32 @@ class HwpxDocument:
|
|
|
89
93
|
"""Return the header parts referenced by the document."""
|
|
90
94
|
return self._root.headers
|
|
91
95
|
|
|
96
|
+
@property
|
|
97
|
+
def master_pages(self) -> List[HwpxOxmlMasterPage]:
|
|
98
|
+
"""Return the master-page parts declared in the manifest."""
|
|
99
|
+
return self._root.master_pages
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def histories(self) -> List[HwpxOxmlHistory]:
|
|
103
|
+
"""Return document history parts referenced by the manifest."""
|
|
104
|
+
return self._root.histories
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def version(self) -> HwpxOxmlVersion | None:
|
|
108
|
+
"""Return the version metadata part if present."""
|
|
109
|
+
return self._root.version
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def border_fills(self) -> dict[str, GenericElement]:
|
|
113
|
+
"""Return border fill definitions declared in the headers."""
|
|
114
|
+
|
|
115
|
+
return self._root.border_fills
|
|
116
|
+
|
|
117
|
+
def border_fill(self, border_fill_id_ref: int | str | None) -> GenericElement | None:
|
|
118
|
+
"""Return the border fill definition referenced by *border_fill_id_ref*."""
|
|
119
|
+
|
|
120
|
+
return self._root.border_fill(border_fill_id_ref)
|
|
121
|
+
|
|
92
122
|
@property
|
|
93
123
|
def memo_shapes(self) -> dict[str, MemoShape]:
|
|
94
124
|
"""Return memo shapes available in the header reference lists."""
|
|
@@ -511,7 +541,7 @@ class HwpxDocument:
|
|
|
511
541
|
section_index: int | None = None,
|
|
512
542
|
width: int | None = None,
|
|
513
543
|
height: int | None = None,
|
|
514
|
-
border_fill_id_ref: str | int =
|
|
544
|
+
border_fill_id_ref: str | int | None = None,
|
|
515
545
|
para_pr_id_ref: str | int | None = None,
|
|
516
546
|
style_id_ref: str | int | None = None,
|
|
517
547
|
char_pr_id_ref: str | int | None = None,
|
|
@@ -520,6 +550,10 @@ class HwpxDocument:
|
|
|
520
550
|
) -> HwpxOxmlTable:
|
|
521
551
|
"""Create a table in a new paragraph and return it."""
|
|
522
552
|
|
|
553
|
+
resolved_border_fill: str | int | None = border_fill_id_ref
|
|
554
|
+
if resolved_border_fill is None:
|
|
555
|
+
resolved_border_fill = self._root.ensure_basic_border_fill()
|
|
556
|
+
|
|
523
557
|
paragraph = self.add_paragraph(
|
|
524
558
|
"",
|
|
525
559
|
section=section,
|
|
@@ -535,7 +569,7 @@ class HwpxDocument:
|
|
|
535
569
|
cols,
|
|
536
570
|
width=width,
|
|
537
571
|
height=height,
|
|
538
|
-
border_fill_id_ref=
|
|
572
|
+
border_fill_id_ref=resolved_border_fill,
|
|
539
573
|
run_attributes=run_attributes,
|
|
540
574
|
char_pr_id_ref=char_pr_id_ref,
|
|
541
575
|
)
|
|
@@ -19,7 +19,9 @@ from .document import (
|
|
|
19
19
|
DocumentNumbering,
|
|
20
20
|
HwpxOxmlDocument,
|
|
21
21
|
HwpxOxmlHeader,
|
|
22
|
+
HwpxOxmlHistory,
|
|
22
23
|
HwpxOxmlInlineObject,
|
|
24
|
+
HwpxOxmlMasterPage,
|
|
23
25
|
HwpxOxmlMemo,
|
|
24
26
|
HwpxOxmlMemoGroup,
|
|
25
27
|
HwpxOxmlParagraph,
|
|
@@ -30,6 +32,7 @@ from .document import (
|
|
|
30
32
|
HwpxOxmlTable,
|
|
31
33
|
HwpxOxmlTableCell,
|
|
32
34
|
HwpxOxmlTableRow,
|
|
35
|
+
HwpxOxmlVersion,
|
|
33
36
|
PageMargins,
|
|
34
37
|
PageSize,
|
|
35
38
|
RunStyle,
|
|
@@ -126,7 +129,9 @@ __all__ = [
|
|
|
126
129
|
"DocumentNumbering",
|
|
127
130
|
"HwpxOxmlDocument",
|
|
128
131
|
"HwpxOxmlHeader",
|
|
132
|
+
"HwpxOxmlHistory",
|
|
129
133
|
"HwpxOxmlInlineObject",
|
|
134
|
+
"HwpxOxmlMasterPage",
|
|
130
135
|
"HwpxOxmlMemo",
|
|
131
136
|
"HwpxOxmlMemoGroup",
|
|
132
137
|
"HwpxOxmlParagraph",
|
|
@@ -137,6 +142,7 @@ __all__ = [
|
|
|
137
142
|
"HwpxOxmlTable",
|
|
138
143
|
"HwpxOxmlTableCell",
|
|
139
144
|
"HwpxOxmlTableRow",
|
|
145
|
+
"HwpxOxmlVersion",
|
|
140
146
|
"KeyDerivation",
|
|
141
147
|
"KeyEncryption",
|
|
142
148
|
"LinkInfo",
|
|
@@ -11,6 +11,7 @@ import xml.etree.ElementTree as ET
|
|
|
11
11
|
from lxml import etree as LET
|
|
12
12
|
|
|
13
13
|
from . import body
|
|
14
|
+
from .common import GenericElement
|
|
14
15
|
from .header import (
|
|
15
16
|
Bullet,
|
|
16
17
|
MemoProperties,
|
|
@@ -21,6 +22,7 @@ from .header import (
|
|
|
21
22
|
TrackChangeAuthor,
|
|
22
23
|
memo_shape_from_attributes,
|
|
23
24
|
parse_bullets,
|
|
25
|
+
parse_border_fills,
|
|
24
26
|
parse_paragraph_properties,
|
|
25
27
|
parse_styles,
|
|
26
28
|
parse_track_change_authors,
|
|
@@ -44,6 +46,23 @@ _DEFAULT_PARAGRAPH_ATTRS = {
|
|
|
44
46
|
_DEFAULT_CELL_WIDTH = 7200
|
|
45
47
|
_DEFAULT_CELL_HEIGHT = 3600
|
|
46
48
|
|
|
49
|
+
_BASIC_BORDER_FILL_ATTRIBUTES = {
|
|
50
|
+
"threeD": "0",
|
|
51
|
+
"shadow": "0",
|
|
52
|
+
"centerLine": "NONE",
|
|
53
|
+
"breakCellSeparateLine": "0",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_BASIC_BORDER_CHILDREN: Tuple[Tuple[str, dict[str, str]], ...] = (
|
|
57
|
+
("slash", {"type": "NONE", "Crooked": "0", "isCounter": "0"}),
|
|
58
|
+
("backSlash", {"type": "NONE", "Crooked": "0", "isCounter": "0"}),
|
|
59
|
+
("leftBorder", {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}),
|
|
60
|
+
("rightBorder", {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}),
|
|
61
|
+
("topBorder", {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}),
|
|
62
|
+
("bottomBorder", {"type": "SOLID", "width": "0.12 mm", "color": "#000000"}),
|
|
63
|
+
("diagonal", {"type": "SOLID", "width": "0.1 mm", "color": "#000000"}),
|
|
64
|
+
)
|
|
65
|
+
|
|
47
66
|
T = TypeVar("T")
|
|
48
67
|
|
|
49
68
|
|
|
@@ -107,6 +126,59 @@ def _element_local_name(node: ET.Element) -> str:
|
|
|
107
126
|
return tag
|
|
108
127
|
|
|
109
128
|
|
|
129
|
+
def _normalize_length(value: str | None) -> str:
|
|
130
|
+
if value is None:
|
|
131
|
+
return ""
|
|
132
|
+
return value.replace(" ", "").lower()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _border_fill_is_basic_solid_line(element: ET.Element) -> bool:
|
|
136
|
+
if _element_local_name(element) != "borderFill":
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
for attr, expected in _BASIC_BORDER_FILL_ATTRIBUTES.items():
|
|
140
|
+
actual = element.get(attr)
|
|
141
|
+
if attr == "centerLine":
|
|
142
|
+
if (actual or "").upper() != expected:
|
|
143
|
+
return False
|
|
144
|
+
else:
|
|
145
|
+
if actual != expected:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
for child_name, child_attrs in _BASIC_BORDER_CHILDREN:
|
|
149
|
+
child = element.find(f"{_HH}{child_name}")
|
|
150
|
+
if child is None:
|
|
151
|
+
return False
|
|
152
|
+
for attr, expected in child_attrs.items():
|
|
153
|
+
actual = child.get(attr)
|
|
154
|
+
if attr == "type":
|
|
155
|
+
if (actual or "").upper() != expected:
|
|
156
|
+
return False
|
|
157
|
+
elif attr == "width":
|
|
158
|
+
if _normalize_length(actual) != _normalize_length(expected):
|
|
159
|
+
return False
|
|
160
|
+
elif attr == "color":
|
|
161
|
+
if (actual or "").upper() != expected.upper():
|
|
162
|
+
return False
|
|
163
|
+
else:
|
|
164
|
+
if actual != expected:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
for child in element:
|
|
168
|
+
if _element_local_name(child) == "fillBrush":
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _create_basic_border_fill_element(border_id: str) -> ET.Element:
|
|
175
|
+
attrs = {"id": border_id, **_BASIC_BORDER_FILL_ATTRIBUTES}
|
|
176
|
+
element = ET.Element(f"{_HH}borderFill", attrs)
|
|
177
|
+
for child_name, child_attrs in _BASIC_BORDER_CHILDREN:
|
|
178
|
+
ET.SubElement(element, f"{_HH}{child_name}", dict(child_attrs))
|
|
179
|
+
return element
|
|
180
|
+
|
|
181
|
+
|
|
110
182
|
def _distribute_size(total: int, parts: int) -> List[int]:
|
|
111
183
|
"""Return *parts* integers that sum to *total* and are as even as possible."""
|
|
112
184
|
|
|
@@ -1525,13 +1597,15 @@ class HwpxOxmlTable:
|
|
|
1525
1597
|
*,
|
|
1526
1598
|
width: int | None = None,
|
|
1527
1599
|
height: int | None = None,
|
|
1528
|
-
border_fill_id_ref: str | int =
|
|
1600
|
+
border_fill_id_ref: str | int | None = None,
|
|
1529
1601
|
) -> ET.Element:
|
|
1530
1602
|
if rows <= 0 or cols <= 0:
|
|
1531
1603
|
raise ValueError("rows and cols must be positive integers")
|
|
1532
1604
|
|
|
1533
1605
|
table_width = width if width is not None else cols * _DEFAULT_CELL_WIDTH
|
|
1534
1606
|
table_height = height if height is not None else rows * _DEFAULT_CELL_HEIGHT
|
|
1607
|
+
if border_fill_id_ref is None:
|
|
1608
|
+
raise ValueError("border_fill_id_ref must be provided")
|
|
1535
1609
|
border_fill = str(border_fill_id_ref)
|
|
1536
1610
|
|
|
1537
1611
|
table_attrs = {
|
|
@@ -1864,10 +1938,19 @@ class HwpxOxmlParagraph:
|
|
|
1864
1938
|
*,
|
|
1865
1939
|
width: int | None = None,
|
|
1866
1940
|
height: int | None = None,
|
|
1867
|
-
border_fill_id_ref: str | int =
|
|
1941
|
+
border_fill_id_ref: str | int | None = None,
|
|
1868
1942
|
run_attributes: dict[str, str] | None = None,
|
|
1869
1943
|
char_pr_id_ref: str | int | None = None,
|
|
1870
1944
|
) -> HwpxOxmlTable:
|
|
1945
|
+
if border_fill_id_ref is None:
|
|
1946
|
+
document = self.section.document
|
|
1947
|
+
if document is not None:
|
|
1948
|
+
resolved_border_fill: str | int = document.ensure_basic_border_fill()
|
|
1949
|
+
else:
|
|
1950
|
+
resolved_border_fill = "0"
|
|
1951
|
+
else:
|
|
1952
|
+
resolved_border_fill = border_fill_id_ref
|
|
1953
|
+
|
|
1871
1954
|
run = self._create_run_for_object(
|
|
1872
1955
|
run_attributes,
|
|
1873
1956
|
char_pr_id_ref=char_pr_id_ref,
|
|
@@ -1877,7 +1960,7 @@ class HwpxOxmlParagraph:
|
|
|
1877
1960
|
cols,
|
|
1878
1961
|
width=width,
|
|
1879
1962
|
height=height,
|
|
1880
|
-
border_fill_id_ref=
|
|
1963
|
+
border_fill_id_ref=resolved_border_fill,
|
|
1881
1964
|
)
|
|
1882
1965
|
run.append(table_element)
|
|
1883
1966
|
self.section.mark_dirty()
|
|
@@ -1999,6 +2082,61 @@ class HwpxOxmlParagraph:
|
|
|
1999
2082
|
self.section.mark_dirty()
|
|
2000
2083
|
|
|
2001
2084
|
|
|
2085
|
+
class _HwpxOxmlSimplePart:
|
|
2086
|
+
"""Common base for standalone XML parts that are not sections or headers."""
|
|
2087
|
+
|
|
2088
|
+
def __init__(
|
|
2089
|
+
self,
|
|
2090
|
+
part_name: str,
|
|
2091
|
+
element: ET.Element,
|
|
2092
|
+
document: "HwpxOxmlDocument" | None = None,
|
|
2093
|
+
):
|
|
2094
|
+
self.part_name = part_name
|
|
2095
|
+
self._element = element
|
|
2096
|
+
self._document = document
|
|
2097
|
+
self._dirty = False
|
|
2098
|
+
|
|
2099
|
+
@property
|
|
2100
|
+
def element(self) -> ET.Element:
|
|
2101
|
+
return self._element
|
|
2102
|
+
|
|
2103
|
+
@property
|
|
2104
|
+
def document(self) -> "HwpxOxmlDocument" | None:
|
|
2105
|
+
return self._document
|
|
2106
|
+
|
|
2107
|
+
def attach_document(self, document: "HwpxOxmlDocument") -> None:
|
|
2108
|
+
self._document = document
|
|
2109
|
+
|
|
2110
|
+
@property
|
|
2111
|
+
def dirty(self) -> bool:
|
|
2112
|
+
return self._dirty
|
|
2113
|
+
|
|
2114
|
+
def mark_dirty(self) -> None:
|
|
2115
|
+
self._dirty = True
|
|
2116
|
+
|
|
2117
|
+
def reset_dirty(self) -> None:
|
|
2118
|
+
self._dirty = False
|
|
2119
|
+
|
|
2120
|
+
def replace_element(self, element: ET.Element) -> None:
|
|
2121
|
+
self._element = element
|
|
2122
|
+
self.mark_dirty()
|
|
2123
|
+
|
|
2124
|
+
def to_bytes(self) -> bytes:
|
|
2125
|
+
return _serialize_xml(self._element)
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
class HwpxOxmlMasterPage(_HwpxOxmlSimplePart):
|
|
2129
|
+
"""Represents a master page part in the package."""
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
class HwpxOxmlHistory(_HwpxOxmlSimplePart):
|
|
2133
|
+
"""Represents a document history part."""
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
class HwpxOxmlVersion(_HwpxOxmlSimplePart):
|
|
2137
|
+
"""Represents the ``version.xml`` part."""
|
|
2138
|
+
|
|
2139
|
+
|
|
2002
2140
|
class HwpxOxmlSection:
|
|
2003
2141
|
"""Represents the contents of a section XML part."""
|
|
2004
2142
|
|
|
@@ -2198,6 +2336,16 @@ class HwpxOxmlHeader:
|
|
|
2198
2336
|
self.mark_dirty()
|
|
2199
2337
|
return element
|
|
2200
2338
|
|
|
2339
|
+
def _border_fills_element(self, create: bool = False) -> ET.Element | None:
|
|
2340
|
+
ref_list = self._ref_list_element(create=create)
|
|
2341
|
+
if ref_list is None:
|
|
2342
|
+
return None
|
|
2343
|
+
element = ref_list.find(f"{_HH}borderFills")
|
|
2344
|
+
if element is None and create:
|
|
2345
|
+
element = ET.SubElement(ref_list, f"{_HH}borderFills", {"itemCnt": "0"})
|
|
2346
|
+
self.mark_dirty()
|
|
2347
|
+
return element
|
|
2348
|
+
|
|
2201
2349
|
def _char_properties_element(self, create: bool = False) -> ET.Element | None:
|
|
2202
2350
|
ref_list = self._ref_list_element(create=create)
|
|
2203
2351
|
if ref_list is None:
|
|
@@ -2212,6 +2360,10 @@ class HwpxOxmlHeader:
|
|
|
2212
2360
|
count = len(list(element.findall(f"{_HH}charPr")))
|
|
2213
2361
|
element.set("itemCnt", str(count))
|
|
2214
2362
|
|
|
2363
|
+
def _update_border_fills_item_count(self, element: ET.Element) -> None:
|
|
2364
|
+
count = len(list(element.findall(f"{_HH}borderFill")))
|
|
2365
|
+
element.set("itemCnt", str(count))
|
|
2366
|
+
|
|
2215
2367
|
def _allocate_char_property_id(
|
|
2216
2368
|
self,
|
|
2217
2369
|
element: ET.Element,
|
|
@@ -2242,6 +2394,26 @@ class HwpxOxmlHeader:
|
|
|
2242
2394
|
candidate = str(next_id)
|
|
2243
2395
|
return candidate
|
|
2244
2396
|
|
|
2397
|
+
def _allocate_border_fill_id(self, element: ET.Element) -> str:
|
|
2398
|
+
existing: set[str] = {
|
|
2399
|
+
child.get("id") or ""
|
|
2400
|
+
for child in element.findall(f"{_HH}borderFill")
|
|
2401
|
+
}
|
|
2402
|
+
existing.discard("")
|
|
2403
|
+
|
|
2404
|
+
numeric_ids: List[int] = []
|
|
2405
|
+
for value in existing:
|
|
2406
|
+
try:
|
|
2407
|
+
numeric_ids.append(int(value))
|
|
2408
|
+
except ValueError:
|
|
2409
|
+
continue
|
|
2410
|
+
next_id = 0 if not numeric_ids else max(numeric_ids) + 1
|
|
2411
|
+
candidate = str(next_id)
|
|
2412
|
+
while candidate in existing:
|
|
2413
|
+
next_id += 1
|
|
2414
|
+
candidate = str(next_id)
|
|
2415
|
+
return candidate
|
|
2416
|
+
|
|
2245
2417
|
def ensure_char_property(
|
|
2246
2418
|
self,
|
|
2247
2419
|
*,
|
|
@@ -2331,6 +2503,59 @@ class HwpxOxmlHeader:
|
|
|
2331
2503
|
return None
|
|
2332
2504
|
return ref_list.find(f"{_HH}trackChangeAuthors")
|
|
2333
2505
|
|
|
2506
|
+
def find_basic_border_fill_id(self) -> str | None:
|
|
2507
|
+
element = self._border_fills_element()
|
|
2508
|
+
if element is None:
|
|
2509
|
+
return None
|
|
2510
|
+
for child in element.findall(f"{_HH}borderFill"):
|
|
2511
|
+
if _border_fill_is_basic_solid_line(child):
|
|
2512
|
+
identifier = child.get("id")
|
|
2513
|
+
if identifier:
|
|
2514
|
+
return identifier
|
|
2515
|
+
return None
|
|
2516
|
+
|
|
2517
|
+
def ensure_basic_border_fill(self) -> str:
|
|
2518
|
+
element = self._border_fills_element(create=True)
|
|
2519
|
+
if element is None: # pragma: no cover - defensive branch
|
|
2520
|
+
raise RuntimeError("failed to create <borderFills> element")
|
|
2521
|
+
|
|
2522
|
+
existing = self.find_basic_border_fill_id()
|
|
2523
|
+
if existing is not None:
|
|
2524
|
+
return existing
|
|
2525
|
+
|
|
2526
|
+
new_id = self._allocate_border_fill_id(element)
|
|
2527
|
+
element.append(_create_basic_border_fill_element(new_id))
|
|
2528
|
+
self._update_border_fills_item_count(element)
|
|
2529
|
+
self.mark_dirty()
|
|
2530
|
+
return new_id
|
|
2531
|
+
|
|
2532
|
+
@property
|
|
2533
|
+
def border_fills(self) -> dict[str, GenericElement]:
|
|
2534
|
+
element = self._border_fills_element()
|
|
2535
|
+
if element is None:
|
|
2536
|
+
return {}
|
|
2537
|
+
|
|
2538
|
+
fill_list = parse_border_fills(self._convert_to_lxml(element))
|
|
2539
|
+
mapping: dict[str, GenericElement] = {}
|
|
2540
|
+
for border_fill in fill_list.fills:
|
|
2541
|
+
raw_id = border_fill.attributes.get("id")
|
|
2542
|
+
keys: List[str] = []
|
|
2543
|
+
if raw_id:
|
|
2544
|
+
keys.append(raw_id)
|
|
2545
|
+
try:
|
|
2546
|
+
normalized = str(int(raw_id))
|
|
2547
|
+
except ValueError:
|
|
2548
|
+
normalized = None
|
|
2549
|
+
if normalized and normalized not in keys:
|
|
2550
|
+
keys.append(normalized)
|
|
2551
|
+
for key in keys:
|
|
2552
|
+
if key not in mapping:
|
|
2553
|
+
mapping[key] = border_fill
|
|
2554
|
+
return mapping
|
|
2555
|
+
|
|
2556
|
+
def border_fill(self, border_fill_id_ref: int | str | None) -> GenericElement | None:
|
|
2557
|
+
return self._lookup_by_id(self.border_fills, border_fill_id_ref)
|
|
2558
|
+
|
|
2334
2559
|
@staticmethod
|
|
2335
2560
|
def _convert_to_lxml(element: ET.Element) -> LET._Element:
|
|
2336
2561
|
return LET.fromstring(ET.tostring(element, encoding="utf-8"))
|
|
@@ -2540,16 +2765,29 @@ class HwpxOxmlDocument:
|
|
|
2540
2765
|
manifest: ET.Element,
|
|
2541
2766
|
sections: Sequence[HwpxOxmlSection],
|
|
2542
2767
|
headers: Sequence[HwpxOxmlHeader],
|
|
2768
|
+
*,
|
|
2769
|
+
master_pages: Sequence[HwpxOxmlMasterPage] | None = None,
|
|
2770
|
+
histories: Sequence[HwpxOxmlHistory] | None = None,
|
|
2771
|
+
version: HwpxOxmlVersion | None = None,
|
|
2543
2772
|
):
|
|
2544
2773
|
self._manifest = manifest
|
|
2545
2774
|
self._sections = list(sections)
|
|
2546
2775
|
self._headers = list(headers)
|
|
2776
|
+
self._master_pages = list(master_pages or [])
|
|
2777
|
+
self._histories = list(histories or [])
|
|
2778
|
+
self._version = version
|
|
2547
2779
|
self._char_property_cache: dict[str, RunStyle] | None = None
|
|
2548
2780
|
|
|
2549
2781
|
for section in self._sections:
|
|
2550
2782
|
section.attach_document(self)
|
|
2551
2783
|
for header in self._headers:
|
|
2552
2784
|
header.attach_document(self)
|
|
2785
|
+
for master_page in self._master_pages:
|
|
2786
|
+
master_page.attach_document(self)
|
|
2787
|
+
for history in self._histories:
|
|
2788
|
+
history.attach_document(self)
|
|
2789
|
+
if self._version is not None:
|
|
2790
|
+
self._version.attach_document(self)
|
|
2553
2791
|
|
|
2554
2792
|
@classmethod
|
|
2555
2793
|
def from_package(cls, package: "HwpxPackage") -> "HwpxOxmlDocument":
|
|
@@ -2561,12 +2799,35 @@ class HwpxOxmlDocument:
|
|
|
2561
2799
|
manifest = package.get_xml(package.MANIFEST_PATH)
|
|
2562
2800
|
section_paths = package.section_paths()
|
|
2563
2801
|
header_paths = package.header_paths()
|
|
2802
|
+
master_page_paths = package.master_page_paths()
|
|
2803
|
+
history_paths = package.history_paths()
|
|
2804
|
+
version_path = package.version_path()
|
|
2564
2805
|
|
|
2565
2806
|
sections = [
|
|
2566
2807
|
HwpxOxmlSection(path, package.get_xml(path)) for path in section_paths
|
|
2567
2808
|
]
|
|
2568
2809
|
headers = [HwpxOxmlHeader(path, package.get_xml(path)) for path in header_paths]
|
|
2569
|
-
|
|
2810
|
+
master_pages = [
|
|
2811
|
+
HwpxOxmlMasterPage(path, package.get_xml(path))
|
|
2812
|
+
for path in master_page_paths
|
|
2813
|
+
if package.has_part(path)
|
|
2814
|
+
]
|
|
2815
|
+
histories = [
|
|
2816
|
+
HwpxOxmlHistory(path, package.get_xml(path))
|
|
2817
|
+
for path in history_paths
|
|
2818
|
+
if package.has_part(path)
|
|
2819
|
+
]
|
|
2820
|
+
version = None
|
|
2821
|
+
if version_path and package.has_part(version_path):
|
|
2822
|
+
version = HwpxOxmlVersion(version_path, package.get_xml(version_path))
|
|
2823
|
+
return cls(
|
|
2824
|
+
manifest,
|
|
2825
|
+
sections,
|
|
2826
|
+
headers,
|
|
2827
|
+
master_pages=master_pages,
|
|
2828
|
+
histories=histories,
|
|
2829
|
+
version=version,
|
|
2830
|
+
)
|
|
2570
2831
|
|
|
2571
2832
|
@property
|
|
2572
2833
|
def manifest(self) -> ET.Element:
|
|
@@ -2580,6 +2841,18 @@ class HwpxOxmlDocument:
|
|
|
2580
2841
|
def headers(self) -> List[HwpxOxmlHeader]:
|
|
2581
2842
|
return list(self._headers)
|
|
2582
2843
|
|
|
2844
|
+
@property
|
|
2845
|
+
def master_pages(self) -> List[HwpxOxmlMasterPage]:
|
|
2846
|
+
return list(self._master_pages)
|
|
2847
|
+
|
|
2848
|
+
@property
|
|
2849
|
+
def histories(self) -> List[HwpxOxmlHistory]:
|
|
2850
|
+
return list(self._histories)
|
|
2851
|
+
|
|
2852
|
+
@property
|
|
2853
|
+
def version(self) -> HwpxOxmlVersion | None:
|
|
2854
|
+
return self._version
|
|
2855
|
+
|
|
2583
2856
|
def _ensure_char_property_cache(self) -> dict[str, RunStyle]:
|
|
2584
2857
|
if self._char_property_cache is None:
|
|
2585
2858
|
mapping: dict[str, RunStyle] = {}
|
|
@@ -2685,6 +2958,27 @@ class HwpxOxmlDocument:
|
|
|
2685
2958
|
raise RuntimeError("charPr element is missing an id")
|
|
2686
2959
|
return char_id
|
|
2687
2960
|
|
|
2961
|
+
@property
|
|
2962
|
+
def border_fills(self) -> dict[str, GenericElement]:
|
|
2963
|
+
mapping: dict[str, GenericElement] = {}
|
|
2964
|
+
for header in self._headers:
|
|
2965
|
+
mapping.update(header.border_fills)
|
|
2966
|
+
return mapping
|
|
2967
|
+
|
|
2968
|
+
def border_fill(self, border_fill_id_ref: int | str | None) -> GenericElement | None:
|
|
2969
|
+
return HwpxOxmlHeader._lookup_by_id(self.border_fills, border_fill_id_ref)
|
|
2970
|
+
|
|
2971
|
+
def ensure_basic_border_fill(self) -> str:
|
|
2972
|
+
if not self._headers:
|
|
2973
|
+
return "0"
|
|
2974
|
+
|
|
2975
|
+
for header in self._headers:
|
|
2976
|
+
existing = header.find_basic_border_fill_id()
|
|
2977
|
+
if existing is not None:
|
|
2978
|
+
return existing
|
|
2979
|
+
|
|
2980
|
+
return self._headers[0].ensure_basic_border_fill()
|
|
2981
|
+
|
|
2688
2982
|
@property
|
|
2689
2983
|
def memo_shapes(self) -> dict[str, MemoShape]:
|
|
2690
2984
|
shapes: dict[str, MemoShape] = {}
|
|
@@ -2812,6 +3106,14 @@ class HwpxOxmlDocument:
|
|
|
2812
3106
|
headers_dirty = True
|
|
2813
3107
|
if headers_dirty:
|
|
2814
3108
|
self.invalidate_char_property_cache()
|
|
3109
|
+
for master_page in self._master_pages:
|
|
3110
|
+
if master_page.dirty:
|
|
3111
|
+
updates[master_page.part_name] = master_page.to_bytes()
|
|
3112
|
+
for history in self._histories:
|
|
3113
|
+
if history.dirty:
|
|
3114
|
+
updates[history.part_name] = history.to_bytes()
|
|
3115
|
+
if self._version is not None and self._version.dirty:
|
|
3116
|
+
updates[self._version.part_name] = self._version.to_bytes()
|
|
2815
3117
|
return updates
|
|
2816
3118
|
|
|
2817
3119
|
def reset_dirty(self) -> None:
|
|
@@ -2820,3 +3122,9 @@ class HwpxOxmlDocument:
|
|
|
2820
3122
|
section.reset_dirty()
|
|
2821
3123
|
for header in self._headers:
|
|
2822
3124
|
header.reset_dirty()
|
|
3125
|
+
for master_page in self._master_pages:
|
|
3126
|
+
master_page.reset_dirty()
|
|
3127
|
+
for history in self._histories:
|
|
3128
|
+
history.reset_dirty()
|
|
3129
|
+
if self._version is not None:
|
|
3130
|
+
self._version.reset_dirty()
|
|
@@ -11,6 +11,21 @@ from zipfile import ZIP_DEFLATED, ZipFile
|
|
|
11
11
|
_OPF_NS = "http://www.idpf.org/2007/opf/"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _normalized_manifest_value(element: ET.Element) -> str:
|
|
15
|
+
values = [
|
|
16
|
+
element.attrib.get("id", ""),
|
|
17
|
+
element.attrib.get("href", ""),
|
|
18
|
+
element.attrib.get("media-type", ""),
|
|
19
|
+
element.attrib.get("properties", ""),
|
|
20
|
+
]
|
|
21
|
+
return " ".join(part.lower() for part in values if part)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _manifest_matches(element: ET.Element, *candidates: str) -> bool:
|
|
25
|
+
normalized = _normalized_manifest_value(element)
|
|
26
|
+
return any(candidate in normalized for candidate in candidates if candidate)
|
|
27
|
+
|
|
28
|
+
|
|
14
29
|
def _ensure_bytes(value: bytes | str | ET.Element) -> bytes:
|
|
15
30
|
if isinstance(value, bytes):
|
|
16
31
|
return value
|
|
@@ -38,6 +53,10 @@ class HwpxPackage:
|
|
|
38
53
|
self._spine_cache: list[str] | None = None
|
|
39
54
|
self._section_paths_cache: list[str] | None = None
|
|
40
55
|
self._header_paths_cache: list[str] | None = None
|
|
56
|
+
self._master_page_paths_cache: list[str] | None = None
|
|
57
|
+
self._history_paths_cache: list[str] | None = None
|
|
58
|
+
self._version_path_cache: str | None = None
|
|
59
|
+
self._version_path_cache_resolved = False
|
|
41
60
|
|
|
42
61
|
# -- construction ----------------------------------------------------
|
|
43
62
|
@classmethod
|
|
@@ -85,6 +104,12 @@ class HwpxPackage:
|
|
|
85
104
|
self._spine_cache = None
|
|
86
105
|
self._section_paths_cache = None
|
|
87
106
|
self._header_paths_cache = None
|
|
107
|
+
self._master_page_paths_cache = None
|
|
108
|
+
self._history_paths_cache = None
|
|
109
|
+
self._version_path_cache = None
|
|
110
|
+
self._version_path_cache_resolved = False
|
|
111
|
+
elif part_name == "version.xml":
|
|
112
|
+
self._version_path_cache_resolved = False
|
|
88
113
|
|
|
89
114
|
def get_xml(self, part_name: str) -> ET.Element:
|
|
90
115
|
return ET.fromstring(self.get_part(part_name))
|
|
@@ -101,6 +126,11 @@ class HwpxPackage:
|
|
|
101
126
|
self._manifest_tree = self.get_xml(self.MANIFEST_PATH)
|
|
102
127
|
return self._manifest_tree
|
|
103
128
|
|
|
129
|
+
def _manifest_items(self) -> list[ET.Element]:
|
|
130
|
+
manifest = self.manifest_tree()
|
|
131
|
+
ns = {"opf": _OPF_NS}
|
|
132
|
+
return list(manifest.findall("./opf:manifest/opf:item", ns))
|
|
133
|
+
|
|
104
134
|
def _resolve_spine_paths(self) -> list[str]:
|
|
105
135
|
if self._spine_cache is None:
|
|
106
136
|
manifest = self.manifest_tree()
|
|
@@ -155,6 +185,64 @@ class HwpxPackage:
|
|
|
155
185
|
self._header_paths_cache = paths
|
|
156
186
|
return list(self._header_paths_cache)
|
|
157
187
|
|
|
188
|
+
def master_page_paths(self) -> list[str]:
|
|
189
|
+
if self._master_page_paths_cache is None:
|
|
190
|
+
from pathlib import PurePosixPath
|
|
191
|
+
|
|
192
|
+
paths = [
|
|
193
|
+
item.attrib.get("href", "")
|
|
194
|
+
for item in self._manifest_items()
|
|
195
|
+
if _manifest_matches(item, "masterpage", "master-page")
|
|
196
|
+
and item.attrib.get("href")
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
if not paths:
|
|
200
|
+
paths = [
|
|
201
|
+
name
|
|
202
|
+
for name in self._parts.keys()
|
|
203
|
+
if "master" in PurePosixPath(name).name.lower()
|
|
204
|
+
and "page" in PurePosixPath(name).name.lower()
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
self._master_page_paths_cache = paths
|
|
208
|
+
return list(self._master_page_paths_cache)
|
|
209
|
+
|
|
210
|
+
def history_paths(self) -> list[str]:
|
|
211
|
+
if self._history_paths_cache is None:
|
|
212
|
+
from pathlib import PurePosixPath
|
|
213
|
+
|
|
214
|
+
paths = [
|
|
215
|
+
item.attrib.get("href", "")
|
|
216
|
+
for item in self._manifest_items()
|
|
217
|
+
if _manifest_matches(item, "history")
|
|
218
|
+
and item.attrib.get("href")
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
if not paths:
|
|
222
|
+
paths = [
|
|
223
|
+
name
|
|
224
|
+
for name in self._parts.keys()
|
|
225
|
+
if "history" in PurePosixPath(name).name.lower()
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
self._history_paths_cache = paths
|
|
229
|
+
return list(self._history_paths_cache)
|
|
230
|
+
|
|
231
|
+
def version_path(self) -> str | None:
|
|
232
|
+
if not self._version_path_cache_resolved:
|
|
233
|
+
path: str | None = None
|
|
234
|
+
for item in self._manifest_items():
|
|
235
|
+
if _manifest_matches(item, "version"):
|
|
236
|
+
href = item.attrib.get("href", "").strip()
|
|
237
|
+
if href:
|
|
238
|
+
path = href
|
|
239
|
+
break
|
|
240
|
+
if path is None and self.has_part("version.xml"):
|
|
241
|
+
path = "version.xml"
|
|
242
|
+
self._version_path_cache = path
|
|
243
|
+
self._version_path_cache_resolved = True
|
|
244
|
+
return self._version_path_cache
|
|
245
|
+
|
|
158
246
|
# -- saving ----------------------------------------------------------
|
|
159
247
|
def save(
|
|
160
248
|
self,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5
|
|
4
4
|
Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License: Non-Commercial License
|
|
@@ -38,7 +38,8 @@ Requires-Dist: pytest>=7.4; extra == "test"
|
|
|
38
38
|
- **문서 편집 API** – `hwpx.document.HwpxDocument`는 문단과 표, 메모, 헤더 속성을 파이썬 객체로 노출하고 새 콘텐츠를 손쉽게 추가합니다. 섹션 머리말·꼬리말을 수정하면 `<hp:headerApply>`/`<hp:footerApply>`와 마스터 페이지 링크도 함께 갱신합니다.
|
|
39
39
|
- **타입이 지정된 본문 모델** – `hwpx.oxml.body`는 표·컨트롤·인라인 도형·변경 추적 태그를 데이터 클래스에 매핑하고, `HwpxOxmlParagraph.model`/`HwpxOxmlRun.model`로 이를 조회·수정한 뒤 XML로 되돌릴 수 있도록 지원합니다.
|
|
40
40
|
- **메모와 필드 앵커** – `add_memo_with_anchor()`로 메모를 생성하면서 MEMO 필드 컨트롤을 자동 삽입해 한/글에서 바로 표시되도록 합니다.
|
|
41
|
-
- **헤더 참조 목록 탐색** – 글머리표, 문단 속성, 스타일, 변경 추적 항목, 작성자 정보를 데이터클래스로 파싱하고 `document.bullets`·`document.styles` 같은 조회 헬퍼로 ID 기반 검색을 단순화했습니다.
|
|
41
|
+
- **헤더 참조 목록 탐색** – 글머리표, 문단 속성, 테두리 채우기, 스타일, 변경 추적 항목, 작성자 정보를 데이터클래스로 파싱하고 `document.border_fills`·`document.bullets`·`document.styles` 같은 조회 헬퍼로 ID 기반 검색을 단순화했습니다.
|
|
42
|
+
- **바탕쪽·이력·버전 파트 제어** – 매니페스트에 포함된 master-page/history/version 파트를 `document.master_pages`, `document.histories`, `document.version`으로 직접 편집하고 저장합니다.
|
|
42
43
|
- **스타일 기반 텍스트 치환** – 런 서식(색상, 밑줄, `charPrIDRef`)으로 필터링해 텍스트를 선택적으로 교체하거나 삭제합니다. 하이라이트
|
|
43
44
|
마커나 태그로 분리된 문자열도 서식을 유지한 채 치환합니다.
|
|
44
45
|
- **텍스트 추출 파이프라인** – `hwpx.tools.text_extractor.TextExtractor`는 하이라이트, 각주, 컨트롤을 원하는 방식으로 표현하며 문단 텍스트를 반환합니다.
|
|
@@ -78,6 +79,7 @@ print("sections:", len(document.sections))
|
|
|
78
79
|
# 2) 문단과 표, 메모 추가
|
|
79
80
|
section = document.sections[0]
|
|
80
81
|
paragraph = document.add_paragraph("자동 생성한 문단", section=section)
|
|
82
|
+
# 표에 사용할 기본 실선 테두리 채우기가 없으면 add_table()이 자동으로 생성합니다.
|
|
81
83
|
table = document.add_table(rows=2, cols=2, section=section)
|
|
82
84
|
table.set_cell_text(0, 0, "항목")
|
|
83
85
|
table.set_cell_text(0, 1, "값")
|
|
@@ -89,6 +91,8 @@ document.add_memo_with_anchor("배포 전 검토", paragraph=paragraph, memo_sha
|
|
|
89
91
|
document.save("output/example.hwpx")
|
|
90
92
|
```
|
|
91
93
|
|
|
94
|
+
`HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
|
|
95
|
+
|
|
92
96
|
더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
|
|
93
97
|
|
|
94
98
|
## 문서
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
|
|
6
7
|
|
|
@@ -9,6 +10,7 @@ import pytest
|
|
|
9
10
|
from hwpx.document import HwpxDocument
|
|
10
11
|
from hwpx.package import HwpxPackage
|
|
11
12
|
from hwpx.tools import load_default_schemas, validate_document
|
|
13
|
+
from hwpx.templates import blank_document_bytes
|
|
12
14
|
|
|
13
15
|
_MIMETYPE = b"application/hwp+zip"
|
|
14
16
|
_VERSION_XML = (
|
|
@@ -137,3 +139,90 @@ def test_fixture_validates_against_reference_schemas(
|
|
|
137
139
|
|
|
138
140
|
bytes_report = validate_document(sample_document_bytes)
|
|
139
141
|
assert bytes_report.ok, "Generated sample failed schema validation from bytes"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_master_page_history_and_version_round_trip(tmp_path: Path) -> None:
|
|
145
|
+
package = HwpxPackage.open(blank_document_bytes())
|
|
146
|
+
|
|
147
|
+
manifest = package.manifest_tree()
|
|
148
|
+
ns = {"opf": "http://www.idpf.org/2007/opf/"}
|
|
149
|
+
manifest_list = manifest.find(f"{{{ns['opf']}}}manifest")
|
|
150
|
+
assert manifest_list is not None
|
|
151
|
+
|
|
152
|
+
def add_manifest_item(item_id: str, href: str) -> None:
|
|
153
|
+
ET.SubElement(
|
|
154
|
+
manifest_list,
|
|
155
|
+
f"{{{ns['opf']}}}item",
|
|
156
|
+
{"id": item_id, "href": href, "media-type": "application/xml"},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
add_manifest_item("master-page-0", "Contents/masterPages/masterPage0.xml")
|
|
160
|
+
add_manifest_item("history", "Contents/history.xml")
|
|
161
|
+
add_manifest_item("version", "version.xml")
|
|
162
|
+
package.set_xml(package.MANIFEST_PATH, manifest)
|
|
163
|
+
|
|
164
|
+
hm_ns = "http://www.hancom.co.kr/hwpml/2011/master-page"
|
|
165
|
+
master_root = ET.Element(f"{{{hm_ns}}}masterPage")
|
|
166
|
+
ET.SubElement(
|
|
167
|
+
master_root,
|
|
168
|
+
f"{{{hm_ns}}}masterPageItem",
|
|
169
|
+
{"id": "0", "type": "BOTH", "name": "초기 바탕쪽"},
|
|
170
|
+
)
|
|
171
|
+
package.set_xml("Contents/masterPages/masterPage0.xml", master_root)
|
|
172
|
+
|
|
173
|
+
hhs_ns = "http://www.hancom.co.kr/hwpml/2011/history"
|
|
174
|
+
history_root = ET.Element(f"{{{hhs_ns}}}history")
|
|
175
|
+
history_entry = ET.SubElement(history_root, f"{{{hhs_ns}}}historyEntry", {"id": "0"})
|
|
176
|
+
comment = ET.SubElement(history_entry, f"{{{hhs_ns}}}comment")
|
|
177
|
+
comment.text = "초기 내역"
|
|
178
|
+
package.set_xml("Contents/history.xml", history_root)
|
|
179
|
+
|
|
180
|
+
document = HwpxDocument.from_package(package)
|
|
181
|
+
|
|
182
|
+
assert len(document.master_pages) == 1
|
|
183
|
+
assert len(document.histories) == 1
|
|
184
|
+
version_part = document.version
|
|
185
|
+
assert version_part is not None
|
|
186
|
+
|
|
187
|
+
master_page = document.master_pages[0]
|
|
188
|
+
master_item = master_page.element.find(f"{{{hm_ns}}}masterPageItem")
|
|
189
|
+
assert master_item is not None
|
|
190
|
+
master_item.set("name", "검토용 바탕쪽")
|
|
191
|
+
master_page.mark_dirty()
|
|
192
|
+
|
|
193
|
+
history_part = document.histories[0]
|
|
194
|
+
history_comment = history_part.element.find(
|
|
195
|
+
f"{{{hhs_ns}}}historyEntry/{{{hhs_ns}}}comment"
|
|
196
|
+
)
|
|
197
|
+
assert history_comment is not None
|
|
198
|
+
history_comment.text = "업데이트된 변경 기록"
|
|
199
|
+
history_part.mark_dirty()
|
|
200
|
+
|
|
201
|
+
version_part.element.set("appVersion", "15.0.0.100 WIN32")
|
|
202
|
+
version_part.mark_dirty()
|
|
203
|
+
|
|
204
|
+
output_path = tmp_path / "master_history_roundtrip.hwpx"
|
|
205
|
+
document.save(output_path)
|
|
206
|
+
|
|
207
|
+
reopened = HwpxDocument.open(output_path)
|
|
208
|
+
assert reopened.master_pages
|
|
209
|
+
assert reopened.histories
|
|
210
|
+
reopened_version = reopened.version
|
|
211
|
+
assert reopened_version is not None
|
|
212
|
+
|
|
213
|
+
reopened_master_item = reopened.master_pages[0].element.find(
|
|
214
|
+
f"{{{hm_ns}}}masterPageItem"
|
|
215
|
+
)
|
|
216
|
+
assert reopened_master_item is not None
|
|
217
|
+
assert reopened_master_item.get("name") == "검토용 바탕쪽"
|
|
218
|
+
|
|
219
|
+
reopened_history_comment = reopened.histories[0].element.find(
|
|
220
|
+
f"{{{hhs_ns}}}historyEntry/{{{hhs_ns}}}comment"
|
|
221
|
+
)
|
|
222
|
+
assert reopened_history_comment is not None
|
|
223
|
+
assert reopened_history_comment.text == "업데이트된 변경 기록"
|
|
224
|
+
|
|
225
|
+
assert reopened_version.element.get("appVersion") == "15.0.0.100 WIN32"
|
|
226
|
+
assert "Contents/masterPages/masterPage0.xml" in reopened.package.master_page_paths()
|
|
227
|
+
assert "Contents/history.xml" in reopened.package.history_paths()
|
|
228
|
+
assert reopened.package.version_path() == "version.xml"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Integration tests for automatic table border fills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
7
|
+
from hwpx.document import HwpxDocument
|
|
8
|
+
|
|
9
|
+
HP_NS = "http://www.hancom.co.kr/hwpml/2011/paragraph"
|
|
10
|
+
HH_NS = "http://www.hancom.co.kr/hwpml/2011/head"
|
|
11
|
+
HP = f"{{{HP_NS}}}"
|
|
12
|
+
HH = f"{{{HH_NS}}}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_add_table_injects_basic_border_fill_when_missing() -> None:
|
|
16
|
+
document = HwpxDocument.new()
|
|
17
|
+
|
|
18
|
+
header = document.headers[0]
|
|
19
|
+
ref_list = header.element.find(f"{HH}refList")
|
|
20
|
+
if ref_list is not None:
|
|
21
|
+
existing = ref_list.find(f"{HH}borderFills")
|
|
22
|
+
if existing is not None:
|
|
23
|
+
ref_list.remove(existing)
|
|
24
|
+
header.reset_dirty()
|
|
25
|
+
|
|
26
|
+
section = document.sections[0]
|
|
27
|
+
section.reset_dirty()
|
|
28
|
+
|
|
29
|
+
table = document.add_table(2, 2, section=section)
|
|
30
|
+
assert table is not None
|
|
31
|
+
|
|
32
|
+
updates = document.oxml.serialize()
|
|
33
|
+
|
|
34
|
+
assert header.part_name in updates
|
|
35
|
+
header_root = ET.fromstring(updates[header.part_name])
|
|
36
|
+
|
|
37
|
+
border_fills_element = header_root.find(f".//{HH}borderFills")
|
|
38
|
+
assert border_fills_element is not None
|
|
39
|
+
assert border_fills_element.get("itemCnt") == "1"
|
|
40
|
+
|
|
41
|
+
border_fill_element = border_fills_element.find(f"{HH}borderFill")
|
|
42
|
+
assert border_fill_element is not None
|
|
43
|
+
border_id = border_fill_element.get("id")
|
|
44
|
+
assert border_id == "0"
|
|
45
|
+
assert border_fill_element.get("threeD") == "0"
|
|
46
|
+
assert border_fill_element.get("shadow") == "0"
|
|
47
|
+
assert border_fill_element.get("centerLine") == "NONE"
|
|
48
|
+
assert border_fill_element.get("breakCellSeparateLine") == "0"
|
|
49
|
+
|
|
50
|
+
slash = border_fill_element.find(f"{HH}slash")
|
|
51
|
+
assert slash is not None
|
|
52
|
+
assert slash.get("type") == "NONE"
|
|
53
|
+
|
|
54
|
+
back_slash = border_fill_element.find(f"{HH}backSlash")
|
|
55
|
+
assert back_slash is not None
|
|
56
|
+
assert back_slash.get("type") == "NONE"
|
|
57
|
+
|
|
58
|
+
for child_name in ("leftBorder", "rightBorder", "topBorder", "bottomBorder"):
|
|
59
|
+
child = border_fill_element.find(f"{HH}{child_name}")
|
|
60
|
+
assert child is not None
|
|
61
|
+
assert child.get("type") == "SOLID"
|
|
62
|
+
assert child.get("width") == "0.12 mm"
|
|
63
|
+
assert child.get("color") == "#000000"
|
|
64
|
+
|
|
65
|
+
diagonal = border_fill_element.find(f"{HH}diagonal")
|
|
66
|
+
assert diagonal is not None
|
|
67
|
+
assert diagonal.get("type") == "SOLID"
|
|
68
|
+
assert diagonal.get("width") == "0.1 mm"
|
|
69
|
+
assert diagonal.get("color") == "#000000"
|
|
70
|
+
|
|
71
|
+
assert section.part_name in updates
|
|
72
|
+
section_root = ET.fromstring(updates[section.part_name])
|
|
73
|
+
|
|
74
|
+
table_element = section_root.find(f".//{HP}tbl")
|
|
75
|
+
assert table_element is not None
|
|
76
|
+
assert table_element.get("borderFillIDRef") == border_id
|
|
77
|
+
|
|
78
|
+
cells = section_root.findall(f".//{HP}tc")
|
|
79
|
+
assert cells
|
|
80
|
+
for cell in cells:
|
|
81
|
+
assert cell.get("borderFillIDRef") == border_id
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|