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,277 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Proxy checks for layout drift between a reference and an output HWPX.
3
+
4
+ This module does not calculate rendered page counts. It compares structural and
5
+ textual metrics that often correlate with page-layout drift.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from typing import BinaryIO, Iterable, Sequence
15
+
16
+ from hwpx import HwpxPackage
17
+ from lxml import etree # pyright: ignore[reportAttributeAccessIssue]
18
+
19
+ NS = {
20
+ "hp": "http://www.hancom.co.kr/hwpml/2011/paragraph",
21
+ "hs": "http://www.hancom.co.kr/hwpml/2011/section",
22
+ "opf": "http://www.idpf.org/2007/opf/",
23
+ }
24
+
25
+ _SHAPE_TAGS = {
26
+ "line",
27
+ "rect",
28
+ "ellipse",
29
+ "arc",
30
+ "polygon",
31
+ "curve",
32
+ "connectLine",
33
+ "textart",
34
+ "pic",
35
+ "compose",
36
+ "equation",
37
+ "ole",
38
+ "container",
39
+ }
40
+
41
+ __all__ = [
42
+ "DocumentMetrics",
43
+ "collect_metrics",
44
+ "compare_metrics",
45
+ "main",
46
+ ]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DocumentMetrics:
51
+ section_count: int
52
+ paragraph_count: int
53
+ page_break_count: int
54
+ column_break_count: int
55
+ table_count: int
56
+ shape_count: int
57
+ control_count: int
58
+ table_shapes: list[tuple[str, str, str, str, str, str]]
59
+ shape_types: list[tuple[str, int]]
60
+ control_types: list[tuple[str, int]]
61
+ text_char_total: int
62
+ text_char_total_nospace: int
63
+ paragraph_text_lengths: list[int]
64
+
65
+
66
+ def _text_of_t_node(node: etree._Element) -> str:
67
+ return "".join(node.itertext())
68
+
69
+
70
+ def _local_name(tag: str) -> str:
71
+ if "}" in tag:
72
+ return tag.split("}", 1)[1]
73
+ return tag
74
+
75
+
76
+ def _iter_section_roots(source: str | Path | bytes | BinaryIO) -> Iterable[etree._Element]:
77
+ package = HwpxPackage.open(source)
78
+ for name in package.section_paths():
79
+ yield package.get_xml(name)
80
+
81
+
82
+ def collect_metrics(source: str | Path | bytes | BinaryIO) -> DocumentMetrics:
83
+ section_roots = list(_iter_section_roots(source))
84
+
85
+ paragraphs: list[etree._Element] = []
86
+ tables: list[etree._Element] = []
87
+ table_shapes: list[tuple[str, str, str, str, str, str]] = []
88
+ shape_types: dict[str, int] = {}
89
+ control_types: dict[str, int] = {}
90
+ paragraph_text_lengths: list[int] = []
91
+ text_char_total = 0
92
+ text_char_total_nospace = 0
93
+ page_break_count = 0
94
+ column_break_count = 0
95
+
96
+ for root in section_roots:
97
+ section_paragraphs = root.xpath(".//hs:sec/hp:p", namespaces=NS)
98
+ if not section_paragraphs:
99
+ section_paragraphs = root.xpath(".//hp:p", namespaces=NS)
100
+ paragraphs.extend(section_paragraphs)
101
+
102
+ section_tables = root.xpath(".//hp:tbl", namespaces=NS)
103
+ tables.extend(section_tables)
104
+
105
+ for element in root.iter():
106
+ name = _local_name(element.tag)
107
+ if name in _SHAPE_TAGS:
108
+ shape_types[name] = shape_types.get(name, 0) + 1
109
+ if name == "ctrl":
110
+ control_counted = False
111
+ for child in element:
112
+ child_name = _local_name(child.tag)
113
+ control_types[child_name] = control_types.get(child_name, 0) + 1
114
+ control_counted = True
115
+ if not control_counted:
116
+ control_types["ctrl"] = control_types.get("ctrl", 0) + 1
117
+
118
+ for table in section_tables:
119
+ size = table.find("hp:sz", namespaces=NS)
120
+ table_shapes.append(
121
+ (
122
+ table.get("rowCnt", ""),
123
+ table.get("colCnt", ""),
124
+ size.get("width", "") if size is not None else "",
125
+ size.get("height", "") if size is not None else "",
126
+ table.get("repeatHeader", ""),
127
+ table.get("pageBreak", ""),
128
+ )
129
+ )
130
+
131
+ for paragraph in section_paragraphs:
132
+ if paragraph.get("pageBreak") == "1":
133
+ page_break_count += 1
134
+ if paragraph.get("columnBreak") == "1":
135
+ column_break_count += 1
136
+ paragraph_length = 0
137
+ for text_node in paragraph.xpath(".//hp:t", namespaces=NS):
138
+ text = _text_of_t_node(text_node)
139
+ paragraph_length += len(text)
140
+ text_char_total += len(text)
141
+ text_char_total_nospace += len("".join(text.split()))
142
+ paragraph_text_lengths.append(paragraph_length)
143
+
144
+ return DocumentMetrics(
145
+ section_count=len(section_roots),
146
+ paragraph_count=len(paragraphs),
147
+ page_break_count=page_break_count,
148
+ column_break_count=column_break_count,
149
+ table_count=len(tables),
150
+ shape_count=sum(shape_types.values()),
151
+ control_count=sum(control_types.values()),
152
+ table_shapes=table_shapes,
153
+ shape_types=sorted(shape_types.items()),
154
+ control_types=sorted(control_types.items()),
155
+ text_char_total=text_char_total,
156
+ text_char_total_nospace=text_char_total_nospace,
157
+ paragraph_text_lengths=paragraph_text_lengths,
158
+ )
159
+
160
+
161
+ def _ratio_delta(reference_value: int, output_value: int) -> float:
162
+ base = max(reference_value, 1)
163
+ return abs(output_value - reference_value) / base
164
+
165
+
166
+ def compare_metrics(
167
+ reference: DocumentMetrics,
168
+ output: DocumentMetrics,
169
+ *,
170
+ max_text_delta_ratio: float = 0.15,
171
+ max_paragraph_delta_ratio: float = 0.25,
172
+ ) -> list[str]:
173
+ errors: list[str] = []
174
+
175
+ if reference.section_count != output.section_count:
176
+ errors.append(
177
+ f"section count mismatch: ref={reference.section_count}, out={output.section_count}"
178
+ )
179
+ if reference.paragraph_count != output.paragraph_count:
180
+ errors.append(
181
+ f"paragraph count mismatch: ref={reference.paragraph_count}, out={output.paragraph_count}"
182
+ )
183
+ if reference.page_break_count != output.page_break_count:
184
+ errors.append(
185
+ "pageBreak count mismatch: "
186
+ f"ref={reference.page_break_count}, out={output.page_break_count}"
187
+ )
188
+ if reference.column_break_count != output.column_break_count:
189
+ errors.append(
190
+ "columnBreak count mismatch: "
191
+ f"ref={reference.column_break_count}, out={output.column_break_count}"
192
+ )
193
+ if reference.table_count != output.table_count:
194
+ errors.append(f"table count mismatch: ref={reference.table_count}, out={output.table_count}")
195
+ if reference.shape_count != output.shape_count:
196
+ errors.append(f"shape count mismatch: ref={reference.shape_count}, out={output.shape_count}")
197
+ if reference.control_count != output.control_count:
198
+ errors.append(
199
+ f"control count mismatch: ref={reference.control_count}, out={output.control_count}"
200
+ )
201
+ if reference.table_shapes != output.table_shapes:
202
+ errors.append("table shape mismatch (rowCnt/colCnt/width/height/repeatHeader/pageBreak)")
203
+ if reference.shape_types != output.shape_types:
204
+ errors.append("shape type histogram mismatch")
205
+ if reference.control_types != output.control_types:
206
+ errors.append("control type histogram mismatch")
207
+
208
+ text_delta = _ratio_delta(reference.text_char_total_nospace, output.text_char_total_nospace)
209
+ if text_delta > max_text_delta_ratio:
210
+ errors.append(
211
+ "total text length drift exceeded: "
212
+ f"ref={reference.text_char_total_nospace}, out={output.text_char_total_nospace}, "
213
+ f"delta={text_delta:.2%}, limit={max_text_delta_ratio:.2%}"
214
+ )
215
+
216
+ if len(reference.paragraph_text_lengths) == len(output.paragraph_text_lengths):
217
+ for index, (ref_len, out_len) in enumerate(
218
+ zip(reference.paragraph_text_lengths, output.paragraph_text_lengths),
219
+ start=1,
220
+ ):
221
+ if ref_len == 0 and out_len == 0:
222
+ continue
223
+ delta = _ratio_delta(ref_len, out_len)
224
+ if delta > max_paragraph_delta_ratio:
225
+ errors.append(
226
+ f"paragraph {index} text drift exceeded: "
227
+ f"ref={ref_len}, out={out_len}, delta={delta:.2%}, "
228
+ f"limit={max_paragraph_delta_ratio:.2%}"
229
+ )
230
+ return errors
231
+
232
+
233
+ def main(argv: Sequence[str] | None = None) -> int:
234
+ parser = argparse.ArgumentParser(
235
+ description="Reference-vs-output HWPX layout-drift proxy checker"
236
+ )
237
+ parser.add_argument("--reference", "-r", required=True, help="Reference HWPX path")
238
+ parser.add_argument("--output", "-o", required=True, help="Output HWPX path")
239
+ parser.add_argument("--max-text-delta-ratio", type=float, default=0.15)
240
+ parser.add_argument("--max-paragraph-delta-ratio", type=float, default=0.25)
241
+ parser.add_argument("--json", action="store_true", help="Print collected metrics as JSON")
242
+ args = parser.parse_args(argv)
243
+
244
+ try:
245
+ reference = collect_metrics(args.reference)
246
+ output = collect_metrics(args.output)
247
+ except Exception as exc:
248
+ print(f"ERROR: {exc}")
249
+ return 1
250
+
251
+ if args.json:
252
+ print(
253
+ json.dumps(
254
+ {"reference": asdict(reference), "output": asdict(output)},
255
+ ensure_ascii=False,
256
+ indent=2,
257
+ )
258
+ )
259
+
260
+ errors = compare_metrics(
261
+ reference,
262
+ output,
263
+ max_text_delta_ratio=args.max_text_delta_ratio,
264
+ max_paragraph_delta_ratio=args.max_paragraph_delta_ratio,
265
+ )
266
+ if errors:
267
+ print("FAIL: page guard")
268
+ for error in errors:
269
+ print(f" - {error}")
270
+ return 1
271
+
272
+ print("PASS: page guard")
273
+ return 0
274
+
275
+
276
+ if __name__ == "__main__": # pragma: no cover - CLI convenience
277
+ raise SystemExit(main())
@@ -0,0 +1,145 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Canonical Hancom rendering owner for the automation application layer.
3
+
4
+ The package owns runtime discovery, renderer execution, serialized worker
5
+ policy, fixture orchestration, and visual-QA measurement. ``python-hwpx``
6
+ supplies only renderer-neutral contracts and deterministic geometry helpers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator
12
+ from contextlib import contextmanager
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from hwpx.quality import SavePipeline
16
+ from hwpx.quality.rendering import EditMask, VisualReport
17
+
18
+ if TYPE_CHECKING:
19
+ from .oracle import (
20
+ MacHancomOracle,
21
+ NullOracle,
22
+ RenderBackend,
23
+ RenderOracle,
24
+ WindowsComOracle,
25
+ resolve_oracle,
26
+ visual_check,
27
+ )
28
+
29
+ _ORACLE_EXPORTS = (
30
+ "MacHancomOracle",
31
+ "NullOracle",
32
+ "RenderBackend",
33
+ "RenderOracle",
34
+ "WindowsComOracle",
35
+ "resolve_oracle",
36
+ "visual_check",
37
+ )
38
+
39
+
40
+ def __getattr__(name: str) -> Any:
41
+ """Load the Hancom side only when someone asks for it.
42
+
43
+ Importing this package eagerly would drag the oracle in behind any import of
44
+ a neutral contract — ``block_splits``, ``detectors``, ``diff`` — because a
45
+ submodule import initialises its package first. Those contracts exist so
46
+ exam typesetting can compute question splits with no renderer present, and
47
+ that promise is worth nothing if reaching them starts one.
48
+
49
+ Core's ``hwpx.visual`` used the same lazy hook for the same reason. The move
50
+ to this owner dropped it, and the boundary test caught it.
51
+ """
52
+ if name in _ORACLE_EXPORTS:
53
+ import importlib
54
+
55
+ return getattr(importlib.import_module(f"{__name__}.oracle"), name)
56
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
57
+
58
+
59
+ def __dir__() -> list[str]:
60
+ return sorted({*globals(), *_ORACLE_EXPORTS})
61
+
62
+
63
+ def _runtime_export(name: str) -> Any:
64
+ """Resolve a lazy export while preserving an explicit runtime patch."""
65
+
66
+ value = globals().get(name)
67
+ return value if value is not None else __getattr__(name)
68
+
69
+
70
+ class HancomRenderBackend:
71
+ """Adapt a discovered Hancom render transport to the core check protocol."""
72
+
73
+ def __init__(self, oracle: Any) -> None:
74
+ self._oracle = oracle
75
+
76
+ def available(self) -> bool:
77
+ return bool(self._oracle is not None and self._oracle.available())
78
+
79
+ def check(
80
+ self,
81
+ before_hwpx: str | None,
82
+ after_hwpx: str,
83
+ *,
84
+ edit_mask: EditMask | None = None,
85
+ diff_eps: float = 0.005,
86
+ dpi: int = 150,
87
+ work_dir: str | None = None,
88
+ keep_artifacts: bool = False,
89
+ ) -> VisualReport:
90
+ # Module-dict lookup, not a direct submodule import: a test that
91
+ # patches ``rendering.visual_check`` must still win, and __getattr__
92
+ # only runs when the name is absent from the module dict.
93
+ return _runtime_export("visual_check")(
94
+ before_hwpx,
95
+ after_hwpx,
96
+ oracle=self._oracle,
97
+ edit_mask=edit_mask,
98
+ diff_eps=diff_eps,
99
+ dpi=dpi,
100
+ work_dir=work_dir,
101
+ keep_artifacts=keep_artifacts,
102
+ )
103
+
104
+
105
+ def resolve_hancom_backend(*, dpi: int = 150, **_options: Any) -> HancomRenderBackend:
106
+ """Discover Hancom at the application boundary and return a core backend."""
107
+
108
+ return HancomRenderBackend(resolve_hancom_oracle(dpi=dpi))
109
+
110
+
111
+ def resolve_hancom_oracle(*, dpi: int = 150, **_options: Any) -> Any:
112
+ """Return the canonical Hancom transport at the automation boundary."""
113
+
114
+ return _runtime_export("resolve_oracle")(dpi=dpi)
115
+
116
+
117
+ @contextmanager
118
+ def bind_document_rendering(document: Any) -> Iterator[None]:
119
+ """Temporarily bind automation-owned rendering to one core document save."""
120
+
121
+ sentinel = object()
122
+ previous = getattr(document, "_save_pipeline", sentinel)
123
+ document._save_pipeline = SavePipeline(oracle_factory=resolve_hancom_backend)
124
+ try:
125
+ yield
126
+ finally:
127
+ if previous is sentinel:
128
+ delattr(document, "_save_pipeline")
129
+ else:
130
+ document._save_pipeline = previous
131
+
132
+
133
+ __all__ = [
134
+ "HancomRenderBackend",
135
+ "MacHancomOracle",
136
+ "NullOracle",
137
+ "RenderBackend",
138
+ "RenderOracle",
139
+ "WindowsComOracle",
140
+ "bind_document_rendering",
141
+ "resolve_hancom_backend",
142
+ "resolve_hancom_oracle",
143
+ "resolve_oracle",
144
+ "visual_check",
145
+ ]