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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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
|
+
]
|