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,624 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Stateless specialized automation handlers for the optional MCP adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ..core.content import (
13
+ collect_full_text,
14
+ )
15
+ from ..core.document import open_doc
16
+ from ..office.authoring.advanced_generators import (
17
+ build_image_grid as build_hwpx_image_grid,
18
+ )
19
+ from ..office.authoring.advanced_generators import (
20
+ build_meeting_nameplates as build_hwpx_meeting_nameplates,
21
+ )
22
+ from ..office.authoring.advanced_generators import (
23
+ build_organization_chart as build_hwpx_organization_chart,
24
+ )
25
+ from ..office.compliance import DEFAULT_POLICY, detect_pii, mask_value
26
+ from ..office.document_ops import (
27
+ build_mail_merge as build_hwpx_mail_merge,
28
+ )
29
+ from ..office.document_ops import (
30
+ inspect_mail_merge_placeholders as inspect_hwpx_mail_merge_placeholders,
31
+ )
32
+ from ..office.exam import (
33
+ ExamParseError,
34
+ FormProfileError,
35
+ compose_exam_into_form,
36
+ measure_question_splits,
37
+ )
38
+ from ..office.form_fill.fit import FitPolicy as _FitPolicy
39
+ from ..office.form_fill.fit import seal as seal_ops
40
+ from ..office.form_fill.fit.wordbox import (
41
+ OracleUnavailable,
42
+ extract_image_boxes,
43
+ render_glyph_boxes,
44
+ )
45
+ from ..office.rendering import NullOracle
46
+ from ..office.rendering import resolve_hancom_oracle as resolve_oracle
47
+ from ..storage import (
48
+ build_hwpx_open_safety_report,
49
+ )
50
+ from ..utils.helpers import resolve_path
51
+ from ._shared import (
52
+ _decode_image_base64,
53
+ _id_integrity_payload,
54
+ _revision_guard,
55
+ _save_doc_verification,
56
+ _with_dry_run_verification,
57
+ _with_save_verification,
58
+ )
59
+
60
+ # Retain the established handler-module binding used by ownership consumers.
61
+ FitPolicy = _FitPolicy
62
+
63
+
64
+ def inspect_mail_merge_placeholders(filename: str) -> dict:
65
+ """메일머지 템플릿의 placeholder key를 확인합니다."""
66
+ if inspect_hwpx_mail_merge_placeholders is None:
67
+ raise RuntimeError("installed python-hwpx does not provide mail merge tools")
68
+ return inspect_hwpx_mail_merge_placeholders(resolve_path(filename))
69
+
70
+
71
+ def _mail_merge_data_source(
72
+ data_rows: list | dict | None, data_filename: str | None
73
+ ) -> Any:
74
+ if data_rows is not None:
75
+ return data_rows
76
+ if data_filename:
77
+ return resolve_path(data_filename)
78
+ raise ValueError("provide data_rows or data_filename")
79
+
80
+
81
+ def _mail_merge_open_safety_summary(report: dict) -> dict:
82
+ row_reports = list(report.get("rows") or [])
83
+ checked = 0
84
+ failures: list[dict[str, Any]] = []
85
+ for row in row_reports:
86
+ open_safety = row.get("openSafety")
87
+ if not isinstance(open_safety, dict):
88
+ continue
89
+ checked += 1
90
+ if not bool(open_safety.get("ok")):
91
+ failures.append(
92
+ {
93
+ "rowIndex": row.get("rowIndex"),
94
+ "filename": row.get("filename"),
95
+ "summary": open_safety.get("summary"),
96
+ }
97
+ )
98
+ return {
99
+ "ok": checked == int(report.get("createdCount", 0)) and not failures,
100
+ "checkedCount": checked,
101
+ "failureCount": len(failures),
102
+ "failures": failures,
103
+ }
104
+
105
+
106
+ def mail_merge(
107
+ template_filename: str,
108
+ data_rows: list | dict = None, # type: ignore[assignment] # Frozen ToolSpec default.
109
+ data_filename: str = None,
110
+ output_dir: str = None,
111
+ filename_pattern: str = "{index:03d}.hwpx",
112
+ zip_filename: str = None,
113
+ strict: bool = False,
114
+ split_newlines: bool = True,
115
+ fit_mode: str | None = None,
116
+ max_lines: int = 1,
117
+ ) -> dict:
118
+ """템플릿 HWPX와 CSV/JSON/XLSX(명부)/rows 데이터로 N부를 생성합니다.
119
+
120
+ ``fit_mode`` (keep·wrap·shrink·wrap_then_shrink·expand_row·truncate_with_report·
121
+ fail_on_overflow)를 주면 **fit-aware** 배치가 됩니다: 각 placeholder 슬롯을 템플릿에서
122
+ 한 번 측정(template-once-measure)하고, 슬롯을 넘치거나 필수값이 빠진 레코드를
123
+ ``needsReview[]``/``skipped[]`` 로 격리합니다(나머지 배치는 안전). zip/openSafety
124
+ evidence 동봉.
125
+ """
126
+ if build_hwpx_mail_merge is None:
127
+ raise RuntimeError("installed python-hwpx does not provide mail merge tools")
128
+ data_source = _mail_merge_data_source(data_rows, data_filename)
129
+ report = build_hwpx_mail_merge(
130
+ resolve_path(template_filename),
131
+ data_source,
132
+ output_dir=resolve_path(output_dir) if output_dir else None,
133
+ filename_pattern=filename_pattern,
134
+ zip_path=resolve_path(zip_filename) if zip_filename else None,
135
+ strict=strict,
136
+ split_newlines=split_newlines,
137
+ fit_mode=fit_mode,
138
+ max_lines=max_lines,
139
+ )
140
+ open_safety = _mail_merge_open_safety_summary(report)
141
+ report["openSafety"] = open_safety
142
+ report["verification"] = {
143
+ "openSafety": open_safety,
144
+ "createdCount": report.get("createdCount", 0),
145
+ "rowCount": report.get("rowCount", 0),
146
+ "rowsWithIssues": report.get("rowsWithIssues", []),
147
+ "zip": report.get("zip"),
148
+ }
149
+ return report
150
+
151
+
152
+ def _single_block_plan(block: dict, *, title: str = "") -> dict:
153
+ return {
154
+ "schemaVersion": "hwpx.document_plan.v2",
155
+ "title": title,
156
+ "sections": [{"blocks": [block]}],
157
+ }
158
+
159
+
160
+ def build_image_grid(
161
+ images: list,
162
+ columns: int = 2,
163
+ image_width_mm: float = None,
164
+ title: str = "사진대지",
165
+ ) -> dict:
166
+ """사진 목록을 plan v2 image_grid block과 생성 가능한 document_plan으로 변환합니다."""
167
+ if build_hwpx_image_grid is None:
168
+ raise RuntimeError(
169
+ "installed python-hwpx does not provide image_grid generator"
170
+ )
171
+ block = build_hwpx_image_grid(
172
+ images or [],
173
+ columns=columns,
174
+ image_width_mm=image_width_mm,
175
+ )
176
+ return {
177
+ "block": block,
178
+ "document_plan": _single_block_plan(block, title=title),
179
+ "next_tool": "create_document_from_plan",
180
+ }
181
+
182
+
183
+ def build_meeting_nameplates(
184
+ names: list[str],
185
+ size: str = "150x70",
186
+ columns: int = 2,
187
+ title: str = "회의 명패",
188
+ ) -> dict:
189
+ """참석자 명단을 회의 명패 table block과 document_plan으로 변환합니다."""
190
+ if build_hwpx_meeting_nameplates is None:
191
+ raise RuntimeError(
192
+ "installed python-hwpx does not provide meeting nameplate generator"
193
+ )
194
+ block = build_hwpx_meeting_nameplates(names or [], size=size, columns=columns)
195
+ return {
196
+ "block": block,
197
+ "document_plan": _single_block_plan(block, title=title),
198
+ "next_tool": "create_document_from_plan",
199
+ }
200
+
201
+
202
+ def build_organization_chart(
203
+ hierarchy: dict | list,
204
+ max_depth: int = 3,
205
+ title: str = "조직도",
206
+ ) -> dict:
207
+ """2~3단 계층 데이터를 표 기반 조직도 block과 document_plan으로 변환합니다."""
208
+ if build_hwpx_organization_chart is None:
209
+ raise RuntimeError(
210
+ "installed python-hwpx does not provide organization chart generator"
211
+ )
212
+ block = build_hwpx_organization_chart(hierarchy or {}, max_depth=max_depth)
213
+ return {
214
+ "block": block,
215
+ "document_plan": _single_block_plan(block, title=title),
216
+ "next_tool": "create_document_from_plan",
217
+ }
218
+
219
+
220
+ def scan_personal_info(filename: str | None = None, text: str | None = None) -> dict:
221
+ """문서/텍스트의 개인정보(PII)를 탐지하는 read-only 감사 (원본값 미노출).
222
+
223
+ 기계검증 세트(주민등록번호·휴대폰·이메일·카드)는 항상 high-confidence, 맥락형(계좌·주소·이름)은
224
+ 라벨 게이트 low-confidence. 반환은 유형별 건수 + **마스킹된 예시만** — 원본 PII는 절대 반환하지 않습니다.
225
+ 마스킹은 따로 하지 않고 이 도구는 탐지/감사만 합니다(마스킹은 fill/merge/extract 도구의 `mask` 기본 ON).
226
+ """
227
+ if text is None:
228
+ if not filename:
229
+ return {"error": "filename 또는 text 중 하나가 필요합니다."}
230
+ text = collect_full_text(open_doc(resolve_path(filename)))
231
+ spans = detect_pii(text or "", DEFAULT_POLICY)
232
+ by_type: dict[str, dict] = {}
233
+ for span in spans:
234
+ kind = span["type"]
235
+ bucket = by_type.setdefault(
236
+ kind,
237
+ {
238
+ "type": kind,
239
+ "confidence": span["confidence"],
240
+ "count": 0,
241
+ "maskedExamples": [],
242
+ },
243
+ )
244
+ bucket["count"] += 1
245
+ if len(bucket["maskedExamples"]) < 3:
246
+ bucket["maskedExamples"].append(
247
+ mask_value(span["value"], kind, DEFAULT_POLICY)
248
+ )
249
+ return {
250
+ "report_version": "pii-scan-v1",
251
+ "total": len(spans),
252
+ "byType": list(by_type.values()),
253
+ "machineSet": ["rrn", "phone", "email", "card"],
254
+ "note": "기계세트=high-confidence 항상 탐지; 맥락형(account/address/name)=라벨게이트 low-confidence. 원본값 미반환(마스킹 예시만).",
255
+ }
256
+
257
+
258
+ def _nearest_rect(rects: list, center: tuple[float, float]):
259
+ cx, cy = center
260
+ return min(
261
+ rects,
262
+ key=lambda r: ((r.x0 + r.x1) / 2 - cx) ** 2 + ((r.y0 + r.y1) / 2 - cy) ** 2,
263
+ )
264
+
265
+
266
+ def _check_seal_compliance_impl(
267
+ path: str,
268
+ sender_text: str,
269
+ *,
270
+ tol_pt: float,
271
+ expected_center: tuple[float, float] | None = None,
272
+ ) -> dict[str, Any]:
273
+ """Render *path* via the Hancom oracle and decide the 직인 rule (pass/fail).
274
+
275
+ The seal is a picture, so it is located with ``extract_image_boxes`` (not text);
276
+ the 발신명의 anchor is found in the same render. Degrades to
277
+ ``renderChecked=False`` when no oracle is reachable — never a silent pass.
278
+ """
279
+
280
+ fd, pdf = tempfile.mkstemp(prefix="seal_verify_", suffix=".pdf")
281
+ os.close(fd)
282
+ try:
283
+ boxes, _sizes, backend = render_glyph_boxes(path, out_pdf=pdf)
284
+ seal_rects = extract_image_boxes(pdf)
285
+ except OracleUnavailable as exc:
286
+ return {
287
+ "ok": False,
288
+ "renderChecked": False,
289
+ "note": "한컴 오라클이 없어 직인 배치를 검증할 수 없습니다.",
290
+ "reason": str(exc),
291
+ }
292
+ finally:
293
+ try:
294
+ os.unlink(pdf)
295
+ except OSError:
296
+ pass
297
+
298
+ if not seal_rects:
299
+ return {
300
+ "ok": False,
301
+ "renderChecked": True,
302
+ "note": "렌더에 직인 이미지가 없습니다 (isEmbeded 누락 또는 미배치).",
303
+ }
304
+ anchor = seal_ops.find_seal_anchor(boxes, sender_text)
305
+ center = expected_center or (anchor.center if anchor is not None else None)
306
+ seal_rect = (
307
+ _nearest_rect(seal_rects, center) if center is not None else seal_rects[-1]
308
+ )
309
+ verdict = seal_ops.check_seal_placement(
310
+ boxes, seal_rect, sender_text, tol_pt=tol_pt
311
+ )
312
+ out: dict[str, Any] = {"ok": verdict.ok, "renderChecked": True, "backend": backend}
313
+ out.update(verdict.to_dict())
314
+ return out
315
+
316
+
317
+ def place_seal(
318
+ filename: str,
319
+ sender_text: str,
320
+ image_base64: str,
321
+ image_format: str = "png",
322
+ seal_width_mm: float = 25.0,
323
+ seal_height_mm: float | None = None,
324
+ anchor_x: float | None = None,
325
+ anchor_y: float | None = None,
326
+ anchor_page: int | None = None,
327
+ verify: bool = True,
328
+ tol_pt: float = 6.0,
329
+ output: str | None = None,
330
+ dry_run: bool = False,
331
+ expected_revision: str = None,
332
+ ) -> dict:
333
+ """발신명의(issuer line) 끝글자에 직인/관인을 floating으로 찍습니다 (FR-003).
334
+
335
+ 한컴 렌더 오라클로 발신명의 위치(앵커)를 찾아 직인을 그 위에 스탬프합니다
336
+ (textWrap=IN_FRONT_OF_TEXT — 겹친 글자를 밀지 않음). 오라클이 없으면 ``anchor_x``/
337
+ ``anchor_y`` 로 PDF 포인트 앵커를 직접 지정할 수 있고, 둘 다 없으면
338
+ ``renderChecked=false`` 로 정직하게 degrade 합니다(임의 배치 금지). ``verify=True``
339
+ 이면 저장 후 재렌더로 직인이 발신명의에 규칙대로 찍혔는지 검증합니다.
340
+ """
341
+ path = resolve_path(filename)
342
+ guard = _revision_guard(path, expected_revision)
343
+ if guard is not None:
344
+ return guard
345
+ target_path = resolve_path(output) if output else path
346
+ image_data = _decode_image_base64(image_base64)
347
+
348
+ # 1) locate the 발신명의 anchor — explicit override, else the render oracle.
349
+ if anchor_x is not None and anchor_y is not None:
350
+ anchor_center = (float(anchor_x), float(anchor_y))
351
+ anchor_page_resolved = anchor_page
352
+ anchor_source = "explicit"
353
+ else:
354
+ try:
355
+ boxes, _sizes, backend = render_glyph_boxes(path)
356
+ except OracleUnavailable as exc:
357
+ return {
358
+ "ok": False,
359
+ "filename": filename,
360
+ "renderChecked": False,
361
+ "note": "한컴 오라클이 없어 발신명의 위치를 찾을 수 없습니다. anchor_x/anchor_y(PDF pt)로 직접 지정하세요.",
362
+ "reason": str(exc),
363
+ }
364
+ anchor = seal_ops.find_seal_anchor(boxes, sender_text)
365
+ if anchor is None:
366
+ return {
367
+ "ok": False,
368
+ "filename": filename,
369
+ "renderChecked": True,
370
+ "note": f"발신명의 '{sender_text}'를 렌더에서 찾지 못했습니다.",
371
+ }
372
+ anchor_center = anchor.center
373
+ anchor_page_resolved = anchor.glyph.page
374
+ anchor_source = backend
375
+
376
+ # 2) stamp the floating seal on the anchor.
377
+ doc = open_doc(path)
378
+ placement = seal_ops.place_seal(
379
+ doc,
380
+ image_data=image_data,
381
+ image_format=image_format,
382
+ sender_text=sender_text,
383
+ anchor_center_pt=anchor_center,
384
+ seal_width_mm=seal_width_mm,
385
+ seal_height_mm=seal_height_mm,
386
+ page=anchor_page_resolved,
387
+ )
388
+ if not placement.placed:
389
+ return {
390
+ "ok": False,
391
+ "filename": filename,
392
+ "placement": placement.to_dict(),
393
+ "note": placement.note or "발신명의 문단을 찾지 못했습니다.",
394
+ }
395
+
396
+ result = {
397
+ "ok": True,
398
+ "filename": filename,
399
+ "outputPath": target_path,
400
+ "anchorSource": anchor_source,
401
+ "placement": placement.to_dict(),
402
+ "idIntegrity": _id_integrity_payload(doc),
403
+ }
404
+ if dry_run:
405
+ return _with_dry_run_verification(result, doc, target_path)
406
+ verification = _save_doc_verification(doc, target_path)
407
+ result = _with_save_verification(result, verification)
408
+
409
+ # 3) optional oracle re-verify against the realized seal center.
410
+ if verify:
411
+ expected_center = (
412
+ (placement.horz_offset + placement.seal_width_hu / 2) / 100.0,
413
+ (placement.vert_offset + placement.seal_height_hu / 2) / 100.0,
414
+ )
415
+ result["sealVerdict"] = _check_seal_compliance_impl(
416
+ target_path, sender_text, tol_pt=tol_pt, expected_center=expected_center
417
+ )
418
+ return result
419
+
420
+
421
+ def check_seal_compliance(
422
+ filename: str,
423
+ sender_text: str,
424
+ tol_pt: float = 6.0,
425
+ ) -> dict:
426
+ """직인이 발신명의 끝글자에 규칙대로 찍혔는지 pass/fail 검사 (FR-003).
427
+
428
+ 한컴으로 렌더해 직인 이미지의 실제 위치를 발신명의 앵커와 비교합니다(중심 tol 이내 +
429
+ 의도치 않은 글자 가림 없음). 평가자가 그대로 돌릴 수 있는 차별적 검사 — 잘 찍힌 직인은
430
+ pass, 어긋난 직인은 fail. 오라클이 없으면 ``renderChecked=false`` 로 degrade 합니다.
431
+ """
432
+ path = resolve_path(filename)
433
+ return _check_seal_compliance_impl(path, sender_text, tol_pt=tol_pt)
434
+
435
+
436
+ def compose_exam(
437
+ form_filename: str,
438
+ output: str,
439
+ exam_md: str | None = None,
440
+ exam_md_filename: str | None = None,
441
+ max_rounds: int = 2,
442
+ verify: bool = True,
443
+ role_style_names: dict[str, str] | None = None,
444
+ ) -> dict:
445
+ """출제 md를 학교 양식 .hwpx에 그 양식의 기존 스타일로 재조판합니다 (시험지 조판).
446
+
447
+ 각 문항이 단/쪽 경계에서 잘리지 않게(keep-together) 하고, 관리박스·머리글/꼬리글은
448
+ 무손실 보존하며, [그림N]/[표N]/[식N]은 텍스트 placeholder로 남깁니다(사람이 그림을
449
+ 나중에 삽입). ``exam_md``(인라인 문자열) 또는 ``exam_md_filename``(경로) 중 정확히
450
+ 하나를 줍니다. ``verify=True``(기본)면 한컴 렌더로 문항-split/overflow/placeholder를
451
+ 검증하고 — 오라클이 없으면 ``renderChecked=false`` 로 정직하게 degrade — ``verify=False``
452
+ 면 렌더 없이 조판만 합니다. 한컴이 본문을 벡터 커브로 export 하는 양식은
453
+ ``splits=null`` + ``needsReview=true`` 로 정직 보고합니다(렌더 이미지로 시각 검증 필요).
454
+ 잘못된 md/양식은 조용히 틀린 문서를 내지 않고 ``ok=false`` 로 실패합니다.
455
+ """
456
+ if compose_exam_into_form is None:
457
+ return {
458
+ "ok": False,
459
+ "filename": form_filename,
460
+ "renderChecked": False,
461
+ "needsReview": True,
462
+ "note": "이 python-hwpx 빌드에는 hwpx.exam 조판 모듈이 없습니다.",
463
+ }
464
+ if (exam_md is None) == (exam_md_filename is None):
465
+ return {
466
+ "ok": False,
467
+ "filename": form_filename,
468
+ "renderChecked": False,
469
+ "needsReview": True,
470
+ "note": "exam_md(인라인) 또는 exam_md_filename(경로) 중 정확히 하나를 지정하세요.",
471
+ }
472
+
473
+ form_path = resolve_path(form_filename)
474
+ if exam_md_filename is not None:
475
+ try:
476
+ exam_md = Path(resolve_path(exam_md_filename)).read_text(encoding="utf-8")
477
+ except (OSError, UnicodeDecodeError) as exc:
478
+ return {
479
+ "ok": False,
480
+ "filename": form_filename,
481
+ "renderChecked": False,
482
+ "needsReview": True,
483
+ "error": type(exc).__name__,
484
+ "note": f"exam_md_filename을 읽을 수 없습니다: {exc}",
485
+ }
486
+ out_path = resolve_path(output)
487
+ oracle = None if verify else NullOracle()
488
+ assert exam_md is not None # narrowed by the XOR guard and file-load branch
489
+ try:
490
+ result = compose_exam_into_form(
491
+ form_path,
492
+ exam_md,
493
+ out_path,
494
+ oracle=oracle,
495
+ max_rounds=max_rounds,
496
+ role_style_names=role_style_names,
497
+ )
498
+ except (ExamParseError, FormProfileError) as exc:
499
+ return {
500
+ "ok": False,
501
+ "filename": form_filename,
502
+ "renderChecked": False,
503
+ "needsReview": True,
504
+ "error": type(exc).__name__,
505
+ "note": str(exc),
506
+ }
507
+
508
+ payload = {
509
+ "ok": True,
510
+ "filename": form_filename,
511
+ "outputPath": out_path,
512
+ "renderChecked": result.render_checked,
513
+ "splits": result.splits,
514
+ "overflow": result.overflow,
515
+ "placeholdersOk": result.placeholders_ok,
516
+ "rounds": result.rounds,
517
+ "needsReview": result.needs_review,
518
+ "notes": list(result.notes),
519
+ }
520
+ payload["openSafety"] = build_hwpx_open_safety_report(Path(out_path))
521
+ return payload
522
+
523
+
524
+ def verify_question_splits(
525
+ filename: str,
526
+ valid_question_numbers: list[str] | None = None,
527
+ marker_regex: str | None = None,
528
+ ) -> dict:
529
+ """한컴 렌더로 시험지 .hwpx의 문항이 단/쪽 경계에서 잘렸는지(문항-split) 검증합니다.
530
+
531
+ 오라클이 없으면 ``renderChecked=false`` 로 정직하게 degrade 합니다(임의 0 금지).
532
+ 한컴이 본문을 벡터 커브로 export 해 추출 텍스트에 조판 문항이 0개면(학교 원안지
533
+ 양식에서 관측) ``splits=null`` + ``needsReview=true`` 로 보고하고 렌더 이미지 기반
534
+ 시각 검증을 요구합니다. ``valid_question_numbers`` 로 측정 대상 문항을 한정하면 양식
535
+ chrome(예: "2026." 연도)이 가짜 문항 블록을 열지 않습니다.
536
+ """
537
+ if measure_question_splits is None:
538
+ return {
539
+ "ok": False,
540
+ "filename": filename,
541
+ "renderChecked": False,
542
+ "needsReview": True,
543
+ "note": "이 python-hwpx 빌드에는 hwpx.exam 조판/측정 모듈이 없습니다.",
544
+ }
545
+ marker_re = None
546
+ if marker_regex:
547
+ try:
548
+ marker_re = re.compile(marker_regex)
549
+ except re.error as exc:
550
+ return {
551
+ "ok": False,
552
+ "filename": filename,
553
+ "renderChecked": False,
554
+ "splits": None,
555
+ "needsReview": True,
556
+ "error": "InvalidRegex",
557
+ "note": f"marker_regex가 올바른 정규식이 아닙니다: {exc}",
558
+ }
559
+ path = resolve_path(filename)
560
+ oracle = resolve_oracle()
561
+ if not oracle.available():
562
+ return {
563
+ "ok": True,
564
+ "filename": filename,
565
+ "renderChecked": False,
566
+ "splits": None,
567
+ "needsReview": True,
568
+ "note": "한컴 오라클이 없어 문항 split을 측정할 수 없습니다 (renderChecked=false).",
569
+ }
570
+ pdf = oracle.render_pdf(path)
571
+ if not pdf:
572
+ return {
573
+ "ok": False,
574
+ "filename": filename,
575
+ "renderChecked": False,
576
+ "splits": None,
577
+ "needsReview": True,
578
+ "note": "한컴 렌더가 PDF를 생성하지 못했습니다 (renderChecked=false).",
579
+ }
580
+ kwargs: dict[str, Any] = {}
581
+ if valid_question_numbers is not None:
582
+ kwargs["valid_ids"] = {str(n) for n in valid_question_numbers}
583
+ if marker_re is not None:
584
+ kwargs["marker_re"] = marker_re
585
+ report = measure_question_splits(pdf, **kwargs)
586
+ if report.n_blocks == 0:
587
+ return {
588
+ "ok": True,
589
+ "filename": filename,
590
+ "renderChecked": True,
591
+ "splits": None,
592
+ "needsReview": True,
593
+ "nBlocks": 0,
594
+ "note": (
595
+ "추출 가능한 텍스트 레이어에서 조판 문항을 찾지 못했습니다(벡터 커브 export 양식"
596
+ " 추정). 문항-split은 텍스트 게이트로 검증할 수 없습니다 — 렌더 이미지로 시각"
597
+ " 검증이 필요합니다."
598
+ ),
599
+ }
600
+ return {
601
+ "ok": True,
602
+ "filename": filename,
603
+ "renderChecked": True,
604
+ "splits": report.n_splits,
605
+ "kinds": dict(report.kinds),
606
+ "splitIds": list(report.split_ids),
607
+ "nBlocks": report.n_blocks,
608
+ "nGlyphs": report.n_glyphs,
609
+ "needsReview": report.n_splits > 0,
610
+ }
611
+
612
+
613
+ __all__ = [
614
+ "build_image_grid",
615
+ "build_meeting_nameplates",
616
+ "build_organization_chart",
617
+ "check_seal_compliance",
618
+ "compose_exam",
619
+ "inspect_mail_merge_placeholders",
620
+ "mail_merge",
621
+ "place_seal",
622
+ "scan_personal_info",
623
+ "verify_question_splits",
624
+ ]