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.
Files changed (40) hide show
  1. {python-hwpx-1.3/src/python_hwpx.egg-info → python-hwpx-1.5}/PKG-INFO +6 -2
  2. {python-hwpx-1.3 → python-hwpx-1.5}/README.md +5 -1
  3. {python-hwpx-1.3 → python-hwpx-1.5}/pyproject.toml +1 -1
  4. python-hwpx-1.5/src/hwpx/data/Skeleton.hwpx +0 -0
  5. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/document.py +36 -2
  6. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/__init__.py +6 -0
  7. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/document.py +312 -4
  8. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/package.py +88 -0
  9. {python-hwpx-1.3 → python-hwpx-1.5/src/python_hwpx.egg-info}/PKG-INFO +6 -2
  10. {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/SOURCES.txt +1 -0
  11. {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_integration_hwpx_compatibility.py +89 -0
  12. python-hwpx-1.5/tests/test_tables_default_border.py +81 -0
  13. python-hwpx-1.3/src/hwpx/data/Skeleton.hwpx +0 -0
  14. {python-hwpx-1.3 → python-hwpx-1.5}/LICENSE +0 -0
  15. {python-hwpx-1.3 → python-hwpx-1.5}/setup.cfg +0 -0
  16. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/__init__.py +0 -0
  17. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/opc/package.py +0 -0
  18. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/body.py +0 -0
  19. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/common.py +0 -0
  20. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/header.py +0 -0
  21. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/parser.py +0 -0
  22. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/schema.py +0 -0
  23. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/oxml/utils.py +0 -0
  24. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/templates.py +0 -0
  25. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/__init__.py +0 -0
  26. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/_schemas/header.xsd +0 -0
  27. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/_schemas/section.xsd +0 -0
  28. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/object_finder.py +0 -0
  29. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/text_extractor.py +0 -0
  30. {python-hwpx-1.3 → python-hwpx-1.5}/src/hwpx/tools/validator.py +0 -0
  31. {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  32. {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  33. {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/requires.txt +0 -0
  34. {python-hwpx-1.3 → python-hwpx-1.5}/src/python_hwpx.egg-info/top_level.txt +0 -0
  35. {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_document_formatting.py +0 -0
  36. {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_inline_models.py +0 -0
  37. {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_memo_and_style_editing.py +0 -0
  38. {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_oxml_parsing.py +0 -0
  39. {python-hwpx-1.3 → python-hwpx-1.5}/tests/test_section_headers.py +0 -0
  40. {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
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.3"
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" }
@@ -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 = "0",
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=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 = "0",
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 = "0",
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=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
- return cls(manifest, sections, headers)
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
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
  ## 문서
@@ -33,4 +33,5 @@ tests/test_integration_hwpx_compatibility.py
33
33
  tests/test_memo_and_style_editing.py
34
34
  tests/test_oxml_parsing.py
35
35
  tests/test_section_headers.py
36
+ tests/test_tables_default_border.py
36
37
  tests/test_text_extractor_annotations.py
@@ -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