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