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.
Files changed (39) hide show
  1. {python_hwpx-1.7/src/python_hwpx.egg-info → python_hwpx-1.9}/PKG-INFO +2 -2
  2. {python_hwpx-1.7 → python_hwpx-1.9}/README.md +1 -1
  3. {python_hwpx-1.7 → python_hwpx-1.9}/pyproject.toml +1 -1
  4. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/__init__.py +2 -0
  5. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/document.py +280 -16
  6. {python_hwpx-1.7 → python_hwpx-1.9/src/python_hwpx.egg-info}/PKG-INFO +2 -2
  7. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_document_formatting.py +93 -0
  8. {python_hwpx-1.7 → python_hwpx-1.9}/LICENSE +0 -0
  9. {python_hwpx-1.7 → python_hwpx-1.9}/setup.cfg +0 -0
  10. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/__init__.py +0 -0
  11. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/data/Skeleton.hwpx +0 -0
  12. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/document.py +0 -0
  13. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/opc/package.py +0 -0
  14. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/body.py +0 -0
  15. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/common.py +0 -0
  16. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/header.py +0 -0
  17. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/parser.py +0 -0
  18. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/schema.py +0 -0
  19. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/oxml/utils.py +0 -0
  20. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/package.py +0 -0
  21. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/templates.py +0 -0
  22. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/__init__.py +0 -0
  23. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/_schemas/header.xsd +0 -0
  24. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/_schemas/section.xsd +0 -0
  25. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/object_finder.py +0 -0
  26. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/text_extractor.py +0 -0
  27. {python_hwpx-1.7 → python_hwpx-1.9}/src/hwpx/tools/validator.py +0 -0
  28. {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/SOURCES.txt +0 -0
  29. {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  30. {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  31. {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/requires.txt +0 -0
  32. {python_hwpx-1.7 → python_hwpx-1.9}/src/python_hwpx.egg-info/top_level.txt +0 -0
  33. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_inline_models.py +0 -0
  34. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_integration_hwpx_compatibility.py +0 -0
  35. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_memo_and_style_editing.py +0 -0
  36. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_oxml_parsing.py +0 -0
  37. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_section_headers.py +0 -0
  38. {python_hwpx-1.7 → python_hwpx-1.9}/tests/test_tables_default_border.py +0 -0
  39. {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.7
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"
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 = {"lineSegArray"}
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 cell(self, row_index: int, col_index: int) -> HwpxOxmlTableCell:
1725
- if row_index < 0 or col_index < 0:
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 cell in row.findall(f"{_HP}tc"):
1730
- wrapper = HwpxOxmlTableCell(cell, self, row)
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
- if (
1734
- start_row <= row_index < start_row + span_row
1735
- and start_col <= col_index < start_col + span_col
1736
- ):
1737
- return wrapper
1738
- raise IndexError("cell coordinates out of range")
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 set_cell_text(self, row_index: int, col_index: int, text: str) -> None:
1741
- cell = self.cell(row_index, col_index)
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.7
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