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,585 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import logging
8
+ import math
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Mapping, Optional, Sequence
15
+
16
+ from ..configuration import env_value
17
+ from ..hwp_converter import HwpConversionError, convert_hwp_to_hwpx
18
+ from ..upstream import (
19
+ export_document,
20
+ )
21
+
22
+ from .context import DocumentContext
23
+
24
+ logger = logging.getLogger("hwpx_automation.hwpx_ops")
25
+
26
+ try: # python-hwpx >= layout preview feature
27
+ from hwpx.tools.layout_preview import (
28
+ render_layout_preview as render_hwpx_layout_preview,
29
+ )
30
+ except Exception as exc: # pragma: no cover - depends on installed python-hwpx
31
+ render_hwpx_layout_preview = None
32
+ _LAYOUT_PREVIEW_IMPORT_ERROR: Exception | None = exc
33
+ else:
34
+ _LAYOUT_PREVIEW_IMPORT_ERROR = None
35
+
36
+ try: # python-hwpx >= document viewer feature ([preview] extra for MathML)
37
+ from hwpx.tools.document_viewer import (
38
+ render_document_viewer as render_hwpx_document_viewer,
39
+ )
40
+ except Exception as exc: # pragma: no cover - depends on installed python-hwpx
41
+ render_hwpx_document_viewer = None
42
+ _DOCUMENT_VIEWER_IMPORT_ERROR: Exception | None = exc
43
+ else:
44
+ _DOCUMENT_VIEWER_IMPORT_ERROR = None
45
+
46
+ try: # optional MathML converter (python-hwpx[preview])
47
+ from hwpx.equation import latex2mathml_available as _latex2mathml_available
48
+ except Exception: # pragma: no cover - depends on installed python-hwpx
49
+ _latex2mathml_available = None
50
+
51
+ _PREVIEW_SCHEMA_VERSION = "hwpx.render-preview.v1"
52
+ _VISUAL_REVIEW_SCHEMA_VERSION = "hwpx.visual-review.v1"
53
+ # Cap for inline-embedded preview PNGs (per page). Oversized pages keep their
54
+ # on-disk path but skip the base64 payload so a response can't balloon.
55
+ _DEFAULT_MAX_PREVIEW_IMAGE_BYTES = 6 * 1024 * 1024
56
+ # Cap for the inline viewer HTML copy. The viewer is always written to disk, so
57
+ # an oversized document degrades to "path only" instead of bloating the response.
58
+ _DEFAULT_MAX_VIEWER_HTML_BYTES = 2 * 1024 * 1024
59
+ _CSS_PX_PER_MM = 96 / 25.4
60
+ _CHROME_CANDIDATES = (
61
+ "chromium",
62
+ "chromium-browser",
63
+ "google-chrome",
64
+ "google-chrome-stable",
65
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
66
+ )
67
+
68
+
69
+ def _preview_slug(path: Path) -> str:
70
+ stem = re.sub(r"[^A-Za-z0-9_.-]+", "-", path.stem).strip("-")
71
+ return stem or "document"
72
+
73
+
74
+ def _chrome_executable() -> str | None:
75
+ env_path = env_value("CHROME_PATH")
76
+ if env_path and Path(env_path).exists():
77
+ return env_path
78
+ for candidate in _CHROME_CANDIDATES:
79
+ if candidate.startswith("/"):
80
+ if Path(candidate).exists():
81
+ return candidate
82
+ continue
83
+ resolved = shutil.which(candidate)
84
+ if resolved:
85
+ return resolved
86
+ return None
87
+
88
+
89
+ def _page_viewport(page: Dict[str, Any]) -> tuple[int, int]:
90
+ width = max(320, math.ceil(float(page.get("widthMm", 210.0)) * _CSS_PX_PER_MM))
91
+ height = max(320, math.ceil(float(page.get("heightMm", 297.0)) * _CSS_PX_PER_MM))
92
+ return width, height
93
+
94
+
95
+ def _write_json(path: Path, payload: Dict[str, Any]) -> None:
96
+ path.write_text(
97
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
98
+ )
99
+
100
+
101
+ # The core equation renderer stamps every equation span with a ``data-eq-mode``
102
+ # marker (``mathml`` on success, ``latex``/``script`` on the fail-closed
103
+ # fallbacks). Counting those markers gives an honest fidelity summary without a
104
+ # new core API.
105
+ _EQ_MODE_KEYS: dict[str, str] = {
106
+ "mathml": "mathml",
107
+ "latex": "latexFallback",
108
+ "script": "scriptFallback",
109
+ }
110
+
111
+
112
+ def _count_equation_modes(html: str) -> dict[str, int]:
113
+ """Summarize equation render fidelity by counting ``data-eq-mode`` markers."""
114
+
115
+ return {
116
+ key: html.count(f'data-eq-mode="{mode}"') for mode, key in _EQ_MODE_KEYS.items()
117
+ }
118
+
119
+
120
+ def _equation_tier(counts: Mapping[str, int]) -> str:
121
+ """Honest worst-case equation tier for the fidelity label."""
122
+
123
+ if sum(counts.values()) == 0:
124
+ return "none"
125
+ if counts.get("scriptFallback"):
126
+ return "script-fallback"
127
+ if counts.get("latexFallback"):
128
+ return "latex-fallback"
129
+ return "mathml"
130
+
131
+
132
+ def _lean_disk_manifest(manifest: Dict[str, Any]) -> Dict[str, Any]:
133
+ """Copy *manifest* for on-disk persistence without the inline payloads.
134
+
135
+ The manifest.json never carries the base64 page PNGs or the inline viewer
136
+ HTML — those live in the response (and, for the viewer, in ``viewer.html``).
137
+ """
138
+ disk_manifest = dict(manifest)
139
+ disk_manifest["screenshots"] = [
140
+ {key: value for key, value in shot.items() if key != "imageBase64"}
141
+ for shot in manifest.get("screenshots", [])
142
+ ]
143
+ viewer_block = manifest.get("viewer")
144
+ if isinstance(viewer_block, dict):
145
+ disk_manifest["viewer"] = {
146
+ key: value for key, value in viewer_block.items() if key != "html"
147
+ }
148
+ return disk_manifest
149
+
150
+
151
+ class PreviewExportService:
152
+ def __init__(self, context: DocumentContext) -> None:
153
+ self._context = context
154
+
155
+ def export_text(self, path: str) -> Dict[str, Any]:
156
+ """Export document content as plain text."""
157
+ document, _ = self._context._open_document(path)
158
+ return {"content": export_document(document, "text"), "format": "text"}
159
+
160
+ def export_html(self, path: str) -> Dict[str, Any]:
161
+ """Export document content as HTML."""
162
+ document, _ = self._context._open_document(path)
163
+ return {"content": export_document(document, "html"), "format": "html"}
164
+
165
+ def export_markdown(self, path: str) -> Dict[str, Any]:
166
+ """Export document content as Markdown."""
167
+ document, _ = self._context._open_document(path)
168
+ return {"content": export_document(document, "markdown"), "format": "markdown"}
169
+
170
+ def _preview_output_dir(self, source_path: Path, output_dir: Optional[str]) -> Path:
171
+ if output_dir:
172
+ resolved = self._context.storage.resolve_path(output_dir, must_exist=False)
173
+ else:
174
+ resolved = source_path.parent / f"{_preview_slug(source_path)}-preview"
175
+ resolved.mkdir(parents=True, exist_ok=True)
176
+ return resolved
177
+
178
+ def _embed_screenshot_image(
179
+ self,
180
+ item: dict[str, Any],
181
+ png_path: Path,
182
+ *,
183
+ embed_images: bool,
184
+ max_image_bytes: int | None,
185
+ ) -> None:
186
+ """Attach a base64 PNG payload to a screenshot item, bounded by a byte cap.
187
+
188
+ Keeps the on-disk artifact regardless; only the inline payload is gated so
189
+ an oversized page degrades to "path only" instead of bloating the response.
190
+ """
191
+ if not embed_images:
192
+ return
193
+ try:
194
+ raw = png_path.read_bytes()
195
+ except OSError as exc: # pragma: no cover - filesystem edge
196
+ item["imageOmitted"] = f"read_error: {exc}"
197
+ return
198
+ item["bytes"] = len(raw)
199
+ if max_image_bytes is not None and len(raw) > max_image_bytes:
200
+ item["imageOmitted"] = "exceeds_max_image_bytes"
201
+ return
202
+ item["imageBase64"] = base64.b64encode(raw).decode("ascii")
203
+ item["imageMime"] = "image/png"
204
+
205
+ def _capture_preview_pages(
206
+ self,
207
+ *,
208
+ page_html_paths: Sequence[Path],
209
+ pages: Sequence[Dict[str, Any]],
210
+ output_dir: Path,
211
+ max_pages: int | None,
212
+ embed_images: bool = False,
213
+ max_image_bytes: int | None = None,
214
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
215
+ capture_count = len(page_html_paths)
216
+ if max_pages is not None:
217
+ capture_count = min(capture_count, max(0, max_pages))
218
+
219
+ try:
220
+ from playwright.sync_api import sync_playwright # pyright: ignore[reportMissingImports] - optional dependency, guarded
221
+ except Exception as playwright_exc:
222
+ playwright_error = str(playwright_exc)
223
+ else: # pragma: no cover - local CI normally uses Chrome CLI fallback
224
+ screenshots: list[dict[str, Any]] = []
225
+ with sync_playwright() as playwright:
226
+ browser = playwright.chromium.launch()
227
+ try:
228
+ for index, html_path in enumerate(page_html_paths[:capture_count]):
229
+ page_info = pages[index]
230
+ width, height = _page_viewport(page_info)
231
+ output_path = output_dir / f"page-{index + 1:03d}.png"
232
+ browser_page = browser.new_page(
233
+ viewport={"width": width, "height": height},
234
+ device_scale_factor=1,
235
+ )
236
+ browser_page.goto(html_path.as_uri(), wait_until="load")
237
+ browser_page.screenshot(path=str(output_path), full_page=True)
238
+ browser_page.close()
239
+ item = {
240
+ "pageIndex": index,
241
+ "path": self._context._relative_path(output_path),
242
+ "backend": "playwright-chromium",
243
+ "widthPx": width,
244
+ "heightPx": height,
245
+ }
246
+ self._embed_screenshot_image(
247
+ item,
248
+ output_path,
249
+ embed_images=embed_images,
250
+ max_image_bytes=max_image_bytes,
251
+ )
252
+ screenshots.append(item)
253
+ finally:
254
+ browser.close()
255
+ return screenshots, {
256
+ "requested": True,
257
+ "available": True,
258
+ "backend": "playwright-chromium",
259
+ "message": f"Captured {len(screenshots)} preview screenshot(s).",
260
+ }
261
+
262
+ chrome = _chrome_executable()
263
+ if chrome is None:
264
+ return [], {
265
+ "requested": True,
266
+ "available": False,
267
+ "backend": None,
268
+ "message": (
269
+ "No screenshot backend available. Install playwright browsers "
270
+ "or set HWPX_AUTOMATION_CHROME_PATH (legacy HWPX_MCP_CHROME_PATH) "
271
+ "to a Chrome executable. "
272
+ f"Playwright import error: {playwright_error}"
273
+ ),
274
+ }
275
+
276
+ screenshots = []
277
+ failures: list[str] = []
278
+ for index, html_path in enumerate(page_html_paths[:capture_count]):
279
+ page_info = pages[index]
280
+ width, height = _page_viewport(page_info)
281
+ output_path = output_dir / f"page-{index + 1:03d}.png"
282
+ command = [
283
+ chrome,
284
+ "--headless=new",
285
+ "--disable-gpu",
286
+ "--hide-scrollbars",
287
+ "--no-first-run",
288
+ "--no-default-browser-check",
289
+ f"--window-size={width},{height}",
290
+ f"--screenshot={output_path}",
291
+ html_path.as_uri(),
292
+ ]
293
+ try:
294
+ completed = subprocess.run(
295
+ command,
296
+ check=False,
297
+ stdout=subprocess.PIPE,
298
+ stderr=subprocess.PIPE,
299
+ text=True,
300
+ timeout=60,
301
+ )
302
+ except Exception as exc: # pragma: no cover - environment specific
303
+ failures.append(f"page {index + 1}: {exc}")
304
+ continue
305
+ if completed.returncode != 0 or not output_path.exists():
306
+ detail = (
307
+ completed.stderr or completed.stdout or "unknown error"
308
+ ).strip()
309
+ failures.append(f"page {index + 1}: {detail}")
310
+ continue
311
+ item = {
312
+ "pageIndex": index,
313
+ "path": self._context._relative_path(output_path),
314
+ "backend": "chrome-headless-cli",
315
+ "widthPx": width,
316
+ "heightPx": height,
317
+ }
318
+ self._embed_screenshot_image(
319
+ item,
320
+ output_path,
321
+ embed_images=embed_images,
322
+ max_image_bytes=max_image_bytes,
323
+ )
324
+ screenshots.append(item)
325
+
326
+ message = f"Captured {len(screenshots)} preview screenshot(s) with Chrome CLI."
327
+ if failures:
328
+ message += " Failures: " + " | ".join(failures[:3])
329
+ return screenshots, {
330
+ "requested": True,
331
+ "available": bool(screenshots),
332
+ "backend": "chrome-headless-cli",
333
+ "executable": chrome,
334
+ "message": message,
335
+ }
336
+
337
+ def _build_viewer_block(
338
+ self,
339
+ source_path: Path,
340
+ mode: str,
341
+ output_path: Path,
342
+ *,
343
+ max_viewer_bytes: Optional[int] = None,
344
+ ) -> Dict[str, Any]:
345
+ """Render the self-contained scrollable document viewer for the manifest.
346
+
347
+ The viewer HTML is always written to ``viewer.html`` inside the resolved
348
+ (workspace-guarded) output directory; the inline ``html`` copy is dropped
349
+ when it exceeds *max_viewer_bytes* (default
350
+ ``_DEFAULT_MAX_VIEWER_HTML_BYTES``) so a response cannot balloon.
351
+ """
352
+ if render_hwpx_document_viewer is None:
353
+ raise self._context._new_error(
354
+ "RENDER_PREVIEW_VIEWER_UNAVAILABLE",
355
+ "Installed python-hwpx does not expose hwpx.tools.document_viewer.",
356
+ details={"importError": str(_DOCUMENT_VIEWER_IMPORT_ERROR)},
357
+ hint="Install python-hwpx[preview] and restart the MCP server.",
358
+ )
359
+ cap = (
360
+ max_viewer_bytes
361
+ if max_viewer_bytes is not None
362
+ else _DEFAULT_MAX_VIEWER_HTML_BYTES
363
+ )
364
+ viewer = render_hwpx_document_viewer(
365
+ source_path, title=source_path.name, mode=mode
366
+ )
367
+ html = viewer.html
368
+ byte_size = len(html.encode("utf-8"))
369
+ viewer_path = output_path / "viewer.html"
370
+ viewer_path.write_text(html, encoding="utf-8")
371
+
372
+ equation_rendering = _count_equation_modes(html)
373
+ library_available = bool(_latex2mathml_available and _latex2mathml_available())
374
+ block: Dict[str, Any] = {
375
+ "viewerPath": self._context._relative_path(viewer_path),
376
+ "byteSize": byte_size,
377
+ "pageCount": viewer.page_count,
378
+ "warnings": list(viewer.preview.warnings),
379
+ "fidelityTier": (
380
+ "text-approx-pagination; "
381
+ f"equations={_equation_tier(equation_rendering)}"
382
+ ),
383
+ "equationLibrary": "latex2mathml" if library_available else "absent",
384
+ "equationRendering": equation_rendering,
385
+ }
386
+ if byte_size > cap:
387
+ block["htmlOmitted"] = "exceeds_max_viewer_bytes"
388
+ else:
389
+ block["html"] = html
390
+ return block
391
+
392
+ def render_preview(
393
+ self,
394
+ path: str,
395
+ output_dir: Optional[str] = None,
396
+ mode: str = "pages",
397
+ screenshot: str = "auto",
398
+ max_pages: Optional[int] = None,
399
+ embed_images: bool = False,
400
+ max_image_bytes: Optional[int] = None,
401
+ viewer: bool = False,
402
+ max_viewer_bytes: Optional[int] = None,
403
+ ) -> Dict[str, Any]:
404
+ """Generate layout-aware HTML and optional PNG preview artifacts.
405
+
406
+ When *embed_images* is true, each captured screenshot item also carries
407
+ an ``imageBase64``/``imageMime`` payload (bounded by *max_image_bytes*,
408
+ defaulting to ``_DEFAULT_MAX_PREVIEW_IMAGE_BYTES``) so a caller can return
409
+ the page as an inline image content block.
410
+
411
+ When *viewer* is true, a self-contained scrollable document viewer
412
+ (equations rendered as native MathML when ``python-hwpx[preview]`` is
413
+ installed) is added to the manifest under ``viewer``. The viewer is
414
+ orthogonal to rasterization; pair it with ``screenshot="off"`` for the
415
+ lightweight text path.
416
+ """
417
+ if embed_images and max_image_bytes is None:
418
+ max_image_bytes = _DEFAULT_MAX_PREVIEW_IMAGE_BYTES
419
+ if render_hwpx_layout_preview is None:
420
+ raise self._context._new_error(
421
+ "RENDER_PREVIEW_UNAVAILABLE",
422
+ "Installed python-hwpx does not expose hwpx.tools.layout_preview.",
423
+ details={"importError": str(_LAYOUT_PREVIEW_IMPORT_ERROR)},
424
+ hint="Install the matching python-hwpx checkout/release and restart the MCP server.",
425
+ )
426
+ if mode not in {"pages", "long"}:
427
+ raise self._context._new_error(
428
+ "RENDER_PREVIEW_INVALID_MODE",
429
+ "mode must be 'pages' or 'long'",
430
+ details={"mode": mode},
431
+ )
432
+ if screenshot not in {"auto", "require", "off"}:
433
+ raise self._context._new_error(
434
+ "RENDER_PREVIEW_INVALID_SCREENSHOT_MODE",
435
+ "screenshot must be 'auto', 'require', or 'off'",
436
+ details={"screenshot": screenshot},
437
+ )
438
+
439
+ source_path = self._context._resolve_path(path)
440
+ output_path = self._preview_output_dir(source_path, output_dir)
441
+ preview = render_hwpx_layout_preview(
442
+ source_path, mode=mode, title=source_path.name
443
+ )
444
+ preview_dict = preview.as_dict()
445
+
446
+ html_path = output_path / "preview.html"
447
+ manifest_path = output_path / "manifest.json"
448
+ evidence_path = output_path / "visual-review.json"
449
+ html_path.write_text(preview.html, encoding="utf-8")
450
+
451
+ page_html_paths: list[Path] = []
452
+ page_documents = preview.page_html_documents(title=source_path.name)
453
+ for index, page_html in enumerate(page_documents):
454
+ page_html_path = output_path / f"page-{index + 1:03d}.html"
455
+ page_html_path.write_text(page_html, encoding="utf-8")
456
+ page_html_paths.append(page_html_path)
457
+
458
+ pages = []
459
+ for index, page in enumerate(preview_dict["pages"]):
460
+ item = dict(page)
461
+ if index < len(page_html_paths):
462
+ item["htmlPath"] = self._context._relative_path(page_html_paths[index])
463
+ pages.append(item)
464
+
465
+ if screenshot == "off":
466
+ screenshots: list[dict[str, Any]] = []
467
+ screenshot_engine = {
468
+ "requested": False,
469
+ "available": False,
470
+ "backend": None,
471
+ "message": "Screenshot generation was disabled by request.",
472
+ }
473
+ status = "html_only"
474
+ else:
475
+ screenshots, screenshot_engine = self._capture_preview_pages(
476
+ page_html_paths=page_html_paths,
477
+ pages=pages,
478
+ output_dir=output_path,
479
+ max_pages=max_pages,
480
+ embed_images=embed_images,
481
+ max_image_bytes=max_image_bytes,
482
+ )
483
+ requested_count = len(page_html_paths)
484
+ if max_pages is not None:
485
+ requested_count = min(requested_count, max(0, max_pages))
486
+ if screenshots and len(screenshots) == requested_count:
487
+ status = "ok"
488
+ elif screenshots:
489
+ status = "partial"
490
+ else:
491
+ status = "blocked"
492
+
493
+ screenshot_by_page = {item["pageIndex"]: item for item in screenshots}
494
+ for page in pages:
495
+ screenshot_item = screenshot_by_page.get(page["index"])
496
+ if screenshot_item is not None:
497
+ page["screenshotPath"] = screenshot_item["path"]
498
+
499
+ generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
500
+ suggestion = None
501
+ if status in {"blocked", "partial"}:
502
+ suggestion = (
503
+ "Open preview.html manually or install a headless browser backend "
504
+ "(Playwright browsers or Chrome via HWPX_AUTOMATION_CHROME_PATH; "
505
+ "legacy HWPX_MCP_CHROME_PATH is also accepted) and rerun render_preview."
506
+ )
507
+
508
+ visual_review = {
509
+ "schemaVersion": _VISUAL_REVIEW_SCHEMA_VERSION,
510
+ "sourcePath": self._context._relative_path(source_path),
511
+ "current": {
512
+ "status": "observed_pass" if status == "ok" else "blocked",
513
+ "method": screenshot_engine.get("backend") or "html-preview",
514
+ "screenshot_path": screenshots[0]["path"] if screenshots else None,
515
+ "notes": (
516
+ "Layout preview screenshots generated. Final Hancom viewer acceptance is still required."
517
+ if status == "ok"
518
+ else screenshot_engine["message"]
519
+ ),
520
+ },
521
+ "summary": {
522
+ "resolved_visual_review_required": "observed_pass"
523
+ if status == "ok"
524
+ else "blocked",
525
+ "layout_preview_status": status,
526
+ },
527
+ }
528
+ _write_json(evidence_path, visual_review)
529
+
530
+ manifest = {
531
+ "schemaVersion": _PREVIEW_SCHEMA_VERSION,
532
+ "status": status,
533
+ "generatedAt": generated_at,
534
+ "sourcePath": self._context._relative_path(source_path),
535
+ "outputDir": self._context._relative_path(output_path),
536
+ "htmlPath": self._context._relative_path(html_path),
537
+ "manifestPath": self._context._relative_path(manifest_path),
538
+ "visualReviewPath": self._context._relative_path(evidence_path),
539
+ "mode": mode,
540
+ "pageCount": len(pages),
541
+ "pages": pages,
542
+ "screenshots": screenshots,
543
+ "screenshotEngine": screenshot_engine,
544
+ "warnings": list(preview.warnings),
545
+ "suggestion": suggestion,
546
+ }
547
+ if viewer:
548
+ manifest["viewer"] = self._build_viewer_block(
549
+ source_path, mode, output_path, max_viewer_bytes=max_viewer_bytes
550
+ )
551
+ # Keep the on-disk manifest lean — never persist inline base64/HTML payloads.
552
+ _write_json(manifest_path, _lean_disk_manifest(manifest))
553
+ return manifest
554
+
555
+ def convert_hwp_to_hwpx(
556
+ self, source: str, output: Optional[str] = None
557
+ ) -> Dict[str, Any]:
558
+ resolved_source = self._context._resolve_path(source)
559
+ if resolved_source.suffix.lower() != ".hwp":
560
+ raise self._context._new_error(
561
+ "SOURCE_FILE_TYPE_INVALID", "source는 .hwp 파일이어야 합니다"
562
+ )
563
+
564
+ if output:
565
+ resolved_output = self._context._resolve_output_path(output)
566
+ else:
567
+ resolved_output = resolved_source.with_suffix(".hwpx")
568
+
569
+ try:
570
+ result = convert_hwp_to_hwpx(str(resolved_source), str(resolved_output))
571
+ except HwpConversionError as exc:
572
+ raise self._context._new_error(
573
+ "HWP_CONVERSION_FAILED", f"HWP 변환 실패: {exc}"
574
+ ) from exc
575
+
576
+ return {
577
+ "success": result.success,
578
+ "outputPath": result.output_path,
579
+ "paragraphsConverted": result.paragraphs_converted,
580
+ "tablesConverted": result.tables_converted,
581
+ "skippedElements": result.skipped_elements,
582
+ "warnings": result.warnings,
583
+ "verification": result.verification,
584
+ "openSafety": result.open_safety,
585
+ }