python-hwpx 1.7__tar.gz → 1.9__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.7/src/python_hwpx.egg-info → python_hwpx-1.9}/PKG-INFO +2 -2
- {python_hwpx-1.7 → python_hwpx-1.9}/README.md +1 -1
- {python_hwpx-1.7 → python_hwpx-1.9}/pyproject.toml +1 -1
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/__init__.py +2 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/document.py +280 -16
- {python_hwpx-1.7 → python_hwpx-1.9/src/python_hwpx.egg-info}/PKG-INFO +2 -2
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_document_formatting.py +93 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/LICENSE +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/setup.cfg +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/__init__.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/document.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/opc/package.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/body.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/package.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/templates.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/object_finder.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/text_extractor.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/SOURCES.txt +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_inline_models.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_memo_and_style_editing.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_section_headers.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_text_extractor_annotations.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9
|
|
4
4
|
Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License: Non-Commercial License
|
|
@@ -126,7 +126,7 @@ document.save("output/example.hwpx")
|
|
|
126
126
|
|
|
127
127
|
`HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
|
|
128
128
|
|
|
129
|
-
표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다.
|
|
129
|
+
표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다. 병합된 표 구조를 다뤄야 한다면 `table.iter_grid()` 또는 `table.get_cell_map()`으로 논리 격자와 실제 셀의 매핑을 확인하고, `set_cell_text(..., logical=True, split_merged=True)`로 논리 좌표 기반 편집과 자동 병합 해제를 동시에 처리할 수 있습니다.
|
|
130
130
|
|
|
131
131
|
더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
|
|
132
132
|
|
|
@@ -63,7 +63,7 @@ document.save("output/example.hwpx")
|
|
|
63
63
|
|
|
64
64
|
`HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
|
|
65
65
|
|
|
66
|
-
표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다.
|
|
66
|
+
표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다. 병합된 표 구조를 다뤄야 한다면 `table.iter_grid()` 또는 `table.get_cell_map()`으로 논리 격자와 실제 셀의 매핑을 확인하고, `set_cell_text(..., logical=True, split_merged=True)`로 논리 좌표 기반 편집과 자동 병합 해제를 동시에 처리할 수 있습니다.
|
|
67
67
|
|
|
68
68
|
더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
|
|
69
69
|
|
|
@@ -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.9"
|
|
8
8
|
description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -31,6 +31,7 @@ from .document import (
|
|
|
31
31
|
HwpxOxmlSectionProperties,
|
|
32
32
|
HwpxOxmlTable,
|
|
33
33
|
HwpxOxmlTableCell,
|
|
34
|
+
HwpxTableGridPosition,
|
|
34
35
|
HwpxOxmlTableRow,
|
|
35
36
|
HwpxOxmlVersion,
|
|
36
37
|
PageMargins,
|
|
@@ -141,6 +142,7 @@ __all__ = [
|
|
|
141
142
|
"HwpxOxmlSectionProperties",
|
|
142
143
|
"HwpxOxmlTable",
|
|
143
144
|
"HwpxOxmlTableCell",
|
|
145
|
+
"HwpxTableGridPosition",
|
|
144
146
|
"HwpxOxmlTableRow",
|
|
145
147
|
"HwpxOxmlVersion",
|
|
146
148
|
"KeyDerivation",
|
|
@@ -119,14 +119,14 @@ def _create_paragraph_element(
|
|
|
119
119
|
return paragraph
|
|
120
120
|
|
|
121
121
|
|
|
122
|
-
_LAYOUT_CACHE_ELEMENT_NAMES = {"
|
|
122
|
+
_LAYOUT_CACHE_ELEMENT_NAMES = {"linesegarray"}
|
|
123
123
|
|
|
124
124
|
|
|
125
125
|
def _clear_paragraph_layout_cache(paragraph: ET.Element) -> None:
|
|
126
126
|
"""Remove cached layout metadata such as ``<hp:lineSegArray>``."""
|
|
127
127
|
|
|
128
128
|
for child in list(paragraph):
|
|
129
|
-
if _element_local_name(child) in _LAYOUT_CACHE_ELEMENT_NAMES:
|
|
129
|
+
if _element_local_name(child).lower() in _LAYOUT_CACHE_ELEMENT_NAMES:
|
|
130
130
|
paragraph.remove(child)
|
|
131
131
|
|
|
132
132
|
|
|
@@ -1572,6 +1572,7 @@ class HwpxOxmlTableCell:
|
|
|
1572
1572
|
def text(self, value: str) -> None:
|
|
1573
1573
|
text_element = self._ensure_text_element()
|
|
1574
1574
|
text_element.text = value
|
|
1575
|
+
self.element.set("dirty", "1")
|
|
1575
1576
|
self.table.mark_dirty()
|
|
1576
1577
|
|
|
1577
1578
|
def remove(self) -> None:
|
|
@@ -1579,6 +1580,29 @@ class HwpxOxmlTableCell:
|
|
|
1579
1580
|
self.table.mark_dirty()
|
|
1580
1581
|
|
|
1581
1582
|
|
|
1583
|
+
@dataclass(frozen=True)
|
|
1584
|
+
class HwpxTableGridPosition:
|
|
1585
|
+
"""Mapping between a logical table position and a physical cell."""
|
|
1586
|
+
|
|
1587
|
+
row: int
|
|
1588
|
+
column: int
|
|
1589
|
+
cell: HwpxOxmlTableCell
|
|
1590
|
+
anchor: Tuple[int, int]
|
|
1591
|
+
span: Tuple[int, int]
|
|
1592
|
+
|
|
1593
|
+
@property
|
|
1594
|
+
def is_anchor(self) -> bool:
|
|
1595
|
+
return (self.row, self.column) == self.anchor
|
|
1596
|
+
|
|
1597
|
+
@property
|
|
1598
|
+
def row_span(self) -> int:
|
|
1599
|
+
return self.span[0]
|
|
1600
|
+
|
|
1601
|
+
@property
|
|
1602
|
+
def col_span(self) -> int:
|
|
1603
|
+
return self.span[1]
|
|
1604
|
+
|
|
1605
|
+
|
|
1582
1606
|
class HwpxOxmlTableRow:
|
|
1583
1607
|
"""Represents a table row."""
|
|
1584
1608
|
|
|
@@ -1721,26 +1745,266 @@ class HwpxOxmlTable:
|
|
|
1721
1745
|
def rows(self) -> List[HwpxOxmlTableRow]:
|
|
1722
1746
|
return [HwpxOxmlTableRow(row, self) for row in self.element.findall(f"{_HP}tr")]
|
|
1723
1747
|
|
|
1724
|
-
def
|
|
1725
|
-
|
|
1726
|
-
raise IndexError("row_index and col_index must be non-negative")
|
|
1727
|
-
|
|
1748
|
+
def _build_cell_grid(self) -> dict[Tuple[int, int], HwpxTableGridPosition]:
|
|
1749
|
+
mapping: dict[Tuple[int, int], HwpxTableGridPosition] = {}
|
|
1728
1750
|
for row in self.element.findall(f"{_HP}tr"):
|
|
1729
|
-
for
|
|
1730
|
-
wrapper = HwpxOxmlTableCell(
|
|
1751
|
+
for cell_element in row.findall(f"{_HP}tc"):
|
|
1752
|
+
wrapper = HwpxOxmlTableCell(cell_element, self, row)
|
|
1731
1753
|
start_row, start_col = wrapper.address
|
|
1732
1754
|
span_row, span_col = wrapper.span
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1755
|
+
for logical_row in range(start_row, start_row + span_row):
|
|
1756
|
+
for logical_col in range(start_col, start_col + span_col):
|
|
1757
|
+
key = (logical_row, logical_col)
|
|
1758
|
+
existing = mapping.get(key)
|
|
1759
|
+
entry = HwpxTableGridPosition(
|
|
1760
|
+
row=logical_row,
|
|
1761
|
+
column=logical_col,
|
|
1762
|
+
cell=wrapper,
|
|
1763
|
+
anchor=(start_row, start_col),
|
|
1764
|
+
span=(span_row, span_col),
|
|
1765
|
+
)
|
|
1766
|
+
if (
|
|
1767
|
+
existing is not None
|
|
1768
|
+
and existing.cell.element is not wrapper.element
|
|
1769
|
+
):
|
|
1770
|
+
raise ValueError(
|
|
1771
|
+
"table grid contains overlapping cell spans"
|
|
1772
|
+
)
|
|
1773
|
+
mapping[key] = entry
|
|
1774
|
+
return mapping
|
|
1739
1775
|
|
|
1740
|
-
def
|
|
1741
|
-
|
|
1776
|
+
def _grid_entry(self, row_index: int, col_index: int) -> HwpxTableGridPosition:
|
|
1777
|
+
if row_index < 0 or col_index < 0:
|
|
1778
|
+
raise IndexError("row_index and col_index must be non-negative")
|
|
1779
|
+
|
|
1780
|
+
row_count = self.row_count
|
|
1781
|
+
col_count = self.column_count
|
|
1782
|
+
if row_index >= row_count or col_index >= col_count:
|
|
1783
|
+
raise IndexError(
|
|
1784
|
+
"cell coordinates (%d, %d) exceed table bounds %dx%d"
|
|
1785
|
+
% (row_index, col_index, row_count, col_count)
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
entry = self._build_cell_grid().get((row_index, col_index))
|
|
1789
|
+
if entry is None:
|
|
1790
|
+
raise IndexError(
|
|
1791
|
+
"cell coordinates (%d, %d) are covered by a merged cell"
|
|
1792
|
+
" without an accessible anchor; inspect iter_grid() for details"
|
|
1793
|
+
% (row_index, col_index)
|
|
1794
|
+
)
|
|
1795
|
+
return entry
|
|
1796
|
+
|
|
1797
|
+
def iter_grid(self) -> Iterator[HwpxTableGridPosition]:
|
|
1798
|
+
"""Yield grid-aware mappings for every logical table position."""
|
|
1799
|
+
|
|
1800
|
+
mapping = self._build_cell_grid()
|
|
1801
|
+
row_count = self.row_count
|
|
1802
|
+
col_count = self.column_count
|
|
1803
|
+
for row_index in range(row_count):
|
|
1804
|
+
for col_index in range(col_count):
|
|
1805
|
+
entry = mapping.get((row_index, col_index))
|
|
1806
|
+
if entry is None:
|
|
1807
|
+
raise IndexError(
|
|
1808
|
+
"cell coordinates (%d, %d) do not resolve to a physical cell"
|
|
1809
|
+
% (row_index, col_index)
|
|
1810
|
+
)
|
|
1811
|
+
yield entry
|
|
1812
|
+
|
|
1813
|
+
def get_cell_map(self) -> List[List[HwpxTableGridPosition]]:
|
|
1814
|
+
"""Return a 2D list mapping logical positions to physical cells."""
|
|
1815
|
+
|
|
1816
|
+
row_count = self.row_count
|
|
1817
|
+
col_count = self.column_count
|
|
1818
|
+
grid: List[List[HwpxTableGridPosition | None]] = [
|
|
1819
|
+
[None for _ in range(col_count)] for _ in range(row_count)
|
|
1820
|
+
]
|
|
1821
|
+
for entry in self.iter_grid():
|
|
1822
|
+
grid[entry.row][entry.column] = entry
|
|
1823
|
+
|
|
1824
|
+
for row_index in range(row_count):
|
|
1825
|
+
for col_index in range(col_count):
|
|
1826
|
+
if grid[row_index][col_index] is None:
|
|
1827
|
+
raise IndexError(
|
|
1828
|
+
"cell coordinates (%d, %d) do not resolve to a physical cell"
|
|
1829
|
+
% (row_index, col_index)
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
return [
|
|
1833
|
+
[grid[row_index][col_index] for col_index in range(col_count)]
|
|
1834
|
+
for row_index in range(row_count)
|
|
1835
|
+
]
|
|
1836
|
+
|
|
1837
|
+
def cell(self, row_index: int, col_index: int) -> HwpxOxmlTableCell:
|
|
1838
|
+
entry = self._grid_entry(row_index, col_index)
|
|
1839
|
+
return entry.cell
|
|
1840
|
+
|
|
1841
|
+
def set_cell_text(
|
|
1842
|
+
self,
|
|
1843
|
+
row_index: int,
|
|
1844
|
+
col_index: int,
|
|
1845
|
+
text: str,
|
|
1846
|
+
*,
|
|
1847
|
+
logical: bool = False,
|
|
1848
|
+
split_merged: bool = False,
|
|
1849
|
+
) -> None:
|
|
1850
|
+
if logical:
|
|
1851
|
+
entry = self._grid_entry(row_index, col_index)
|
|
1852
|
+
if split_merged and not entry.is_anchor:
|
|
1853
|
+
cell = self.split_merged_cell(row_index, col_index)
|
|
1854
|
+
else:
|
|
1855
|
+
cell = entry.cell
|
|
1856
|
+
else:
|
|
1857
|
+
cell = self.cell(row_index, col_index)
|
|
1742
1858
|
cell.text = text
|
|
1743
1859
|
|
|
1860
|
+
def split_merged_cell(
|
|
1861
|
+
self, row_index: int, col_index: int
|
|
1862
|
+
) -> HwpxOxmlTableCell:
|
|
1863
|
+
entry = self._grid_entry(row_index, col_index)
|
|
1864
|
+
cell = entry.cell
|
|
1865
|
+
start_row, start_col = entry.anchor
|
|
1866
|
+
span_row, span_col = entry.span
|
|
1867
|
+
|
|
1868
|
+
if span_row == 1 and span_col == 1:
|
|
1869
|
+
return cell
|
|
1870
|
+
|
|
1871
|
+
row_elements = self.element.findall(f"{_HP}tr")
|
|
1872
|
+
if len(row_elements) < start_row + span_row:
|
|
1873
|
+
raise IndexError(
|
|
1874
|
+
"table rows missing while splitting merged cell covering"
|
|
1875
|
+
f" ({start_row}, {start_col})"
|
|
1876
|
+
)
|
|
1877
|
+
|
|
1878
|
+
width_segments = _distribute_size(cell.width, span_col)
|
|
1879
|
+
height_segments = _distribute_size(cell.height, span_row)
|
|
1880
|
+
if not width_segments:
|
|
1881
|
+
width_segments = [cell.width] + [0] * (span_col - 1)
|
|
1882
|
+
if not height_segments:
|
|
1883
|
+
height_segments = [cell.height] + [0] * (span_row - 1)
|
|
1884
|
+
|
|
1885
|
+
template_attrs = {key: value for key, value in cell.element.attrib.items()}
|
|
1886
|
+
preserved_children = [
|
|
1887
|
+
deepcopy(child)
|
|
1888
|
+
for child in cell.element
|
|
1889
|
+
if _element_local_name(child)
|
|
1890
|
+
not in {"subList", "cellAddr", "cellSpan", "cellSz", "cellMargin"}
|
|
1891
|
+
]
|
|
1892
|
+
template_sublist = cell.element.find(f"{_HP}subList")
|
|
1893
|
+
template_margin = cell.element.find(f"{_HP}cellMargin")
|
|
1894
|
+
|
|
1895
|
+
for row_offset in range(span_row):
|
|
1896
|
+
logical_row = start_row + row_offset
|
|
1897
|
+
row_element = row_elements[logical_row]
|
|
1898
|
+
row_height = height_segments[row_offset] if row_offset < len(height_segments) else cell.height
|
|
1899
|
+
for col_offset in range(span_col):
|
|
1900
|
+
logical_col = start_col + col_offset
|
|
1901
|
+
col_width = width_segments[col_offset] if col_offset < len(width_segments) else cell.width
|
|
1902
|
+
|
|
1903
|
+
if row_offset == 0 and col_offset == 0:
|
|
1904
|
+
addr = cell._addr_element()
|
|
1905
|
+
if addr is None:
|
|
1906
|
+
addr = ET.SubElement(cell.element, f"{_HP}cellAddr")
|
|
1907
|
+
addr.set("rowAddr", str(start_row))
|
|
1908
|
+
addr.set("colAddr", str(start_col))
|
|
1909
|
+
span_element = cell._span_element()
|
|
1910
|
+
span_element.set("rowSpan", "1")
|
|
1911
|
+
span_element.set("colSpan", "1")
|
|
1912
|
+
size_element = cell._size_element()
|
|
1913
|
+
size_element.set("width", str(col_width))
|
|
1914
|
+
size_element.set("height", str(row_height))
|
|
1915
|
+
continue
|
|
1916
|
+
|
|
1917
|
+
existing_target: HwpxOxmlTableCell | None = None
|
|
1918
|
+
for existing in row_element.findall(f"{_HP}tc"):
|
|
1919
|
+
wrapper = HwpxOxmlTableCell(existing, self, row_element)
|
|
1920
|
+
existing_row, existing_col = wrapper.address
|
|
1921
|
+
span_r, span_c = wrapper.span
|
|
1922
|
+
if existing_row == logical_row and existing_col == logical_col:
|
|
1923
|
+
existing_target = wrapper
|
|
1924
|
+
break
|
|
1925
|
+
if (
|
|
1926
|
+
existing_row <= logical_row < existing_row + span_r
|
|
1927
|
+
and existing_col <= logical_col < existing_col + span_c
|
|
1928
|
+
):
|
|
1929
|
+
if wrapper.element is cell.element:
|
|
1930
|
+
continue
|
|
1931
|
+
raise ValueError(
|
|
1932
|
+
"Cannot split merged cell covering (%d, %d) because"
|
|
1933
|
+
" position (%d, %d) overlaps another merged cell"
|
|
1934
|
+
% (start_row, start_col, logical_row, logical_col)
|
|
1935
|
+
)
|
|
1936
|
+
|
|
1937
|
+
if existing_target is not None:
|
|
1938
|
+
existing_target.set_span(1, 1)
|
|
1939
|
+
existing_target.set_size(col_width, row_height)
|
|
1940
|
+
continue
|
|
1941
|
+
|
|
1942
|
+
new_cell_element = ET.Element(f"{_HP}tc", dict(template_attrs))
|
|
1943
|
+
for child in preserved_children:
|
|
1944
|
+
new_cell_element.append(deepcopy(child))
|
|
1945
|
+
|
|
1946
|
+
sublist_attrs = _default_sublist_attributes()
|
|
1947
|
+
template_para = None
|
|
1948
|
+
if template_sublist is not None:
|
|
1949
|
+
for key, value in template_sublist.attrib.items():
|
|
1950
|
+
if key == "id":
|
|
1951
|
+
continue
|
|
1952
|
+
sublist_attrs.setdefault(key, value)
|
|
1953
|
+
template_para = template_sublist.find(f"{_HP}p")
|
|
1954
|
+
|
|
1955
|
+
sublist = ET.SubElement(new_cell_element, f"{_HP}subList", sublist_attrs)
|
|
1956
|
+
paragraph_attrs = _default_cell_paragraph_attributes()
|
|
1957
|
+
run_attrs = {"charPrIDRef": "0"}
|
|
1958
|
+
if template_para is not None:
|
|
1959
|
+
for key, value in template_para.attrib.items():
|
|
1960
|
+
if key == "id":
|
|
1961
|
+
continue
|
|
1962
|
+
paragraph_attrs.setdefault(key, value)
|
|
1963
|
+
template_run = template_para.find(f"{_HP}run")
|
|
1964
|
+
if template_run is not None:
|
|
1965
|
+
run_attrs = dict(template_run.attrib)
|
|
1966
|
+
if "charPrIDRef" not in run_attrs:
|
|
1967
|
+
run_attrs["charPrIDRef"] = "0"
|
|
1968
|
+
paragraph = ET.SubElement(sublist, f"{_HP}p", paragraph_attrs)
|
|
1969
|
+
run = ET.SubElement(paragraph, f"{_HP}run", run_attrs)
|
|
1970
|
+
ET.SubElement(run, f"{_HP}t")
|
|
1971
|
+
|
|
1972
|
+
ET.SubElement(
|
|
1973
|
+
new_cell_element,
|
|
1974
|
+
f"{_HP}cellAddr",
|
|
1975
|
+
{"rowAddr": str(logical_row), "colAddr": str(logical_col)},
|
|
1976
|
+
)
|
|
1977
|
+
ET.SubElement(
|
|
1978
|
+
new_cell_element,
|
|
1979
|
+
f"{_HP}cellSpan",
|
|
1980
|
+
{"rowSpan": "1", "colSpan": "1"},
|
|
1981
|
+
)
|
|
1982
|
+
ET.SubElement(
|
|
1983
|
+
new_cell_element,
|
|
1984
|
+
f"{_HP}cellSz",
|
|
1985
|
+
{"width": str(col_width), "height": str(row_height)},
|
|
1986
|
+
)
|
|
1987
|
+
if template_margin is not None:
|
|
1988
|
+
new_cell_element.append(deepcopy(template_margin))
|
|
1989
|
+
else:
|
|
1990
|
+
ET.SubElement(
|
|
1991
|
+
new_cell_element,
|
|
1992
|
+
f"{_HP}cellMargin",
|
|
1993
|
+
_default_cell_margin_attributes(),
|
|
1994
|
+
)
|
|
1995
|
+
|
|
1996
|
+
existing_cells = list(row_element.findall(f"{_HP}tc"))
|
|
1997
|
+
insert_index = len(existing_cells)
|
|
1998
|
+
for idx, existing in enumerate(existing_cells):
|
|
1999
|
+
wrapper = HwpxOxmlTableCell(existing, self, row_element)
|
|
2000
|
+
if wrapper.address[1] > logical_col:
|
|
2001
|
+
insert_index = idx
|
|
2002
|
+
break
|
|
2003
|
+
row_element.insert(insert_index, new_cell_element)
|
|
2004
|
+
|
|
2005
|
+
self.mark_dirty()
|
|
2006
|
+
return self.cell(row_index, col_index)
|
|
2007
|
+
|
|
1744
2008
|
def merge_cells(
|
|
1745
2009
|
self,
|
|
1746
2010
|
start_row: int,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9
|
|
4
4
|
Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License: Non-Commercial License
|
|
@@ -126,7 +126,7 @@ document.save("output/example.hwpx")
|
|
|
126
126
|
|
|
127
127
|
`HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
|
|
128
128
|
|
|
129
|
-
표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다.
|
|
129
|
+
표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다. 병합된 표 구조를 다뤄야 한다면 `table.iter_grid()` 또는 `table.get_cell_map()`으로 논리 격자와 실제 셀의 매핑을 확인하고, `set_cell_text(..., logical=True, split_merged=True)`로 논리 좌표 기반 편집과 자동 병합 해제를 동시에 처리할 수 있습니다.
|
|
130
130
|
|
|
131
131
|
더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
|
|
132
132
|
|
|
@@ -309,7 +309,9 @@ def test_table_set_cell_text_removes_layout_cache() -> None:
|
|
|
309
309
|
paragraph = sublist.find(f"{HP}p")
|
|
310
310
|
assert paragraph is not None
|
|
311
311
|
ET.SubElement(paragraph, f"{HP}lineSegArray")
|
|
312
|
+
ET.SubElement(paragraph, f"{HP}linesegarray")
|
|
312
313
|
assert paragraph.find(f"{HP}lineSegArray") is not None
|
|
314
|
+
assert paragraph.find(f"{HP}linesegarray") is not None
|
|
313
315
|
text_element = paragraph.find(f".//{HP}t")
|
|
314
316
|
assert text_element is not None
|
|
315
317
|
text_element.text = "Cached"
|
|
@@ -318,6 +320,29 @@ def test_table_set_cell_text_removes_layout_cache() -> None:
|
|
|
318
320
|
|
|
319
321
|
assert table.cell(0, 0).text == "Updated"
|
|
320
322
|
assert paragraph.find(f"{HP}lineSegArray") is None
|
|
323
|
+
assert paragraph.find(f"{HP}linesegarray") is None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_table_cell_text_marks_cell_dirty_attribute() -> None:
|
|
327
|
+
section_element = ET.Element(f"{HS}sec")
|
|
328
|
+
section = HwpxOxmlSection("section0.xml", section_element)
|
|
329
|
+
manifest = ET.Element("manifest")
|
|
330
|
+
root = HwpxOxmlDocument(manifest, [section], [])
|
|
331
|
+
document = HwpxDocument(cast(HwpxPackage, object()), root)
|
|
332
|
+
|
|
333
|
+
table = document.add_table(1, 1, section=section)
|
|
334
|
+
cell = table.cell(0, 0)
|
|
335
|
+
assert cell.element.get("dirty") == "0"
|
|
336
|
+
|
|
337
|
+
cell.text = "Updated"
|
|
338
|
+
|
|
339
|
+
assert cell.element.get("dirty") == "1"
|
|
340
|
+
|
|
341
|
+
cell.element.set("dirty", "0")
|
|
342
|
+
|
|
343
|
+
table.set_cell_text(0, 0, "Again")
|
|
344
|
+
|
|
345
|
+
assert table.cell(0, 0).element.get("dirty") == "1"
|
|
321
346
|
|
|
322
347
|
|
|
323
348
|
def test_table_merge_cells_updates_spans_and_structure() -> None:
|
|
@@ -358,6 +383,74 @@ def test_table_merge_cells_rejects_partial_overlap() -> None:
|
|
|
358
383
|
table.merge_cells(0, 1, 1, 1)
|
|
359
384
|
|
|
360
385
|
|
|
386
|
+
def test_table_iter_grid_reports_merged_cells() -> None:
|
|
387
|
+
section_element = ET.Element(f"{HS}sec")
|
|
388
|
+
section = HwpxOxmlSection("section0.xml", section_element)
|
|
389
|
+
manifest = ET.Element("manifest")
|
|
390
|
+
root = HwpxOxmlDocument(manifest, [section], [])
|
|
391
|
+
document = HwpxDocument(cast(HwpxPackage, object()), root)
|
|
392
|
+
|
|
393
|
+
table = document.add_table(2, 2, section=section)
|
|
394
|
+
table.merge_cells(0, 0, 0, 1)
|
|
395
|
+
|
|
396
|
+
entries = list(table.iter_grid())
|
|
397
|
+
assert len(entries) == 4
|
|
398
|
+
mapping = {(entry.row, entry.column): entry for entry in entries}
|
|
399
|
+
top_left = mapping[(0, 0)]
|
|
400
|
+
right = mapping[(0, 1)]
|
|
401
|
+
|
|
402
|
+
assert top_left.is_anchor is True
|
|
403
|
+
assert top_left.row_span == 1
|
|
404
|
+
assert top_left.col_span == 2
|
|
405
|
+
assert right.is_anchor is False
|
|
406
|
+
assert right.cell.element is top_left.cell.element
|
|
407
|
+
assert right.row_span == top_left.row_span
|
|
408
|
+
assert right.col_span == top_left.col_span
|
|
409
|
+
|
|
410
|
+
cell_map = table.get_cell_map()
|
|
411
|
+
assert cell_map[0][1].cell.element is top_left.cell.element
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_table_logical_editing_can_split_merged_cells() -> None:
|
|
415
|
+
section_element = ET.Element(f"{HS}sec")
|
|
416
|
+
section = HwpxOxmlSection("section0.xml", section_element)
|
|
417
|
+
manifest = ET.Element("manifest")
|
|
418
|
+
root = HwpxOxmlDocument(manifest, [section], [])
|
|
419
|
+
document = HwpxDocument(cast(HwpxPackage, object()), root)
|
|
420
|
+
|
|
421
|
+
table = document.add_table(2, 2, section=section)
|
|
422
|
+
table.merge_cells(0, 0, 0, 1)
|
|
423
|
+
|
|
424
|
+
table.set_cell_text(0, 1, "Shared", logical=True)
|
|
425
|
+
assert table.cell(0, 0).text == "Shared"
|
|
426
|
+
assert table.cell(0, 1).element is table.cell(0, 0).element
|
|
427
|
+
|
|
428
|
+
table.set_cell_text(0, 1, "Right", logical=True, split_merged=True)
|
|
429
|
+
|
|
430
|
+
left_cell = table.cell(0, 0)
|
|
431
|
+
right_cell = table.cell(0, 1)
|
|
432
|
+
|
|
433
|
+
assert left_cell.element is not right_cell.element
|
|
434
|
+
assert left_cell.text == "Shared"
|
|
435
|
+
assert right_cell.text == "Right"
|
|
436
|
+
assert right_cell.span == (1, 1)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_table_cell_out_of_bounds_error_mentions_bounds() -> None:
|
|
440
|
+
section_element = ET.Element(f"{HS}sec")
|
|
441
|
+
section = HwpxOxmlSection("section0.xml", section_element)
|
|
442
|
+
manifest = ET.Element("manifest")
|
|
443
|
+
root = HwpxOxmlDocument(manifest, [section], [])
|
|
444
|
+
document = HwpxDocument(cast(HwpxPackage, object()), root)
|
|
445
|
+
|
|
446
|
+
table = document.add_table(1, 1, section=section)
|
|
447
|
+
|
|
448
|
+
with pytest.raises(IndexError) as excinfo:
|
|
449
|
+
table.cell(5, 0)
|
|
450
|
+
|
|
451
|
+
assert "exceed table bounds" in str(excinfo.value)
|
|
452
|
+
|
|
453
|
+
|
|
361
454
|
def test_paragraph_tables_property_returns_wrappers() -> None:
|
|
362
455
|
section, paragraph = _build_section_with_paragraph()
|
|
363
456
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|