python-hwpx-automation 6.0.3__py3-none-any.whl

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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,755 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # ruff: noqa: E402
3
+ """Thin integration helpers for the upstream ``python-hwpx`` library.
4
+
5
+ Keep version-sensitive imports and non-obvious upstream calls here so the rest
6
+ of the codebase has fewer direct dependencies on internal upstream details.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from copy import deepcopy
12
+ from os import PathLike
13
+ from typing import Any, Iterable, Literal
14
+ from xml.etree import ElementTree as ET
15
+
16
+ from .compat import patch_python_hwpx
17
+
18
+ # Compatibility patching must precede version-sensitive python-hwpx imports.
19
+ patch_python_hwpx()
20
+
21
+ from hwpx import ObjectFinder
22
+ from hwpx.document import (
23
+ HwpxDocument,
24
+ )
25
+ from hwpx.oxml.memo import HwpxOxmlMemo
26
+ from hwpx.oxml.paragraph import HwpxOxmlParagraph
27
+ from hwpx.oxml.run import HwpxOxmlRun
28
+ from hwpx.oxml.table import HwpxOxmlTable
29
+ from hwpx.opc.package import HwpxPackage
30
+ from hwpx.templates import blank_document_bytes
31
+ from hwpx.tools.text_extractor import AnnotationOptions, TextExtractor
32
+ from hwpx.tools.validator import ValidationReport, validate_document
33
+ from hwpx.oxml.namespaces import HH as HH_NS, HP as HP_NS
34
+
35
+ _FONT_REF_KEYS = (
36
+ "hangul",
37
+ "latin",
38
+ "hanja",
39
+ "japanese",
40
+ "other",
41
+ "symbol",
42
+ "user",
43
+ )
44
+ _FONT_FACE_LANGS = {
45
+ "HANGUL": "hangul",
46
+ "LATIN": "latin",
47
+ "HANJA": "hanja",
48
+ "JAPANESE": "japanese",
49
+ "OTHER": "other",
50
+ "SYMBOL": "symbol",
51
+ "USER": "user",
52
+ }
53
+ _DEFAULT_TEXT_COLOR = "#000000"
54
+ _DEFAULT_CHAR_HEIGHT = "1000"
55
+ _PATHOLOGICAL_CHAR_SPACING_LIMIT = -40
56
+
57
+
58
+ def open_document(source: str | PathLike[str] | Any) -> HwpxDocument:
59
+ return HwpxDocument.open(source)
60
+
61
+
62
+ def new_document() -> HwpxDocument:
63
+ return HwpxDocument.new()
64
+
65
+
66
+ def blank_document_template_bytes() -> bytes:
67
+ return blank_document_bytes()
68
+
69
+
70
+ def open_package(path: str | PathLike[str]) -> HwpxPackage:
71
+ return HwpxPackage.open(path)
72
+
73
+
74
+ def create_text_extractor(path: str | PathLike[str]) -> TextExtractor:
75
+ return TextExtractor(path)
76
+
77
+
78
+ def create_object_finder(path: str | PathLike[str]) -> ObjectFinder:
79
+ return ObjectFinder(path)
80
+
81
+
82
+ def validate_document_path(path: str | PathLike[str]) -> ValidationReport:
83
+ return validate_document(path)
84
+
85
+
86
+ def export_document(document: HwpxDocument, output_format: Literal["text", "html", "markdown"]) -> str:
87
+ if output_format == "text":
88
+ return document.export_text()
89
+ if output_format == "html":
90
+ return document.export_html()
91
+ if output_format == "markdown":
92
+ return document.export_markdown()
93
+ raise ValueError(f"unsupported export format: {output_format}")
94
+
95
+
96
+ def default_cell_width() -> int:
97
+ """Return the upstream default table-cell width.
98
+
99
+ ``python-hwpx`` currently stores this as the private module constant
100
+ ``hwpx.oxml.document._DEFAULT_CELL_WIDTH``. Centralize that lookup here so a
101
+ future upstream change only touches one place downstream.
102
+ """
103
+
104
+ try: # pragma: no branch - single lookup with defensive fallback
105
+ from hwpx.oxml import document as hwpx_document_module
106
+
107
+ return int(getattr(hwpx_document_module, "_DEFAULT_CELL_WIDTH", 7200))
108
+ except Exception: # pragma: no cover - fallback only used if upstream internals move
109
+ return 7200
110
+
111
+
112
+ def append_xml_child(parent: ET.Element, tag: str, attrs: dict[str, str] | None = None) -> ET.Element:
113
+ payload = dict(attrs or {})
114
+ try:
115
+ return ET.SubElement(parent, tag, payload)
116
+ except TypeError:
117
+ maker = getattr(parent, "makeelement", None)
118
+ if not callable(maker):
119
+ raise
120
+ child = maker(tag, payload)
121
+ parent.append(child)
122
+ return child
123
+
124
+
125
+ def primary_header(document: HwpxDocument) -> Any:
126
+ if not document.headers:
127
+ raise RuntimeError("document does not contain any headers to host styles")
128
+ return document.headers[0]
129
+
130
+
131
+ def style_identifier(style: Any) -> str | None:
132
+ raw_id = getattr(style, "raw_id", None)
133
+ if raw_id:
134
+ return raw_id
135
+ identifier = getattr(style, "id", None)
136
+ if identifier is not None:
137
+ return str(identifier)
138
+ return None
139
+
140
+
141
+ def iter_unique_styles(document: HwpxDocument):
142
+ seen: set[str] = set()
143
+ for style in document.styles.values():
144
+ style_id = style_identifier(style)
145
+ if style_id is None or style_id in seen:
146
+ continue
147
+ seen.add(style_id)
148
+ yield style
149
+
150
+
151
+ def resolve_style_id(document: HwpxDocument, style: str | None) -> str | None:
152
+ if style is None:
153
+ return None
154
+ value = style.strip()
155
+ if not value:
156
+ return None
157
+
158
+ resolved = document.style(value)
159
+ if resolved is not None:
160
+ style_id = style_identifier(resolved)
161
+ if style_id is not None:
162
+ return style_id
163
+
164
+ for candidate in iter_unique_styles(document):
165
+ if value not in {candidate.name, candidate.eng_name}:
166
+ continue
167
+ style_id = style_identifier(candidate)
168
+ if style_id is not None:
169
+ return style_id
170
+
171
+ raise ValueError(f"unknown style reference: {value}")
172
+
173
+
174
+ def normalize_hex_color(color: str | None, *, field_name: str = "color") -> str | None:
175
+ if color is None:
176
+ return None
177
+ value = color.strip()
178
+ if not value:
179
+ return None
180
+ if not value.startswith("#"):
181
+ value = "#" + value
182
+ if len(value) != 7 or any(ch not in "0123456789abcdefABCDEF#" for ch in value):
183
+ raise ValueError(f"{field_name} must be a 6-digit hexadecimal value")
184
+ return value.upper()
185
+
186
+
187
+ def normalize_font_size(font_size: float | None) -> str | None:
188
+ if font_size is None:
189
+ return None
190
+ if font_size <= 0:
191
+ raise ValueError("font_size must be greater than 0")
192
+ return str(int(round(font_size * 100)))
193
+
194
+
195
+ def run_style_flags(run_style: Any) -> tuple[bool, bool, bool]:
196
+ if run_style is None:
197
+ return False, False, False
198
+ children = getattr(run_style, "child_attributes", {})
199
+ underline_attrs = children.get("underline")
200
+ underline = False
201
+ if underline_attrs is not None:
202
+ underline = underline_attrs.get("type", "").upper() != "NONE"
203
+ return "bold" in children, "italic" in children, underline
204
+
205
+
206
+ def run_style_height(run_style: Any) -> str | None:
207
+ if run_style is None:
208
+ return None
209
+ return getattr(run_style, "attributes", {}).get("height")
210
+
211
+
212
+ def run_style_font_refs(run_style: Any) -> dict[str, str]:
213
+ if run_style is None:
214
+ return {}
215
+ refs = dict(getattr(run_style, "child_attributes", {}).get("fontRef", {}))
216
+ return {key: str(value) for key, value in refs.items() if key in _FONT_REF_KEYS}
217
+
218
+
219
+ def element_style_flags(element: ET.Element) -> tuple[bool, bool, bool]:
220
+ underline_node = element.find(f"{HH_NS}underline")
221
+ underline = False
222
+ if underline_node is not None:
223
+ underline = underline_node.get("type", "").upper() != "NONE"
224
+ return (
225
+ element.find(f"{HH_NS}bold") is not None,
226
+ element.find(f"{HH_NS}italic") is not None,
227
+ underline,
228
+ )
229
+
230
+
231
+ def element_font_refs(element: ET.Element) -> dict[str, str]:
232
+ font_ref = element.find(f"{HH_NS}fontRef")
233
+ if font_ref is None:
234
+ return {}
235
+ return {key: str(value) for key, value in font_ref.attrib.items() if key in _FONT_REF_KEYS}
236
+
237
+
238
+ def char_style_matches(
239
+ run_style: Any,
240
+ *,
241
+ flags: tuple[bool, bool, bool],
242
+ color: str,
243
+ height: str,
244
+ font_refs: dict[str, str],
245
+ ) -> bool:
246
+ if run_style_flags(run_style) != flags:
247
+ return False
248
+ if ((run_style.text_color() if run_style is not None else None) or _DEFAULT_TEXT_COLOR).upper() != color:
249
+ return False
250
+ if (run_style_height(run_style) or _DEFAULT_CHAR_HEIGHT) != height:
251
+ return False
252
+ current_font_refs = run_style_font_refs(run_style)
253
+ for key in _FONT_REF_KEYS:
254
+ if current_font_refs.get(key) != font_refs.get(key):
255
+ return False
256
+ return True
257
+
258
+
259
+ def _ref_list_element(header: Any) -> ET.Element:
260
+ element = header.element.find(f"{HH_NS}refList")
261
+ if element is None:
262
+ raise RuntimeError("document header does not expose a refList element")
263
+ return element
264
+
265
+
266
+ def _fontfaces_element(header: Any) -> ET.Element:
267
+ ref_list = _ref_list_element(header)
268
+ fontfaces = ref_list.find(f"{HH_NS}fontfaces")
269
+ if fontfaces is None:
270
+ raise RuntimeError("document header does not expose fontfaces")
271
+ return fontfaces
272
+
273
+
274
+ def _find_fontface_bucket(fontfaces: ET.Element, lang: str) -> ET.Element | None:
275
+ for bucket in fontfaces.findall(f"{HH_NS}fontface"):
276
+ if (bucket.get("lang") or "").upper() == lang:
277
+ return bucket
278
+ return None
279
+
280
+
281
+ def _next_font_id(bucket: ET.Element) -> str:
282
+ numeric_ids: list[int] = []
283
+ for child in bucket.findall(f"{HH_NS}font"):
284
+ raw_id = child.get("id")
285
+ if raw_id is None:
286
+ continue
287
+ try:
288
+ numeric_ids.append(int(raw_id))
289
+ except ValueError:
290
+ continue
291
+ return "0" if not numeric_ids else str(max(numeric_ids) + 1)
292
+
293
+
294
+ def ensure_font_face_refs(header: Any, font_name: str) -> dict[str, str]:
295
+ name = (font_name or "").strip()
296
+ if not name:
297
+ raise ValueError("font_name cannot be empty")
298
+
299
+ fontfaces = _fontfaces_element(header)
300
+ refs: dict[str, str] = {}
301
+ changed = False
302
+
303
+ for lang, ref_key in _FONT_FACE_LANGS.items():
304
+ bucket = _find_fontface_bucket(fontfaces, lang)
305
+ if bucket is None:
306
+ bucket = append_xml_child(fontfaces, f"{HH_NS}fontface", {"lang": lang, "fontCnt": "0"})
307
+ changed = True
308
+
309
+ font_element = None
310
+ for candidate in bucket.findall(f"{HH_NS}font"):
311
+ if (candidate.get("face") or "").strip() == name:
312
+ font_element = candidate
313
+ break
314
+
315
+ if font_element is None:
316
+ font_element = append_xml_child(
317
+ bucket,
318
+ f"{HH_NS}font",
319
+ {
320
+ "id": _next_font_id(bucket),
321
+ "face": name,
322
+ "type": "TTF",
323
+ "isEmbedded": "0",
324
+ },
325
+ )
326
+ bucket.set("fontCnt", str(len(bucket.findall(f"{HH_NS}font"))))
327
+ changed = True
328
+
329
+ font_id = font_element.get("id")
330
+ if not font_id:
331
+ raise RuntimeError("fontface entry is missing an id")
332
+ refs[ref_key] = font_id
333
+
334
+ if changed:
335
+ header.mark_dirty()
336
+ return refs
337
+
338
+
339
+ def ensure_char_style(
340
+ document: HwpxDocument,
341
+ *,
342
+ base_char_pr_id: str | int | None,
343
+ bold: bool | None = None,
344
+ italic: bool | None = None,
345
+ underline: bool | None = None,
346
+ font_size: float | None = None,
347
+ font_name: str | None = None,
348
+ color: str | None = None,
349
+ ) -> str:
350
+ """Return a ``charPr`` id matching the requested formatting.
351
+
352
+ This prefers the public-ish upstream helper ``document.ensure_run_style()``
353
+ when only bold/italic/underline flags are involved. When color, font size,
354
+ or font family overrides are requested, we fall back to the header-level
355
+ ``ensure_char_property()`` hook so downstream behavior stays aligned with the
356
+ current upstream document engine.
357
+ """
358
+
359
+ base_style = document.char_property(base_char_pr_id)
360
+ base_flags = run_style_flags(base_style)
361
+ target_flags = (
362
+ base_flags[0] if bold is None else bool(bold),
363
+ base_flags[1] if italic is None else bool(italic),
364
+ base_flags[2] if underline is None else bool(underline),
365
+ )
366
+
367
+ if font_size is None and font_name is None and color is None:
368
+ return document.ensure_run_style(
369
+ bold=target_flags[0],
370
+ italic=target_flags[1],
371
+ underline=target_flags[2],
372
+ base_char_pr_id=base_char_pr_id,
373
+ )
374
+
375
+ header = primary_header(document)
376
+ target_color = normalize_hex_color(color) or (
377
+ (base_style.text_color() if base_style is not None else None) or _DEFAULT_TEXT_COLOR
378
+ ).upper()
379
+ target_height = normalize_font_size(font_size) or run_style_height(base_style) or _DEFAULT_CHAR_HEIGHT
380
+ target_font_refs = {
381
+ key: value for key, value in run_style_font_refs(base_style).items() if key in _FONT_REF_KEYS
382
+ }
383
+ if not target_font_refs:
384
+ target_font_refs = {key: "0" for key in _FONT_REF_KEYS}
385
+ if font_name is not None:
386
+ target_font_refs.update(ensure_font_face_refs(header, font_name))
387
+
388
+ if base_char_pr_id is not None and char_style_matches(
389
+ base_style,
390
+ flags=target_flags,
391
+ color=target_color,
392
+ height=target_height,
393
+ font_refs=target_font_refs,
394
+ ):
395
+ return str(base_char_pr_id)
396
+
397
+ def predicate(element: ET.Element) -> bool:
398
+ if element_style_flags(element) != target_flags:
399
+ return False
400
+ if (element.get("textColor", _DEFAULT_TEXT_COLOR) or _DEFAULT_TEXT_COLOR).upper() != target_color:
401
+ return False
402
+ if element.get("height", _DEFAULT_CHAR_HEIGHT) != target_height:
403
+ return False
404
+ existing_font_refs = element_font_refs(element)
405
+ for key in _FONT_REF_KEYS:
406
+ if existing_font_refs.get(key) != target_font_refs.get(key):
407
+ return False
408
+ return True
409
+
410
+ def modifier(element: ET.Element) -> None:
411
+ underline_nodes = list(element.findall(f"{HH_NS}underline"))
412
+ existing_underline = dict(underline_nodes[0].attrib) if underline_nodes else {}
413
+
414
+ for child in list(element.findall(f"{HH_NS}bold")):
415
+ element.remove(child)
416
+ for child in list(element.findall(f"{HH_NS}italic")):
417
+ element.remove(child)
418
+ for child in underline_nodes:
419
+ element.remove(child)
420
+
421
+ if target_flags[0]:
422
+ append_xml_child(element, f"{HH_NS}bold")
423
+ if target_flags[1]:
424
+ append_xml_child(element, f"{HH_NS}italic")
425
+
426
+ underline_attrs = dict(existing_underline)
427
+ underline_attrs.setdefault("shape", existing_underline.get("shape", "SOLID"))
428
+ underline_attrs["color"] = underline_attrs.get("color", target_color) or target_color
429
+ if target_flags[2]:
430
+ underline_attrs["type"] = underline_attrs.get("type", "SOLID") or "SOLID"
431
+ if underline_attrs["type"].upper() == "NONE":
432
+ underline_attrs["type"] = "SOLID"
433
+ underline_attrs["color"] = target_color
434
+ else:
435
+ underline_attrs["type"] = "NONE"
436
+ append_xml_child(element, f"{HH_NS}underline", underline_attrs)
437
+
438
+ element.set("textColor", target_color)
439
+ element.set("height", target_height)
440
+
441
+ font_ref = element.find(f"{HH_NS}fontRef")
442
+ if font_ref is None:
443
+ font_ref = append_xml_child(element, f"{HH_NS}fontRef")
444
+ for key in _FONT_REF_KEYS:
445
+ value = target_font_refs.get(key)
446
+ if value is not None:
447
+ font_ref.set(key, str(value))
448
+
449
+ char_element = header.ensure_char_property(
450
+ predicate=predicate,
451
+ modifier=modifier,
452
+ base_char_pr_id=base_char_pr_id,
453
+ )
454
+ char_id = char_element.get("id")
455
+ if not char_id:
456
+ raise RuntimeError("char property does not expose an identifier")
457
+ return char_id
458
+
459
+
460
+ def _char_property_element(
461
+ document: HwpxDocument,
462
+ char_pr_id_ref: str | int | None,
463
+ ) -> tuple[Any, ET.Element] | None:
464
+ if char_pr_id_ref is None:
465
+ return None
466
+ target = str(char_pr_id_ref).strip()
467
+ if not target:
468
+ return None
469
+
470
+ for header in document.headers:
471
+ ref_list = header.element.find(f"{HH_NS}refList")
472
+ if ref_list is None:
473
+ continue
474
+ char_properties = ref_list.find(f"{HH_NS}charProperties")
475
+ if char_properties is None:
476
+ continue
477
+ for element in char_properties.findall(f"{HH_NS}charPr"):
478
+ if (element.get("id") or "").strip() == target:
479
+ return header, element
480
+ return None
481
+
482
+
483
+ def _spacing_values(element: ET.Element | None) -> dict[str, int]:
484
+ if element is None:
485
+ return {}
486
+ spacing = element.find(f"{HH_NS}spacing")
487
+ if spacing is None:
488
+ return {}
489
+
490
+ values: dict[str, int] = {}
491
+ for key in _FONT_REF_KEYS:
492
+ raw = spacing.get(key)
493
+ if raw is None:
494
+ continue
495
+ try:
496
+ values[key] = int(raw)
497
+ except ValueError:
498
+ continue
499
+ return values
500
+
501
+
502
+ def _safe_spacing_fallback_refs(
503
+ document: HwpxDocument,
504
+ paragraph: Any | None,
505
+ explicit_ref: str | int | None,
506
+ ) -> list[str]:
507
+ refs: list[str] = []
508
+
509
+ def append(value: str | int | None) -> None:
510
+ if value is None:
511
+ return
512
+ normalized = str(value).strip()
513
+ if normalized and normalized not in refs:
514
+ refs.append(normalized)
515
+
516
+ append(explicit_ref)
517
+ if paragraph is not None:
518
+ style = document.style(getattr(paragraph, "style_id_ref", None))
519
+ append(getattr(style, "char_pr_id_ref", None))
520
+ append(getattr(paragraph, "char_pr_id_ref", None))
521
+
522
+ style_zero_ref: str | int | None = None
523
+ for style in iter_unique_styles(document):
524
+ style_id = style_identifier(style)
525
+ name = str(getattr(style, "name", None) or "").strip()
526
+ eng_name = str(getattr(style, "eng_name", None) or "").strip().lower()
527
+ char_ref = getattr(style, "char_pr_id_ref", None)
528
+ if name in {"바탕글", "본문"} or eng_name in {"normal", "body"}:
529
+ append(char_ref)
530
+ if style_id == "0":
531
+ style_zero_ref = char_ref
532
+ append(style_zero_ref)
533
+ append("0")
534
+ return refs
535
+
536
+
537
+ def _element_signature(element: ET.Element) -> bytes:
538
+ clone = deepcopy(element)
539
+ clone.attrib.pop("id", None)
540
+ return ET.tostring(clone, encoding="utf-8")
541
+
542
+
543
+ def _run_element(run: Any) -> ET.Element | None:
544
+ element = getattr(run, "element", None)
545
+ if element is not None:
546
+ return element
547
+ if hasattr(run, "get") and hasattr(run, "set"):
548
+ return run
549
+ return None
550
+
551
+
552
+ def repair_pathological_text_spacing(
553
+ document: HwpxDocument,
554
+ *,
555
+ paragraph: Any | None = None,
556
+ runs: Iterable[Any] | None = None,
557
+ fallback_char_pr_id: str | int | None = None,
558
+ ) -> int:
559
+ """Remap touched text runs whose character spacing would over-print glyphs.
560
+
561
+ Some form templates use ``hh:spacing=-50`` on short placeholder runs. If a
562
+ replacement or newly-added paragraph blindly keeps that ``charPr``, Hancom
563
+ renders normal text with roughly half-width glyph advance and the letters
564
+ overlap. Only the touched runs are remapped: the source ``charPr`` remains
565
+ byte-for-byte intact, while a deduplicated clone changes only pathological
566
+ spacing values (``<= -40``) to a safe paragraph/body-style fallback.
567
+ """
568
+
569
+ selected_runs = list(runs if runs is not None else (getattr(paragraph, "runs", None) or []))
570
+ if not selected_runs:
571
+ return 0
572
+
573
+ fallback_values: list[dict[str, int]] = []
574
+ for fallback_ref in _safe_spacing_fallback_refs(document, paragraph, fallback_char_pr_id):
575
+ found = _char_property_element(document, fallback_ref)
576
+ if found is not None:
577
+ fallback_values.append(_spacing_values(found[1]))
578
+
579
+ remapped = 0
580
+ replacements_by_ref: dict[str, str] = {}
581
+ for run in selected_runs:
582
+ element = _run_element(run)
583
+ if element is None:
584
+ continue
585
+ base_ref = (element.get("charPrIDRef") or "").strip()
586
+ if not base_ref:
587
+ continue
588
+
589
+ found = _char_property_element(document, base_ref)
590
+ if found is None:
591
+ continue
592
+ header, base_element = found
593
+ base_spacing = _spacing_values(base_element)
594
+ unsafe_keys = [
595
+ key
596
+ for key, value in base_spacing.items()
597
+ if value <= _PATHOLOGICAL_CHAR_SPACING_LIMIT
598
+ ]
599
+ if not unsafe_keys:
600
+ continue
601
+
602
+ target_ref = replacements_by_ref.get(base_ref)
603
+ if target_ref is None:
604
+ target_values: dict[str, int] = {}
605
+ for key in unsafe_keys:
606
+ safe_value = 0
607
+ for candidate in fallback_values:
608
+ value = candidate.get(key, 0)
609
+ if value > _PATHOLOGICAL_CHAR_SPACING_LIMIT:
610
+ safe_value = value
611
+ break
612
+ target_values[key] = safe_value
613
+
614
+ target_element = deepcopy(base_element)
615
+ target_element.attrib.pop("id", None)
616
+ target_spacing = target_element.find(f"{HH_NS}spacing")
617
+ if target_spacing is None: # pragma: no cover - unsafe keys imply spacing exists
618
+ target_spacing = append_xml_child(target_element, f"{HH_NS}spacing")
619
+ for key, value in target_values.items():
620
+ target_spacing.set(key, str(value))
621
+ target_signature = _element_signature(target_element)
622
+
623
+ def predicate(candidate: ET.Element) -> bool:
624
+ return _element_signature(candidate) == target_signature
625
+
626
+ def modifier(candidate: ET.Element) -> None:
627
+ spacing = candidate.find(f"{HH_NS}spacing")
628
+ if spacing is None: # pragma: no cover - cloned unsafe style has spacing
629
+ spacing = append_xml_child(candidate, f"{HH_NS}spacing")
630
+ for key, value in target_values.items():
631
+ spacing.set(key, str(value))
632
+
633
+ safe_element = header.ensure_char_property(
634
+ predicate=predicate,
635
+ modifier=modifier,
636
+ base_char_pr_id=base_ref,
637
+ )
638
+ target_ref = safe_element.get("id")
639
+ if not target_ref: # pragma: no cover - upstream guarantees an id
640
+ raise RuntimeError("safe character property does not expose an identifier")
641
+ replacements_by_ref[base_ref] = target_ref
642
+
643
+ if hasattr(run, "char_pr_id_ref"):
644
+ run.char_pr_id_ref = target_ref
645
+ else:
646
+ element.set("charPrIDRef", target_ref)
647
+ section = getattr(paragraph, "section", None)
648
+ if section is not None and hasattr(section, "mark_dirty"):
649
+ section.mark_dirty()
650
+ remapped += 1
651
+
652
+ return remapped
653
+
654
+
655
+ def document_styles_element(document: HwpxDocument) -> ET.Element:
656
+ """Return the raw ``<hh:styles>`` element for the primary header.
657
+
658
+ This currently relies on the private upstream helper ``_styles_element()``
659
+ because ``python-hwpx`` does not expose a public style-creation API yet.
660
+ """
661
+
662
+ header = primary_header(document)
663
+ styles_element = header._styles_element()
664
+ if styles_element is None:
665
+ raise RuntimeError("document header does not expose styles")
666
+ return styles_element
667
+
668
+
669
+ def style_element_by_id(styles_element: ET.Element, style_id: str) -> ET.Element | None:
670
+ for style_element in styles_element.findall(f"{HH_NS}style"):
671
+ raw_id = style_element.get("id")
672
+ if raw_id == style_id:
673
+ return style_element
674
+ try:
675
+ if raw_id is not None and str(int(raw_id)) == style_id:
676
+ return style_element
677
+ except ValueError:
678
+ continue
679
+ return None
680
+
681
+
682
+ def next_numeric_style_id(styles_element: ET.Element) -> str:
683
+ numeric_ids: list[int] = []
684
+ for style_element in styles_element.findall(f"{HH_NS}style"):
685
+ raw_id = style_element.get("id")
686
+ if raw_id is None:
687
+ continue
688
+ try:
689
+ numeric_ids.append(int(raw_id))
690
+ except ValueError:
691
+ continue
692
+ return "0" if not numeric_ids else str(max(numeric_ids) + 1)
693
+
694
+
695
+ def update_styles_item_count(styles_element: ET.Element) -> None:
696
+ styles_element.set("itemCnt", str(len(styles_element.findall(f"{HH_NS}style"))))
697
+
698
+
699
+ def default_base_style_id(document: HwpxDocument) -> str:
700
+ for candidate in ("1", "본문", "Body", "0", "바탕글", "Normal"):
701
+ try:
702
+ resolved = resolve_style_id(document, candidate)
703
+ except ValueError:
704
+ continue
705
+ if resolved is not None:
706
+ return resolved
707
+
708
+ for style in iter_unique_styles(document):
709
+ style_id = style_identifier(style)
710
+ if style_id is not None:
711
+ return style_id
712
+
713
+ raise RuntimeError("document does not contain any styles")
714
+
715
+
716
+ __all__ = [
717
+ "AnnotationOptions",
718
+ "HH_NS",
719
+ "HP_NS",
720
+ "HwpxDocument",
721
+ "HwpxOxmlMemo",
722
+ "HwpxOxmlParagraph",
723
+ "HwpxOxmlRun",
724
+ "HwpxOxmlTable",
725
+ "ValidationReport",
726
+ "append_xml_child",
727
+ "blank_document_template_bytes",
728
+ "char_style_matches",
729
+ "create_object_finder",
730
+ "create_text_extractor",
731
+ "default_base_style_id",
732
+ "default_cell_width",
733
+ "document_styles_element",
734
+ "element_font_refs",
735
+ "element_style_flags",
736
+ "ensure_char_style",
737
+ "ensure_font_face_refs",
738
+ "export_document",
739
+ "iter_unique_styles",
740
+ "new_document",
741
+ "normalize_font_size",
742
+ "normalize_hex_color",
743
+ "open_document",
744
+ "open_package",
745
+ "primary_header",
746
+ "resolve_style_id",
747
+ "run_style_flags",
748
+ "run_style_font_refs",
749
+ "run_style_height",
750
+ "style_element_by_id",
751
+ "style_identifier",
752
+ "update_styles_item_count",
753
+ "validate_document_path",
754
+ "next_numeric_style_id",
755
+ ]