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,909 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The canonical automation VisualComplete render gate.
3
+
4
+ The oracle is the *renderer*, not the *transport*: any reachable Hancom (한글)
5
+ is a faithful backend (implementation plan §0.0). This module ships two, behind
6
+ one interface, plus a resolver that picks the best reachable one:
7
+
8
+ * :class:`WindowsComOracle` — Hancom COM via a packaged PowerShell backend.
9
+ Canonical for CI/scale: fast, deterministic, batchable.
10
+ * :class:`MacHancomOracle` — ``Hancom Office HWP.app`` driven through the GUI
11
+ (no AppleScript dictionary / headless CLI on that build), via a packaged
12
+ AppleScript that scripts the menus. Same render engine; ideal for
13
+ dev/spot-check, but slower and brittle (modal dialogs, single GUI session) —
14
+ renders are serialized.
15
+ * :class:`NullOracle` — ``available() == False``; the degrade sentinel when no
16
+ Hancom is reachable.
17
+
18
+ ``resolve_oracle()`` returns the first reachable backend (Windows → Mac → Null).
19
+ ``RenderOracle`` is a backward-compatible alias of :class:`WindowsComOracle`.
20
+
21
+ ``visual_check`` renders a before/after ``.hwpx`` pair through whichever backend
22
+ it is given, scores the result with :mod:`.diff`, and returns a
23
+ :class:`hwpx.quality.rendering.VisualReport`. It only needs ``available()`` and
24
+ ``render_many()``, so it is backend-agnostic.
25
+
26
+ Assurance is tiered and never blurred (implementation plan §0.0): off-oracle, or
27
+ without the imaging stack, ``visual_check`` degrades to ``render_checked=False``
28
+ with a warning — it never raises and never silently claims a visual pass.
29
+
30
+ CLI::
31
+
32
+ python -m hwpx_automation.office.rendering.oracle \
33
+ --before a.hwpx --after b.hwpx --out report/
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ import shutil
40
+ import subprocess
41
+ import sys
42
+ import tempfile
43
+ import time
44
+ from importlib import resources
45
+ from pathlib import Path
46
+
47
+ import hwpx_automation.office.rendering.detectors as detectors
48
+ import hwpx_automation.office.rendering.diff as diff
49
+ from hwpx.quality.rendering import EditMask, VisualReport
50
+ from .block_splits import Block, BlockSplit, detect_block_splits
51
+
52
+ _BACKEND_SCRIPT = "_render_hwpx.ps1"
53
+ _OPEN_RATE_SCRIPT = "_hancom_open_rate.ps1"
54
+ _MAC_BACKEND_SCRIPT = "_render_hwpx_mac.applescript"
55
+ _MAC_REFRESH_SCRIPT = "_refresh_hwpx_mac.applescript"
56
+ _COM_REGISTRY_KEYS = (
57
+ r"HWPFrame.HwpObject\CLSID",
58
+ r"SOFTWARE\Classes\HWPFrame.HwpObject\CLSID",
59
+ r"SOFTWARE\Classes\Wow6432Node\HWPFrame.HwpObject\CLSID",
60
+ )
61
+ _MAC_BUNDLE_ID = "com.hancom.office.hwp12.mac.general"
62
+ _MAC_APP_CANDIDATES = (
63
+ "/Applications/Hancom Office HWP.app",
64
+ "/Applications/Hancom Office HWP 2024.app",
65
+ "/Applications/Hancom Office HWP 2022.app",
66
+ )
67
+ _STRUCTURAL_ONLY_ENV = "HWPX_ORACLE_STRUCTURAL_ONLY"
68
+ _TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
69
+ # The reachability probe must answer well under the customer E2E budget; a
70
+ # TCC-blocked osascript otherwise hangs until the full render timeout.
71
+ _MAC_PROBE_TIMEOUT = 5.0
72
+ _MAC_PROBE_SCRIPT = 'tell application "System Events" to count processes'
73
+ # Process-lifetime probe verdict per osascript binary: the TCC grant cannot
74
+ # change for an already-running process, so one probe per process is honest.
75
+ _MAC_PROBE_CACHE: dict[str, bool] = {}
76
+
77
+
78
+ _BUDGET_ENV = "HWPX_ORACLE_BUDGET_SECONDS"
79
+
80
+
81
+ def structural_only() -> bool:
82
+ """True when ``HWPX_ORACLE_STRUCTURAL_ONLY`` requests no-oracle operation.
83
+
84
+ In this mode every verification degrades to the labelled structural path:
85
+ ``resolve_oracle`` returns :class:`NullOracle` and the Mac GUI backend
86
+ reports ``available() == False`` even when Hancom is installed.
87
+ """
88
+
89
+ return os.environ.get(_STRUCTURAL_ONLY_ENV, "").strip().lower() in _TRUTHY_ENV_VALUES
90
+
91
+
92
+ def env_budget_seconds() -> float | None:
93
+ """The externally-declared oracle budget, or ``None`` when unset/invalid.
94
+
95
+ ``HWPX_ORACLE_BUDGET_SECONDS`` is the single deadline a hosting process
96
+ (customer E2E, installed verify) declares once; ``resolve_oracle`` threads
97
+ it into every backend subprocess timeout. Non-numeric values are ignored;
98
+ zero or negative means the budget is already exhausted.
99
+ """
100
+
101
+ raw = os.environ.get(_BUDGET_ENV, "").strip()
102
+ if not raw:
103
+ return None
104
+ try:
105
+ value = float(raw)
106
+ except ValueError:
107
+ return None
108
+ return max(0.0, value)
109
+
110
+
111
+ def _deadline_from(budget_seconds: float | None) -> float | None:
112
+ """Turn an optional budget into an absolute monotonic deadline."""
113
+
114
+ if budget_seconds is None:
115
+ return None
116
+ return time.monotonic() + max(0.0, budget_seconds)
117
+
118
+
119
+ def _clamped_timeout(base: float, deadline: float | None) -> float | None:
120
+ """Clamp ``base`` to the remaining deadline budget.
121
+
122
+ ``None`` means the budget is already exhausted: the caller must degrade
123
+ without spawning the subprocess at all.
124
+ """
125
+
126
+ if deadline is None:
127
+ return base
128
+ remaining = deadline - time.monotonic()
129
+ if remaining <= 0:
130
+ return None
131
+ return min(base, remaining)
132
+
133
+
134
+ class RenderBackend:
135
+ """Common render-oracle interface.
136
+
137
+ ``visual_check`` only requires :meth:`available` and :meth:`render_many`.
138
+ The default :meth:`render_many` serializes :meth:`render_pdf` — correct for
139
+ the GUI backend (single session, one doc at a time); the COM backend
140
+ overrides it to reuse one Hancom session across the batch.
141
+ """
142
+
143
+ def available(self) -> bool: # pragma: no cover - overridden
144
+ return False
145
+
146
+ def render_pdf(self, hwpx_path: str, out_pdf: str | None = None) -> str | None:
147
+ raise NotImplementedError
148
+
149
+ def render_many(self, pairs: list[tuple[str, str]]) -> dict[str, str | None]:
150
+ result: dict[str, str | None] = {src: None for src, _ in pairs}
151
+ if not pairs or not self.available():
152
+ return result
153
+ for src, pdf in pairs:
154
+ try:
155
+ result[src] = self.render_pdf(src, pdf)
156
+ except Exception:
157
+ result[src] = None
158
+ return result
159
+
160
+ def check(
161
+ self,
162
+ before_hwpx: str | None,
163
+ after_hwpx: str,
164
+ *,
165
+ edit_mask: EditMask | None = None,
166
+ diff_eps: float = 0.005,
167
+ dpi: int = 150,
168
+ work_dir: str | None = None,
169
+ keep_artifacts: bool = False,
170
+ ) -> VisualReport:
171
+ """Adapt the legacy render transport to the neutral quality contract."""
172
+
173
+ return visual_check(
174
+ before_hwpx,
175
+ after_hwpx,
176
+ oracle=self,
177
+ edit_mask=edit_mask,
178
+ diff_eps=diff_eps,
179
+ dpi=dpi,
180
+ work_dir=work_dir,
181
+ keep_artifacts=keep_artifacts,
182
+ )
183
+
184
+
185
+ class WindowsComOracle(RenderBackend):
186
+ """Adapter that renders ``.hwpx`` → PDF through Hancom (한글) COM.
187
+
188
+ Isolated and swappable: off-Windows (or where Hancom is not registered)
189
+ :meth:`available` returns ``False`` and the engine degrades to structural
190
+ checks instead of crashing. A fake oracle with ``available() -> False`` is
191
+ the supported way to exercise the degrade path in tests.
192
+ """
193
+
194
+ def __init__(
195
+ self,
196
+ *,
197
+ powershell: str | None = None,
198
+ timeout: float = 300.0,
199
+ dpi: int = 150,
200
+ budget_seconds: float | None = None,
201
+ ) -> None:
202
+ self._powershell = powershell or "powershell"
203
+ self.timeout = timeout
204
+ self.dpi = dpi
205
+ # Single externally-propagated deadline: every subprocess timeout in
206
+ # one public call is clamped so the whole call fits this budget.
207
+ self.budget_seconds = budget_seconds
208
+
209
+ def available(self) -> bool:
210
+ """True only on Windows with the ``HWPFrame.HwpObject`` COM class registered."""
211
+
212
+ if sys.platform != "win32":
213
+ return False
214
+ try:
215
+ import winreg
216
+ except Exception:
217
+ return False
218
+ roots = (winreg.HKEY_CLASSES_ROOT, winreg.HKEY_LOCAL_MACHINE)
219
+ for root in roots:
220
+ for sub in _COM_REGISTRY_KEYS:
221
+ try:
222
+ with winreg.OpenKey(root, sub):
223
+ return True
224
+ except OSError:
225
+ continue
226
+ return False
227
+
228
+ def render_many(self, pairs: list[tuple[str, str]]) -> dict[str, str | None]:
229
+ """Render ``(src_hwpx, out_pdf)`` pairs in one Hancom session.
230
+
231
+ Returns ``{src: pdf_path or None}``. A single COM session is reused for
232
+ the whole batch (Hancom startup dominates), and dialogs are auto-dismissed.
233
+ """
234
+
235
+ result: dict[str, str | None] = {src: None for src, _ in pairs}
236
+ if not pairs or not self.available():
237
+ return result
238
+ deadline = _deadline_from(self.budget_seconds)
239
+ run_timeout = _clamped_timeout(self.timeout + 60.0 * len(pairs), deadline)
240
+ if run_timeout is None:
241
+ return result
242
+
243
+ tmp = tempfile.mkdtemp(prefix="hwpx-render-")
244
+ try:
245
+ jobs = [{"src": os.path.abspath(src), "pdf": os.path.abspath(pdf)} for src, pdf in pairs]
246
+ jobs_path = os.path.join(tmp, "jobs.json")
247
+ res_path = os.path.join(tmp, "result.json")
248
+ with open(jobs_path, "w", encoding="utf-8") as handle:
249
+ json.dump(jobs, handle, ensure_ascii=False)
250
+
251
+ with resources.as_file(
252
+ resources.files("hwpx_automation.office.rendering").joinpath(
253
+ _BACKEND_SCRIPT
254
+ )
255
+ ) as ps1:
256
+ cmd = [
257
+ self._powershell, "-NoProfile", "-NonInteractive",
258
+ "-ExecutionPolicy", "Bypass", "-File", str(ps1),
259
+ "-Jobs", jobs_path, "-ResultPath", res_path,
260
+ ]
261
+ try:
262
+ subprocess.run(
263
+ cmd, capture_output=True, timeout=run_timeout,
264
+ check=False,
265
+ )
266
+ except (subprocess.TimeoutExpired, OSError):
267
+ return result
268
+
269
+ if not os.path.exists(res_path):
270
+ return result
271
+ try:
272
+ # PowerShell Set-Content -Encoding UTF8 prepends a BOM; utf-8-sig
273
+ # strips it (and reads BOM-less output fine too).
274
+ with open(res_path, encoding="utf-8-sig") as handle:
275
+ entries = json.load(handle)
276
+ except (json.JSONDecodeError, ValueError, OSError):
277
+ # PowerShell/COM failure left no parseable result -> all unrendered.
278
+ return result
279
+ if isinstance(entries, dict): # single job -> ConvertTo-Json emits an object
280
+ entries = [entries]
281
+ if not isinstance(entries, list):
282
+ return result
283
+ for (src, _pdf), entry in zip(pairs, entries):
284
+ pdf = entry.get("pdf")
285
+ if entry.get("saved") and pdf and os.path.exists(pdf):
286
+ result[src] = pdf
287
+ return result
288
+ finally:
289
+ shutil.rmtree(tmp, ignore_errors=True)
290
+
291
+ def render_pdf(self, hwpx_path: str, out_pdf: str | None = None) -> str | None:
292
+ """Render a single ``.hwpx`` to PDF; returns the PDF path or ``None``."""
293
+
294
+ if out_pdf is None:
295
+ handle, out_pdf = tempfile.mkstemp(suffix=".pdf")
296
+ os.close(handle)
297
+ return self.render_many([(hwpx_path, out_pdf)]).get(hwpx_path)
298
+
299
+ def open_check_many(self, paths: list[str]) -> list[dict[str, object]]:
300
+ """OPEN-check ``paths`` through Hancom COM and return per-file verdicts.
301
+
302
+ This is the M9 open-rate primitive (specs/007-open-rate FR-001). It is
303
+ deliberately DISTINCT from :meth:`render_many`: it surfaces the real
304
+ Hancom ``opened`` boolean as its own signal — never conflated with the
305
+ ``saved``/render verdict that :meth:`render_many` (and ``visual_check``
306
+ at oracle.py:402) report. ``opened`` answers "did real Hancom load this
307
+ generated file without a corruption modal", which is the published
308
+ open-rate; ``saved`` answers a different question (did it render to PDF).
309
+
310
+ Each entry is::
311
+
312
+ {
313
+ "path": str, # the input path as requested
314
+ "opened": bool | None, # True/False from Hancom; None = unverified
315
+ "parsed": bool | None, # opened and GetPageText(1..) textLength>0
316
+ "text_length": int | None,
317
+ "error": str | None,
318
+ "retried": bool, # opened only on the single retry pass
319
+ "status": str, # "ok" | "open_failed" | "unverified"
320
+ }
321
+
322
+ Honest degrade (constitution V/VI): off-Windows or where Hancom is not
323
+ reachable, EVERY entry is returned with ``opened=None`` and
324
+ ``status="unverified"`` — NEVER ``False`` (that would slander a file we
325
+ never tested) and NEVER a silent ``True``. The aggregator maps
326
+ ``unverified`` to the unverified bucket, not the numerator or denominator
327
+ success count.
328
+ """
329
+
330
+ if not paths:
331
+ return []
332
+ if not self.available():
333
+ return [self._unverified_entry(p) for p in paths]
334
+ deadline = _deadline_from(self.budget_seconds)
335
+ run_timeout = _clamped_timeout(self.timeout + 60.0 * len(paths), deadline)
336
+ if run_timeout is None:
337
+ return [self._unverified_entry(p) for p in paths]
338
+
339
+ abs_paths = [os.path.abspath(p) for p in paths]
340
+ # path -> requested (original) string, for surfacing the caller's path.
341
+ requested = {os.path.abspath(p): p for p in paths}
342
+
343
+ tmp = tempfile.mkdtemp(prefix="hwpx-open-rate-")
344
+ try:
345
+ jsonl_path = os.path.join(tmp, "checkpoint.jsonl")
346
+ res_path = os.path.join(tmp, "result.json")
347
+ with resources.as_file(
348
+ resources.files("hwpx_automation.office.rendering").joinpath(
349
+ _OPEN_RATE_SCRIPT
350
+ )
351
+ ) as ps1:
352
+ cmd = [
353
+ self._powershell, "-NoProfile", "-NonInteractive",
354
+ "-ExecutionPolicy", "Bypass", "-File", str(ps1),
355
+ "-OutJsonl", jsonl_path, "-OutJson", res_path,
356
+ "-Path", *abs_paths,
357
+ ]
358
+ try:
359
+ subprocess.run(
360
+ cmd, capture_output=True,
361
+ timeout=run_timeout, check=False,
362
+ )
363
+ except (subprocess.TimeoutExpired, OSError):
364
+ # Subprocess never finished: prefer the crash-safe checkpoint
365
+ # (records written per-file) before degrading the rest.
366
+ return self._merge_checkpoint(abs_paths, requested, jsonl_path)
367
+
368
+ entries = self._read_open_result(res_path)
369
+ if entries is None:
370
+ # No parseable consolidated result: fall back to the checkpoint.
371
+ return self._merge_checkpoint(abs_paths, requested, jsonl_path)
372
+ return self._entries_from_records(abs_paths, requested, entries)
373
+ finally:
374
+ shutil.rmtree(tmp, ignore_errors=True)
375
+
376
+ @staticmethod
377
+ def _unverified_entry(path: str) -> dict[str, object]:
378
+ return {
379
+ "path": path,
380
+ "opened": None,
381
+ "parsed": None,
382
+ "text_length": None,
383
+ "error": "OPEN_ORACLE_UNAVAILABLE: no Hancom reachable on this platform",
384
+ "retried": False,
385
+ "status": "unverified",
386
+ }
387
+
388
+ @staticmethod
389
+ def _normalise_record(record: dict[str, object]) -> dict[str, object]:
390
+ """Map one PS1 ``{sourcePath,opened,textLength,error,retried}`` record to
391
+ the ``open_check_many`` entry shape (open/render distinction preserved)."""
392
+
393
+ opened_raw = record.get("opened")
394
+ opened = bool(opened_raw) if opened_raw is not None else None
395
+ text_length: int | None = None
396
+ text_length_raw = record.get("textLength")
397
+ if isinstance(text_length_raw, (bool, int, float, str)):
398
+ try:
399
+ text_length = int(text_length_raw)
400
+ except (TypeError, ValueError):
401
+ text_length = None
402
+ error = record.get("error")
403
+ parsed: bool | None
404
+ if opened is None:
405
+ parsed = None
406
+ else:
407
+ parsed = bool(opened and (text_length or 0) > 0)
408
+ status = "ok" if opened else ("unverified" if opened is None else "open_failed")
409
+ return {
410
+ "path": record.get("sourcePath"),
411
+ "opened": opened,
412
+ "parsed": parsed,
413
+ "text_length": text_length,
414
+ "error": error,
415
+ "retried": bool(record.get("retried", False)),
416
+ "status": status,
417
+ }
418
+
419
+ @staticmethod
420
+ def _read_open_result(res_path: str) -> list[dict[str, object]] | None:
421
+ if not os.path.exists(res_path):
422
+ return None
423
+ try:
424
+ # PowerShell Set-Content -Encoding UTF8 prepends a BOM; utf-8-sig
425
+ # strips it (and reads BOM-less output fine too).
426
+ with open(res_path, encoding="utf-8-sig") as handle:
427
+ data = json.load(handle)
428
+ except (json.JSONDecodeError, ValueError, OSError):
429
+ return None
430
+ if isinstance(data, dict): # single file -> ConvertTo-Json emits an object
431
+ data = [data]
432
+ if not isinstance(data, list):
433
+ return None
434
+ return data
435
+
436
+ def _entries_from_records(
437
+ self,
438
+ abs_paths: list[str],
439
+ requested: dict[str, str],
440
+ records: list[dict[str, object]],
441
+ ) -> list[dict[str, object]]:
442
+ """Align PS1 records back to the requested order, degrading any missing
443
+ file to ``unverified`` (never silently dropped)."""
444
+
445
+ by_path: dict[str, dict[str, object]] = {}
446
+ for record in records:
447
+ if not isinstance(record, dict):
448
+ continue
449
+ if record.get("_meta"): # repair-mode-probe meta line, not a verdict
450
+ continue
451
+ norm = self._normalise_record(record)
452
+ src = norm.get("path")
453
+ if isinstance(src, str):
454
+ by_path[os.path.abspath(src)] = norm
455
+ out: list[dict[str, object]] = []
456
+ for abs_path in abs_paths:
457
+ found = by_path.get(abs_path)
458
+ if found is None:
459
+ out.append(self._unverified_entry(requested.get(abs_path, abs_path)))
460
+ else:
461
+ # Surface the caller's original path string.
462
+ found["path"] = requested.get(abs_path, found.get("path"))
463
+ out.append(found)
464
+ return out
465
+
466
+ def _merge_checkpoint(
467
+ self,
468
+ abs_paths: list[str],
469
+ requested: dict[str, str],
470
+ jsonl_path: str,
471
+ ) -> list[dict[str, object]]:
472
+ """Recover verdicts from the per-file JSONL checkpoint after a crash or
473
+ timeout; files with no checkpoint record degrade to ``unverified``."""
474
+
475
+ records: list[dict[str, object]] = []
476
+ if os.path.exists(jsonl_path):
477
+ try:
478
+ with open(jsonl_path, encoding="utf-8-sig") as handle:
479
+ for line in handle:
480
+ line = line.strip()
481
+ if not line:
482
+ continue
483
+ try:
484
+ records.append(json.loads(line))
485
+ except (json.JSONDecodeError, ValueError):
486
+ continue
487
+ except OSError:
488
+ records = []
489
+ return self._entries_from_records(abs_paths, requested, records)
490
+
491
+
492
+ class MacHancomOracle(RenderBackend):
493
+ """Adapter that renders ``.hwpx`` → PDF through ``Hancom Office HWP.app``.
494
+
495
+ *Dev / spot-check grade.* The render is as faithful as COM (same Hancom
496
+ engine), but the transport is GUI automation: this build ships no AppleScript
497
+ dictionary and no headless convert CLI, so a packaged AppleScript
498
+ (``_render_hwpx_mac.applescript``) drives the menus through System Events:
499
+
500
+ open <input> → 파일 (File) > "PDF로 저장하기..." → NSSavePanel (Return = 저장)
501
+ → 파일 > "문서 닫기"
502
+
503
+ The save panel is *document-relative* (it pre-fills 위치 = the input's
504
+ directory and the name field = the input's stem), so :meth:`render_pdf`
505
+ stages the input as ``<out_dir>/<out_stem>.hwpx`` and the panel writes exactly
506
+ ``<out_dir>/<out_stem>.pdf`` with no path typing. The target is pre-deleted so
507
+ no overwrite sheet appears.
508
+
509
+ Operational notes: the GUI is a single shared session, so renders MUST be
510
+ serialized (the default serial :meth:`render_many` does this). Requires a
511
+ logged-in GUI session and Automation + Accessibility permission for the
512
+ process that runs ``osascript``. Windows COM stays canonical for CI/scale.
513
+ """
514
+
515
+ def __init__(
516
+ self,
517
+ *,
518
+ timeout: float = 300.0,
519
+ dpi: int = 150,
520
+ osascript: str = "osascript",
521
+ budget_seconds: float | None = None,
522
+ ) -> None:
523
+ self.timeout = timeout
524
+ self.dpi = dpi
525
+ self._osascript = osascript
526
+ # Single externally-propagated deadline: every subprocess timeout in
527
+ # one public call is clamped so the whole call fits this budget.
528
+ self.budget_seconds = budget_seconds
529
+
530
+ def _app_path(self) -> str | None:
531
+ for candidate in _MAC_APP_CANDIDATES:
532
+ if os.path.isdir(candidate):
533
+ return candidate
534
+ # Fall back to a Spotlight lookup by bundle id (handles non-default
535
+ # install locations / localized app names).
536
+ try:
537
+ proc = subprocess.run(
538
+ ["mdfind", f"kMDItemCFBundleIdentifier == '{_MAC_BUNDLE_ID}'"],
539
+ capture_output=True, text=True, timeout=10.0, check=False,
540
+ )
541
+ except (OSError, subprocess.TimeoutExpired):
542
+ return None
543
+ for line in proc.stdout.splitlines():
544
+ line = line.strip()
545
+ if line.endswith(".app") and os.path.isdir(line):
546
+ return line
547
+ return None
548
+
549
+ def _automation_reachable(self) -> bool:
550
+ """Fast preflight for GUI automation (System Events + Automation TCC).
551
+
552
+ An installed Hancom does NOT imply the GUI transport works: without a
553
+ logged-in GUI session or with Automation/Apple Events denied, osascript
554
+ blocks until its own timeout — far beyond any caller budget. This probe
555
+ answers within :data:`_MAC_PROBE_TIMEOUT` seconds and is cached for the
556
+ process lifetime (TCC grants cannot change for a running process).
557
+ """
558
+
559
+ cached = _MAC_PROBE_CACHE.get(self._osascript)
560
+ if cached is not None:
561
+ return cached
562
+ reachable = False
563
+ try:
564
+ proc = subprocess.run(
565
+ [self._osascript, "-e", _MAC_PROBE_SCRIPT],
566
+ capture_output=True, text=True,
567
+ timeout=_MAC_PROBE_TIMEOUT, check=False,
568
+ )
569
+ reachable = proc.returncode == 0
570
+ except (OSError, subprocess.TimeoutExpired):
571
+ reachable = False
572
+ _MAC_PROBE_CACHE[self._osascript] = reachable
573
+ return reachable
574
+
575
+ def available(self) -> bool:
576
+ """True only on macOS with Hancom installed AND GUI automation reachable.
577
+
578
+ ``HWPX_ORACLE_STRUCTURAL_ONLY`` forces ``False`` so no caller can enter
579
+ GUI automation in structural-only operation.
580
+ """
581
+
582
+ if structural_only():
583
+ return False
584
+ if sys.platform != "darwin":
585
+ return False
586
+ if self._app_path() is None:
587
+ return False
588
+ return self._automation_reachable()
589
+
590
+ def render_pdf(self, hwpx_path: str, out_pdf: str | None = None) -> str | None:
591
+ """Render a single ``.hwpx`` to PDF via the GUI; returns the path or ``None``."""
592
+
593
+ if not self.available():
594
+ return None
595
+ if out_pdf is None:
596
+ handle, out_pdf = tempfile.mkstemp(suffix=".pdf")
597
+ os.close(handle)
598
+
599
+ src = os.path.abspath(hwpx_path)
600
+ out_pdf = os.path.abspath(out_pdf)
601
+ out_dir = os.path.dirname(out_pdf)
602
+ out_stem = os.path.splitext(os.path.basename(out_pdf))[0]
603
+ os.makedirs(out_dir, exist_ok=True)
604
+
605
+ # Stage the input next to the target, named as the target stem, so the
606
+ # document-relative save panel needs no typing (see class docstring).
607
+ staged = os.path.join(out_dir, out_stem + ".hwpx")
608
+ staged_is_source = os.path.abspath(staged) == src
609
+
610
+ try: # pre-delete target -> the overwrite ("대치?") sheet never appears
611
+ if os.path.exists(out_pdf):
612
+ os.remove(out_pdf)
613
+ except OSError:
614
+ pass
615
+
616
+ deadline = _deadline_from(self.budget_seconds)
617
+ run_timeout = _clamped_timeout(self.timeout + 60.0, deadline)
618
+ if run_timeout is None:
619
+ return None
620
+ # The AppleScript receives its own internal wait limit; keep it inside
621
+ # the clamped subprocess timeout so the script never outlives the budget.
622
+ script_timeout = max(1, int(min(self.timeout, run_timeout)))
623
+
624
+ cleanup_staged = False
625
+ try:
626
+ if not staged_is_source:
627
+ shutil.copyfile(src, staged)
628
+ cleanup_staged = True
629
+ with resources.as_file(
630
+ resources.files("hwpx_automation.office.rendering").joinpath(
631
+ _MAC_BACKEND_SCRIPT
632
+ )
633
+ ) as script:
634
+ cmd = [
635
+ self._osascript, str(script), staged, out_pdf, str(script_timeout),
636
+ ]
637
+ try:
638
+ subprocess.run(
639
+ cmd, capture_output=True, text=True,
640
+ timeout=run_timeout, check=False,
641
+ )
642
+ except (subprocess.TimeoutExpired, OSError):
643
+ return None
644
+ if os.path.exists(out_pdf) and os.path.getsize(out_pdf) > 0:
645
+ return out_pdf
646
+ return None
647
+ finally:
648
+ if cleanup_staged:
649
+ try:
650
+ os.remove(staged)
651
+ except OSError:
652
+ pass
653
+
654
+ def refresh_document(self, hwpx_path: str) -> bool:
655
+ """Open ``hwpx_path``, let dirty fields regenerate, save in place, close.
656
+
657
+ The measured native-TOC re-number trigger (M7): a ``dirty="1"``
658
+ TABLEOFCONTENTS is rebuilt on open — Hancom itself computes entries and
659
+ page numbers — and CROSSREF caches recompute automatically. Exporting a
660
+ PDF from the same regenerating session crashes this Hancom build
661
+ (deterministic truncated PDFs, then the process dies), so refresh and
662
+ render are intentionally two separate sessions. Returns True when the
663
+ file was re-saved.
664
+ """
665
+ if not self.available():
666
+ return False
667
+ deadline = _deadline_from(self.budget_seconds)
668
+ run_timeout = _clamped_timeout(self.timeout + 60.0, deadline)
669
+ if run_timeout is None:
670
+ return False
671
+ script_timeout = max(1, int(min(self.timeout, run_timeout)))
672
+ src = os.path.abspath(hwpx_path)
673
+ try:
674
+ before = os.stat(src).st_mtime_ns
675
+ except OSError:
676
+ return False
677
+ with resources.as_file(
678
+ resources.files("hwpx_automation.office.rendering").joinpath(
679
+ _MAC_REFRESH_SCRIPT
680
+ )
681
+ ) as script:
682
+ cmd = [self._osascript, str(script), src, str(script_timeout)]
683
+ try:
684
+ proc = subprocess.run(
685
+ cmd, capture_output=True, text=True,
686
+ timeout=run_timeout, check=False,
687
+ )
688
+ except (subprocess.TimeoutExpired, OSError):
689
+ return False
690
+ if "OK" not in (proc.stdout or ""):
691
+ return False
692
+ try:
693
+ return os.stat(src).st_mtime_ns != before
694
+ except OSError:
695
+ return False
696
+
697
+
698
+ class NullOracle(RenderBackend):
699
+ """Sentinel backend for environments with no reachable Hancom.
700
+
701
+ ``available()`` is always ``False`` so ``visual_check`` degrades to a
702
+ labelled structural pass rather than crashing.
703
+ """
704
+
705
+ def available(self) -> bool:
706
+ return False
707
+
708
+ def render_pdf(self, hwpx_path: str, out_pdf: str | None = None) -> str | None:
709
+ return None
710
+
711
+
712
+ def resolve_oracle(
713
+ *,
714
+ powershell: str | None = None,
715
+ timeout: float = 300.0,
716
+ dpi: int = 150,
717
+ osascript: str = "osascript",
718
+ budget_seconds: float | None = None,
719
+ ) -> RenderBackend:
720
+ """Return the best reachable render backend (Windows COM → Mac GUI → Null).
721
+
722
+ Windows COM is canonical (CI/scale); Mac GUI is the dev/spot-check fallback;
723
+ :class:`NullOracle` is the degrade sentinel when no Hancom is reachable.
724
+ ``HWPX_ORACLE_STRUCTURAL_ONLY`` short-circuits to :class:`NullOracle`, and
725
+ ``budget_seconds`` propagates one external deadline into every backend
726
+ subprocess timeout.
727
+ """
728
+
729
+ if structural_only():
730
+ return NullOracle()
731
+ if budget_seconds is None:
732
+ budget_seconds = env_budget_seconds()
733
+ windows = WindowsComOracle(
734
+ powershell=powershell, timeout=timeout, dpi=dpi, budget_seconds=budget_seconds,
735
+ )
736
+ if windows.available():
737
+ return windows
738
+ mac = MacHancomOracle(
739
+ timeout=timeout, dpi=dpi, osascript=osascript, budget_seconds=budget_seconds,
740
+ )
741
+ if mac.available():
742
+ return mac
743
+ return NullOracle()
744
+
745
+
746
+ # Backward-compatible alias: ``RenderOracle`` was the Windows COM oracle.
747
+ RenderOracle = WindowsComOracle
748
+
749
+
750
+ def _degraded(message: str) -> VisualReport:
751
+ return VisualReport(ok=True, render_checked=False, warnings=[message])
752
+
753
+
754
+ def visual_check(
755
+ before_hwpx: str | None,
756
+ after_hwpx: str,
757
+ *,
758
+ oracle: RenderBackend,
759
+ edit_mask: EditMask | None = None,
760
+ diff_eps: float = 0.005,
761
+ dpi: int = 150,
762
+ work_dir: str | None = None,
763
+ keep_artifacts: bool = False,
764
+ ) -> VisualReport:
765
+ """Render ``before``/``after`` through ``oracle`` and judge the change.
766
+
767
+ ``before_hwpx=None`` requests a single new-doc structural-visual pass.
768
+ Off-oracle or without imaging deps the report degrades
769
+ (``render_checked=False``, ``ok=True``, warning) and never raises.
770
+ """
771
+
772
+ if oracle is None or not oracle.available():
773
+ return _degraded(
774
+ "RENDER_ORACLE_UNAVAILABLE: no Hancom reachable on this platform; "
775
+ "structural degrade -- visual_complete is unverified, not confirmed."
776
+ )
777
+ if not (detectors.imaging_available() and diff.pymupdf_available()):
778
+ return _degraded(
779
+ "visual scoring dependencies (pymupdf/Pillow/numpy) unavailable; "
780
+ "structural degrade -- visual_complete is unverified, not confirmed."
781
+ )
782
+
783
+ retain = keep_artifacts or work_dir is not None
784
+ created_tmp = work_dir is None
785
+ work = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="hwpx-visual-"))
786
+ work.mkdir(parents=True, exist_ok=True)
787
+ warnings: list[str] = []
788
+ errors: list[str] = []
789
+
790
+ try:
791
+ after_abs = os.path.abspath(after_hwpx)
792
+ pairs: list[tuple[str, str]] = [(after_abs, str(work / "after.pdf"))]
793
+ before_abs: str | None = None
794
+ if before_hwpx is not None:
795
+ before_abs = os.path.abspath(before_hwpx)
796
+ pairs.append((before_abs, str(work / "before.pdf")))
797
+
798
+ rendered = oracle.render_many(pairs)
799
+ after_render = rendered.get(after_abs)
800
+ if after_render is None:
801
+ errors.append("RENDER_ORACLE_UNAVAILABLE: Hancom failed to render the output document.")
802
+ return VisualReport(ok=False, render_checked=False, warnings=warnings, errors=errors)
803
+
804
+ if before_abs is not None:
805
+ before_render = rendered.get(before_abs)
806
+ if before_render is None:
807
+ errors.append("RENDER_ORACLE_UNAVAILABLE: Hancom failed to render the original document.")
808
+ return VisualReport(
809
+ ok=False, render_checked=False, output_render=after_render,
810
+ warnings=warnings, errors=errors,
811
+ )
812
+ signals = diff.compare_renders(
813
+ before_render, after_render, edit_mask=edit_mask, diff_eps=diff_eps,
814
+ dpi=dpi, diff_image_path=str(work / "diff.png"),
815
+ )
816
+ original_render: str | None = before_render
817
+ else:
818
+ signals = diff.analyze_single(after_render, dpi=dpi)
819
+ original_render = None
820
+ warnings.append(
821
+ "no before document: single-render structural-visual only "
822
+ "(overlap baseline unavailable)."
823
+ )
824
+
825
+ problem = (
826
+ bool(signals.get("unexpected_diff_outside_mask"))
827
+ or bool(signals.get("overlap_detected"))
828
+ or bool(signals.get("overflow_detected"))
829
+ or bool(signals.get("page_count_changed"))
830
+ )
831
+ report = VisualReport(
832
+ ok=not problem,
833
+ render_checked=True,
834
+ original_render=original_render,
835
+ output_render=after_render,
836
+ diff_image=signals.get("diff_image"),
837
+ unexpected_diff_outside_mask=bool(signals.get("unexpected_diff_outside_mask")),
838
+ overlap_detected=bool(signals.get("overlap_detected")),
839
+ overflow_detected=bool(signals.get("overflow_detected")),
840
+ table_break_detected=False,
841
+ page_count_changed=signals.get("page_count_changed"),
842
+ warnings=warnings,
843
+ errors=errors,
844
+ max_diff_ratio=signals.get("max_diff_ratio"),
845
+ before_page_count=signals.get("before_page_count"),
846
+ after_page_count=signals.get("after_page_count"),
847
+ )
848
+ if not retain:
849
+ # Verdict-only: artifacts are not kept, so don't return dangling paths.
850
+ report.original_render = None
851
+ report.output_render = None
852
+ report.diff_image = None
853
+ return report
854
+ finally:
855
+ if created_tmp and not retain:
856
+ shutil.rmtree(work, ignore_errors=True)
857
+
858
+
859
+ def _main(argv: list[str] | None = None) -> int:
860
+ import argparse
861
+
862
+ parser = argparse.ArgumentParser(
863
+ prog="python -m hwpx_automation.office.rendering.oracle",
864
+ description="Render before/after .hwpx through Hancom and emit a VisualReport.",
865
+ )
866
+ parser.add_argument("--before", default=None, help="original .hwpx (omit for new-doc check)")
867
+ parser.add_argument("--after", required=True, help="output .hwpx to verify")
868
+ parser.add_argument("--out", default=None, help="dir for report.json + render artifacts")
869
+ parser.add_argument("--diff-eps", type=float, default=0.005)
870
+ parser.add_argument("--dpi", type=int, default=150)
871
+ args = parser.parse_args(argv)
872
+
873
+ try: # keep Korean paths/warnings printable on cp949 Windows consoles
874
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
875
+ except Exception:
876
+ pass
877
+
878
+ oracle = resolve_oracle(dpi=args.dpi)
879
+ work = None
880
+ if args.out:
881
+ os.makedirs(args.out, exist_ok=True)
882
+ work = args.out
883
+ report = visual_check(
884
+ args.before, args.after, oracle=oracle, diff_eps=args.diff_eps,
885
+ dpi=args.dpi, work_dir=work, keep_artifacts=bool(args.out),
886
+ )
887
+ text = json.dumps(report.to_dict(), ensure_ascii=False, indent=2)
888
+ if args.out:
889
+ Path(args.out, "report.json").write_text(text, encoding="utf-8")
890
+ print(text)
891
+ return 0 if report.ok else 1
892
+
893
+
894
+ if __name__ == "__main__":
895
+ raise SystemExit(_main())
896
+
897
+
898
+ __all__ = [
899
+ "RenderBackend",
900
+ "WindowsComOracle",
901
+ "MacHancomOracle",
902
+ "NullOracle",
903
+ "RenderOracle",
904
+ "resolve_oracle",
905
+ "visual_check",
906
+ "Block",
907
+ "BlockSplit",
908
+ "detect_block_splits",
909
+ ]