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,1343 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Word/glyph-box geometry oracle for form-fill verification (M2 / rhwp ⑨).
|
|
3
|
+
|
|
4
|
+
The faithful renderer for HWPX is Hancom (Constitution IV); there is no free
|
|
5
|
+
headless one. Rather than drive the Hancom GUI on every check, this module makes
|
|
6
|
+
the cost amortizable:
|
|
7
|
+
|
|
8
|
+
1. Hancom renders a filled ``.hwpx`` to PDF **once** (the faithful capture).
|
|
9
|
+
2. PyMuPDF (``fitz``) extracts **per-glyph** boxes (``get_text("rawdict")``) for
|
|
10
|
+
collision detection and **word** boxes for slot-overflow.
|
|
11
|
+
3. The boxes are **frozen** as a fixture (:class:`WordBoxFixture`) bound to a
|
|
12
|
+
sha256 of the source document, so a stale or forged fixture **fails closed**.
|
|
13
|
+
4. Overlap (two glyph boxes colliding — 글자 겹침) and overflow (a box escaping
|
|
14
|
+
its cell rectangle) are decided **by geometry**, offline, against the frozen
|
|
15
|
+
boxes — corpus-scalable.
|
|
16
|
+
|
|
17
|
+
Why glyph granularity: a form value (이름/번호/날짜) is usually one whitespace-free
|
|
18
|
+
token, so ``get_text("words")`` returns a single box and an *intra-word* collision
|
|
19
|
+
(the dominant 글자 겹침 mode, e.g. the 방송신청서 case) would be geometrically
|
|
20
|
+
invisible. Collisions are therefore detected per glyph.
|
|
21
|
+
|
|
22
|
+
``fitz`` is an **oracle-only** dependency. When unavailable, extraction degrades
|
|
23
|
+
to an honest ``unverified`` verdict (Constitution V/IX) — it never crashes the
|
|
24
|
+
fill path and never asserts a silent pass.
|
|
25
|
+
|
|
26
|
+
All coordinates are PDF points (the unit ``fitz`` reports), origin top-left
|
|
27
|
+
(y grows downward).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import os
|
|
36
|
+
import tempfile
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
# A box smaller than this in either dimension is rendering noise, not a glyph.
|
|
42
|
+
_MIN_GLYPH_PT = 0.5
|
|
43
|
+
# Genuine collision = overlap on both axes whose *area* exceeds this. An area
|
|
44
|
+
# gate (not an AND-of-axes gate) dismisses the thin kerning seam between adjacent
|
|
45
|
+
# glyphs (one axis ~0 -> area ~0) while still catching a diagonal/partial stack.
|
|
46
|
+
_OVERLAP_AREA_EPS_PT2 = 0.20
|
|
47
|
+
# Default overflow tolerance (pt). Calibrated on real clean gov forms: glyphs
|
|
48
|
+
# owned by a table cell stay inside it with ~1pt slack, so 0 false positives at
|
|
49
|
+
# tol=0; 1pt absorbs find_tables border-rounding without masking a real escape.
|
|
50
|
+
_OVERFLOW_TOL_PT = 1.0
|
|
51
|
+
# A collision is an OVER-PRINT (a 글자 겹침 candidate, not normal flow) when the
|
|
52
|
+
# intersection covers at least this fraction of the SMALLER glyph's area. The gate
|
|
53
|
+
# is axis-agnostic: normal horizontal flow (a thin advance-axis sliver) AND normal
|
|
54
|
+
# vertical flow (edge-to-edge touch) both yield a small fraction and are excluded,
|
|
55
|
+
# while a real over-print — collapsed 자간, a punctuation/digit crammed onto a wide
|
|
56
|
+
# CJK glyph, a collapsed line — yields a large fraction. Calibrated on a real gov
|
|
57
|
+
# form (public_official_table, 2904 glyphs): frac>=0.30 collapses 1371 raw
|
|
58
|
+
# collisions to 13 stable over-prints (identical blank vs filled), the identity
|
|
59
|
+
# differential then cancels that baseline to 0 on a clean fill, while catching the
|
|
60
|
+
# period-in-CJK (frac 1.0) and collapsed-자간 (frac ~0.45) over-prints that a
|
|
61
|
+
# center-distance gate missed (adversarial review, 2026-06-25).
|
|
62
|
+
_OVERLAP_AREA_FRAC = 0.30
|
|
63
|
+
# Fixture schema version (load migrates / rejects unknown majors).
|
|
64
|
+
_FIXTURE_VERSION = 1
|
|
65
|
+
# Tool stamp recorded in fixture provenance.
|
|
66
|
+
_BACKEND_UNKNOWN = "unknown"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _finite(*vals: float) -> bool:
|
|
70
|
+
return all(isinstance(v, (int, float)) and math.isfinite(v) for v in vals)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Rect:
|
|
75
|
+
"""An axis-aligned cell rectangle in PDF points (origin top-left)."""
|
|
76
|
+
|
|
77
|
+
x0: float
|
|
78
|
+
y0: float
|
|
79
|
+
x1: float
|
|
80
|
+
y1: float
|
|
81
|
+
label: str | None = None
|
|
82
|
+
page: int = 0
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def width(self) -> float:
|
|
86
|
+
return self.x1 - self.x0
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def height(self) -> float:
|
|
90
|
+
return self.y1 - self.y0
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def area(self) -> float:
|
|
94
|
+
return max(0.0, self.width) * max(0.0, self.height)
|
|
95
|
+
|
|
96
|
+
def contains_center(self, box: WordBox) -> bool:
|
|
97
|
+
"""True when ``box``'s center is inside this rect AND same page."""
|
|
98
|
+
|
|
99
|
+
if box.page != self.page:
|
|
100
|
+
return False
|
|
101
|
+
cx = (box.x0 + box.x1) / 2.0
|
|
102
|
+
cy = (box.y0 + box.y1) / 2.0
|
|
103
|
+
return self.x0 <= cx <= self.x1 and self.y0 <= cy <= self.y1
|
|
104
|
+
|
|
105
|
+
def overflow_of(self, box: WordBox) -> float:
|
|
106
|
+
"""Max signed escape (pt) of ``box`` beyond any edge; <=0 means inside."""
|
|
107
|
+
|
|
108
|
+
return max(
|
|
109
|
+
self.x0 - box.x0, # escapes left
|
|
110
|
+
box.x1 - self.x1, # escapes right
|
|
111
|
+
self.y0 - box.y0, # escapes top
|
|
112
|
+
box.y1 - self.y1, # escapes bottom
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class WordBox:
|
|
118
|
+
"""One text unit (glyph or word) with its bounding box (PDF points)."""
|
|
119
|
+
|
|
120
|
+
x0: float
|
|
121
|
+
y0: float
|
|
122
|
+
x1: float
|
|
123
|
+
y1: float
|
|
124
|
+
text: str
|
|
125
|
+
page: int = 0
|
|
126
|
+
block: int = -1
|
|
127
|
+
line: int = -1
|
|
128
|
+
word_no: int = -1
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def width(self) -> float:
|
|
132
|
+
return self.x1 - self.x0
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def height(self) -> float:
|
|
136
|
+
return self.y1 - self.y0
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def finite(self) -> bool:
|
|
140
|
+
return _finite(self.x0, self.y0, self.x1, self.y1)
|
|
141
|
+
|
|
142
|
+
def overlap_area(self, other: WordBox) -> float:
|
|
143
|
+
"""Intersection area (pt²) with ``other``; 0 when on different pages."""
|
|
144
|
+
|
|
145
|
+
if self.page != other.page:
|
|
146
|
+
return 0.0
|
|
147
|
+
dx = min(self.x1, other.x1) - max(self.x0, other.x0)
|
|
148
|
+
dy = min(self.y1, other.y1) - max(self.y0, other.y0)
|
|
149
|
+
if dx <= 0.0 or dy <= 0.0:
|
|
150
|
+
return 0.0
|
|
151
|
+
return dx * dy
|
|
152
|
+
|
|
153
|
+
def collides(
|
|
154
|
+
self, other: WordBox, *, area_eps: float = _OVERLAP_AREA_EPS_PT2
|
|
155
|
+
) -> bool:
|
|
156
|
+
"""True when this box and ``other`` overlap (same page) beyond ``area_eps``.
|
|
157
|
+
|
|
158
|
+
Area-gated so the kerning seam between adjacent glyphs (a sliver) does not
|
|
159
|
+
count, but a genuine stack/collision (글자 겹침) does.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
if not (self.finite and other.finite):
|
|
163
|
+
return False
|
|
164
|
+
return self.overlap_area(other) > area_eps
|
|
165
|
+
|
|
166
|
+
def to_tuple(self) -> tuple[float, float, float, float, str, int, int, int, int]:
|
|
167
|
+
return (
|
|
168
|
+
self.x0,
|
|
169
|
+
self.y0,
|
|
170
|
+
self.x1,
|
|
171
|
+
self.y1,
|
|
172
|
+
self.text,
|
|
173
|
+
self.page,
|
|
174
|
+
self.block,
|
|
175
|
+
self.line,
|
|
176
|
+
self.word_no,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class OracleUnavailable(RuntimeError):
|
|
181
|
+
"""Raised when the render/extraction backend is not reachable."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def fitz_available() -> bool:
|
|
185
|
+
"""True when PyMuPDF (``fitz``) can be imported (the extraction backend)."""
|
|
186
|
+
|
|
187
|
+
try: # pragma: no cover - trivial import probe
|
|
188
|
+
import fitz # noqa: F401
|
|
189
|
+
except Exception:
|
|
190
|
+
return False
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _page_indices(doc: Any, page: int | None) -> list[int]:
|
|
195
|
+
n = len(doc)
|
|
196
|
+
if page is None:
|
|
197
|
+
return list(range(n))
|
|
198
|
+
if not (0 <= page < n):
|
|
199
|
+
raise OracleUnavailable(f"page {page} out of range (0..{n - 1})")
|
|
200
|
+
return [page]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def extract_word_boxes(pdf_path: str, *, page: int | None = None) -> list[WordBox]:
|
|
204
|
+
"""Extract whitespace-delimited **word** boxes (for slot-overflow checks)."""
|
|
205
|
+
|
|
206
|
+
if not fitz_available():
|
|
207
|
+
raise OracleUnavailable("PyMuPDF (fitz) is not installed")
|
|
208
|
+
import fitz
|
|
209
|
+
|
|
210
|
+
boxes: list[WordBox] = []
|
|
211
|
+
try:
|
|
212
|
+
doc = fitz.open(pdf_path)
|
|
213
|
+
except Exception as exc: # truncated / non-PDF bytes -> degrade, never crash
|
|
214
|
+
raise OracleUnavailable(f"unreadable PDF: {type(exc).__name__}") from exc
|
|
215
|
+
with doc:
|
|
216
|
+
for pno in _page_indices(doc, page):
|
|
217
|
+
try:
|
|
218
|
+
words = doc[pno].get_text("words") # encrypted -> ValueError
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
raise OracleUnavailable(
|
|
221
|
+
f"unreadable PDF page: {type(exc).__name__}"
|
|
222
|
+
) from exc
|
|
223
|
+
for w in words if isinstance(words, (list, tuple)) else []:
|
|
224
|
+
if not isinstance(w, (list, tuple)) or len(w) < 8:
|
|
225
|
+
continue
|
|
226
|
+
x0, y0, x1, y1, text, block, line, word_no = w[:8]
|
|
227
|
+
if not _finite(x0, y0, x1, y1):
|
|
228
|
+
continue
|
|
229
|
+
if (x1 - x0) < _MIN_GLYPH_PT or (y1 - y0) < _MIN_GLYPH_PT:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
box = WordBox(
|
|
233
|
+
x0=float(x0),
|
|
234
|
+
y0=float(y0),
|
|
235
|
+
x1=float(x1),
|
|
236
|
+
y1=float(y1),
|
|
237
|
+
text=str(text),
|
|
238
|
+
page=int(pno),
|
|
239
|
+
block=int(block),
|
|
240
|
+
line=int(line),
|
|
241
|
+
word_no=int(word_no),
|
|
242
|
+
)
|
|
243
|
+
except (TypeError, ValueError):
|
|
244
|
+
continue
|
|
245
|
+
boxes.append(box)
|
|
246
|
+
return boxes
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def extract_glyph_boxes(pdf_path: str, *, page: int | None = None) -> list[WordBox]:
|
|
250
|
+
"""Extract **per-glyph** boxes via ``get_text("rawdict")`` (for collisions).
|
|
251
|
+
|
|
252
|
+
Whitespace glyphs, non-finite coordinates and noise-sized boxes are dropped.
|
|
253
|
+
This is the granularity at which intra-word 글자 겹침 becomes visible.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
if not fitz_available():
|
|
257
|
+
raise OracleUnavailable("PyMuPDF (fitz) is not installed")
|
|
258
|
+
import fitz
|
|
259
|
+
|
|
260
|
+
boxes: list[WordBox] = []
|
|
261
|
+
try:
|
|
262
|
+
doc = fitz.open(pdf_path)
|
|
263
|
+
except Exception as exc: # truncated / non-PDF bytes -> degrade, never crash
|
|
264
|
+
raise OracleUnavailable(f"unreadable PDF: {type(exc).__name__}") from exc
|
|
265
|
+
with doc:
|
|
266
|
+
for pno in _page_indices(doc, page):
|
|
267
|
+
try:
|
|
268
|
+
raw = doc[pno].get_text("rawdict") # encrypted -> ValueError
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
raise OracleUnavailable(
|
|
271
|
+
f"unreadable PDF page: {type(exc).__name__}"
|
|
272
|
+
) from exc
|
|
273
|
+
blocks = raw.get("blocks", []) if isinstance(raw, dict) else []
|
|
274
|
+
for bi, block in enumerate(blocks if isinstance(blocks, list) else []):
|
|
275
|
+
if not isinstance(block, dict):
|
|
276
|
+
continue
|
|
277
|
+
for li, line in enumerate(
|
|
278
|
+
block.get("lines", []) or []
|
|
279
|
+
): # image blocks have none
|
|
280
|
+
if not isinstance(line, dict):
|
|
281
|
+
continue
|
|
282
|
+
for span in line.get("spans", []) or []:
|
|
283
|
+
if not isinstance(span, dict):
|
|
284
|
+
continue
|
|
285
|
+
for ch in span.get("chars", []) or []:
|
|
286
|
+
if not isinstance(ch, dict):
|
|
287
|
+
continue
|
|
288
|
+
c = ch.get("c", "")
|
|
289
|
+
if not isinstance(c, str) or not c or c.isspace():
|
|
290
|
+
continue
|
|
291
|
+
bbox = ch.get("bbox")
|
|
292
|
+
if not bbox or len(bbox) < 4:
|
|
293
|
+
continue
|
|
294
|
+
x0, y0, x1, y1 = bbox[:4]
|
|
295
|
+
if not _finite(x0, y0, x1, y1):
|
|
296
|
+
continue
|
|
297
|
+
if (x1 - x0) < _MIN_GLYPH_PT or (y1 - y0) < _MIN_GLYPH_PT:
|
|
298
|
+
continue
|
|
299
|
+
boxes.append(
|
|
300
|
+
WordBox(
|
|
301
|
+
x0=float(x0),
|
|
302
|
+
y0=float(y0),
|
|
303
|
+
x1=float(x1),
|
|
304
|
+
y1=float(y1),
|
|
305
|
+
text=str(c),
|
|
306
|
+
page=int(pno),
|
|
307
|
+
block=int(bi),
|
|
308
|
+
line=int(li),
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
return boxes
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def detect_overlaps(
|
|
315
|
+
boxes: Sequence[WordBox], *, area_eps: float = _OVERLAP_AREA_EPS_PT2
|
|
316
|
+
) -> list[tuple[WordBox, WordBox]]:
|
|
317
|
+
"""Pairwise glyph collisions (글자 겹침) among ``boxes`` (same page only)."""
|
|
318
|
+
|
|
319
|
+
hits: list[tuple[WordBox, WordBox]] = []
|
|
320
|
+
n = len(boxes)
|
|
321
|
+
for i in range(n):
|
|
322
|
+
a = boxes[i]
|
|
323
|
+
for j in range(i + 1, n):
|
|
324
|
+
if a.collides(boxes[j], area_eps=area_eps):
|
|
325
|
+
hits.append((a, boxes[j]))
|
|
326
|
+
return hits
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _overprint_fraction(a: WordBox, b: WordBox) -> float:
|
|
330
|
+
"""Intersection area as a fraction of the smaller glyph's area (0..1).
|
|
331
|
+
|
|
332
|
+
Axis-agnostic measure of *how much* two glyphs over-print. Normal flow
|
|
333
|
+
(horizontal sliver or vertical edge-touch) is a small fraction; a stack/cram is
|
|
334
|
+
a large one. This succeeds where a center-distance gate fails: it does not
|
|
335
|
+
assume the advance axis (so collapsed 자간, vertical text and collapsed lines
|
|
336
|
+
are all covered) and it is symmetric to glyph size (a tiny glyph fully inside a
|
|
337
|
+
wide CJK glyph scores 1.0, the canonical 글자 겹침 a ``min(width)`` gate missed).
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
overlap = a.overlap_area(b)
|
|
341
|
+
if overlap <= 0.0:
|
|
342
|
+
return 0.0
|
|
343
|
+
smaller = min(a.width * a.height, b.width * b.height)
|
|
344
|
+
if smaller <= 0.0:
|
|
345
|
+
return 1.0 # degenerate glyph fully covered: treat as an over-print
|
|
346
|
+
return overlap / smaller
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _overprint_collisions(
|
|
350
|
+
boxes: Sequence[WordBox], *, area_eps: float, area_frac: float
|
|
351
|
+
) -> list[tuple[WordBox, WordBox]]:
|
|
352
|
+
return [
|
|
353
|
+
(a, b)
|
|
354
|
+
for a, b in detect_overlaps(boxes, area_eps=area_eps)
|
|
355
|
+
if _overprint_fraction(a, b) >= area_frac
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _overlap_identity(a: WordBox, b: WordBox) -> tuple:
|
|
360
|
+
"""Position-invariant identity of a collision: page + canonical glyph-text pair.
|
|
361
|
+
|
|
362
|
+
Keying on glyph identity (not absolute position) makes the differential robust
|
|
363
|
+
to benign intra-page reflow — a baseline over-print that merely translates keeps
|
|
364
|
+
its identity and cancels — while a genuinely new over-print carries a new pair,
|
|
365
|
+
or a higher *count* of an existing pair on the page.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
return (a.page,) + tuple(sorted((a.text, b.text)))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def diff_overlaps(
|
|
372
|
+
blank_boxes: Sequence[WordBox],
|
|
373
|
+
filled_boxes: Sequence[WordBox],
|
|
374
|
+
*,
|
|
375
|
+
area_eps: float = _OVERLAP_AREA_EPS_PT2,
|
|
376
|
+
area_frac: float = _OVERLAP_AREA_FRAC,
|
|
377
|
+
) -> list[tuple[WordBox, WordBox]]:
|
|
378
|
+
"""Glyph over-prints the *fill* introduces (filled render minus blank baseline).
|
|
379
|
+
|
|
380
|
+
Absolute glyph-overlap is unusable on real forms — a clean gov form already
|
|
381
|
+
carries ~1371 benign collisions, almost all normal adjacent CJK flow. Two gates
|
|
382
|
+
isolate the new 글자 겹침 a fill causes:
|
|
383
|
+
|
|
384
|
+
1. **over-print gate** (:func:`_overprint_fraction`) — keep only collisions whose
|
|
385
|
+
intersection covers a real fraction (``area_frac``) of the smaller glyph,
|
|
386
|
+
dropping normal horizontal *and* vertical flow. This collapses the ~1371 raw
|
|
387
|
+
collisions to ~13 stable over-prints (identical count blank vs filled).
|
|
388
|
+
2. **identity differential** — cancel each surviving filled over-print against a
|
|
389
|
+
blank one of the *same identity* (page + glyph-text pair), count-aware. A
|
|
390
|
+
filled over-print is new only when its identity is absent from the blank
|
|
391
|
+
baseline or appears more times than in it.
|
|
392
|
+
|
|
393
|
+
Identity (not position) keying is deliberate: an earlier position-keyed version
|
|
394
|
+
both masked a real cram landing near a benign seam and flooded false positives
|
|
395
|
+
whenever a benign stack merely translated >tol under an in-page reflow that
|
|
396
|
+
:func:`diff_layout` cannot see (adversarial review, 2026-06-25). On a clean fill
|
|
397
|
+
the result is 0; what remains is the new over-print the fill introduced.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
from collections import Counter
|
|
401
|
+
|
|
402
|
+
blank_hits = _overprint_collisions(
|
|
403
|
+
blank_boxes, area_eps=area_eps, area_frac=area_frac
|
|
404
|
+
)
|
|
405
|
+
filled_hits = _overprint_collisions(
|
|
406
|
+
filled_boxes, area_eps=area_eps, area_frac=area_frac
|
|
407
|
+
)
|
|
408
|
+
baseline = Counter(_overlap_identity(a, b) for a, b in blank_hits)
|
|
409
|
+
seen: Counter[tuple] = Counter()
|
|
410
|
+
new: list[tuple[WordBox, WordBox]] = []
|
|
411
|
+
for a, b in filled_hits:
|
|
412
|
+
key = _overlap_identity(a, b)
|
|
413
|
+
seen[key] += 1
|
|
414
|
+
if seen[key] > baseline.get(key, 0):
|
|
415
|
+
new.append((a, b))
|
|
416
|
+
return new
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _owning_clip(box: WordBox, clips: Sequence[Rect]) -> Rect | None:
|
|
420
|
+
"""Tightest-area clip on the same page whose interior holds ``box``'s center."""
|
|
421
|
+
|
|
422
|
+
best: Rect | None = None
|
|
423
|
+
for clip in clips:
|
|
424
|
+
if not isinstance(clip, Rect): # tolerate caller type-misuse, never crash
|
|
425
|
+
continue
|
|
426
|
+
if clip.contains_center(box) and (best is None or clip.area < best.area):
|
|
427
|
+
best = clip
|
|
428
|
+
return best
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def detect_overflow(
|
|
432
|
+
boxes: Sequence[WordBox], clips: Sequence[Rect], *, tol: float = 0.0
|
|
433
|
+
) -> list[tuple[WordBox, Rect]]:
|
|
434
|
+
"""Boxes that escape the (same-page) cell they belong to.
|
|
435
|
+
|
|
436
|
+
A box outside every clip is page chrome, not a slot fill, and is ignored.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
out: list[tuple[WordBox, Rect]] = []
|
|
440
|
+
for box in boxes:
|
|
441
|
+
if not box.finite:
|
|
442
|
+
continue
|
|
443
|
+
clip = _owning_clip(box, clips)
|
|
444
|
+
if clip is None:
|
|
445
|
+
continue
|
|
446
|
+
if clip.overflow_of(box) > tol:
|
|
447
|
+
out.append((box, clip))
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def diff_overflow(
|
|
452
|
+
blank_boxes: Sequence[WordBox],
|
|
453
|
+
blank_clips: Sequence[Rect],
|
|
454
|
+
filled_boxes: Sequence[WordBox],
|
|
455
|
+
filled_clips: Sequence[Rect],
|
|
456
|
+
*,
|
|
457
|
+
tol: float = _OVERFLOW_TOL_PT,
|
|
458
|
+
) -> list[tuple[WordBox, Rect]]:
|
|
459
|
+
"""Cell-escapes the *fill* introduces (filled render minus blank baseline).
|
|
460
|
+
|
|
461
|
+
Absolute cell-clip overflow is baseline-prone on dense real forms: ``find_
|
|
462
|
+
tables`` reconstructs cell borders with ~1pt imprecision, so a clean form flags
|
|
463
|
+
a steady population of marginal escapes that exist *identically* in the blank
|
|
464
|
+
render (measured: 171 on a clean ``form_002``, all < 4pt, median 1.25pt). Like
|
|
465
|
+
:func:`diff_overlaps`, this subtracts that baseline by glyph identity (page +
|
|
466
|
+
text), count-aware: a filled escape counts only when its identity is absent from
|
|
467
|
+
the blank baseline or appears more times than in it. On a clean fill the result
|
|
468
|
+
is 0; what remains is the new escape the fill actually caused.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
from collections import Counter
|
|
472
|
+
|
|
473
|
+
blank_esc = detect_overflow(blank_boxes, blank_clips, tol=tol)
|
|
474
|
+
filled_esc = detect_overflow(filled_boxes, filled_clips, tol=tol)
|
|
475
|
+
baseline = Counter((b.page, b.text) for b, _r in blank_esc)
|
|
476
|
+
seen: Counter[tuple] = Counter()
|
|
477
|
+
new: list[tuple[WordBox, Rect]] = []
|
|
478
|
+
for box, clip in filled_esc:
|
|
479
|
+
key = (box.page, box.text)
|
|
480
|
+
seen[key] += 1
|
|
481
|
+
if seen[key] > baseline.get(key, 0):
|
|
482
|
+
new.append((box, clip))
|
|
483
|
+
return new
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# --- frozen fixture (provenance-bound, fail-closed) -------------------------
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def sha256_file(path: str) -> str:
|
|
490
|
+
"""sha256 hex of a file's bytes (the fixture<->source binding)."""
|
|
491
|
+
|
|
492
|
+
h = hashlib.sha256()
|
|
493
|
+
with open(path, "rb") as fh:
|
|
494
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
495
|
+
h.update(chunk)
|
|
496
|
+
return h.hexdigest()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@dataclass
|
|
500
|
+
class WordBoxFixture:
|
|
501
|
+
"""Frozen Hancom-render glyph boxes for offline regression.
|
|
502
|
+
|
|
503
|
+
Faithfulness is *not* a bare boolean: ``checked`` is True only for a real
|
|
504
|
+
faithful capture, ``source_sha256``/``backend`` record provenance, and a
|
|
505
|
+
consumer rebinds the fixture to its source by hash. A fixture missing
|
|
506
|
+
``checked`` or provenance loads **fail-closed** (``checked=False``), so a
|
|
507
|
+
forged or schema-drifted file cannot masquerade as a faithful render.
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
boxes: list[WordBox]
|
|
511
|
+
page_sizes: list[tuple[float, float]] = field(default_factory=list)
|
|
512
|
+
source: str = ""
|
|
513
|
+
checked: bool = False
|
|
514
|
+
source_sha256: str | None = None
|
|
515
|
+
backend: str = _BACKEND_UNKNOWN
|
|
516
|
+
|
|
517
|
+
def freeze(self, path: str) -> None:
|
|
518
|
+
payload = {
|
|
519
|
+
"version": _FIXTURE_VERSION,
|
|
520
|
+
"source": self.source,
|
|
521
|
+
"checked": self.checked,
|
|
522
|
+
"sourceSha256": self.source_sha256,
|
|
523
|
+
"backend": self.backend,
|
|
524
|
+
"pageSizes": [list(p) for p in self.page_sizes],
|
|
525
|
+
"boxes": [b.to_tuple() for b in self.boxes],
|
|
526
|
+
}
|
|
527
|
+
# Atomic write: a crash mid-serialization must not leave a torn fixture.
|
|
528
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
529
|
+
fd, tmp = tempfile.mkstemp(suffix=".tmp", dir=directory)
|
|
530
|
+
try:
|
|
531
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
532
|
+
json.dump(payload, fh, ensure_ascii=False, indent=1)
|
|
533
|
+
os.replace(tmp, path)
|
|
534
|
+
except BaseException:
|
|
535
|
+
try:
|
|
536
|
+
os.remove(tmp)
|
|
537
|
+
except OSError:
|
|
538
|
+
pass
|
|
539
|
+
raise
|
|
540
|
+
|
|
541
|
+
@classmethod
|
|
542
|
+
def load(cls, path: str) -> WordBoxFixture:
|
|
543
|
+
with open(path, encoding="utf-8") as fh:
|
|
544
|
+
payload = json.load(fh)
|
|
545
|
+
version = payload.get("version", 0)
|
|
546
|
+
if version > _FIXTURE_VERSION:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"fixture version {version} is newer than supported {_FIXTURE_VERSION}"
|
|
549
|
+
)
|
|
550
|
+
boxes = [
|
|
551
|
+
WordBox(t[0], t[1], t[2], t[3], t[4], t[5], t[6], t[7], t[8])
|
|
552
|
+
for t in payload.get("boxes", [])
|
|
553
|
+
if isinstance(t, (list, tuple)) and len(t) >= 9
|
|
554
|
+
]
|
|
555
|
+
sizes = [tuple(p) for p in payload.get("pageSizes", [])]
|
|
556
|
+
# Strict: only a real JSON bool true means faithful. A non-bool (e.g. the
|
|
557
|
+
# STRING "false", which is truthy) or a missing key fails closed.
|
|
558
|
+
raw_checked = payload.get("checked", False)
|
|
559
|
+
checked = raw_checked if isinstance(raw_checked, bool) else False
|
|
560
|
+
raw_sha = payload.get("sourceSha256")
|
|
561
|
+
source_sha = raw_sha if isinstance(raw_sha, str) and raw_sha else None
|
|
562
|
+
return cls(
|
|
563
|
+
boxes=boxes,
|
|
564
|
+
page_sizes=sizes,
|
|
565
|
+
source=payload.get("source", ""),
|
|
566
|
+
checked=checked,
|
|
567
|
+
source_sha256=source_sha,
|
|
568
|
+
backend=payload.get("backend", _BACKEND_UNKNOWN),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# --- verdict ---------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@dataclass
|
|
576
|
+
class FormFillVerdict:
|
|
577
|
+
"""Geometry verdict for a filled form (rides onto ``FitResult``/``FormReport``).
|
|
578
|
+
|
|
579
|
+
``render_checked`` is the Constitution V contract: True only when backed by a
|
|
580
|
+
real render (or a faithful, source-bound frozen fixture). ``overflow_checked``
|
|
581
|
+
distinguishes "no overflow" from "overflow not evaluated" (no clips). A verdict
|
|
582
|
+
that is not ``render_checked`` is ``unverified`` — never a silent pass.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
render_checked: bool
|
|
586
|
+
overflow_detected: bool
|
|
587
|
+
overlap_detected: bool
|
|
588
|
+
overflow_checked: bool = False
|
|
589
|
+
layout_stable: bool | None = None
|
|
590
|
+
overflow: list[dict[str, Any]] = field(default_factory=list)
|
|
591
|
+
overlap: list[dict[str, Any]] = field(default_factory=list)
|
|
592
|
+
note: str = ""
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def ok(self) -> bool:
|
|
596
|
+
"""A pass requires a faithful check AND no overflow/overlap detected."""
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
self.render_checked
|
|
600
|
+
and not self.overflow_detected
|
|
601
|
+
and not self.overlap_detected
|
|
602
|
+
and self.layout_stable is not False
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def to_dict(self) -> dict[str, Any]:
|
|
606
|
+
return {
|
|
607
|
+
"renderChecked": self.render_checked,
|
|
608
|
+
"ok": self.ok,
|
|
609
|
+
"overflowDetected": self.overflow_detected,
|
|
610
|
+
"overflowChecked": self.overflow_checked,
|
|
611
|
+
"overlapDetected": self.overlap_detected,
|
|
612
|
+
"layoutStable": self.layout_stable,
|
|
613
|
+
"overflow": self.overflow,
|
|
614
|
+
"overlap": self.overlap,
|
|
615
|
+
"note": self.note,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _box_brief(box: WordBox) -> dict[str, Any]:
|
|
620
|
+
return {
|
|
621
|
+
"text": box.text,
|
|
622
|
+
"page": box.page,
|
|
623
|
+
"bbox": [
|
|
624
|
+
round(box.x0, 2),
|
|
625
|
+
round(box.y0, 2),
|
|
626
|
+
round(box.x1, 2),
|
|
627
|
+
round(box.y1, 2),
|
|
628
|
+
],
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def verdict_from_boxes(
|
|
633
|
+
boxes: Sequence[WordBox],
|
|
634
|
+
clips: Sequence[Rect] | None = None,
|
|
635
|
+
*,
|
|
636
|
+
render_checked: bool = False,
|
|
637
|
+
layout_stable: bool | None = None,
|
|
638
|
+
area_eps: float = _OVERLAP_AREA_EPS_PT2,
|
|
639
|
+
tol: float = 0.0,
|
|
640
|
+
) -> FormFillVerdict:
|
|
641
|
+
"""Build a :class:`FormFillVerdict` from glyph boxes (+ optional clips).
|
|
642
|
+
|
|
643
|
+
``render_checked`` defaults **False** (fail-closed): the caller must assert a
|
|
644
|
+
faithful capture explicitly.
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
overlaps = detect_overlaps(boxes, area_eps=area_eps)
|
|
648
|
+
overflow = detect_overflow(boxes, clips or [], tol=tol)
|
|
649
|
+
return FormFillVerdict(
|
|
650
|
+
render_checked=render_checked,
|
|
651
|
+
overflow_detected=bool(overflow),
|
|
652
|
+
overflow_checked=bool(clips),
|
|
653
|
+
overlap_detected=bool(overlaps),
|
|
654
|
+
layout_stable=layout_stable,
|
|
655
|
+
overflow=[
|
|
656
|
+
{
|
|
657
|
+
"box": _box_brief(b),
|
|
658
|
+
"clip": r.label,
|
|
659
|
+
"escapePt": round(r.overflow_of(b), 2),
|
|
660
|
+
}
|
|
661
|
+
for b, r in overflow
|
|
662
|
+
],
|
|
663
|
+
overlap=[{"a": _box_brief(a), "b": _box_brief(b)} for a, b in overlaps],
|
|
664
|
+
note="" if render_checked else "unverified",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def unverified_verdict(reason: str) -> FormFillVerdict:
|
|
669
|
+
"""The honest degrade: no faithful capture was reachable (Constitution V/IX)."""
|
|
670
|
+
|
|
671
|
+
return FormFillVerdict(
|
|
672
|
+
render_checked=False,
|
|
673
|
+
overflow_detected=False,
|
|
674
|
+
overlap_detected=False,
|
|
675
|
+
overflow_checked=False,
|
|
676
|
+
layout_stable=None,
|
|
677
|
+
note=f"unverified: {reason}",
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# --- end-to-end capture (the one place Hancom is touched) -------------------
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def render_glyph_boxes(
|
|
685
|
+
hwpx_path: str,
|
|
686
|
+
*,
|
|
687
|
+
oracle: Any = None,
|
|
688
|
+
page: int | None = None,
|
|
689
|
+
out_pdf: str | None = None,
|
|
690
|
+
) -> tuple[list[WordBox], list[tuple[float, float]], str]:
|
|
691
|
+
"""Render *hwpx_path* via the Hancom oracle and extract per-glyph boxes.
|
|
692
|
+
|
|
693
|
+
Returns ``(glyph_boxes, page_sizes, backend_name)``. Raises
|
|
694
|
+
:class:`OracleUnavailable` for ANY backend/parse failure — resolution,
|
|
695
|
+
``available()``/``render_pdf()`` raising (the real ``MacHancomOracle`` does
|
|
696
|
+
``shutil.copyfile`` which throws on a directory/unreadable path), an empty or
|
|
697
|
+
unparseable PDF — so the caller degrades honestly instead of crashing the fill
|
|
698
|
+
path (Constitution V/VI/IX: the fill path must never crash).
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
if oracle is None:
|
|
703
|
+
from ...rendering import resolve_hancom_oracle
|
|
704
|
+
|
|
705
|
+
oracle = resolve_hancom_oracle()
|
|
706
|
+
if not oracle.available():
|
|
707
|
+
raise OracleUnavailable("no reachable Hancom render backend")
|
|
708
|
+
pdf = oracle.render_pdf(hwpx_path, out_pdf)
|
|
709
|
+
if not pdf or not os.path.exists(pdf) or os.path.getsize(pdf) == 0:
|
|
710
|
+
raise OracleUnavailable("Hancom render produced no PDF")
|
|
711
|
+
boxes = extract_glyph_boxes(pdf, page=page)
|
|
712
|
+
import fitz
|
|
713
|
+
|
|
714
|
+
with fitz.open(pdf) as doc:
|
|
715
|
+
if len(doc) == 0: # a 0-page render is a failed render, not a clean pass
|
|
716
|
+
raise OracleUnavailable("Hancom render produced an empty (0-page) PDF")
|
|
717
|
+
sizes = [(float(p.rect.width), float(p.rect.height)) for p in doc]
|
|
718
|
+
return boxes, sizes, type(oracle).__name__
|
|
719
|
+
except OracleUnavailable:
|
|
720
|
+
raise
|
|
721
|
+
except (
|
|
722
|
+
Exception
|
|
723
|
+
) as exc: # backend/copyfile/subprocess/parse -> degrade, never crash
|
|
724
|
+
raise OracleUnavailable(f"render backend failed: {type(exc).__name__}") from exc
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def extract_cell_clips(pdf_path: str, *, page: int | None = None) -> list[Rect]:
|
|
728
|
+
"""Extract table cell rectangles (PDF points) via ``fitz.find_tables()``.
|
|
729
|
+
|
|
730
|
+
These are the overflow clips — same coordinate space as the glyph boxes — so
|
|
731
|
+
no HWPX→PDF transform is needed. ``find_tables`` can raise on odd pages; such
|
|
732
|
+
a page contributes no clips rather than crashing.
|
|
733
|
+
|
|
734
|
+
Borders-only (``strategy="lines_strict"``): a cell rectangle is defined by its
|
|
735
|
+
*drawn borders*, not by the text inside it. The ``find_tables`` default snaps
|
|
736
|
+
edges to glyph positions, so a filled value can appear to *escape* a clip that
|
|
737
|
+
was mis-sized around the very text it holds — a false overflow. Corpus analysis
|
|
738
|
+
measured this: the only 3 corpus pairs that flagged ``newOverflow`` (the nts
|
|
739
|
+
forms) all had the fill value escaping a text-snapped clip under the default,
|
|
740
|
+
and 0 escapes under ``lines_strict``; there were no real escapes to mask.
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
if not fitz_available():
|
|
744
|
+
raise OracleUnavailable("PyMuPDF (fitz) is not installed")
|
|
745
|
+
import fitz
|
|
746
|
+
|
|
747
|
+
clips: list[Rect] = []
|
|
748
|
+
try:
|
|
749
|
+
doc = fitz.open(pdf_path)
|
|
750
|
+
except Exception as exc:
|
|
751
|
+
raise OracleUnavailable(f"unreadable PDF: {type(exc).__name__}") from exc
|
|
752
|
+
with doc:
|
|
753
|
+
for pno in _page_indices(doc, page):
|
|
754
|
+
try:
|
|
755
|
+
found = doc[pno].find_tables(strategy="lines_strict")
|
|
756
|
+
tables = found.tables if found is not None else []
|
|
757
|
+
except Exception:
|
|
758
|
+
continue # layout analysis failed on this page -> no clips, no crash
|
|
759
|
+
for ti, table in enumerate(tables):
|
|
760
|
+
for ci, cell in enumerate(getattr(table, "cells", []) or []):
|
|
761
|
+
if not cell or len(cell) < 4 or not _finite(*cell[:4]):
|
|
762
|
+
continue
|
|
763
|
+
clips.append(
|
|
764
|
+
Rect(
|
|
765
|
+
float(cell[0]),
|
|
766
|
+
float(cell[1]),
|
|
767
|
+
float(cell[2]),
|
|
768
|
+
float(cell[3]),
|
|
769
|
+
label=f"p{pno}t{ti}c{ci}",
|
|
770
|
+
page=int(pno),
|
|
771
|
+
)
|
|
772
|
+
)
|
|
773
|
+
return clips
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def extract_image_boxes(pdf_path: str, *, page: int | None = None) -> list[Rect]:
|
|
777
|
+
"""Extract embedded-image rectangles (PDF points) from a render.
|
|
778
|
+
|
|
779
|
+
A placed 직인/관인 is a *picture*, so it never appears in the ``get_text`` glyph
|
|
780
|
+
boxes; ``page.get_image_info()`` reports each image's ``bbox`` in the same
|
|
781
|
+
top-left PDF-point space as the glyph boxes, so the seal rect feeds straight into
|
|
782
|
+
:func:`hwpx.form_fit.seal.check_seal_placement` with no transform. A page whose
|
|
783
|
+
image analysis raises contributes no boxes rather than crashing the check.
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
if not fitz_available():
|
|
787
|
+
raise OracleUnavailable("PyMuPDF (fitz) is not installed")
|
|
788
|
+
import fitz
|
|
789
|
+
|
|
790
|
+
boxes: list[Rect] = []
|
|
791
|
+
try:
|
|
792
|
+
doc = fitz.open(pdf_path)
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
raise OracleUnavailable(f"unreadable PDF: {type(exc).__name__}") from exc
|
|
795
|
+
with doc:
|
|
796
|
+
for pno in _page_indices(doc, page):
|
|
797
|
+
try:
|
|
798
|
+
infos = doc[pno].get_image_info()
|
|
799
|
+
except Exception:
|
|
800
|
+
continue # image analysis failed on this page -> no boxes, no crash
|
|
801
|
+
for ii, info in enumerate(infos):
|
|
802
|
+
bbox = info.get("bbox") if isinstance(info, dict) else None
|
|
803
|
+
if not bbox or len(bbox) < 4 or not _finite(*bbox[:4]):
|
|
804
|
+
continue
|
|
805
|
+
boxes.append(
|
|
806
|
+
Rect(
|
|
807
|
+
float(bbox[0]),
|
|
808
|
+
float(bbox[1]),
|
|
809
|
+
float(bbox[2]),
|
|
810
|
+
float(bbox[3]),
|
|
811
|
+
label=f"p{pno}i{ii}",
|
|
812
|
+
page=int(pno),
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
return boxes
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def render_form_geometry(
|
|
819
|
+
hwpx_path: str, *, oracle: Any = None, page: int | None = None
|
|
820
|
+
) -> tuple[list[WordBox], list[Rect], list[tuple[float, float]], str]:
|
|
821
|
+
"""Render once via Hancom and return ``(glyph_boxes, cell_clips, sizes, backend)``.
|
|
822
|
+
|
|
823
|
+
The whole leg is wrapped: any backend/parse/IO failure degrades to
|
|
824
|
+
:class:`OracleUnavailable` (the fill path never crashes).
|
|
825
|
+
"""
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
if oracle is None:
|
|
829
|
+
from ...rendering import resolve_hancom_oracle
|
|
830
|
+
|
|
831
|
+
oracle = resolve_hancom_oracle()
|
|
832
|
+
if not oracle.available():
|
|
833
|
+
raise OracleUnavailable("no reachable Hancom render backend")
|
|
834
|
+
fd, pdf = tempfile.mkstemp(suffix=".pdf")
|
|
835
|
+
os.close(fd)
|
|
836
|
+
try:
|
|
837
|
+
rendered = oracle.render_pdf(hwpx_path, pdf)
|
|
838
|
+
if (
|
|
839
|
+
not rendered
|
|
840
|
+
or not os.path.exists(rendered)
|
|
841
|
+
or os.path.getsize(rendered) == 0
|
|
842
|
+
):
|
|
843
|
+
raise OracleUnavailable("Hancom render produced no PDF")
|
|
844
|
+
glyphs = extract_glyph_boxes(rendered, page=page)
|
|
845
|
+
clips = extract_cell_clips(rendered, page=page)
|
|
846
|
+
import fitz
|
|
847
|
+
|
|
848
|
+
with fitz.open(rendered) as doc:
|
|
849
|
+
if len(doc) == 0:
|
|
850
|
+
raise OracleUnavailable(
|
|
851
|
+
"Hancom render produced an empty (0-page) PDF"
|
|
852
|
+
)
|
|
853
|
+
sizes = [(float(p.rect.width), float(p.rect.height)) for p in doc]
|
|
854
|
+
return glyphs, clips, sizes, type(oracle).__name__
|
|
855
|
+
finally:
|
|
856
|
+
try:
|
|
857
|
+
os.remove(pdf)
|
|
858
|
+
except OSError:
|
|
859
|
+
pass
|
|
860
|
+
except OracleUnavailable:
|
|
861
|
+
raise
|
|
862
|
+
except Exception as exc:
|
|
863
|
+
raise OracleUnavailable(f"render backend failed: {type(exc).__name__}") from exc
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def verify_form_overflow(
|
|
867
|
+
hwpx_path: str,
|
|
868
|
+
*,
|
|
869
|
+
oracle: Any = None,
|
|
870
|
+
tol: float = _OVERFLOW_TOL_PT,
|
|
871
|
+
layout_stable: bool | None = None,
|
|
872
|
+
) -> FormFillVerdict:
|
|
873
|
+
"""Live overflow verdict for a filled form (P2 primary signal).
|
|
874
|
+
|
|
875
|
+
Renders once, auto-extracts table cell clips, and flags any glyph escaping
|
|
876
|
+
its owning cell. Absolute glyph-*overlap* is intentionally NOT gated here — it
|
|
877
|
+
is unreliable on real forms (punctuation-after-CJK, Hancom PUA symbols); the
|
|
878
|
+
overlap signal is computed *differentially* (blank vs filled) elsewhere. With
|
|
879
|
+
no detectable table cells, overflow is reported as not-evaluated (honest),
|
|
880
|
+
not a silent clean pass.
|
|
881
|
+
"""
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
glyphs, clips, _sizes, _backend = render_form_geometry(hwpx_path, oracle=oracle)
|
|
885
|
+
except OracleUnavailable as exc:
|
|
886
|
+
return unverified_verdict(str(exc))
|
|
887
|
+
overflow = detect_overflow(glyphs, clips, tol=tol)
|
|
888
|
+
return FormFillVerdict(
|
|
889
|
+
render_checked=True,
|
|
890
|
+
overflow_detected=bool(overflow),
|
|
891
|
+
overflow_checked=bool(clips),
|
|
892
|
+
overlap_detected=False,
|
|
893
|
+
layout_stable=layout_stable,
|
|
894
|
+
overflow=[
|
|
895
|
+
{
|
|
896
|
+
"box": _box_brief(b),
|
|
897
|
+
"clip": r.label,
|
|
898
|
+
"escapePt": round(r.overflow_of(b), 2),
|
|
899
|
+
}
|
|
900
|
+
for b, r in overflow
|
|
901
|
+
],
|
|
902
|
+
note="" if clips else "no table cells detected (overflow not evaluated)",
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# --- layout stability (differential: blank render vs filled render) ---------
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@dataclass(frozen=True, slots=True)
|
|
910
|
+
class LayoutSignature:
|
|
911
|
+
"""Structural fingerprint of a rendered form: page count + per-table shape.
|
|
912
|
+
|
|
913
|
+
The layout-stability signal is **differential** — a fill must not change the
|
|
914
|
+
document's structure, so we compare the blank render's signature against the
|
|
915
|
+
filled render's and never assert an absolute (Constitution V; mirrors the P1
|
|
916
|
+
lesson that absolute glyph-overlap is unusable on real forms). Page count is
|
|
917
|
+
the dominant, high-confidence signal — a fill that spills onto a new page is
|
|
918
|
+
the canonical layout collapse. Per-table ``(rows, cols)`` is the finer
|
|
919
|
+
row/column signal, extracted from *drawn table borders only*
|
|
920
|
+
(``find_tables(strategy="lines_strict")``): a structural fingerprint must be
|
|
921
|
+
text-independent, so it is judged as a multiset *delta* (blank vs filled),
|
|
922
|
+
not an absolute count.
|
|
923
|
+
|
|
924
|
+
Why ``lines_strict`` and not the ``find_tables`` default (``"lines"``): the
|
|
925
|
+
default snaps table edges to *text* positions, so injecting a value into a
|
|
926
|
+
previously-empty cell makes it hallucinate a phantom ``(2,2)``/``(1,2)`` table
|
|
927
|
+
or wobble a real one by ±1 row/col even when the drawn grid and page count are
|
|
928
|
+
unchanged. This was measured on the frozen corpus (all 6 "table-shape"
|
|
929
|
+
differential failures were page-stable phantoms; under ``lines_strict`` blank
|
|
930
|
+
and filled agree, 0 regressions across 31 pairs; ``sen-24`` has byte-identical
|
|
931
|
+
drawn rectangles yet the default strategy still invented a ``(1,2)`` table from
|
|
932
|
+
the 3 added glyphs). Borders-only detection measures the grid the fill did not
|
|
933
|
+
touch.
|
|
934
|
+
"""
|
|
935
|
+
|
|
936
|
+
page_count: int
|
|
937
|
+
table_shapes: tuple[tuple[int, int], ...] = ()
|
|
938
|
+
page_sizes: tuple[tuple[float, float], ...] = ()
|
|
939
|
+
|
|
940
|
+
@property
|
|
941
|
+
def table_shape_multiset(self) -> tuple[tuple[int, int], ...]:
|
|
942
|
+
"""Order-independent ``(rows, cols)`` multiset — table *order* is not structure."""
|
|
943
|
+
|
|
944
|
+
return tuple(sorted(self.table_shapes))
|
|
945
|
+
|
|
946
|
+
@property
|
|
947
|
+
def row_total(self) -> int:
|
|
948
|
+
return sum(rows for rows, _cols in self.table_shapes)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def extract_layout_signature(pdf_path: str) -> LayoutSignature:
|
|
952
|
+
"""Extract the structural signature (page count + per-table shape) of a PDF.
|
|
953
|
+
|
|
954
|
+
Per-page ``find_tables`` failures contribute no tables rather than crashing
|
|
955
|
+
(same tolerance as :func:`extract_cell_clips`).
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
if not fitz_available():
|
|
959
|
+
raise OracleUnavailable("PyMuPDF (fitz) is not installed")
|
|
960
|
+
import fitz
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
doc = fitz.open(pdf_path)
|
|
964
|
+
except Exception as exc: # truncated / non-PDF bytes -> degrade, never crash
|
|
965
|
+
raise OracleUnavailable(f"unreadable PDF: {type(exc).__name__}") from exc
|
|
966
|
+
shapes: list[tuple[int, int]] = []
|
|
967
|
+
sizes: list[tuple[float, float]] = []
|
|
968
|
+
with doc:
|
|
969
|
+
for page in doc:
|
|
970
|
+
sizes.append((float(page.rect.width), float(page.rect.height)))
|
|
971
|
+
try:
|
|
972
|
+
# Borders-only ("lines_strict"): a *structural* fingerprint must
|
|
973
|
+
# not react to cell text. The default strategy snaps to glyph
|
|
974
|
+
# positions and invents phantom tables from a fill's added text
|
|
975
|
+
# on the frozen corpus. Bordered form grids are detected identically either
|
|
976
|
+
# way; the text-sensitivity is the only difference we drop.
|
|
977
|
+
found = page.find_tables(strategy="lines_strict")
|
|
978
|
+
tables = found.tables if found is not None else []
|
|
979
|
+
except Exception:
|
|
980
|
+
continue # layout analysis failed on this page -> no tables, no crash
|
|
981
|
+
for table in tables:
|
|
982
|
+
rows = getattr(table, "row_count", None)
|
|
983
|
+
cols = getattr(table, "col_count", None)
|
|
984
|
+
if (
|
|
985
|
+
isinstance(rows, int)
|
|
986
|
+
and isinstance(cols, int)
|
|
987
|
+
and rows >= 0
|
|
988
|
+
and cols >= 0
|
|
989
|
+
):
|
|
990
|
+
shapes.append((rows, cols))
|
|
991
|
+
page_count = len(doc)
|
|
992
|
+
return LayoutSignature(
|
|
993
|
+
page_count=page_count, table_shapes=tuple(shapes), page_sizes=tuple(sizes)
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
@dataclass(frozen=True, slots=True)
|
|
998
|
+
class LayoutDiff:
|
|
999
|
+
"""Differential layout-stability verdict (blank render vs filled render)."""
|
|
1000
|
+
|
|
1001
|
+
stable: bool
|
|
1002
|
+
page_count_stable: bool
|
|
1003
|
+
table_shapes_stable: bool
|
|
1004
|
+
blank: LayoutSignature
|
|
1005
|
+
filled: LayoutSignature
|
|
1006
|
+
reasons: tuple[str, ...] = ()
|
|
1007
|
+
|
|
1008
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1009
|
+
return {
|
|
1010
|
+
"stable": self.stable,
|
|
1011
|
+
"pageCountStable": self.page_count_stable,
|
|
1012
|
+
"tableShapesStable": self.table_shapes_stable,
|
|
1013
|
+
"blankPages": self.blank.page_count,
|
|
1014
|
+
"filledPages": self.filled.page_count,
|
|
1015
|
+
"reasons": list(self.reasons),
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def diff_layout(
|
|
1020
|
+
blank: LayoutSignature,
|
|
1021
|
+
filled: LayoutSignature,
|
|
1022
|
+
*,
|
|
1023
|
+
require_table_shapes: bool = True,
|
|
1024
|
+
) -> LayoutDiff:
|
|
1025
|
+
"""Compare a blank render's signature to the filled render's.
|
|
1026
|
+
|
|
1027
|
+
``page_count`` must match (the hard signal). ``table_shapes`` must match as a
|
|
1028
|
+
multiset when ``require_table_shapes`` (default): a fill that grows or splits a
|
|
1029
|
+
row changes a table's ``(rows, cols)``. A corpus where ``find_tables`` proves
|
|
1030
|
+
too content-sensitive to bind can pass ``require_table_shapes=False`` to keep
|
|
1031
|
+
table shapes *advisory* (still reported in ``reasons``, but not gating
|
|
1032
|
+
``stable``) — the calibration knob, decided by measurement, not assumption.
|
|
1033
|
+
"""
|
|
1034
|
+
|
|
1035
|
+
page_count_stable = blank.page_count == filled.page_count
|
|
1036
|
+
table_shapes_stable = blank.table_shape_multiset == filled.table_shape_multiset
|
|
1037
|
+
reasons: list[str] = []
|
|
1038
|
+
if not page_count_stable:
|
|
1039
|
+
reasons.append(f"page count {blank.page_count}->{filled.page_count}")
|
|
1040
|
+
if not table_shapes_stable:
|
|
1041
|
+
reasons.append(
|
|
1042
|
+
f"table shapes {list(blank.table_shape_multiset)}"
|
|
1043
|
+
f"->{list(filled.table_shape_multiset)}"
|
|
1044
|
+
)
|
|
1045
|
+
stable = page_count_stable and (table_shapes_stable or not require_table_shapes)
|
|
1046
|
+
return LayoutDiff(
|
|
1047
|
+
stable=stable,
|
|
1048
|
+
page_count_stable=page_count_stable,
|
|
1049
|
+
table_shapes_stable=table_shapes_stable,
|
|
1050
|
+
blank=blank,
|
|
1051
|
+
filled=filled,
|
|
1052
|
+
reasons=tuple(reasons),
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def render_form_layout(
|
|
1057
|
+
hwpx_path: str, *, oracle: Any = None, page: int | None = None
|
|
1058
|
+
) -> tuple[list[WordBox], list[Rect], LayoutSignature, str]:
|
|
1059
|
+
"""Render once via Hancom and return ``(glyphs, clips, signature, backend)``.
|
|
1060
|
+
|
|
1061
|
+
One render feeds all three offline checks (overflow, overlap, layout). Any
|
|
1062
|
+
backend/parse/IO failure degrades to :class:`OracleUnavailable` (the fill path
|
|
1063
|
+
never crashes), exactly like :func:`render_form_geometry`.
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
try:
|
|
1067
|
+
if oracle is None:
|
|
1068
|
+
from ...rendering import resolve_hancom_oracle
|
|
1069
|
+
|
|
1070
|
+
oracle = resolve_hancom_oracle()
|
|
1071
|
+
if not oracle.available():
|
|
1072
|
+
raise OracleUnavailable("no reachable Hancom render backend")
|
|
1073
|
+
fd, pdf = tempfile.mkstemp(suffix=".pdf")
|
|
1074
|
+
os.close(fd)
|
|
1075
|
+
try:
|
|
1076
|
+
rendered = oracle.render_pdf(hwpx_path, pdf)
|
|
1077
|
+
if (
|
|
1078
|
+
not rendered
|
|
1079
|
+
or not os.path.exists(rendered)
|
|
1080
|
+
or os.path.getsize(rendered) == 0
|
|
1081
|
+
):
|
|
1082
|
+
raise OracleUnavailable("Hancom render produced no PDF")
|
|
1083
|
+
glyphs = extract_glyph_boxes(rendered, page=page)
|
|
1084
|
+
clips = extract_cell_clips(rendered, page=page)
|
|
1085
|
+
signature = extract_layout_signature(rendered)
|
|
1086
|
+
if signature.page_count == 0: # a 0-page render is a failed render
|
|
1087
|
+
raise OracleUnavailable("Hancom render produced an empty (0-page) PDF")
|
|
1088
|
+
return glyphs, clips, signature, type(oracle).__name__
|
|
1089
|
+
finally:
|
|
1090
|
+
try:
|
|
1091
|
+
os.remove(pdf)
|
|
1092
|
+
except OSError:
|
|
1093
|
+
pass
|
|
1094
|
+
except OracleUnavailable:
|
|
1095
|
+
raise
|
|
1096
|
+
except (
|
|
1097
|
+
Exception
|
|
1098
|
+
) as exc: # backend/copyfile/subprocess/parse -> degrade, never crash
|
|
1099
|
+
raise OracleUnavailable(f"render backend failed: {type(exc).__name__}") from exc
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def verify_form_layout_stability(
|
|
1103
|
+
filled_hwpx: str,
|
|
1104
|
+
*,
|
|
1105
|
+
blank_hwpx: str | None = None,
|
|
1106
|
+
blank_signature: LayoutSignature | None = None,
|
|
1107
|
+
oracle: Any = None,
|
|
1108
|
+
tol: float = _OVERFLOW_TOL_PT,
|
|
1109
|
+
require_table_shapes: bool = True,
|
|
1110
|
+
) -> FormFillVerdict:
|
|
1111
|
+
"""Differential overflow + layout-stability verdict for a filled form (P2).
|
|
1112
|
+
|
|
1113
|
+
The blank baseline is ``blank_signature`` when given (template-once / frozen,
|
|
1114
|
+
so a corpus pays the blank render only once), else a one-time render of
|
|
1115
|
+
``blank_hwpx``. The filled form is rendered once for overflow + its own
|
|
1116
|
+
signature, and ``layout_stable`` becomes the REAL :func:`diff_layout` verdict
|
|
1117
|
+
(no longer a passthrough). Honest-degrades to ``unverified`` when no baseline is
|
|
1118
|
+
available or any render fails (Constitution V/IX) — never a silent pass.
|
|
1119
|
+
"""
|
|
1120
|
+
|
|
1121
|
+
if blank_signature is None:
|
|
1122
|
+
if not blank_hwpx:
|
|
1123
|
+
return unverified_verdict(
|
|
1124
|
+
"no blank baseline (need blank_hwpx or blank_signature)"
|
|
1125
|
+
)
|
|
1126
|
+
try:
|
|
1127
|
+
_bg, _bc, blank_signature, _bb = render_form_layout(
|
|
1128
|
+
blank_hwpx, oracle=oracle
|
|
1129
|
+
)
|
|
1130
|
+
except OracleUnavailable as exc:
|
|
1131
|
+
return unverified_verdict(f"blank baseline render failed: {exc}")
|
|
1132
|
+
try:
|
|
1133
|
+
glyphs, clips, filled_sig, _backend = render_form_layout(
|
|
1134
|
+
filled_hwpx, oracle=oracle
|
|
1135
|
+
)
|
|
1136
|
+
except OracleUnavailable as exc:
|
|
1137
|
+
return unverified_verdict(f"filled render failed: {exc}")
|
|
1138
|
+
diff = diff_layout(
|
|
1139
|
+
blank_signature, filled_sig, require_table_shapes=require_table_shapes
|
|
1140
|
+
)
|
|
1141
|
+
overflow = detect_overflow(glyphs, clips, tol=tol)
|
|
1142
|
+
note_bits: list[str] = []
|
|
1143
|
+
if not clips:
|
|
1144
|
+
note_bits.append("no table cells detected (overflow not evaluated)")
|
|
1145
|
+
if not diff.stable:
|
|
1146
|
+
note_bits.append("layout unstable: " + "; ".join(diff.reasons))
|
|
1147
|
+
return FormFillVerdict(
|
|
1148
|
+
render_checked=True,
|
|
1149
|
+
overflow_detected=bool(overflow),
|
|
1150
|
+
overflow_checked=bool(clips),
|
|
1151
|
+
overlap_detected=False, # differential overlap is the next P2 slice
|
|
1152
|
+
layout_stable=diff.stable,
|
|
1153
|
+
overflow=[
|
|
1154
|
+
{
|
|
1155
|
+
"box": _box_brief(b),
|
|
1156
|
+
"clip": r.label,
|
|
1157
|
+
"escapePt": round(r.overflow_of(b), 2),
|
|
1158
|
+
}
|
|
1159
|
+
for b, r in overflow
|
|
1160
|
+
],
|
|
1161
|
+
note="; ".join(note_bits),
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def verify_form_fill_differential(
|
|
1166
|
+
blank_hwpx: str,
|
|
1167
|
+
filled_hwpx: str,
|
|
1168
|
+
*,
|
|
1169
|
+
oracle: Any = None,
|
|
1170
|
+
tol: float = _OVERFLOW_TOL_PT,
|
|
1171
|
+
require_table_shapes: bool = True,
|
|
1172
|
+
area_eps: float = _OVERLAP_AREA_EPS_PT2,
|
|
1173
|
+
area_frac: float = _OVERLAP_AREA_FRAC,
|
|
1174
|
+
) -> FormFillVerdict:
|
|
1175
|
+
"""Full P2 verdict for a filled form: overflow + layout-stability + overlap.
|
|
1176
|
+
|
|
1177
|
+
Renders blank and filled once each, then decides all three offline against the
|
|
1178
|
+
same captures:
|
|
1179
|
+
|
|
1180
|
+
* **overflow** — NEW cell-escapes the fill introduced (:func:`diff_overflow`
|
|
1181
|
+
subtracts the blank baseline; absolute overflow is baseline-prone — a dense
|
|
1182
|
+
clean form carries ~171 find_tables border-noise escapes identical in both
|
|
1183
|
+
renders);
|
|
1184
|
+
* **layout_stable** — the blank→filled structural diff (page count + table
|
|
1185
|
+
shapes), the signal that catches a reflow overflow alone misses;
|
|
1186
|
+
* **overlap_detected** — the NEW glyph collisions the fill introduced
|
|
1187
|
+
(:func:`diff_overlaps` subtracts the blank baseline), the reliable 글자 겹침
|
|
1188
|
+
signal — absolute overlap is not used (P2 calibration: a clean form has
|
|
1189
|
+
~1371 benign collisions).
|
|
1190
|
+
|
|
1191
|
+
Honest-degrades to ``unverified`` if either render fails (Constitution V/IX) —
|
|
1192
|
+
never a silent pass.
|
|
1193
|
+
"""
|
|
1194
|
+
|
|
1195
|
+
try:
|
|
1196
|
+
blank_glyphs, blank_clips, blank_sig, _bb = render_form_layout(
|
|
1197
|
+
blank_hwpx, oracle=oracle
|
|
1198
|
+
)
|
|
1199
|
+
except OracleUnavailable as exc:
|
|
1200
|
+
return unverified_verdict(f"blank render failed: {exc}")
|
|
1201
|
+
try:
|
|
1202
|
+
filled_glyphs, clips, filled_sig, _fb = render_form_layout(
|
|
1203
|
+
filled_hwpx, oracle=oracle
|
|
1204
|
+
)
|
|
1205
|
+
except OracleUnavailable as exc:
|
|
1206
|
+
return unverified_verdict(f"filled render failed: {exc}")
|
|
1207
|
+
|
|
1208
|
+
diff = diff_layout(blank_sig, filled_sig, require_table_shapes=require_table_shapes)
|
|
1209
|
+
# overflow is DIFFERENTIAL (blank baseline subtracted): dense real forms carry a
|
|
1210
|
+
# steady find_tables border-noise population of marginal escapes identical in both
|
|
1211
|
+
# renders (171 on a clean form_002) — absolute overflow would false-fail them.
|
|
1212
|
+
overflow = diff_overflow(blank_glyphs, blank_clips, filled_glyphs, clips, tol=tol)
|
|
1213
|
+
new_overlaps = diff_overlaps(
|
|
1214
|
+
blank_glyphs, filled_glyphs, area_eps=area_eps, area_frac=area_frac
|
|
1215
|
+
)
|
|
1216
|
+
note_bits: list[str] = []
|
|
1217
|
+
if not clips:
|
|
1218
|
+
note_bits.append("no table cells detected (overflow not evaluated)")
|
|
1219
|
+
if not diff.stable:
|
|
1220
|
+
note_bits.append("layout unstable: " + "; ".join(diff.reasons))
|
|
1221
|
+
if new_overlaps:
|
|
1222
|
+
note_bits.append(f"{len(new_overlaps)} new glyph overlap(s) introduced by fill")
|
|
1223
|
+
return FormFillVerdict(
|
|
1224
|
+
render_checked=True,
|
|
1225
|
+
overflow_detected=bool(overflow),
|
|
1226
|
+
overflow_checked=bool(clips),
|
|
1227
|
+
overlap_detected=bool(new_overlaps),
|
|
1228
|
+
layout_stable=diff.stable,
|
|
1229
|
+
overflow=[
|
|
1230
|
+
{
|
|
1231
|
+
"box": _box_brief(b),
|
|
1232
|
+
"clip": r.label,
|
|
1233
|
+
"escapePt": round(r.overflow_of(b), 2),
|
|
1234
|
+
}
|
|
1235
|
+
for b, r in overflow
|
|
1236
|
+
],
|
|
1237
|
+
overlap=[{"a": _box_brief(a), "b": _box_brief(b)} for a, b in new_overlaps],
|
|
1238
|
+
note="; ".join(note_bits),
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def verify_form_fill(
|
|
1243
|
+
hwpx_path: str,
|
|
1244
|
+
*,
|
|
1245
|
+
clips: Sequence[Rect] | None = None,
|
|
1246
|
+
oracle: Any = None,
|
|
1247
|
+
frozen_path: str | None = None,
|
|
1248
|
+
freeze_to: str | None = None,
|
|
1249
|
+
layout_stable: bool | None = None,
|
|
1250
|
+
) -> FormFillVerdict:
|
|
1251
|
+
"""End-to-end glyph-box verdict for a filled form (the P1 oracle path).
|
|
1252
|
+
|
|
1253
|
+
Resolution order (Constitution IV/V/VI — fail closed):
|
|
1254
|
+
|
|
1255
|
+
1. ``frozen_path`` exists → load the frozen capture. The fixture is **rebound
|
|
1256
|
+
to ``hwpx_path`` by sha256**: a mismatch (stale/forged/edited source) or
|
|
1257
|
+
missing provenance degrades to ``unverified``. A corrupt fixture degrades
|
|
1258
|
+
too (never crashes). Otherwise decide offline (no Hancom call).
|
|
1259
|
+
2. else a faithful oracle renders once → ``render_checked = True``; optionally
|
|
1260
|
+
``freeze_to`` persists the provenance-bound capture for later regression.
|
|
1261
|
+
3. else → :func:`unverified_verdict`.
|
|
1262
|
+
"""
|
|
1263
|
+
|
|
1264
|
+
if frozen_path and os.path.exists(frozen_path):
|
|
1265
|
+
try:
|
|
1266
|
+
fixture = WordBoxFixture.load(frozen_path)
|
|
1267
|
+
except Exception as exc: # corrupt/unreadable -> honest degrade, never crash
|
|
1268
|
+
return unverified_verdict(f"unreadable frozen fixture: {exc}")
|
|
1269
|
+
if not fixture.checked:
|
|
1270
|
+
return unverified_verdict(
|
|
1271
|
+
"frozen fixture is not a faithful capture (checked=false)"
|
|
1272
|
+
)
|
|
1273
|
+
if not fixture.source_sha256:
|
|
1274
|
+
return unverified_verdict("frozen fixture has no render provenance")
|
|
1275
|
+
if not (hwpx_path and os.path.exists(hwpx_path)):
|
|
1276
|
+
return unverified_verdict(
|
|
1277
|
+
"source document unavailable to bind frozen fixture"
|
|
1278
|
+
)
|
|
1279
|
+
try:
|
|
1280
|
+
src_hash = sha256_file(hwpx_path) # directory/unreadable -> OSError
|
|
1281
|
+
except OSError as exc:
|
|
1282
|
+
return unverified_verdict(
|
|
1283
|
+
f"source document unreadable to bind frozen fixture: {type(exc).__name__}"
|
|
1284
|
+
)
|
|
1285
|
+
if src_hash != fixture.source_sha256:
|
|
1286
|
+
return unverified_verdict(
|
|
1287
|
+
"frozen fixture does not match source document (stale)"
|
|
1288
|
+
)
|
|
1289
|
+
return verdict_from_boxes(
|
|
1290
|
+
fixture.boxes, clips, render_checked=True, layout_stable=layout_stable
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
try:
|
|
1294
|
+
boxes, sizes, backend = render_glyph_boxes(hwpx_path, oracle=oracle)
|
|
1295
|
+
except OracleUnavailable as exc:
|
|
1296
|
+
return unverified_verdict(str(exc))
|
|
1297
|
+
if freeze_to: # persisting the regression fixture is best-effort: never sink a faithful verdict
|
|
1298
|
+
try:
|
|
1299
|
+
src_sha = sha256_file(hwpx_path) if os.path.exists(hwpx_path) else None
|
|
1300
|
+
WordBoxFixture(
|
|
1301
|
+
boxes=boxes,
|
|
1302
|
+
page_sizes=sizes,
|
|
1303
|
+
source=os.path.basename(hwpx_path),
|
|
1304
|
+
checked=True,
|
|
1305
|
+
source_sha256=src_sha,
|
|
1306
|
+
backend=backend,
|
|
1307
|
+
).freeze(freeze_to)
|
|
1308
|
+
except Exception: # any persistence failure (OSError, ValueError on NUL path, ...) is non-fatal
|
|
1309
|
+
pass
|
|
1310
|
+
return verdict_from_boxes(
|
|
1311
|
+
boxes, clips, render_checked=True, layout_stable=layout_stable
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
__all__ = [
|
|
1316
|
+
"FormFillVerdict",
|
|
1317
|
+
"LayoutDiff",
|
|
1318
|
+
"LayoutSignature",
|
|
1319
|
+
"OracleUnavailable",
|
|
1320
|
+
"Rect",
|
|
1321
|
+
"WordBox",
|
|
1322
|
+
"WordBoxFixture",
|
|
1323
|
+
"detect_overflow",
|
|
1324
|
+
"detect_overlaps",
|
|
1325
|
+
"diff_layout",
|
|
1326
|
+
"diff_overflow",
|
|
1327
|
+
"diff_overlaps",
|
|
1328
|
+
"extract_cell_clips",
|
|
1329
|
+
"extract_glyph_boxes",
|
|
1330
|
+
"extract_layout_signature",
|
|
1331
|
+
"extract_word_boxes",
|
|
1332
|
+
"fitz_available",
|
|
1333
|
+
"render_form_geometry",
|
|
1334
|
+
"render_form_layout",
|
|
1335
|
+
"render_glyph_boxes",
|
|
1336
|
+
"sha256_file",
|
|
1337
|
+
"unverified_verdict",
|
|
1338
|
+
"verdict_from_boxes",
|
|
1339
|
+
"verify_form_fill",
|
|
1340
|
+
"verify_form_fill_differential",
|
|
1341
|
+
"verify_form_layout_stability",
|
|
1342
|
+
"verify_form_overflow",
|
|
1343
|
+
]
|