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,178 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import logging
8
+ from typing import Any, Dict, Optional
9
+
10
+ from ..core.transactions import (
11
+ save_dry_run,
12
+ )
13
+ from ..upstream import (
14
+ HwpxDocument,
15
+ )
16
+ from hwpx.tools.id_integrity import check_id_integrity
17
+
18
+ from .context import DocumentContext
19
+ from .save_policy import SavePolicy
20
+
21
+ logger = logging.getLogger("hwpx_automation.hwpx_ops")
22
+
23
+
24
+ class MediaService:
25
+ def __init__(self, context: DocumentContext, save: SavePolicy) -> None:
26
+ self._context = context
27
+ self._save = save
28
+
29
+ def _decode_image_base64(self, image_base64: str) -> bytes:
30
+ try:
31
+ payload = base64.b64decode((image_base64 or "").strip(), validate=True)
32
+ except (binascii.Error, ValueError) as exc:
33
+ raise ValueError("invalid image_base64 payload") from exc
34
+ if not payload:
35
+ raise ValueError("image_base64 decoded to empty payload")
36
+ return payload
37
+
38
+ def _id_integrity_payload(self, document: HwpxDocument) -> Dict[str, Any]:
39
+ report = check_id_integrity(document)
40
+ return {
41
+ "ok": report.ok,
42
+ "dangling": [str(item) for item in report.dangling],
43
+ "orphanBinData": [str(item) for item in report.orphan_bin_data],
44
+ "ignored": [str(item) for item in report.ignored],
45
+ }
46
+
47
+ def add_shape(
48
+ self,
49
+ path: str,
50
+ *,
51
+ shape_type: str = "RECTANGLE",
52
+ section_index: Optional[int] = None,
53
+ dry_run: bool = True,
54
+ ) -> Dict[str, Any]:
55
+ document, resolved = self._context._open_document(path)
56
+ shape = document.add_shape(shape_type, section_index=section_index)
57
+ if not dry_run:
58
+ self._save._save_document(document, resolved)
59
+ return {"objectId": shape.element.get("id")}
60
+
61
+ def add_control(
62
+ self,
63
+ path: str,
64
+ *,
65
+ control_type: str = "TEXTBOX",
66
+ section_index: Optional[int] = None,
67
+ dry_run: bool = True,
68
+ ) -> Dict[str, Any]:
69
+ document, resolved = self._context._open_document(path)
70
+ control = document.add_control(
71
+ control_type=control_type, section_index=section_index
72
+ )
73
+ if not dry_run:
74
+ self._save._save_document(document, resolved)
75
+ return {"objectId": control.element.get("id")}
76
+
77
+ def insert_picture(
78
+ self,
79
+ path: str,
80
+ image_base64: str,
81
+ *,
82
+ image_format: str = "png",
83
+ width: Optional[int] = None,
84
+ height: Optional[int] = None,
85
+ width_mm: Optional[float] = None,
86
+ height_mm: Optional[float] = None,
87
+ section_index: Optional[int] = None,
88
+ align: Optional[str] = None,
89
+ output: Optional[str] = None,
90
+ dry_run: bool = False,
91
+ ) -> Dict[str, Any]:
92
+ document, resolved = self._context._open_document(path)
93
+ target = (
94
+ self._context._resolve_path(output, must_exist=False)
95
+ if output
96
+ else resolved
97
+ )
98
+ document.add_picture(
99
+ self._decode_image_base64(image_base64),
100
+ image_format,
101
+ width=width,
102
+ height=height,
103
+ width_mm=width_mm,
104
+ height_mm=height_mm,
105
+ section_index=section_index,
106
+ align=align,
107
+ )
108
+ picture_refs = document.picture_references()
109
+ result: Dict[str, Any] = {
110
+ "ok": True,
111
+ "dryRun": dry_run,
112
+ "filename": path,
113
+ "outputPath": str(target),
114
+ "picture": picture_refs[-1] if picture_refs else None,
115
+ "pictureReferences": picture_refs,
116
+ "idIntegrity": self._id_integrity_payload(document),
117
+ }
118
+ if dry_run:
119
+ result.update(save_dry_run(document, target))
120
+ return result
121
+ verification = self._save._save_transaction_document(document, target)
122
+ result["verificationReport"] = verification
123
+ result["openSafety"] = verification.get("openSafety")
124
+ if "visualComplete" in verification:
125
+ result["visualComplete"] = verification["visualComplete"]
126
+ if "backup" in verification:
127
+ result["backup"] = verification["backup"]
128
+ if "semanticDiff" in verification:
129
+ result["semanticDiff"] = verification["semanticDiff"]
130
+ return result
131
+
132
+ def replace_picture(
133
+ self,
134
+ path: str,
135
+ image_base64: str,
136
+ *,
137
+ image_format: str = "png",
138
+ picture_index: int = 0,
139
+ binary_item_id_ref: Optional[str] = None,
140
+ remove_orphaned: bool = True,
141
+ output: Optional[str] = None,
142
+ dry_run: bool = False,
143
+ ) -> Dict[str, Any]:
144
+ document, resolved = self._context._open_document(path)
145
+ target = (
146
+ self._context._resolve_path(output, must_exist=False)
147
+ if output
148
+ else resolved
149
+ )
150
+ replacement = document.replace_picture(
151
+ self._decode_image_base64(image_base64),
152
+ image_format,
153
+ picture_index=picture_index,
154
+ binary_item_id_ref=binary_item_id_ref,
155
+ remove_orphaned=remove_orphaned,
156
+ )
157
+ result: Dict[str, Any] = {
158
+ "ok": True,
159
+ "dryRun": dry_run,
160
+ "filename": path,
161
+ "outputPath": str(target),
162
+ "replacement": replacement,
163
+ "pictureReferences": document.picture_references(),
164
+ "idIntegrity": self._id_integrity_payload(document),
165
+ }
166
+ if dry_run:
167
+ result.update(save_dry_run(document, target))
168
+ return result
169
+ verification = self._save._save_transaction_document(document, target)
170
+ result["verificationReport"] = verification
171
+ result["openSafety"] = verification.get("openSafety")
172
+ if "visualComplete" in verification:
173
+ result["visualComplete"] = verification["visualComplete"]
174
+ if "backup" in verification:
175
+ result["backup"] = verification["backup"]
176
+ if "semanticDiff" in verification:
177
+ result["semanticDiff"] = verification["semanticDiff"]
178
+ return result
@@ -0,0 +1,477 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import dataclasses
7
+ import logging
8
+ from dataclasses import asdict
9
+ from datetime import datetime
10
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, cast
11
+ from xml.etree import ElementTree as ET
12
+
13
+ from ..upstream import (
14
+ HP_NS,
15
+ HwpxDocument,
16
+ HwpxOxmlMemo,
17
+ HwpxOxmlParagraph,
18
+ HwpxOxmlRun,
19
+ ensure_char_style,
20
+ normalize_hex_color,
21
+ )
22
+
23
+ from .context import DocumentContext
24
+ from .save_policy import SavePolicy
25
+
26
+ logger = logging.getLogger("hwpx_automation.hwpx_ops")
27
+
28
+
29
+ class _Segment:
30
+ __slots__ = ("element", "attr", "text")
31
+
32
+ def __init__(self, element: ET.Element, attr: str, text: str) -> None:
33
+ self.element = element
34
+ self.attr = attr
35
+ self.text = text
36
+
37
+ def set(self, value: str) -> None:
38
+ self.text = value
39
+ if value:
40
+ setattr(self.element, self.attr, value)
41
+ else:
42
+ setattr(self.element, self.attr, "")
43
+
44
+
45
+ def _gather_segments(element: ET.Element) -> List[_Segment]:
46
+ segments: List[_Segment] = []
47
+
48
+ def visit(node: ET.Element) -> None:
49
+ text_value = node.text or ""
50
+ segments.append(_Segment(node, "text", text_value))
51
+ for child in list(node):
52
+ visit(child)
53
+ tail_value = child.tail or ""
54
+ segments.append(_Segment(child, "tail", tail_value))
55
+
56
+ for text_node in element.findall(f"{HP_NS}t"):
57
+ visit(text_node)
58
+ return segments
59
+
60
+
61
+ def _slice_run(run_obj: HwpxOxmlRun, start: int, end: int) -> None:
62
+ segments = _gather_segments(run_obj.element)
63
+ if not segments:
64
+ return
65
+ total_length = sum(len(segment.text) for segment in segments)
66
+ start = max(0, min(start, total_length))
67
+ end = max(0, min(end, total_length))
68
+ if start >= end:
69
+ for segment in segments:
70
+ if segment.text:
71
+ segment.set("")
72
+ run_obj.paragraph.section.mark_dirty()
73
+ return
74
+ changed = False
75
+ offset = 0
76
+ for segment in segments:
77
+ seg_start = offset
78
+ seg_end = seg_start + len(segment.text)
79
+ offset = seg_end
80
+ if end <= seg_start or start >= seg_end:
81
+ if segment.text:
82
+ segment.set("")
83
+ changed = True
84
+ continue
85
+ local_start = max(start, seg_start) - seg_start
86
+ local_end = min(end, seg_end) - seg_start
87
+ new_value = segment.text[local_start:local_end]
88
+ if segment.text != new_value:
89
+ segment.set(new_value)
90
+ changed = True
91
+ if changed:
92
+ run_obj.paragraph.section.mark_dirty()
93
+
94
+
95
+ def _split_run(
96
+ run_obj: HwpxOxmlRun, local_start: int, local_end: int, char_pr_id_ref: str
97
+ ) -> None:
98
+ text_value = run_obj.text or ""
99
+ length = len(text_value)
100
+ if length == 0:
101
+ return
102
+ local_start = max(0, min(local_start, length))
103
+ local_end = max(0, min(local_end, length))
104
+ if local_start >= local_end:
105
+ return
106
+ if local_start == 0 and local_end == length:
107
+ run_obj.char_pr_id_ref = char_pr_id_ref
108
+ return
109
+
110
+ segments: List[Tuple[int, int, Optional[str]]] = []
111
+ original_char = run_obj.char_pr_id_ref
112
+ if local_start > 0:
113
+ segments.append((0, local_start, original_char))
114
+ segments.append((local_start, local_end, char_pr_id_ref))
115
+ if local_end < length:
116
+ segments.append((local_end, length, original_char))
117
+
118
+ parent = run_obj.paragraph.element
119
+ run_children = list(parent)
120
+ try:
121
+ index = run_children.index(run_obj.element)
122
+ except ValueError: # pragma: no cover - defensive branch
123
+ return
124
+
125
+ new_elements: List[ET.Element] = []
126
+ for seg_start, seg_end, char_id in segments:
127
+ if seg_start >= seg_end:
128
+ continue
129
+ element_copy = copy.deepcopy(run_obj.element)
130
+ segment_run = HwpxOxmlRun(element_copy, run_obj.paragraph)
131
+ _slice_run(segment_run, seg_start, seg_end)
132
+ if char_id is None:
133
+ segment_run.char_pr_id_ref = None
134
+ else:
135
+ segment_run.char_pr_id_ref = char_id
136
+ new_elements.append(element_copy)
137
+
138
+ if not new_elements:
139
+ parent.remove(run_obj.element)
140
+ run_obj.paragraph.section.mark_dirty()
141
+ return
142
+
143
+ for offset, element in enumerate(new_elements):
144
+ parent.insert(index + offset, element)
145
+ parent.remove(run_obj.element)
146
+ run_obj.paragraph.section.mark_dirty()
147
+
148
+
149
+ def _paragraph_length(paragraph: HwpxOxmlParagraph) -> int:
150
+ return sum(len(run.text or "") for run in paragraph.runs)
151
+
152
+
153
+ def _apply_span(
154
+ paragraph: HwpxOxmlParagraph,
155
+ span_start: int,
156
+ span_end: int,
157
+ char_pr_id_ref: str,
158
+ ) -> bool:
159
+ if span_start >= span_end:
160
+ return False
161
+ applied = False
162
+ cursor = span_start
163
+ while cursor < span_end:
164
+ runs = list(paragraph.runs)
165
+ offset = 0
166
+ target: Tuple[HwpxOxmlRun, int, int, int] | None = None
167
+ for candidate in runs:
168
+ text = candidate.text or ""
169
+ length = len(text)
170
+ run_start = offset
171
+ run_end = run_start + length
172
+ if run_end <= cursor:
173
+ offset = run_end
174
+ continue
175
+ if run_start >= span_end:
176
+ target = None
177
+ break
178
+ if length == 0:
179
+ offset = run_end
180
+ continue
181
+ target = (candidate, run_start, run_end, length)
182
+ break
183
+
184
+ if target is None:
185
+ break
186
+
187
+ run_obj, run_start, run_end, length = target
188
+ local_start = max(0, cursor - run_start)
189
+ local_end = min(length, span_end - run_start)
190
+ if local_start >= local_end:
191
+ cursor = max(cursor + 1, run_end)
192
+ continue
193
+
194
+ _split_run(run_obj, local_start, local_end, char_pr_id_ref)
195
+ applied = True
196
+ cursor = min(span_end, run_end)
197
+
198
+ return applied
199
+
200
+
201
+ def _span_bounds(span: Any) -> Tuple[int, int, int]:
202
+ if isinstance(span, dict):
203
+ paragraph_index = int(span.get("paragraph_index", -1))
204
+ start = int(span.get("start", 0))
205
+ end = int(span.get("end", 0))
206
+ else:
207
+ paragraph_index = int(
208
+ getattr(span, "paragraph_index", getattr(span, "paragraphIndex", -1))
209
+ )
210
+ start = int(getattr(span, "start", 0))
211
+ end = int(getattr(span, "end", 0))
212
+ return paragraph_index, start, end
213
+
214
+
215
+ class MemoStyleService:
216
+ def __init__(self, context: DocumentContext, save: SavePolicy) -> None:
217
+ self._context = context
218
+ self._save = save
219
+
220
+ def _normalize_color(self, color: str | None) -> Optional[str]:
221
+ return normalize_hex_color(color, field_name="colorHex")
222
+
223
+ def _ensure_char_style(
224
+ self,
225
+ document: HwpxDocument,
226
+ run_style: Optional[Dict[str, Any]],
227
+ ) -> Optional[str]:
228
+ if not run_style:
229
+ return None
230
+ bold = bool(run_style.get("bold", False))
231
+ italic = bool(run_style.get("italic", False))
232
+ underline = bool(run_style.get("underline", False))
233
+ color = self._normalize_color(run_style.get("colorHex"))
234
+ try:
235
+ return ensure_char_style(
236
+ document,
237
+ base_char_pr_id=None,
238
+ bold=bold,
239
+ italic=italic,
240
+ underline=underline,
241
+ color=color,
242
+ )
243
+ except (ValueError, RuntimeError) as exc:
244
+ message = str(exc)
245
+ if "document does not contain any headers" in message:
246
+ raise self._context._new_error("STYLE_HEADER_MISSING", message) from exc
247
+ if "char property does not expose an identifier" in message:
248
+ raise self._context._new_error(
249
+ "STYLE_CHAR_PROPERTY_ID_MISSING", message
250
+ ) from exc
251
+ raise
252
+
253
+ def find_runs_by_style(
254
+ self,
255
+ path: str,
256
+ *,
257
+ filters: Optional[Dict[str, Any]] = None,
258
+ max_results: int = 200,
259
+ ) -> Dict[str, Any]:
260
+ document, _ = self._context._open_document(path)
261
+ filter_args: Dict[str, Any] = {}
262
+ if filters:
263
+ if "colorHex" in filters and filters["colorHex"]:
264
+ filter_args["text_color"] = self._normalize_color(filters["colorHex"])
265
+ if "underline" in filters:
266
+ filter_args["underline_type"] = (
267
+ "SOLID" if filters["underline"] else "NONE"
268
+ )
269
+ if "charPrIDRef" in filters and filters["charPrIDRef"]:
270
+ filter_args["char_pr_id_ref"] = filters["charPrIDRef"]
271
+ runs = document.find_runs_by_style(**filter_args)
272
+ paragraph_index_map: Dict[int, int] = {}
273
+ paragraphs = self._context._iter_paragraphs(document)
274
+ for index, paragraph in enumerate(paragraphs):
275
+ paragraph_index_map[id(paragraph.element)] = index
276
+ results: List[Dict[str, Any]] = []
277
+ for run in runs[:max_results]:
278
+ paragraph = run.paragraph
279
+ para_index = paragraph_index_map.get(id(paragraph.element), -1)
280
+ style = {}
281
+ if run.style is not None:
282
+ style_data = run.style
283
+ if dataclasses.is_dataclass(style_data):
284
+ style = asdict(cast(Any, style_data))
285
+ results.append(
286
+ {
287
+ "text": run.text,
288
+ "paragraphIndex": para_index,
289
+ "charPrIDRef": run.char_pr_id_ref,
290
+ "style": style,
291
+ }
292
+ )
293
+ return {"runs": results}
294
+
295
+ def add_memo(
296
+ self,
297
+ path: str,
298
+ text: str,
299
+ *,
300
+ section_index: Optional[int] = None,
301
+ author: str | None = None,
302
+ timestamp: str | None = None,
303
+ ) -> Dict[str, Any]:
304
+ document, resolved = self._context._open_document(path)
305
+ memo = document.add_memo(
306
+ text,
307
+ section_index=section_index,
308
+ attributes={
309
+ "author": author or "",
310
+ "createDateTime": timestamp
311
+ or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
312
+ },
313
+ )
314
+ self._save._save_document(document, resolved)
315
+ return {"memoId": memo.id}
316
+
317
+ def attach_memo_field(
318
+ self,
319
+ path: str,
320
+ paragraph_index: int,
321
+ memo_id: str,
322
+ ) -> Dict[str, Any]:
323
+ document, resolved = self._context._open_document(path)
324
+ paragraphs = self._context._iter_paragraphs(document)
325
+ try:
326
+ paragraph = paragraphs[paragraph_index]
327
+ except IndexError as exc:
328
+ raise self._context._new_error(
329
+ "PARAGRAPH_INDEX_OUT_OF_RANGE",
330
+ "paragraphIndex out of range",
331
+ details={"paragraphIndex": paragraph_index},
332
+ ) from exc
333
+ memo = self._find_memo(document, memo_id)
334
+ if memo is None:
335
+ raise self._context._new_error(
336
+ "MEMO_NOT_FOUND",
337
+ f"memo '{memo_id}' not found",
338
+ details={"memoId": memo_id},
339
+ )
340
+ field_id = document.attach_memo_field(paragraph, memo)
341
+ self._save._save_document(document, resolved)
342
+ return {"fieldId": field_id}
343
+
344
+ def add_memo_with_anchor(
345
+ self,
346
+ path: str,
347
+ *,
348
+ text: str,
349
+ section_index: Optional[int] = None,
350
+ memo_shape_id_ref: str | None = None,
351
+ ) -> Dict[str, Any]:
352
+ document, resolved = self._context._open_document(path)
353
+ memo, paragraph, field_id = document.add_memo_with_anchor(
354
+ text,
355
+ section_index=section_index,
356
+ memo_shape_id_ref=memo_shape_id_ref,
357
+ )
358
+ paragraphs = self._context._iter_paragraphs(document)
359
+ paragraph_index = len(paragraphs) - 1
360
+ paragraph_element_id = id(paragraph.element)
361
+ for idx, candidate in enumerate(paragraphs):
362
+ if id(candidate.element) == paragraph_element_id:
363
+ paragraph_index = idx
364
+ break
365
+ self._save._save_document(document, resolved)
366
+ return {
367
+ "memoId": memo.id,
368
+ "paragraphIndex": paragraph_index,
369
+ "fieldId": field_id,
370
+ }
371
+
372
+ def remove_memo(
373
+ self,
374
+ path: str,
375
+ memo_id: str,
376
+ *,
377
+ dry_run: bool = True,
378
+ ) -> Dict[str, Any]:
379
+ document, resolved = self._context._open_document(path)
380
+ memo = self._find_memo(document, memo_id)
381
+ if memo is None:
382
+ return {"removed": False}
383
+ memo.remove()
384
+ if not dry_run:
385
+ self._save._save_document(document, resolved)
386
+ return {"removed": True}
387
+
388
+ def _find_memo(
389
+ self, document: HwpxDocument, memo_id: str
390
+ ) -> Optional[HwpxOxmlMemo]:
391
+ for section in document.sections:
392
+ for memo in section.memos:
393
+ if memo.id == memo_id:
394
+ return memo
395
+ return None
396
+
397
+ def ensure_run_style(self, path: str, **run_style: Any) -> Dict[str, Any]:
398
+ document, _ = self._context._open_document(path)
399
+ char_id = self._ensure_char_style(document, run_style)
400
+ return {"charPrIDRef": char_id}
401
+
402
+ def list_styles_and_bullets(self, path: str) -> Dict[str, Any]:
403
+ document, _ = self._context._open_document(path)
404
+ styles = [
405
+ asdict(cast(Any, style))
406
+ for style in document.styles.values()
407
+ if dataclasses.is_dataclass(style)
408
+ ]
409
+ bullets = [
410
+ asdict(cast(Any, bullet))
411
+ for bullet in document.bullets.values()
412
+ if dataclasses.is_dataclass(bullet)
413
+ ]
414
+ return {"styles": styles, "bullets": bullets}
415
+
416
+ def apply_style_to_text_ranges(
417
+ self,
418
+ path: str,
419
+ spans: Sequence[Dict[str, int]],
420
+ char_pr_id_ref: str,
421
+ *,
422
+ dry_run: bool = True,
423
+ ) -> Dict[str, Any]:
424
+ if not char_pr_id_ref:
425
+ raise ValueError("char_pr_id_ref must be provided")
426
+
427
+ document, resolved = self._context._open_document(path)
428
+ paragraphs = self._context._iter_paragraphs(document)
429
+
430
+ styled = 0
431
+ for span in spans:
432
+ paragraph_index, start, end = _span_bounds(span)
433
+
434
+ if paragraph_index < 0 or paragraph_index >= len(paragraphs):
435
+ continue
436
+
437
+ start = max(0, start)
438
+ end = max(start, end)
439
+ if start >= end:
440
+ continue
441
+
442
+ paragraph = paragraphs[paragraph_index]
443
+ total_length = _paragraph_length(paragraph)
444
+ if total_length == 0 or start >= total_length:
445
+ continue
446
+ clamped_end = min(end, total_length)
447
+
448
+ if _apply_span(paragraph, start, clamped_end, char_pr_id_ref):
449
+ styled += 1
450
+
451
+ if not dry_run and styled:
452
+ self._save._save_document(document, resolved)
453
+
454
+ return {"styledSpans": styled}
455
+
456
+ def apply_style_to_paragraphs(
457
+ self,
458
+ path: str,
459
+ paragraph_indexes: Sequence[int],
460
+ char_pr_id_ref: str,
461
+ *,
462
+ dry_run: bool = True,
463
+ ) -> Dict[str, Any]:
464
+ document, resolved = self._context._open_document(path)
465
+ paragraphs = self._context._iter_paragraphs(document)
466
+ updated = 0
467
+ for index in paragraph_indexes:
468
+ if index < 0 or index >= len(paragraphs):
469
+ continue
470
+ paragraph = paragraphs[index]
471
+ paragraph.char_pr_id_ref = char_pr_id_ref
472
+ for run in paragraph.runs:
473
+ run.char_pr_id_ref = char_pr_id_ref
474
+ updated += 1
475
+ if not dry_run and updated:
476
+ self._save._save_document(document, resolved)
477
+ return {"updated": updated}