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,249 @@
|
|
|
1
|
+
-- SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
-- Mac Hancom (한컴오피스 한글) render backend for the VisualComplete oracle.
|
|
3
|
+
--
|
|
4
|
+
-- This build of Hancom Office HWP (com.hancom.office.hwp12.mac.general) ships
|
|
5
|
+
-- NO AppleScript dictionary (sdef) and NO headless convert CLI, so PDF export is
|
|
6
|
+
-- GUI-only. This script drives that GUI deterministically through System Events
|
|
7
|
+
-- (UI scripting), the Mac analogue of the Windows COM backend (_render_hwpx.ps1).
|
|
8
|
+
--
|
|
9
|
+
-- Flow, anchored on STABLE MENU-ITEM NAMES (prefix match), not pixels:
|
|
10
|
+
-- open <input> -> 파일 (File) > "PDF로 저장하기..." -> NSSavePanel
|
|
11
|
+
-- -> Return (= the default 저장 button) -> 파일 > "문서 닫기"
|
|
12
|
+
--
|
|
13
|
+
-- Why no typing is needed: the save panel is document-relative — it pre-fills
|
|
14
|
+
-- 위치 (location) = the input file's directory and the name field = the input
|
|
15
|
+
-- stem. The Python caller therefore STAGES the input as <out_dir>/<out_stem>.hwpx,
|
|
16
|
+
-- so pressing 저장 writes exactly <out_dir>/<out_stem>.pdf. The caller also
|
|
17
|
+
-- pre-deletes the target, so the "대치(replace)?" sheet never appears in normal
|
|
18
|
+
-- operation; it is still dismissed defensively here. A "문서 닫기" save-changes
|
|
19
|
+
-- prompt is likewise discarded (we only exported; the doc is unmodified).
|
|
20
|
+
--
|
|
21
|
+
-- Usage: osascript _render_hwpx_mac.applescript <input.hwpx> <out.pdf> [timeoutSecs]
|
|
22
|
+
-- Output: prints "OK" on success; prints "ERR: <reason>" and exits 1 otherwise.
|
|
23
|
+
|
|
24
|
+
property procName : "Hancom Office HWP"
|
|
25
|
+
property appName : "Hancom Office HWP"
|
|
26
|
+
property pdfDialogTitle : "PDF로 저장하기"
|
|
27
|
+
property fileMenuName : "파일"
|
|
28
|
+
property savePdfPrefix : "PDF로 저장하기"
|
|
29
|
+
property closeDocPrefix : "문서 닫기"
|
|
30
|
+
|
|
31
|
+
on run argv
|
|
32
|
+
if (count of argv) < 2 then return "ERR: usage: <input.hwpx> <out.pdf> [timeoutSecs]"
|
|
33
|
+
set inputPath to item 1 of argv
|
|
34
|
+
set outPdf to item 2 of argv
|
|
35
|
+
set timeoutSecs to 90
|
|
36
|
+
if (count of argv) ≥ 3 then
|
|
37
|
+
try
|
|
38
|
+
set timeoutSecs to (item 3 of argv) as integer
|
|
39
|
+
end try
|
|
40
|
+
end if
|
|
41
|
+
|
|
42
|
+
set inputBase to do shell script "basename " & quoted form of inputPath
|
|
43
|
+
|
|
44
|
+
try
|
|
45
|
+
-- 1) Open the staged input. LaunchServices focuses Hancom (launching it
|
|
46
|
+
-- if needed) and opens the document.
|
|
47
|
+
do shell script "open -a " & quoted form of appName & " " & quoted form of inputPath
|
|
48
|
+
|
|
49
|
+
-- 2) Wait for the document window to exist.
|
|
50
|
+
if not (waitForWindowNamed(inputBase, timeoutSecs)) then
|
|
51
|
+
return "ERR: document window did not open: " & inputBase
|
|
52
|
+
end if
|
|
53
|
+
-- A prior timed-out render can leave a same-named window behind, and
|
|
54
|
+
-- LaunchServices does not guarantee that the newly opened document is
|
|
55
|
+
-- frontmost. Raise the exact staged document before using its File menu.
|
|
56
|
+
raiseWindowNamed(inputBase)
|
|
57
|
+
delay 0.3
|
|
58
|
+
|
|
59
|
+
-- 3) 파일 > "PDF로 저장하기..." (open the export save panel)
|
|
60
|
+
clickFileMenuItemByPrefix(savePdfPrefix)
|
|
61
|
+
|
|
62
|
+
-- 4) Wait for the export dialog.
|
|
63
|
+
if not (waitForWindowNamed(pdfDialogTitle, 30)) then
|
|
64
|
+
return "ERR: PDF save dialog did not appear"
|
|
65
|
+
end if
|
|
66
|
+
delay 1.0
|
|
67
|
+
|
|
68
|
+
-- 5) Press Return = the default 저장 button (no typing: panel is
|
|
69
|
+
-- pre-filled with 위치=out_dir, name=out_stem from the staged input).
|
|
70
|
+
tell application "System Events" to tell process procName
|
|
71
|
+
set frontmost to true
|
|
72
|
+
delay 0.3
|
|
73
|
+
key code 36 -- Return
|
|
74
|
+
end tell
|
|
75
|
+
-- Some Hancom builds expose the dialog before its default button is
|
|
76
|
+
-- ready. Retry Return once only while the same dialog is still present.
|
|
77
|
+
delay 1.0
|
|
78
|
+
if listContains(windowNames(), pdfDialogTitle) then
|
|
79
|
+
tell application "System Events" to tell process procName
|
|
80
|
+
set frontmost to true
|
|
81
|
+
key code 36
|
|
82
|
+
end tell
|
|
83
|
+
end if
|
|
84
|
+
|
|
85
|
+
-- 6) Defensive: a "대치(replace)?" sheet only appears if the target still
|
|
86
|
+
-- exists. Dismiss it (대치) if present, then wait for the file.
|
|
87
|
+
dismissOverwriteSheetIfPresent()
|
|
88
|
+
|
|
89
|
+
-- 7) The render is asynchronous — wait until the PDF lands at out_pdf and
|
|
90
|
+
-- the dialog has closed.
|
|
91
|
+
set wrote to waitForFile(outPdf, timeoutSecs)
|
|
92
|
+
waitForWindowGone(pdfDialogTitle, 15)
|
|
93
|
+
|
|
94
|
+
-- 8) Always close the document so the next render starts from a clean
|
|
95
|
+
-- session, even if the wait above was noisy. Discard any save-changes
|
|
96
|
+
-- prompt (we only exported; the doc content is unmodified).
|
|
97
|
+
try
|
|
98
|
+
clickFileMenuItemByPrefix(closeDocPrefix)
|
|
99
|
+
dismissCloseSheetIfPresent()
|
|
100
|
+
end try
|
|
101
|
+
|
|
102
|
+
if not wrote then return "ERR: PDF not written to " & outPdf
|
|
103
|
+
return "OK"
|
|
104
|
+
on error errMsg number errNum
|
|
105
|
+
-- Best-effort cleanup so a mid-flow error doesn't leak an open document.
|
|
106
|
+
try
|
|
107
|
+
clickFileMenuItemByPrefix(closeDocPrefix)
|
|
108
|
+
dismissCloseSheetIfPresent()
|
|
109
|
+
end try
|
|
110
|
+
return "ERR: " & errMsg & " (" & errNum & ")"
|
|
111
|
+
end try
|
|
112
|
+
end run
|
|
113
|
+
|
|
114
|
+
-- Find the 파일 menu by name, then click the first menu item whose name starts
|
|
115
|
+
-- with `prefix`. Name-based so it survives index drift across Hancom versions.
|
|
116
|
+
on clickFileMenuItemByPrefix(prefix)
|
|
117
|
+
tell application "System Events" to tell process procName
|
|
118
|
+
set frontmost to true
|
|
119
|
+
delay 0.2
|
|
120
|
+
set fileMenu to missing value
|
|
121
|
+
repeat with mbi in menu bar items of menu bar 1
|
|
122
|
+
if (name of mbi as string) is fileMenuName then
|
|
123
|
+
set fileMenu to menu 1 of mbi
|
|
124
|
+
exit repeat
|
|
125
|
+
end if
|
|
126
|
+
end repeat
|
|
127
|
+
if fileMenu is missing value then error "menu '" & fileMenuName & "' not found"
|
|
128
|
+
repeat with mi in menu items of fileMenu
|
|
129
|
+
try
|
|
130
|
+
if (name of mi as string) starts with prefix then
|
|
131
|
+
click mi
|
|
132
|
+
return
|
|
133
|
+
end if
|
|
134
|
+
end try
|
|
135
|
+
end repeat
|
|
136
|
+
error "menu item not found: " & prefix
|
|
137
|
+
end tell
|
|
138
|
+
end clickFileMenuItemByPrefix
|
|
139
|
+
|
|
140
|
+
on raiseWindowNamed(winName)
|
|
141
|
+
tell application "System Events" to tell process procName
|
|
142
|
+
set frontmost to true
|
|
143
|
+
try
|
|
144
|
+
perform action "AXRaise" of (first window whose name is winName)
|
|
145
|
+
end try
|
|
146
|
+
end tell
|
|
147
|
+
end raiseWindowNamed
|
|
148
|
+
|
|
149
|
+
-- Window polling uses the ATOMIC ``name of windows`` string list (never a held
|
|
150
|
+
-- ``repeat with w in windows`` element reference): the save dialog appears and
|
|
151
|
+
-- disappears mid-flow, so a lazily-resolved ``item N of every window`` can become
|
|
152
|
+
-- an invalid index (-1719). Snapshotting names and guarding with ``try`` is
|
|
153
|
+
-- race-safe.
|
|
154
|
+
on windowNames()
|
|
155
|
+
try
|
|
156
|
+
tell application "System Events" to tell process procName
|
|
157
|
+
return (name of windows) as list
|
|
158
|
+
end tell
|
|
159
|
+
on error
|
|
160
|
+
return {}
|
|
161
|
+
end try
|
|
162
|
+
end windowNames
|
|
163
|
+
|
|
164
|
+
on listContains(theList, theValue)
|
|
165
|
+
repeat with x in theList
|
|
166
|
+
if (x as string) is theValue then return true
|
|
167
|
+
end repeat
|
|
168
|
+
return false
|
|
169
|
+
end listContains
|
|
170
|
+
|
|
171
|
+
on waitForWindowNamed(winName, secs)
|
|
172
|
+
repeat (secs * 2) times
|
|
173
|
+
if listContains(windowNames(), winName) then return true
|
|
174
|
+
delay 0.5
|
|
175
|
+
end repeat
|
|
176
|
+
return false
|
|
177
|
+
end waitForWindowNamed
|
|
178
|
+
|
|
179
|
+
on waitForWindowGone(winName, secs)
|
|
180
|
+
repeat (secs * 2) times
|
|
181
|
+
if not (listContains(windowNames(), winName)) then return true
|
|
182
|
+
delay 0.5
|
|
183
|
+
end repeat
|
|
184
|
+
return false
|
|
185
|
+
end waitForWindowGone
|
|
186
|
+
|
|
187
|
+
on waitForFile(p, secs)
|
|
188
|
+
-- size>0 alone is NOT completion: Hancom streams the PDF asynchronously and
|
|
189
|
+
-- closing the document mid-write truncates it (measured: a TOC-regenerating
|
|
190
|
+
-- document produced a deterministic %%EOF-less torso). Require the PDF
|
|
191
|
+
-- trailer marker so the export has actually finished before we move on.
|
|
192
|
+
repeat (secs * 2) times
|
|
193
|
+
if (do shell script "test -s " & quoted form of p & " && tail -c 64 " & quoted form of p & " | grep -q '%%EOF' && echo 1 || echo 0") is "1" then
|
|
194
|
+
return true
|
|
195
|
+
end if
|
|
196
|
+
delay 0.5
|
|
197
|
+
end repeat
|
|
198
|
+
return false
|
|
199
|
+
end waitForFile
|
|
200
|
+
|
|
201
|
+
-- The overwrite confirmation is a SHEET of the PDF dialog window; its buttons are
|
|
202
|
+
-- directly accessible (unlike the save panel's own nested buttons). Re-fetch the
|
|
203
|
+
-- window by name each pass and guard, since it is being torn down concurrently.
|
|
204
|
+
on dismissOverwriteSheetIfPresent()
|
|
205
|
+
repeat 6 times
|
|
206
|
+
try
|
|
207
|
+
tell application "System Events" to tell process procName
|
|
208
|
+
set dlg to (first window whose name is pdfDialogTitle)
|
|
209
|
+
if (count of sheets of dlg) > 0 then
|
|
210
|
+
tell sheet 1 of dlg
|
|
211
|
+
if (exists button "대치") then
|
|
212
|
+
click button "대치"
|
|
213
|
+
return true
|
|
214
|
+
end if
|
|
215
|
+
end tell
|
|
216
|
+
end if
|
|
217
|
+
end tell
|
|
218
|
+
end try
|
|
219
|
+
delay 0.3
|
|
220
|
+
end repeat
|
|
221
|
+
return false
|
|
222
|
+
end dismissOverwriteSheetIfPresent
|
|
223
|
+
|
|
224
|
+
-- If closing surfaces a save-changes sheet, discard (저장 안 함 / 안 함). We only
|
|
225
|
+
-- exported a PDF, so the document content is unchanged.
|
|
226
|
+
on dismissCloseSheetIfPresent()
|
|
227
|
+
repeat 6 times
|
|
228
|
+
set handled to false
|
|
229
|
+
try
|
|
230
|
+
tell application "System Events" to tell process procName
|
|
231
|
+
repeat with wn in windowNames()
|
|
232
|
+
set w to (first window whose name is (wn as string))
|
|
233
|
+
if (count of sheets of w) > 0 then
|
|
234
|
+
repeat with b in buttons of (sheet 1 of w)
|
|
235
|
+
set bn to (name of b as string)
|
|
236
|
+
if bn contains "안 함" or bn contains "안함" then
|
|
237
|
+
click b
|
|
238
|
+
set handled to true
|
|
239
|
+
end if
|
|
240
|
+
end repeat
|
|
241
|
+
end if
|
|
242
|
+
end repeat
|
|
243
|
+
end tell
|
|
244
|
+
end try
|
|
245
|
+
if handled then return true
|
|
246
|
+
delay 0.3
|
|
247
|
+
end repeat
|
|
248
|
+
return false
|
|
249
|
+
end dismissCloseSheetIfPresent
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Renderer-neutral block split geometry.
|
|
3
|
+
|
|
4
|
+
This module deliberately knows nothing about Hancom discovery, subprocesses,
|
|
5
|
+
GUI automation, MCP, or form-fill policy. Callers provide glyph-like objects
|
|
6
|
+
with ``page``, ``x0`` and ``x1`` attributes and explicit column boundaries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PositionedGlyph(Protocol):
|
|
16
|
+
"""Minimum geometry consumed by :func:`detect_block_splits`."""
|
|
17
|
+
|
|
18
|
+
page: int
|
|
19
|
+
x0: float
|
|
20
|
+
x1: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Block:
|
|
25
|
+
"""One logical render block and its positioned glyphs."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
glyphs: list
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class BlockSplit:
|
|
33
|
+
"""A block that straddles a column or page boundary."""
|
|
34
|
+
|
|
35
|
+
block_id: str
|
|
36
|
+
kind: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _column_index(x_center: float, column_x_bounds: list[tuple[float, float]]) -> int:
|
|
40
|
+
for index, (x0, x1) in enumerate(column_x_bounds):
|
|
41
|
+
if x0 <= x_center <= x1:
|
|
42
|
+
return index
|
|
43
|
+
return -1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_block_splits(
|
|
47
|
+
blocks: list,
|
|
48
|
+
column_x_bounds: list,
|
|
49
|
+
page_height: float,
|
|
50
|
+
) -> list:
|
|
51
|
+
"""Return blocks whose glyphs span more than one page or column.
|
|
52
|
+
|
|
53
|
+
``page_height`` remains accepted for compatibility with the established
|
|
54
|
+
contract. Page membership comes from each glyph's explicit ``page`` field.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
del page_height
|
|
58
|
+
splits: list[BlockSplit] = []
|
|
59
|
+
for block in blocks:
|
|
60
|
+
if not block.glyphs:
|
|
61
|
+
continue
|
|
62
|
+
pages = {glyph.page for glyph in block.glyphs}
|
|
63
|
+
if len(pages) > 1:
|
|
64
|
+
splits.append(BlockSplit(block_id=block.id, kind="page"))
|
|
65
|
+
continue
|
|
66
|
+
columns = {
|
|
67
|
+
_column_index((glyph.x0 + glyph.x1) / 2.0, column_x_bounds)
|
|
68
|
+
for glyph in block.glyphs
|
|
69
|
+
}
|
|
70
|
+
columns.discard(-1)
|
|
71
|
+
if len(columns) > 1:
|
|
72
|
+
splits.append(BlockSplit(block_id=block.id, kind="column"))
|
|
73
|
+
return splits
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["Block", "BlockSplit", "PositionedGlyph", "detect_block_splits"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Pixel-level visual detectors (ported from the baseline measurement harness).
|
|
3
|
+
|
|
4
|
+
``diff_ratio`` and ``overlap_score`` are byte-for-byte the same heuristics the
|
|
5
|
+
one-off ``scripts/visualcomplete-baseline/overlap_detect.py`` used, so the gate
|
|
6
|
+
reproduces the harness verdicts on the measured corpus. Added here:
|
|
7
|
+
|
|
8
|
+
* mask-aware variants (diff / new-ink restricted to outside the edit mask),
|
|
9
|
+
* a diff-image generator for report artifacts.
|
|
10
|
+
|
|
11
|
+
``numpy`` and ``Pillow`` are imported lazily so importing this module (and the
|
|
12
|
+
whole :mod:`hwpx.visual` package) never requires the imaging stack; callers must
|
|
13
|
+
gate on :func:`imaging_available` and degrade otherwise.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
20
|
+
from PIL import Image
|
|
21
|
+
|
|
22
|
+
# Pixels darker than this (0-255 grayscale) count as "ink".
|
|
23
|
+
_INK_THRESHOLD = 160
|
|
24
|
+
# Row is "inked" when at least this fraction of its pixels are ink.
|
|
25
|
+
_ROW_INK_FRACTION = 0.01
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def imaging_available() -> bool:
|
|
29
|
+
"""True when numpy + Pillow can be imported (required by every detector)."""
|
|
30
|
+
|
|
31
|
+
try: # pragma: no cover - trivial import probe
|
|
32
|
+
import numpy # noqa: F401
|
|
33
|
+
from PIL import Image # noqa: F401
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ink_mask(img: "Image.Image", threshold: int = _INK_THRESHOLD):
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
gray = np.asarray(img.convert("L"))
|
|
43
|
+
return gray < threshold
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _match_size(a: "Image.Image", b: "Image.Image"):
|
|
47
|
+
if a.size != b.size:
|
|
48
|
+
width = min(a.size[0], b.size[0])
|
|
49
|
+
height = min(a.size[1], b.size[1])
|
|
50
|
+
a = a.crop((0, 0, width, height))
|
|
51
|
+
b = b.crop((0, 0, width, height))
|
|
52
|
+
return a, b
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _zero_rects(mask, rects) -> None:
|
|
56
|
+
"""In-place: clear the given pixel rectangles in a boolean ink/diff mask."""
|
|
57
|
+
|
|
58
|
+
for x0, y0, x1, y1 in rects:
|
|
59
|
+
mask[y0:y1, x0:x1] = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def diff_ratio(a: "Image.Image", b: "Image.Image") -> float:
|
|
63
|
+
"""Fraction of pixels whose ink/no-ink state differs between ``a`` and ``b``."""
|
|
64
|
+
|
|
65
|
+
import numpy as np
|
|
66
|
+
|
|
67
|
+
a, b = _match_size(a, b)
|
|
68
|
+
diff = np.logical_xor(_ink_mask(a), _ink_mask(b))
|
|
69
|
+
return float(diff.sum()) / float(diff.size or 1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def diff_ratio_outside_mask(a: "Image.Image", b: "Image.Image", rects) -> float:
|
|
73
|
+
"""Like :func:`diff_ratio` but ignoring pixels inside ``rects`` (allowed region)."""
|
|
74
|
+
|
|
75
|
+
import numpy as np
|
|
76
|
+
|
|
77
|
+
a, b = _match_size(a, b)
|
|
78
|
+
diff = np.logical_xor(_ink_mask(a), _ink_mask(b))
|
|
79
|
+
if rects:
|
|
80
|
+
_zero_rects(diff, rects)
|
|
81
|
+
return float(diff.sum()) / float(diff.size or 1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def new_ink_ratio_outside_mask(before: "Image.Image", after: "Image.Image", rects) -> float:
|
|
85
|
+
"""Fraction of pixels that gained ink in ``after`` *outside* the mask.
|
|
86
|
+
|
|
87
|
+
This is the "the fill spilled out of its slot" signal: ink present in the
|
|
88
|
+
output but not the original, in a region the edit was not allowed to paint.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
before, after = _match_size(before, after)
|
|
93
|
+
new_ink = _ink_mask(after) & ~_ink_mask(before)
|
|
94
|
+
if rects:
|
|
95
|
+
_zero_rects(new_ink, rects)
|
|
96
|
+
return float(new_ink.sum()) / float(new_ink.size or 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def overlap_score(img: "Image.Image") -> dict[str, float]:
|
|
100
|
+
"""Row-band analysis. A band far taller than the median line suggests
|
|
101
|
+
collapsed / overlapping lines (글자 겹침)."""
|
|
102
|
+
|
|
103
|
+
import numpy as np
|
|
104
|
+
|
|
105
|
+
mask = _ink_mask(img)
|
|
106
|
+
row_density = mask.mean(axis=1)
|
|
107
|
+
inked_rows = row_density > _ROW_INK_FRACTION
|
|
108
|
+
|
|
109
|
+
bands: list[tuple[int, int]] = []
|
|
110
|
+
start: int | None = None
|
|
111
|
+
for i, inked in enumerate(inked_rows):
|
|
112
|
+
if inked and start is None:
|
|
113
|
+
start = i
|
|
114
|
+
elif not inked and start is not None:
|
|
115
|
+
bands.append((start, i))
|
|
116
|
+
start = None
|
|
117
|
+
if start is not None:
|
|
118
|
+
bands.append((start, len(inked_rows)))
|
|
119
|
+
|
|
120
|
+
heights = [end - begin for begin, end in bands]
|
|
121
|
+
median_h = float(np.median(heights)) if heights else 0.0
|
|
122
|
+
max_h = float(max(heights)) if heights else 0.0
|
|
123
|
+
return {
|
|
124
|
+
"ink_ratio": float(mask.mean()),
|
|
125
|
+
"num_bands": float(len(bands)),
|
|
126
|
+
"median_band_height": median_h,
|
|
127
|
+
"max_band_height": max_h,
|
|
128
|
+
"tall_band_ratio": (max_h / median_h) if median_h else 0.0,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def make_diff_image(a: "Image.Image", b: "Image.Image") -> "Image.Image":
|
|
133
|
+
"""Render the ink XOR of two pages as a black-on-white diff image."""
|
|
134
|
+
|
|
135
|
+
import numpy as np
|
|
136
|
+
from PIL import Image
|
|
137
|
+
|
|
138
|
+
a, b = _match_size(a, b)
|
|
139
|
+
diff = np.logical_xor(_ink_mask(a), _ink_mask(b))
|
|
140
|
+
out = np.where(diff, 0, 255).astype("uint8")
|
|
141
|
+
return Image.fromarray(out) # 2-D uint8 -> mode "L" inferred
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"imaging_available",
|
|
146
|
+
"diff_ratio",
|
|
147
|
+
"diff_ratio_outside_mask",
|
|
148
|
+
"new_ink_ratio_outside_mask",
|
|
149
|
+
"overlap_score",
|
|
150
|
+
"make_diff_image",
|
|
151
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Rasterize Hancom-exported PDFs and turn page pairs into visual signals.
|
|
3
|
+
|
|
4
|
+
This is the scoring half of the gate: render PDF pages to images (``pymupdf``,
|
|
5
|
+
imported lazily) and run the :mod:`.detectors` over them. The
|
|
6
|
+
before/after comparison reproduces the baseline harness's verdict logic
|
|
7
|
+
(``diff_ratio`` ≥ eps, relative tall-band overlap, page-count change), extended
|
|
8
|
+
with edit-mask awareness for FormFit.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import hwpx_automation.office.rendering.detectors as detectors
|
|
16
|
+
from hwpx.quality.rendering import EditMask
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
19
|
+
from PIL import Image
|
|
20
|
+
|
|
21
|
+
# A page whose tallest ink band is this many times the median line height is a
|
|
22
|
+
# collapse signature; only flagged when the *after* page is meaningfully worse
|
|
23
|
+
# than the *before* page (relative, like the baseline harness).
|
|
24
|
+
_TALL_BAND_ABS = 3.0
|
|
25
|
+
_TALL_BAND_REL = 1.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pymupdf_available() -> bool:
|
|
29
|
+
try: # pragma: no cover - trivial import probe
|
|
30
|
+
import fitz # noqa: F401
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_pdf_to_images(pdf_path: str | Path, dpi: int = 150) -> list["Image.Image"]:
|
|
37
|
+
"""Rasterize every page of ``pdf_path`` to a list of RGB ``PIL.Image``."""
|
|
38
|
+
|
|
39
|
+
import fitz # pymupdf
|
|
40
|
+
from PIL import Image
|
|
41
|
+
|
|
42
|
+
pages: list[Image.Image] = []
|
|
43
|
+
with fitz.open(str(pdf_path)) as doc:
|
|
44
|
+
for page in doc:
|
|
45
|
+
pix = page.get_pixmap(dpi=dpi)
|
|
46
|
+
pages.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
|
|
47
|
+
return pages
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _page_overlap(before_ov: dict[str, float], after_ov: dict[str, float]) -> bool:
|
|
51
|
+
return (
|
|
52
|
+
after_ov["tall_band_ratio"] >= _TALL_BAND_ABS
|
|
53
|
+
and after_ov["tall_band_ratio"] > before_ov["tall_band_ratio"] + _TALL_BAND_REL
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compare_renders(
|
|
58
|
+
before_pdf: str | Path,
|
|
59
|
+
after_pdf: str | Path,
|
|
60
|
+
*,
|
|
61
|
+
edit_mask: EditMask | None = None,
|
|
62
|
+
diff_eps: float = 0.005,
|
|
63
|
+
dpi: int = 150,
|
|
64
|
+
diff_image_path: str | Path | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Compare two rendered PDFs and return the visual signal dictionary."""
|
|
67
|
+
|
|
68
|
+
before_pages = render_pdf_to_images(before_pdf, dpi)
|
|
69
|
+
after_pages = render_pdf_to_images(after_pdf, dpi)
|
|
70
|
+
page_count = min(len(before_pages), len(after_pages))
|
|
71
|
+
|
|
72
|
+
max_full_diff = 0.0
|
|
73
|
+
max_outside_diff = 0.0
|
|
74
|
+
max_new_ink_outside = 0.0
|
|
75
|
+
overlap_flag = False
|
|
76
|
+
worst_page = 0
|
|
77
|
+
|
|
78
|
+
for i in range(page_count):
|
|
79
|
+
before_img, after_img = before_pages[i], after_pages[i]
|
|
80
|
+
full = detectors.diff_ratio(before_img, after_img)
|
|
81
|
+
if full > max_full_diff:
|
|
82
|
+
max_full_diff = full
|
|
83
|
+
worst_page = i
|
|
84
|
+
|
|
85
|
+
if edit_mask is not None and not edit_mask.is_empty:
|
|
86
|
+
rects = edit_mask.rects_for(i, before_img.size[0], before_img.size[1])
|
|
87
|
+
max_outside_diff = max(
|
|
88
|
+
max_outside_diff, detectors.diff_ratio_outside_mask(before_img, after_img, rects)
|
|
89
|
+
)
|
|
90
|
+
max_new_ink_outside = max(
|
|
91
|
+
max_new_ink_outside,
|
|
92
|
+
detectors.new_ink_ratio_outside_mask(before_img, after_img, rects),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if _page_overlap(detectors.overlap_score(before_img), detectors.overlap_score(after_img)):
|
|
96
|
+
overlap_flag = True
|
|
97
|
+
|
|
98
|
+
page_count_changed = len(before_pages) != len(after_pages)
|
|
99
|
+
has_mask = edit_mask is not None and not edit_mask.is_empty
|
|
100
|
+
|
|
101
|
+
effective_diff = max_outside_diff if has_mask else max_full_diff
|
|
102
|
+
unexpected_diff_outside_mask = effective_diff >= diff_eps
|
|
103
|
+
# Overflow: the fill painted ink outside its slot, or content spilled onto
|
|
104
|
+
# extra pages. Without a mask, the page-growth proxy is the available signal.
|
|
105
|
+
overflow_detected = len(after_pages) > len(before_pages)
|
|
106
|
+
if has_mask:
|
|
107
|
+
overflow_detected = overflow_detected or (max_new_ink_outside >= diff_eps)
|
|
108
|
+
|
|
109
|
+
diff_image_out: str | None = None
|
|
110
|
+
if diff_image_path is not None and page_count > 0:
|
|
111
|
+
image = detectors.make_diff_image(before_pages[worst_page], after_pages[worst_page])
|
|
112
|
+
image.save(str(diff_image_path))
|
|
113
|
+
diff_image_out = str(diff_image_path)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"render_checked": True,
|
|
117
|
+
"page_count": page_count,
|
|
118
|
+
"before_page_count": len(before_pages),
|
|
119
|
+
"after_page_count": len(after_pages),
|
|
120
|
+
"page_count_changed": page_count_changed,
|
|
121
|
+
"max_diff_ratio": round(max_full_diff, 6),
|
|
122
|
+
"max_diff_outside_mask": round(max_outside_diff, 6),
|
|
123
|
+
"max_new_ink_outside_mask": round(max_new_ink_outside, 6),
|
|
124
|
+
"unexpected_diff_outside_mask": unexpected_diff_outside_mask,
|
|
125
|
+
"overlap_detected": overlap_flag,
|
|
126
|
+
"overflow_detected": overflow_detected,
|
|
127
|
+
"diff_image": diff_image_out,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def analyze_single(after_pdf: str | Path, *, dpi: int = 150) -> dict[str, Any]:
|
|
132
|
+
"""Single-render structural-visual pass (new-doc generation, no before).
|
|
133
|
+
|
|
134
|
+
Without a reference there is no faithful overlap baseline, so this only
|
|
135
|
+
confirms the doc rasterizes and reports its page count. Overlap/overflow
|
|
136
|
+
judgement for new docs is a Phase-E concern; here it stays conservative.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
after_pages = render_pdf_to_images(after_pdf, dpi)
|
|
140
|
+
return {
|
|
141
|
+
"render_checked": True,
|
|
142
|
+
"before_page_count": None,
|
|
143
|
+
"after_page_count": len(after_pages),
|
|
144
|
+
"page_count_changed": None,
|
|
145
|
+
"max_diff_ratio": None,
|
|
146
|
+
"unexpected_diff_outside_mask": False,
|
|
147
|
+
"overlap_detected": False,
|
|
148
|
+
"overflow_detected": False,
|
|
149
|
+
"diff_image": None,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["pymupdf_available", "render_pdf_to_images", "compare_renders", "analyze_single"]
|