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.
- {python-hwpx-1.0/src/python_hwpx.egg-info → python-hwpx-1.2}/PKG-INFO +6 -9
- {python-hwpx-1.0 → python-hwpx-1.2}/README.md +2 -5
- {python-hwpx-1.0 → python-hwpx-1.2}/pyproject.toml +5 -4
- python-hwpx-1.2/src/hwpx/data/Skeleton.hwpx +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/document.py +26 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/document.py +304 -2
- python-hwpx-1.2/src/hwpx/templates.py +33 -0
- {python-hwpx-1.0 → python-hwpx-1.2/src/python_hwpx.egg-info}/PKG-INFO +6 -9
- {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/SOURCES.txt +2 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_document_formatting.py +25 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_memo_and_style_editing.py +67 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/LICENSE +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/setup.cfg +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/__init__.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/opc/package.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/__init__.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/body.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/common.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/header.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/parser.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/schema.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/oxml/utils.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/package.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/__init__.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/object_finder.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/text_extractor.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/hwpx/tools/validator.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python-hwpx-1.0 → python-hwpx-1.2}/tests/test_oxml_parsing.py +0 -0
- {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.
|
|
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/
|
|
8
|
-
Project-URL: Documentation, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
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
|
-
|
|
48
|
+
pip를 이용해 패키지를 설치합니다.
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
python
|
|
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
|
-
|
|
18
|
+
pip를 이용해 패키지를 설치합니다.
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
python
|
|
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.
|
|
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/
|
|
43
|
-
Documentation = "https://github.com/
|
|
44
|
-
Issues = "https://github.com/
|
|
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"]
|
|
Binary file
|
|
@@ -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.
|
|
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/
|
|
8
|
-
Project-URL: Documentation, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
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
|
-
|
|
48
|
+
pip를 이용해 패키지를 설치합니다.
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
python
|
|
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
|
최신 개발 버전을 사용하거나 소스 코드를 수정하려면 편집 가능한 설치를 권장합니다.
|
|
@@ -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
|
|
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
|