python-hwpx 1.8__tar.gz → 2.0__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 (56) hide show
  1. {python_hwpx-1.8/src/python_hwpx.egg-info → python_hwpx-2.0}/PKG-INFO +19 -4
  2. {python_hwpx-1.8 → python_hwpx-2.0}/README.md +14 -3
  3. {python_hwpx-1.8 → python_hwpx-2.0}/pyproject.toml +23 -1
  4. python_hwpx-2.0/src/hwpx/__init__.py +36 -0
  5. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/document.py +205 -39
  6. python_hwpx-2.0/src/hwpx/opc/package.py +514 -0
  7. python_hwpx-2.0/src/hwpx/opc/xml_utils.py +50 -0
  8. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/__init__.py +11 -5
  9. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/body.py +3 -0
  10. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/common.py +3 -0
  11. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/document.py +444 -91
  12. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/header.py +3 -0
  13. python_hwpx-2.0/src/hwpx/oxml/header_part.py +10 -0
  14. python_hwpx-2.0/src/hwpx/oxml/memo.py +10 -0
  15. python_hwpx-2.0/src/hwpx/oxml/paragraph.py +10 -0
  16. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/parser.py +3 -0
  17. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/schema.py +3 -0
  18. python_hwpx-2.0/src/hwpx/oxml/section.py +10 -0
  19. python_hwpx-2.0/src/hwpx/oxml/table.py +10 -0
  20. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/oxml/utils.py +3 -0
  21. python_hwpx-2.0/src/hwpx/package.py +24 -0
  22. python_hwpx-2.0/src/hwpx/py.typed +0 -0
  23. {python_hwpx-1.8 → python_hwpx-2.0/src/python_hwpx.egg-info}/PKG-INFO +19 -4
  24. {python_hwpx-1.8 → python_hwpx-2.0}/src/python_hwpx.egg-info/SOURCES.txt +15 -1
  25. {python_hwpx-1.8 → python_hwpx-2.0}/src/python_hwpx.egg-info/requires.txt +5 -0
  26. python_hwpx-2.0/tests/test_coverage_targets.py +74 -0
  27. python_hwpx-2.0/tests/test_document_context_manager.py +65 -0
  28. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_document_formatting.py +91 -1
  29. python_hwpx-2.0/tests/test_document_save_api.py +55 -0
  30. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_integration_hwpx_compatibility.py +8 -8
  31. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_memo_and_style_editing.py +1 -1
  32. python_hwpx-2.0/tests/test_opc_package.py +81 -0
  33. python_hwpx-2.0/tests/test_packaging_py_typed.py +38 -0
  34. python_hwpx-2.0/tests/test_repr_snapshots.py +68 -0
  35. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_section_headers.py +1 -1
  36. python_hwpx-2.0/tests/test_version_metadata.py +25 -0
  37. python_hwpx-1.8/src/hwpx/__init__.py +0 -23
  38. python_hwpx-1.8/src/hwpx/opc/package.py +0 -274
  39. python_hwpx-1.8/src/hwpx/package.py +0 -290
  40. {python_hwpx-1.8 → python_hwpx-2.0}/LICENSE +0 -0
  41. {python_hwpx-1.8 → python_hwpx-2.0}/setup.cfg +0 -0
  42. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/data/Skeleton.hwpx +0 -0
  43. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/templates.py +0 -0
  44. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/tools/__init__.py +0 -0
  45. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/tools/_schemas/header.xsd +0 -0
  46. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/tools/_schemas/section.xsd +0 -0
  47. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/tools/object_finder.py +0 -0
  48. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/tools/text_extractor.py +0 -0
  49. {python_hwpx-1.8 → python_hwpx-2.0}/src/hwpx/tools/validator.py +0 -0
  50. {python_hwpx-1.8 → python_hwpx-2.0}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  51. {python_hwpx-1.8 → python_hwpx-2.0}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  52. {python_hwpx-1.8 → python_hwpx-2.0}/src/python_hwpx.egg-info/top_level.txt +0 -0
  53. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_inline_models.py +0 -0
  54. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_oxml_parsing.py +0 -0
  55. {python_hwpx-1.8 → python_hwpx-2.0}/tests/test_tables_default_border.py +0 -0
  56. {python_hwpx-1.8 → python_hwpx-2.0}/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.8
3
+ Version: 2.0
4
4
  Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
5
5
  Author: python-hwpx Maintainers
6
6
  License: Non-Commercial License
@@ -59,6 +59,10 @@ Requires-Dist: twine>=4.0; extra == "dev"
59
59
  Requires-Dist: pytest>=7.4; extra == "dev"
60
60
  Provides-Extra: test
61
61
  Requires-Dist: pytest>=7.4; extra == "test"
62
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
63
+ Provides-Extra: typecheck
64
+ Requires-Dist: mypy>=1.10; extra == "typecheck"
65
+ Requires-Dist: pyright>=1.1.390; extra == "typecheck"
62
66
  Dynamic: license-file
63
67
 
64
68
  # python-hwpx
@@ -101,7 +105,7 @@ Sphinx 문서는 `docs/` 아래에 있으며, `python -m pip install -r docs/req
101
105
  ```python
102
106
  from io import BytesIO
103
107
 
104
- from hwpx.document import HwpxDocument
108
+ from hwpx import HwpxDocument
105
109
  from hwpx.templates import blank_document_bytes
106
110
 
107
111
  # 1) 빈 템플릿으로 문서 열기
@@ -121,15 +125,26 @@ table.set_cell_text(1, 1, str(len(document.paragraphs)))
121
125
  document.add_memo_with_anchor("배포 전 검토", paragraph=paragraph, memo_shape_id_ref="0")
122
126
 
123
127
  # 3) 다른 이름으로 저장
124
- document.save("output/example.hwpx")
128
+ document.save_to_path("output/example.hwpx")
125
129
  ```
126
130
 
127
131
  `HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다.
128
132
 
129
- 표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다.
133
+ 표 셀 텍스트를 편집하는 `table.set_cell_text()`는 기존 단락에 남아 있는 `lineSegArray`와 같은 줄 배치 캐시를 제거하여 한/글이 문서를 다시 열 때 줄바꿈을 새로 계산하도록 합니다. 병합된 표 구조를 다뤄야 한다면 `table.iter_grid()` 또는 `table.get_cell_map()`으로 논리 격자와 실제 셀의 매핑을 확인하고, `set_cell_text(..., logical=True, split_merged=True)`로 논리 좌표 기반 편집과 자동 병합 해제를 동시에 처리할 수 있습니다.
130
134
 
131
135
  더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다.
132
136
 
137
+ ### 저장 API 변경 안내
138
+
139
+ `HwpxDocument`는 저장 사용 케이스를 다음처럼 분리해 제공합니다.
140
+
141
+ - `save_to_path(path) -> str | PathLike[str]`: 지정한 경로로 저장하고 같은 경로를 반환
142
+ - `save_to_stream(stream) -> BinaryIO`: 파일/버퍼 스트림에 저장하고 같은 스트림을 반환
143
+ - `to_bytes() -> bytes`: 메모리에서 직렬화한 바이트를 반환
144
+
145
+ 기존 `save()`는 하위 호환을 위해 유지되지만 deprecated 경고를 발생시킵니다. 새 코드에서는 위 3개 메서드 사용을 권장합니다.
146
+
147
+
133
148
  ## 문서
134
149
  [사용법](https://airmang.github.io/python-hwpx/)
135
150
 
@@ -38,7 +38,7 @@ Sphinx 문서는 `docs/` 아래에 있으며, `python -m pip install -r docs/req
38
38
  ```python
39
39
  from io import BytesIO
40
40
 
41
- from hwpx.document import HwpxDocument
41
+ from hwpx import HwpxDocument
42
42
  from hwpx.templates import blank_document_bytes
43
43
 
44
44
  # 1) 빈 템플릿으로 문서 열기
@@ -58,15 +58,26 @@ table.set_cell_text(1, 1, str(len(document.paragraphs)))
58
58
  document.add_memo_with_anchor("배포 전 검토", paragraph=paragraph, memo_shape_id_ref="0")
59
59
 
60
60
  # 3) 다른 이름으로 저장
61
- document.save("output/example.hwpx")
61
+ document.save_to_path("output/example.hwpx")
62
62
  ```
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
 
70
+ ### 저장 API 변경 안내
71
+
72
+ `HwpxDocument`는 저장 사용 케이스를 다음처럼 분리해 제공합니다.
73
+
74
+ - `save_to_path(path) -> str | PathLike[str]`: 지정한 경로로 저장하고 같은 경로를 반환
75
+ - `save_to_stream(stream) -> BinaryIO`: 파일/버퍼 스트림에 저장하고 같은 스트림을 반환
76
+ - `to_bytes() -> bytes`: 메모리에서 직렬화한 바이트를 반환
77
+
78
+ 기존 `save()`는 하위 호환을 위해 유지되지만 deprecated 경고를 발생시킵니다. 새 코드에서는 위 3개 메서드 사용을 권장합니다.
79
+
80
+
70
81
  ## 문서
71
82
  [사용법](https://airmang.github.io/python-hwpx/)
72
83
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "1.8"
7
+ version = "2.0"
8
8
  description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { file = "LICENSE" }
@@ -36,6 +36,11 @@ dev = [
36
36
  ]
37
37
  test = [
38
38
  "pytest>=7.4",
39
+ "pytest-cov>=5.0",
40
+ ]
41
+ typecheck = [
42
+ "mypy>=1.10",
43
+ "pyright>=1.1.390",
39
44
  ]
40
45
 
41
46
  [project.urls]
@@ -55,6 +60,7 @@ where = ["src"]
55
60
  include = ["hwpx*"]
56
61
 
57
62
  [tool.setuptools.package-data]
63
+ "hwpx" = ["py.typed"]
58
64
  "hwpx.tools" = ["_schemas/*.xsd"]
59
65
  "hwpx.data" = ["Skeleton.hwpx"]
60
66
 
@@ -62,3 +68,19 @@ include = ["hwpx*"]
62
68
  pythonpath = ["src"]
63
69
  addopts = "-ra"
64
70
  testpaths = ["tests"]
71
+
72
+
73
+ [tool.mypy]
74
+ python_version = "3.10"
75
+ files = ["src/hwpx/document.py", "src/hwpx/oxml/document.py"]
76
+ ignore_missing_imports = true
77
+
78
+ [[tool.mypy.overrides]]
79
+ module = ["hwpx.document", "hwpx.oxml.document"]
80
+ ignore_errors = true
81
+
82
+ [tool.pyright]
83
+ include = ["src/hwpx/document.py", "src/hwpx/oxml/document.py"]
84
+ pythonVersion = "3.10"
85
+ typeCheckingMode = "off"
86
+ reportMissingTypeStubs = false
@@ -0,0 +1,36 @@
1
+ """High-level utilities for working with HWPX documents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _metadata_version
4
+
5
+
6
+ def _resolve_version() -> str:
7
+ """패키지 메타데이터에서 현재 배포 버전을 조회합니다."""
8
+ try:
9
+ return _metadata_version("python-hwpx")
10
+ except PackageNotFoundError:
11
+ return "0+unknown"
12
+
13
+
14
+ __version__ = _resolve_version()
15
+
16
+ from .tools.text_extractor import (
17
+ DEFAULT_NAMESPACES,
18
+ ParagraphInfo,
19
+ SectionInfo,
20
+ TextExtractor,
21
+ )
22
+ from .tools.object_finder import FoundElement, ObjectFinder
23
+ from .document import HwpxDocument
24
+ from .package import HwpxPackage
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "DEFAULT_NAMESPACES",
29
+ "ParagraphInfo",
30
+ "SectionInfo",
31
+ "TextExtractor",
32
+ "FoundElement",
33
+ "ObjectFinder",
34
+ "HwpxDocument",
35
+ "HwpxPackage",
36
+ ]
@@ -2,12 +2,16 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import io
6
+ import warnings
5
7
  from datetime import datetime
8
+ import logging
6
9
  import uuid
7
- import xml.etree.ElementTree as ET
8
10
 
9
11
  from os import PathLike
10
- from typing import BinaryIO, Iterator, List, Tuple
12
+ from typing import Any, BinaryIO, Iterator, overload
13
+
14
+ from lxml import etree
11
15
 
12
16
  from .oxml import (
13
17
  Bullet,
@@ -31,21 +35,62 @@ from .oxml import (
31
35
  TrackChange,
32
36
  TrackChangeAuthor,
33
37
  )
34
- from .package import HwpxPackage
38
+ from .opc.package import HwpxPackage
35
39
  from .templates import blank_document_bytes
36
40
 
41
+ ET.register_namespace("hp", "http://www.hancom.co.kr/hwpml/2011/paragraph")
42
+ ET.register_namespace("hs", "http://www.hancom.co.kr/hwpml/2011/section")
43
+ ET.register_namespace("hc", "http://www.hancom.co.kr/hwpml/2011/core")
44
+ ET.register_namespace("hh", "http://www.hancom.co.kr/hwpml/2011/head")
45
+
37
46
  _HP_NS = "http://www.hancom.co.kr/hwpml/2011/paragraph"
38
47
  _HP = f"{{{_HP_NS}}}"
39
48
  _HH_NS = "http://www.hancom.co.kr/hwpml/2011/head"
40
49
  _HH = f"{{{_HH_NS}}}"
41
50
 
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ def _append_element(
55
+ parent: Any,
56
+ tag: str,
57
+ attributes: dict[str, str] | None = None,
58
+ ) -> Any:
59
+ """Create and append a child element that matches *parent*'s element type."""
60
+
61
+ child = parent.makeelement(tag, attributes or {})
62
+ parent.append(child)
63
+ return child
64
+
42
65
 
43
66
  class HwpxDocument:
44
67
  """Provides a user-friendly API for editing HWPX documents."""
45
68
 
46
- def __init__(self, package: HwpxPackage, root: HwpxOxmlDocument):
69
+ def __init__(
70
+ self,
71
+ package: HwpxPackage,
72
+ root: HwpxOxmlDocument,
73
+ *,
74
+ managed_resources: tuple[Any, ...] = (),
75
+ ):
47
76
  self._package = package
48
77
  self._root = root
78
+ self._managed_resources = list(managed_resources)
79
+ self._closed = False
80
+
81
+ def __repr__(self) -> str:
82
+ """Return a compact and safe summary of the document state."""
83
+
84
+ return (
85
+ f"{self.__class__.__name__}("
86
+ f"sections={len(self.sections)}, "
87
+ f"paragraphs={len(self.paragraphs)}, "
88
+ f"headers={len(self.headers)}, "
89
+ f"master_pages={len(self.master_pages)}, "
90
+ f"histories={len(self.histories)}, "
91
+ f"closed={self._closed}"
92
+ ")"
93
+ )
49
94
 
50
95
  # ------------------------------------------------------------------
51
96
  # construction helpers
@@ -54,10 +99,21 @@ class HwpxDocument:
54
99
  cls,
55
100
  source: str | PathLike[str] | bytes | BinaryIO,
56
101
  ) -> "HwpxDocument":
57
- """Open *source* and return a :class:`HwpxDocument` instance."""
58
- package = HwpxPackage.open(source)
102
+ """Open *source* and return a :class:`HwpxDocument` instance.
103
+
104
+ Raises:
105
+ HwpxStructureError: 필수 파일이나 구조가 올바르지 않은 HWPX를 열 때 발생합니다.
106
+ HwpxPackageError: 패키지를 여는 과정에서 일반적인 I/O/포맷 오류가 발생하면 전달됩니다.
107
+ """
108
+ internal_resources: list[Any] = []
109
+ open_source = source
110
+ if isinstance(source, bytes):
111
+ stream = io.BytesIO(source)
112
+ open_source = stream
113
+ internal_resources.append(stream)
114
+ package = HwpxPackage.open(open_source)
59
115
  root = HwpxOxmlDocument.from_package(package)
60
- return cls(package, root)
116
+ return cls(package, root, managed_resources=tuple(internal_resources))
61
117
 
62
118
  @classmethod
63
119
  def new(cls) -> "HwpxDocument":
@@ -67,10 +123,69 @@ class HwpxDocument:
67
123
 
68
124
  @classmethod
69
125
  def from_package(cls, package: HwpxPackage) -> "HwpxDocument":
70
- """Create a document backed by an existing :class:`HwpxPackage`."""
126
+ """Create a document backed by an existing :class:`HwpxPackage`.
127
+
128
+ Args:
129
+ package: :class:`hwpx.opc.package.HwpxPackage` 인스턴스.
130
+ """
71
131
  root = HwpxOxmlDocument.from_package(package)
72
132
  return cls(package, root)
73
133
 
134
+ def __enter__(self) -> "HwpxDocument":
135
+ """컨텍스트 매니저 진입 시 현재 문서 인스턴스를 반환합니다."""
136
+
137
+ return self
138
+
139
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
140
+ """예외 발생 여부와 무관하게 내부 자원을 안전하게 정리합니다."""
141
+
142
+ self.close()
143
+ return False
144
+
145
+ def close(self) -> None:
146
+ """문서가 관리하는 내부 패키지/스트림 자원을 정리합니다.
147
+
148
+ 정리 정책:
149
+ - ``flush()`` 가능한 자원은 먼저 flush를 시도합니다.
150
+ - ``close()`` 가능한 자원은 flush 이후 close를 시도합니다.
151
+ - flush/close 중 발생한 예외는 로깅하고 무시하여 정리 루틴을 계속 진행합니다.
152
+ - 같은 문서에서 ``close()``를 여러 번 호출해도 안전합니다.
153
+ """
154
+
155
+ if self._closed:
156
+ return
157
+
158
+ self._flush_resource(self._package)
159
+ for resource in self._managed_resources:
160
+ self._flush_resource(resource)
161
+
162
+ self._close_resource(self._package)
163
+ for resource in self._managed_resources:
164
+ self._close_resource(resource)
165
+
166
+ self._managed_resources.clear()
167
+ self._closed = True
168
+
169
+ @staticmethod
170
+ def _flush_resource(resource: Any) -> None:
171
+ flush = getattr(resource, "flush", None)
172
+ if not callable(flush):
173
+ return
174
+ try:
175
+ flush()
176
+ except Exception:
177
+ logger.debug("자원 flush 중 예외를 무시합니다: resource=%r", resource, exc_info=True)
178
+
179
+ @staticmethod
180
+ def _close_resource(resource: Any) -> None:
181
+ close = getattr(resource, "close", None)
182
+ if not callable(close):
183
+ return
184
+ try:
185
+ close()
186
+ except Exception:
187
+ logger.debug("자원 close 중 예외를 무시합니다: resource=%r", resource, exc_info=True)
188
+
74
189
  # ------------------------------------------------------------------
75
190
  # properties exposing document content
76
191
  @property
@@ -84,22 +199,22 @@ class HwpxDocument:
84
199
  return self._root
85
200
 
86
201
  @property
87
- def sections(self) -> List[HwpxOxmlSection]:
202
+ def sections(self) -> list[HwpxOxmlSection]:
88
203
  """Return the sections contained in the document."""
89
204
  return self._root.sections
90
205
 
91
206
  @property
92
- def headers(self) -> List[HwpxOxmlHeader]:
207
+ def headers(self) -> list[HwpxOxmlHeader]:
93
208
  """Return the header parts referenced by the document."""
94
209
  return self._root.headers
95
210
 
96
211
  @property
97
- def master_pages(self) -> List[HwpxOxmlMasterPage]:
212
+ def master_pages(self) -> list[HwpxOxmlMasterPage]:
98
213
  """Return the master-page parts declared in the manifest."""
99
214
  return self._root.master_pages
100
215
 
101
216
  @property
102
- def histories(self) -> List[HwpxOxmlHistory]:
217
+ def histories(self) -> list[HwpxOxmlHistory]:
103
218
  """Return document history parts referenced by the manifest."""
104
219
  return self._root.histories
105
220
 
@@ -190,10 +305,10 @@ class HwpxDocument:
190
305
  return self._root.track_change_author(author_id_ref)
191
306
 
192
307
  @property
193
- def memos(self) -> List[HwpxOxmlMemo]:
308
+ def memos(self) -> list[HwpxOxmlMemo]:
194
309
  """Return all memo entries declared in every section."""
195
310
 
196
- memos: List[HwpxOxmlMemo] = []
311
+ memos: list[HwpxOxmlMemo] = []
197
312
  for section in self._root.sections:
198
313
  memos.extend(section.memos)
199
314
  return memos
@@ -270,9 +385,10 @@ class HwpxDocument:
270
385
  char_ref = "0"
271
386
  char_ref = str(char_ref)
272
387
 
273
- run_begin = ET.Element(f"{_HP}run", {"charPrIDRef": char_ref})
274
- ctrl_begin = ET.SubElement(run_begin, f"{_HP}ctrl")
275
- field_begin = ET.SubElement(
388
+ paragraph_element = paragraph.element
389
+ run_begin = paragraph_element.makeelement(f"{_HP}run", {"charPrIDRef": char_ref})
390
+ ctrl_begin = _append_element(run_begin, f"{_HP}ctrl")
391
+ field_begin = _append_element(
276
392
  ctrl_begin,
277
393
  f"{_HP}fieldBegin",
278
394
  {
@@ -284,14 +400,14 @@ class HwpxDocument:
284
400
  },
285
401
  )
286
402
 
287
- parameters = ET.SubElement(field_begin, f"{_HP}parameters", {"count": "5", "name": ""})
288
- ET.SubElement(parameters, f"{_HP}stringParam", {"name": "ID"}).text = memo.id or ""
289
- ET.SubElement(parameters, f"{_HP}integerParam", {"name": "Number"}).text = str(max(1, number))
290
- ET.SubElement(parameters, f"{_HP}stringParam", {"name": "CreateDateTime"}).text = created_value
291
- ET.SubElement(parameters, f"{_HP}stringParam", {"name": "Author"}).text = author_value
292
- ET.SubElement(parameters, f"{_HP}stringParam", {"name": "MemoShapeID"}).text = memo_shape_id
403
+ parameters = _append_element(field_begin, f"{_HP}parameters", {"count": "5", "name": ""})
404
+ _append_element(parameters, f"{_HP}stringParam", {"name": "ID"}).text = memo.id or ""
405
+ _append_element(parameters, f"{_HP}integerParam", {"name": "Number"}).text = str(max(1, number))
406
+ _append_element(parameters, f"{_HP}stringParam", {"name": "CreateDateTime"}).text = created_value
407
+ _append_element(parameters, f"{_HP}stringParam", {"name": "Author"}).text = author_value
408
+ _append_element(parameters, f"{_HP}stringParam", {"name": "MemoShapeID"}).text = memo_shape_id
293
409
 
294
- sub_list = ET.SubElement(
410
+ sub_list = _append_element(
295
411
  field_begin,
296
412
  f"{_HP}subList",
297
413
  {
@@ -301,7 +417,7 @@ class HwpxDocument:
301
417
  "vertAlign": "TOP",
302
418
  },
303
419
  )
304
- sub_para = ET.SubElement(
420
+ sub_para = _append_element(
305
421
  sub_list,
306
422
  f"{_HP}p",
307
423
  {
@@ -313,12 +429,12 @@ class HwpxDocument:
313
429
  "merged": "0",
314
430
  },
315
431
  )
316
- sub_run = ET.SubElement(sub_para, f"{_HP}run", {"charPrIDRef": char_ref})
317
- ET.SubElement(sub_run, f"{_HP}t").text = memo.id or field_value
432
+ sub_run = _append_element(sub_para, f"{_HP}run", {"charPrIDRef": char_ref})
433
+ _append_element(sub_run, f"{_HP}t").text = memo.id or field_value
318
434
 
319
- run_end = ET.Element(f"{_HP}run", {"charPrIDRef": char_ref})
320
- ctrl_end = ET.SubElement(run_end, f"{_HP}ctrl")
321
- ET.SubElement(ctrl_end, f"{_HP}fieldEnd", {"beginIDRef": field_value, "fieldid": field_value})
435
+ run_end = paragraph_element.makeelement(f"{_HP}run", {"charPrIDRef": char_ref})
436
+ ctrl_end = _append_element(run_end, f"{_HP}ctrl")
437
+ _append_element(ctrl_end, f"{_HP}fieldEnd", {"beginIDRef": field_value, "fieldid": field_value})
322
438
 
323
439
  paragraph.element.insert(0, run_begin)
324
440
  paragraph.element.append(run_end)
@@ -384,7 +500,7 @@ class HwpxDocument:
384
500
  return memo, target_paragraph, field_value
385
501
 
386
502
  @property
387
- def paragraphs(self) -> List[HwpxOxmlParagraph]:
503
+ def paragraphs(self) -> list[HwpxOxmlParagraph]:
388
504
  """Return all paragraphs across every section."""
389
505
  return self._root.paragraphs
390
506
 
@@ -430,10 +546,10 @@ class HwpxDocument:
430
546
  underline_type: str | None = None,
431
547
  underline_color: str | None = None,
432
548
  char_pr_id_ref: str | int | None = None,
433
- ) -> List[HwpxOxmlRun]:
549
+ ) -> list[HwpxOxmlRun]:
434
550
  """Return runs matching the requested style criteria."""
435
551
 
436
- matches: List[HwpxOxmlRun] = []
552
+ matches: list[HwpxOxmlRun] = []
437
553
  target_char = str(char_pr_id_ref).strip() if char_pr_id_ref is not None else None
438
554
 
439
555
  for run in self.iter_runs():
@@ -712,12 +828,62 @@ class HwpxDocument:
712
828
  target_section = self._root.sections[-1]
713
829
  target_section.properties.remove_footer(page_type=page_type)
714
830
 
831
+ def save_to_path(self, path: str | PathLike[str]) -> str | PathLike[str]:
832
+ """Persist pending changes to *path* and return the same path."""
833
+
834
+ updates = self._root.serialize()
835
+ result = self._package.save(path, updates)
836
+ self._root.reset_dirty()
837
+ return path if result is None else result
838
+
839
+ def save_to_stream(self, stream: BinaryIO) -> BinaryIO:
840
+ """Persist pending changes to *stream* and return the same stream."""
841
+
842
+ updates = self._root.serialize()
843
+ result = self._package.save(stream, updates)
844
+ self._root.reset_dirty()
845
+ return stream if result is None else result
846
+
847
+ def to_bytes(self) -> bytes:
848
+ """Serialize pending changes and return the HWPX archive as bytes."""
849
+
850
+ updates = self._root.serialize()
851
+ result = self._package.save(None, updates)
852
+ self._root.reset_dirty()
853
+ if isinstance(result, bytes):
854
+ return result
855
+ raise TypeError("package.save(None) must return bytes")
856
+
857
+ @overload
858
+ def save(self, path_or_stream: None = None) -> bytes: ...
859
+
860
+ @overload
861
+ def save(self, path_or_stream: str | PathLike[str]) -> str | PathLike[str]: ...
862
+
863
+ @overload
864
+ def save(self, path_or_stream: BinaryIO) -> BinaryIO: ...
865
+
715
866
  def save(
716
867
  self,
717
868
  path_or_stream: str | PathLike[str] | BinaryIO | None = None,
718
- ) -> str | PathLike[str] | BinaryIO | bytes | None:
719
- """Persist pending changes to *path_or_stream* or the original source."""
720
- updates = self._root.serialize()
721
- result = self._package.save(path_or_stream, updates)
722
- self._root.reset_dirty()
723
- return result
869
+ ) -> str | PathLike[str] | BinaryIO | bytes:
870
+ """Deprecated compatibility wrapper around save_to_path/save_to_stream/to_bytes.
871
+
872
+ Deprecated:
873
+ ``save()``는 하위 호환을 위해 유지되며 향후 제거될 수 있습니다.
874
+ - 경로 저장: ``save_to_path(path)``
875
+ - 스트림 저장: ``save_to_stream(stream)``
876
+ - 바이트 반환: ``to_bytes()``
877
+ """
878
+
879
+ warnings.warn(
880
+ "HwpxDocument.save()는 deprecated 예정입니다. "
881
+ "save_to_path()/save_to_stream()/to_bytes() 사용을 권장합니다.",
882
+ DeprecationWarning,
883
+ stacklevel=2,
884
+ )
885
+ if path_or_stream is None:
886
+ return self.to_bytes()
887
+ if isinstance(path_or_stream, (str, PathLike)):
888
+ return self.save_to_path(path_or_stream)
889
+ return self.save_to_stream(path_or_stream)