python-hwpx 1.0__tar.gz → 1.2__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 (36) hide show
  1. {python-hwpx-1.0/src/python_hwpx.egg-info → python-hwpx-1.2}/PKG-INFO +6 -9
  2. {python-hwpx-1.0 → python-hwpx-1.2}/README.md +2 -5
  3. {python-hwpx-1.0 → python-hwpx-1.2}/pyproject.toml +5 -4
  4. python-hwpx-1.2/src/hwpx/data/Skeleton.hwpx +0 -0
  5. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/document.py +26 -0
  6. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/document.py +304 -2
  7. python-hwpx-1.2/src/hwpx/templates.py +33 -0
  8. {python-hwpx-1.0 → python-hwpx-1.2/src/python_hwpx.egg-info}/PKG-INFO +6 -9
  9. {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/SOURCES.txt +2 -0
  10. {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_document_formatting.py +25 -0
  11. {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_memo_and_style_editing.py +67 -0
  12. {python-hwpx-1.0 → python-hwpx-1.2}/LICENSE +0 -0
  13. {python-hwpx-1.0 → python-hwpx-1.2}/setup.cfg +0 -0
  14. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/__init__.py +0 -0
  15. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/opc/package.py +0 -0
  16. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/__init__.py +0 -0
  17. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/body.py +0 -0
  18. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/common.py +0 -0
  19. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/header.py +0 -0
  20. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/parser.py +0 -0
  21. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/schema.py +0 -0
  22. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/utils.py +0 -0
  23. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/package.py +0 -0
  24. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/__init__.py +0 -0
  25. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/_schemas/header.xsd +0 -0
  26. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/_schemas/section.xsd +0 -0
  27. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/object_finder.py +0 -0
  28. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/text_extractor.py +0 -0
  29. {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/validator.py +0 -0
  30. {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  31. {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  32. {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/requires.txt +0 -0
  33. {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/top_level.txt +0 -0
  34. {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_integration_hwpx_compatibility.py +0 -0
  35. {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_oxml_parsing.py +0 -0
  36. {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_text_extractor_annotations.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-hwpx
3
- Version: 1.0
3
+ Version: 1.2
4
4
  Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
5
5
  Author: python-hwpx Maintainers
6
6
  License: Non-Commercial License
7
- Project-URL: Homepage, https://github.com/hancom-io/python-hwpx
8
- Project-URL: Documentation, https://github.com/hancom-io/python-hwpx/tree/main/docs
9
- Project-URL: Issues, https://github.com/hancom-io/python-hwpx/issues
7
+ Project-URL: Homepage, https://github.com/airmang/python-hwpx
8
+ Project-URL: Documentation, https://github.com/airmang/python-hwpx/tree/main/docs
9
+ Project-URL: Issues, https://github.com/airmang/python-hwpx/issues
10
10
  Keywords: hwp,hwpx,hancom,opc,xml
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
@@ -45,13 +45,10 @@ python-hwpx는 Hancom HWPX 패키지를 분석하고 편집하기 위한 Python
45
45
 
46
46
  ### 1. 환경 준비
47
47
 
48
- 가상 환경을 만든 뒤 PyPI에 배포된 패키지를 설치하세요.
48
+ pip를 이용해 패키지를 설치합니다.
49
49
 
50
50
  ```bash
51
- python -m venv .venv
52
- source .venv/bin/activate
53
- python -m pip install --upgrade pip
54
- python -m pip install python-hwpx
51
+ python pip install python-hwpx
55
52
  ```
56
53
 
57
54
  최신 개발 버전을 사용하거나 소스 코드를 수정하려면 편집 가능한 설치를 권장합니다.
@@ -15,13 +15,10 @@ python-hwpx는 Hancom HWPX 패키지를 분석하고 편집하기 위한 Python
15
15
 
16
16
  ### 1. 환경 준비
17
17
 
18
- 가상 환경을 만든 뒤 PyPI에 배포된 패키지를 설치하세요.
18
+ pip를 이용해 패키지를 설치합니다.
19
19
 
20
20
  ```bash
21
- python -m venv .venv
22
- source .venv/bin/activate
23
- python -m pip install --upgrade pip
24
- python -m pip install python-hwpx
21
+ python pip install python-hwpx
25
22
  ```
26
23
 
27
24
  최신 개발 버전을 사용하거나 소스 코드를 수정하려면 편집 가능한 설치를 권장합니다.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "1.0"
7
+ version = "1.2"
8
8
  description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { text = "Non-Commercial License" }
@@ -39,9 +39,9 @@ test = [
39
39
  ]
40
40
 
41
41
  [project.urls]
42
- Homepage = "https://github.com/hancom-io/python-hwpx"
43
- Documentation = "https://github.com/hancom-io/python-hwpx/tree/main/docs"
44
- Issues = "https://github.com/hancom-io/python-hwpx/issues"
42
+ Homepage = "https://github.com/airmang/python-hwpx"
43
+ Documentation = "https://github.com/airmang/python-hwpx/tree/main/docs"
44
+ Issues = "https://github.com/airmang/python-hwpx/issues"
45
45
 
46
46
  [project.scripts]
47
47
  hwpx-validate = "hwpx.tools.validator:main"
@@ -57,6 +57,7 @@ include = ["hwpx*"]
57
57
 
58
58
  [tool.setuptools.package-data]
59
59
  "hwpx.tools" = ["_schemas/*.xsd"]
60
+ "hwpx.data" = ["Skeleton.hwpx"]
60
61
 
61
62
  [tool.pytest.ini_options]
62
63
  pythonpath = ["src"]
@@ -22,9 +22,12 @@ from .oxml import (
22
22
  RunStyle,
23
23
  )
24
24
  from .package import HwpxPackage
25
+ from .templates import blank_document_bytes
25
26
 
26
27
  _HP_NS = "http://www.hancom.co.kr/hwpml/2011/paragraph"
27
28
  _HP = f"{{{_HP_NS}}}"
29
+ _HH_NS = "http://www.hancom.co.kr/hwpml/2011/head"
30
+ _HH = f"{{{_HH_NS}}}"
28
31
 
29
32
 
30
33
  class HwpxDocument:
@@ -46,6 +49,12 @@ class HwpxDocument:
46
49
  root = HwpxOxmlDocument.from_package(package)
47
50
  return cls(package, root)
48
51
 
52
+ @classmethod
53
+ def new(cls) -> "HwpxDocument":
54
+ """Return a new blank document based on the default skeleton template."""
55
+
56
+ return cls.open(blank_document_bytes())
57
+
49
58
  @classmethod
50
59
  def from_package(cls, package: HwpxPackage) -> "HwpxDocument":
51
60
  """Create a document backed by an existing :class:`HwpxPackage`."""
@@ -295,6 +304,23 @@ class HwpxDocument:
295
304
 
296
305
  return self._root.char_property(char_pr_id_ref)
297
306
 
307
+ def ensure_run_style(
308
+ self,
309
+ *,
310
+ bold: bool = False,
311
+ italic: bool = False,
312
+ underline: bool = False,
313
+ base_char_pr_id: str | int | None = None,
314
+ ) -> str:
315
+ """Return a ``charPr`` identifier matching the requested flags."""
316
+
317
+ return self._root.ensure_run_style(
318
+ bold=bold,
319
+ italic=italic,
320
+ underline=underline,
321
+ base_char_pr_id=base_char_pr_id,
322
+ )
323
+
298
324
  def iter_runs(self) -> Iterator[HwpxOxmlRun]:
299
325
  """Yield every run element contained in the document."""
300
326
 
@@ -2,8 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from copy import deepcopy
5
6
  from dataclasses import dataclass
6
- from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
7
+ from typing import Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
7
8
  from uuid import uuid4
8
9
  import xml.etree.ElementTree as ET
9
10
 
@@ -625,6 +626,49 @@ class HwpxOxmlRun:
625
626
  self.element = element
626
627
  self.paragraph = paragraph
627
628
 
629
+ def _current_format_flags(self) -> Tuple[bool, bool, bool] | None:
630
+ style = self.style
631
+ if style is None:
632
+ return None
633
+ bold = "bold" in style.child_attributes
634
+ italic = "italic" in style.child_attributes
635
+ underline_attrs = style.child_attributes.get("underline")
636
+ underline = False
637
+ if underline_attrs is not None:
638
+ underline = underline_attrs.get("type", "").upper() != "NONE"
639
+ return bold, italic, underline
640
+
641
+ def _apply_format_change(
642
+ self,
643
+ *,
644
+ bold: bool | None = None,
645
+ italic: bool | None = None,
646
+ underline: bool | None = None,
647
+ ) -> None:
648
+ document = self.paragraph.section.document
649
+ if document is None:
650
+ raise RuntimeError("run is not attached to a document")
651
+
652
+ current = self._current_format_flags()
653
+ if current is None:
654
+ current = (False, False, False)
655
+
656
+ target = [
657
+ current[0] if bold is None else bool(bold),
658
+ current[1] if italic is None else bool(italic),
659
+ current[2] if underline is None else bool(underline),
660
+ ]
661
+
662
+ if tuple(target) == current:
663
+ return
664
+
665
+ style_id = document.ensure_run_style(
666
+ bold=target[0],
667
+ italic=target[1],
668
+ underline=target[2],
669
+ )
670
+ self.char_pr_id_ref = style_id
671
+
628
672
  @property
629
673
  def char_pr_id_ref(self) -> str | None:
630
674
  """Return the character property reference applied to the run."""
@@ -737,6 +781,39 @@ class HwpxOxmlRun:
737
781
  return
738
782
  self.paragraph.section.mark_dirty()
739
783
 
784
+ @property
785
+ def bold(self) -> bool | None:
786
+ flags = self._current_format_flags()
787
+ if flags is None:
788
+ return None
789
+ return flags[0]
790
+
791
+ @bold.setter
792
+ def bold(self, value: bool | None) -> None:
793
+ self._apply_format_change(bold=value)
794
+
795
+ @property
796
+ def italic(self) -> bool | None:
797
+ flags = self._current_format_flags()
798
+ if flags is None:
799
+ return None
800
+ return flags[1]
801
+
802
+ @italic.setter
803
+ def italic(self, value: bool | None) -> None:
804
+ self._apply_format_change(italic=value)
805
+
806
+ @property
807
+ def underline(self) -> bool | None:
808
+ flags = self._current_format_flags()
809
+ if flags is None:
810
+ return None
811
+ return flags[2]
812
+
813
+ @underline.setter
814
+ def underline(self, value: bool | None) -> None:
815
+ self._apply_format_change(underline=value)
816
+
740
817
 
741
818
  class HwpxOxmlMemoGroup:
742
819
  """Wrapper providing access to ``<hp:memogroup>`` containers."""
@@ -1323,6 +1400,43 @@ class HwpxOxmlParagraph:
1323
1400
  attrs["charPrIDRef"] = str(default_char)
1324
1401
  return ET.SubElement(self.element, f"{_HP}run", attrs)
1325
1402
 
1403
+ def add_run(
1404
+ self,
1405
+ text: str = "",
1406
+ *,
1407
+ char_pr_id_ref: str | int | None = None,
1408
+ bold: bool = False,
1409
+ italic: bool = False,
1410
+ underline: bool = False,
1411
+ attributes: dict[str, str] | None = None,
1412
+ ) -> HwpxOxmlRun:
1413
+ """Append a new run to the paragraph and return its wrapper."""
1414
+
1415
+ run_attrs = dict(attributes or {})
1416
+
1417
+ if "charPrIDRef" not in run_attrs:
1418
+ if char_pr_id_ref is not None:
1419
+ run_attrs["charPrIDRef"] = str(char_pr_id_ref)
1420
+ else:
1421
+ document = self.section.document
1422
+ if document is not None:
1423
+ style_id = document.ensure_run_style(
1424
+ bold=bool(bold),
1425
+ italic=bool(italic),
1426
+ underline=bool(underline),
1427
+ )
1428
+ run_attrs["charPrIDRef"] = style_id
1429
+ else:
1430
+ default_char = self.char_pr_id_ref or "0"
1431
+ if default_char is not None:
1432
+ run_attrs["charPrIDRef"] = str(default_char)
1433
+
1434
+ run_element = ET.SubElement(self.element, f"{_HP}run", run_attrs)
1435
+ text_element = ET.SubElement(run_element, f"{_HP}t")
1436
+ text_element.text = text
1437
+ self.section.mark_dirty()
1438
+ return HwpxOxmlRun(run_element, self)
1439
+
1326
1440
  @property
1327
1441
  def tables(self) -> List["HwpxOxmlTable"]:
1328
1442
  """Return the tables embedded within this paragraph."""
@@ -1645,21 +1759,133 @@ class HwpxOxmlSection:
1645
1759
  class HwpxOxmlHeader:
1646
1760
  """Represents a header XML part."""
1647
1761
 
1648
- def __init__(self, part_name: str, element: ET.Element):
1762
+ def __init__(self, part_name: str, element: ET.Element, document: "HwpxOxmlDocument" | None = None):
1649
1763
  self.part_name = part_name
1650
1764
  self._element = element
1651
1765
  self._dirty = False
1766
+ self._document = document
1652
1767
 
1653
1768
  @property
1654
1769
  def element(self) -> ET.Element:
1655
1770
  return self._element
1656
1771
 
1772
+ @property
1773
+ def document(self) -> "HwpxOxmlDocument" | None:
1774
+ return self._document
1775
+
1776
+ def attach_document(self, document: "HwpxOxmlDocument") -> None:
1777
+ self._document = document
1778
+
1657
1779
  def _begin_num_element(self, create: bool = False) -> ET.Element | None:
1658
1780
  element = self._element.find(f"{_HH}beginNum")
1659
1781
  if element is None and create:
1660
1782
  element = ET.SubElement(self._element, f"{_HH}beginNum")
1661
1783
  return element
1662
1784
 
1785
+ def _ref_list_element(self, create: bool = False) -> ET.Element | None:
1786
+ element = self._element.find(f"{_HH}refList")
1787
+ if element is None and create:
1788
+ element = ET.SubElement(self._element, f"{_HH}refList")
1789
+ self.mark_dirty()
1790
+ return element
1791
+
1792
+ def _char_properties_element(self, create: bool = False) -> ET.Element | None:
1793
+ ref_list = self._ref_list_element(create=create)
1794
+ if ref_list is None:
1795
+ return None
1796
+ element = ref_list.find(f"{_HH}charProperties")
1797
+ if element is None and create:
1798
+ element = ET.SubElement(ref_list, f"{_HH}charProperties", {"itemCnt": "0"})
1799
+ self.mark_dirty()
1800
+ return element
1801
+
1802
+ def _update_char_properties_item_count(self, element: ET.Element) -> None:
1803
+ count = len(list(element.findall(f"{_HH}charPr")))
1804
+ element.set("itemCnt", str(count))
1805
+
1806
+ def _allocate_char_property_id(
1807
+ self,
1808
+ element: ET.Element,
1809
+ *,
1810
+ preferred_id: str | int | None = None,
1811
+ ) -> str:
1812
+ existing: set[str] = {
1813
+ child.get("id") or ""
1814
+ for child in element.findall(f"{_HH}charPr")
1815
+ }
1816
+ existing.discard("")
1817
+
1818
+ if preferred_id is not None:
1819
+ candidate = str(preferred_id)
1820
+ if candidate not in existing:
1821
+ return candidate
1822
+
1823
+ numeric_ids: List[int] = []
1824
+ for value in existing:
1825
+ try:
1826
+ numeric_ids.append(int(value))
1827
+ except ValueError:
1828
+ continue
1829
+ next_id = 0 if not numeric_ids else max(numeric_ids) + 1
1830
+ candidate = str(next_id)
1831
+ while candidate in existing:
1832
+ next_id += 1
1833
+ candidate = str(next_id)
1834
+ return candidate
1835
+
1836
+ def ensure_char_property(
1837
+ self,
1838
+ *,
1839
+ predicate: Callable[[ET.Element], bool] | None = None,
1840
+ modifier: Callable[[ET.Element], None] | None = None,
1841
+ base_char_pr_id: str | int | None = None,
1842
+ preferred_id: str | int | None = None,
1843
+ ) -> ET.Element:
1844
+ """Return a ``<hh:charPr>`` element matching *predicate* or create one.
1845
+
1846
+ When an existing entry satisfies *predicate*, it is returned unchanged.
1847
+ Otherwise a new element is produced by cloning ``base_char_pr_id`` (or the
1848
+ first available entry) and applying *modifier* before assigning a fresh
1849
+ identifier and updating ``itemCnt``.
1850
+ """
1851
+
1852
+ char_props = self._char_properties_element(create=True)
1853
+ if char_props is None: # pragma: no cover - defensive branch
1854
+ raise RuntimeError("failed to create <charProperties> element")
1855
+
1856
+ if predicate is not None:
1857
+ for child in char_props.findall(f"{_HH}charPr"):
1858
+ if predicate(child):
1859
+ return child
1860
+
1861
+ base_element: ET.Element | None = None
1862
+ if base_char_pr_id is not None:
1863
+ base_element = char_props.find(f"{_HH}charPr[@id='{base_char_pr_id}']")
1864
+ if base_element is None:
1865
+ existing = char_props.find(f"{_HH}charPr")
1866
+ if existing is not None:
1867
+ base_element = existing
1868
+
1869
+ if base_element is None:
1870
+ new_char_pr = ET.Element(f"{_HH}charPr")
1871
+ else:
1872
+ new_char_pr = deepcopy(base_element)
1873
+ if "id" in new_char_pr.attrib:
1874
+ del new_char_pr.attrib["id"]
1875
+
1876
+ if modifier is not None:
1877
+ modifier(new_char_pr)
1878
+
1879
+ char_id = self._allocate_char_property_id(char_props, preferred_id=preferred_id)
1880
+ new_char_pr.set("id", char_id)
1881
+ char_props.append(new_char_pr)
1882
+ self._update_char_properties_item_count(char_props)
1883
+ self.mark_dirty()
1884
+ document = self.document
1885
+ if document is not None:
1886
+ document.invalidate_char_property_cache()
1887
+ return new_char_pr
1888
+
1663
1889
  def _memo_properties_element(self) -> ET.Element | None:
1664
1890
  ref_list = self._element.find(f"{_HH}refList")
1665
1891
  if ref_list is None:
@@ -1788,6 +2014,8 @@ class HwpxOxmlDocument:
1788
2014
 
1789
2015
  for section in self._sections:
1790
2016
  section.attach_document(self)
2017
+ for header in self._headers:
2018
+ header.attach_document(self)
1791
2019
 
1792
2020
  @classmethod
1793
2021
  def from_package(cls, package: "HwpxPackage") -> "HwpxOxmlDocument":
@@ -1849,6 +2077,80 @@ class HwpxOxmlDocument:
1849
2077
  return None
1850
2078
  return cache.get(normalized)
1851
2079
 
2080
+ def ensure_run_style(
2081
+ self,
2082
+ *,
2083
+ bold: bool = False,
2084
+ italic: bool = False,
2085
+ underline: bool = False,
2086
+ base_char_pr_id: str | int | None = None,
2087
+ ) -> str:
2088
+ """Return a char property identifier matching the requested flags."""
2089
+
2090
+ if not self._headers:
2091
+ raise ValueError("document does not contain any headers")
2092
+
2093
+ target = (bool(bold), bool(italic), bool(underline))
2094
+ header = self._headers[0]
2095
+
2096
+ def element_flags(element: ET.Element) -> Tuple[bool, bool, bool]:
2097
+ bold_present = element.find(f"{_HH}bold") is not None
2098
+ italic_present = element.find(f"{_HH}italic") is not None
2099
+ underline_element = element.find(f"{_HH}underline")
2100
+ underline_present = False
2101
+ if underline_element is not None:
2102
+ underline_present = underline_element.get("type", "").upper() != "NONE"
2103
+ return bold_present, italic_present, underline_present
2104
+
2105
+ def predicate(element: ET.Element) -> bool:
2106
+ return element_flags(element) == target
2107
+
2108
+ def modifier(element: ET.Element) -> None:
2109
+ underline_nodes = list(element.findall(f"{_HH}underline"))
2110
+ base_underline_attrs = dict(underline_nodes[0].attrib) if underline_nodes else {}
2111
+
2112
+ for child in list(element.findall(f"{_HH}bold")):
2113
+ element.remove(child)
2114
+ for child in list(element.findall(f"{_HH}italic")):
2115
+ element.remove(child)
2116
+ for child in underline_nodes:
2117
+ element.remove(child)
2118
+
2119
+ if target[0]:
2120
+ ET.SubElement(element, f"{_HH}bold")
2121
+ if target[1]:
2122
+ ET.SubElement(element, f"{_HH}italic")
2123
+
2124
+ underline_attrs = dict(base_underline_attrs)
2125
+ if target[2]:
2126
+ underline_attrs.setdefault("type", "SOLID")
2127
+ if underline_attrs.get("type", "").upper() == "NONE":
2128
+ underline_attrs["type"] = "SOLID"
2129
+ underline_attrs.setdefault("shape", base_underline_attrs.get("shape", "SOLID"))
2130
+ if "color" not in underline_attrs and "color" in base_underline_attrs:
2131
+ underline_attrs["color"] = base_underline_attrs["color"]
2132
+ if "color" not in underline_attrs:
2133
+ underline_attrs["color"] = "#000000"
2134
+ ET.SubElement(element, f"{_HH}underline", underline_attrs)
2135
+ else:
2136
+ attrs = dict(base_underline_attrs)
2137
+ attrs["type"] = "NONE"
2138
+ attrs.setdefault("shape", base_underline_attrs.get("shape", "SOLID"))
2139
+ if "color" in base_underline_attrs:
2140
+ attrs["color"] = base_underline_attrs["color"]
2141
+ ET.SubElement(element, f"{_HH}underline", attrs)
2142
+
2143
+ element = header.ensure_char_property(
2144
+ predicate=predicate,
2145
+ modifier=modifier,
2146
+ base_char_pr_id=base_char_pr_id,
2147
+ )
2148
+
2149
+ char_id = element.get("id")
2150
+ if char_id is None: # pragma: no cover - defensive branch
2151
+ raise RuntimeError("charPr element is missing an id")
2152
+ return char_id
2153
+
1852
2154
  @property
1853
2155
  def memo_shapes(self) -> dict[str, MemoShape]:
1854
2156
  shapes: dict[str, MemoShape] = {}
@@ -0,0 +1,33 @@
1
+ """Embedded templates and sample payloads for HWPX documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from importlib import resources
7
+ from pathlib import Path
8
+
9
+
10
+ @lru_cache(maxsize=None)
11
+ def blank_document_bytes() -> bytes:
12
+ """Return the binary payload for a minimal blank HWPX document."""
13
+
14
+ # Prefer a packaged asset when available.
15
+ try:
16
+ data_pkg = resources.files("hwpx.data")
17
+ except (ModuleNotFoundError, AttributeError): # pragma: no cover - optional dependency
18
+ data_pkg = None
19
+ if data_pkg is not None:
20
+ skeleton = data_pkg / "Skeleton.hwpx"
21
+ if skeleton.is_file(): # type: ignore[call-arg]
22
+ with skeleton.open("rb") as stream: # type: ignore[call-arg]
23
+ return stream.read()
24
+
25
+ # Fall back to the repository examples folder during development.
26
+ root = Path(__file__).resolve().parent.parent.parent
27
+ fallback = root / "examples" / "Skeleton.hwpx"
28
+ if fallback.is_file():
29
+ return fallback.read_bytes()
30
+
31
+ raise FileNotFoundError(
32
+ "Could not locate Skeleton.hwpx; ensure the template asset is packaged."
33
+ )
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-hwpx
3
- Version: 1.0
3
+ Version: 1.2
4
4
  Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
5
5
  Author: python-hwpx Maintainers
6
6
  License: Non-Commercial License
7
- Project-URL: Homepage, https://github.com/hancom-io/python-hwpx
8
- Project-URL: Documentation, https://github.com/hancom-io/python-hwpx/tree/main/docs
9
- Project-URL: Issues, https://github.com/hancom-io/python-hwpx/issues
7
+ Project-URL: Homepage, https://github.com/airmang/python-hwpx
8
+ Project-URL: Documentation, https://github.com/airmang/python-hwpx/tree/main/docs
9
+ Project-URL: Issues, https://github.com/airmang/python-hwpx/issues
10
10
  Keywords: hwp,hwpx,hancom,opc,xml
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
@@ -45,13 +45,10 @@ python-hwpx는 Hancom HWPX 패키지를 분석하고 편집하기 위한 Python
45
45
 
46
46
  ### 1. 환경 준비
47
47
 
48
- 가상 환경을 만든 뒤 PyPI에 배포된 패키지를 설치하세요.
48
+ pip를 이용해 패키지를 설치합니다.
49
49
 
50
50
  ```bash
51
- python -m venv .venv
52
- source .venv/bin/activate
53
- python -m pip install --upgrade pip
54
- python -m pip install python-hwpx
51
+ python pip install python-hwpx
55
52
  ```
56
53
 
57
54
  최신 개발 버전을 사용하거나 소스 코드를 수정하려면 편집 가능한 설치를 권장합니다.
@@ -4,6 +4,8 @@ pyproject.toml
4
4
  src/hwpx/__init__.py
5
5
  src/hwpx/document.py
6
6
  src/hwpx/package.py
7
+ src/hwpx/templates.py
8
+ src/hwpx/data/Skeleton.hwpx
7
9
  src/hwpx/opc/package.py
8
10
  src/hwpx/oxml/__init__.py
9
11
  src/hwpx/oxml/body.py
@@ -384,6 +384,31 @@ def test_header_begin_numbering_creates_element_when_missing() -> None:
384
384
  assert header.dirty is True
385
385
 
386
386
 
387
+ def test_header_ensure_char_property_creates_blocks_and_ids() -> None:
388
+ head_element = ET.Element(f"{HH}head", {"version": "1.4", "secCnt": "1"})
389
+ header = HwpxOxmlHeader("header.xml", head_element)
390
+
391
+ created = header.ensure_char_property(
392
+ modifier=lambda el: ET.SubElement(el, f"{HH}bold"),
393
+ )
394
+
395
+ ref_list = head_element.find(f"{HH}refList")
396
+ assert ref_list is not None
397
+ char_props = ref_list.find(f"{HH}charProperties")
398
+ assert char_props is not None
399
+ assert char_props.get("itemCnt") == "1"
400
+ assert created.get("id") == "0"
401
+
402
+ def italic_modifier(element: ET.Element) -> None:
403
+ for child in list(element.findall(f"{HH}bold")):
404
+ element.remove(child)
405
+ ET.SubElement(element, f"{HH}italic")
406
+
407
+ second = header.ensure_char_property(modifier=italic_modifier)
408
+ assert second.get("id") == "1"
409
+ assert char_props.get("itemCnt") == "2"
410
+
411
+
387
412
  def test_paragraph_add_shape_and_control_updates_attributes() -> None:
388
413
  section, paragraph = _build_section_with_paragraph()
389
414
 
@@ -236,3 +236,70 @@ def test_add_memo_with_anchor_creates_paragraph_when_missing() -> None:
236
236
  field_end = runs[-1].find(f"{HP}ctrl/{HP}fieldEnd")
237
237
  assert field_end is not None
238
238
  assert field_end.get("beginIDRef") == "field-02"
239
+
240
+
241
+ def test_document_ensure_run_style_creates_bold_entry() -> None:
242
+ document, _, header = _build_document()
243
+
244
+ char_props = header.element.find(f"{HH}refList/{HH}charProperties")
245
+ assert char_props is not None
246
+ initial_count = len(list(char_props.findall(f"{HH}charPr")))
247
+
248
+ style_id = document.ensure_run_style(bold=True)
249
+ assert style_id != "10"
250
+
251
+ assert char_props.get("itemCnt") == str(initial_count + 1)
252
+ created = char_props.find(f"{HH}charPr[@id='{style_id}']")
253
+ assert created is not None
254
+ assert created.find(f"{HH}bold") is not None
255
+ underline = created.find(f"{HH}underline")
256
+ assert underline is not None and underline.get("type") == "NONE"
257
+
258
+ style = document.char_property(style_id)
259
+ assert style is not None and "bold" in style.child_attributes
260
+
261
+ assert document.ensure_run_style(bold=True) == style_id
262
+ assert char_props.get("itemCnt") == str(initial_count + 1)
263
+
264
+
265
+ def test_paragraph_add_run_and_toggle_formatting() -> None:
266
+ document, section, header = _build_document()
267
+ paragraph = section.paragraphs[0]
268
+ char_props = header.element.find(f"{HH}refList/{HH}charProperties")
269
+ assert char_props is not None
270
+
271
+ initial_count = len(list(char_props.findall(f"{HH}charPr")))
272
+ run = paragraph.add_run("서식 적용", bold=True)
273
+
274
+ assert run.text == "서식 적용"
275
+ assert run.bold is True
276
+ assert run.italic is False
277
+ assert run.underline is False
278
+
279
+ bold_id = run.char_pr_id_ref
280
+ assert bold_id is not None
281
+ bold_entry = char_props.find(f"{HH}charPr[@id='{bold_id}']")
282
+ assert bold_entry is not None
283
+ assert len(list(char_props.findall(f"{HH}charPr"))) == initial_count + 1
284
+
285
+ run.italic = True
286
+ assert run.bold is True
287
+ assert run.italic is True
288
+ italic_id = run.char_pr_id_ref
289
+ assert italic_id is not None and italic_id != bold_id
290
+ italic_entry = char_props.find(f"{HH}charPr[@id='{italic_id}']")
291
+ assert italic_entry is not None
292
+ assert italic_entry.find(f"{HH}bold") is not None
293
+ assert italic_entry.find(f"{HH}italic") is not None
294
+
295
+ run.underline = True
296
+ assert run.underline is True
297
+ underline_id = run.char_pr_id_ref
298
+ underline_entry = char_props.find(f"{HH}charPr[@id='{underline_id}']")
299
+ assert underline_entry is not None
300
+ underline_node = underline_entry.find(f"{HH}underline")
301
+ assert underline_node is not None and underline_node.get("type", "") != "NONE"
302
+
303
+ total_styles = len(list(char_props.findall(f"{HH}charPr")))
304
+ assert total_styles == initial_count + 3
305
+ assert char_props.get("itemCnt") == str(total_styles)
File without changes
File without changes
File without changes