python-hwpx-automation 6.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ ]