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,704 @@
1
+ """Form guidance scanner — 임의 양식의 안내문·placeholder·색 신호 정찰 (비변형).
2
+
3
+ 목적: 처음 보는 양식(예시·안내문·지워야 할 내용 혼재)에서 "지울 것 / 수정·채울 것 /
4
+ 그대로 둘 것" 후보를 위치·근거·신뢰도와 함께 보고한다. 채움을 실행하지 않는다.
5
+
6
+ 좌표 규약: table_index는 섹션 내 ``<hp:tbl>`` 문서순(중첩 포함 pre-order) —
7
+ ``table_patch``(fill_cells/apply_table_ops)와 동일하므로 후속 op 주소로 그대로 쓸 수 있다.
8
+ row/col은 ``<hp:cellAddr>`` 값(병합 앵커 좌표).
9
+
10
+ 정직 한계(리포트에도 명시):
11
+ - 색 신호는 run의 charPrIDRef가 가리키는 charPr textColor 기준. charPrIDRef가 없는
12
+ run은 기본 charPr("0")로 간주한다.
13
+ - 배경색(cell borderFill/shade) 기반 신호는 이 스캐너가 다루지 않는다.
14
+ - 여기서 내는 것은 "후보"다 — 삭제/수정 확정은 사용자 승인 후에만 한다.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Iterable
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from hwpx.document import HwpxDocument
25
+ from hwpx.oxml.namespaces import tag_local_name
26
+
27
+ __all__ = [
28
+ "Candidate",
29
+ "CellLoc",
30
+ "GuidanceReport",
31
+ "LegendBinding",
32
+ "ScannedParagraph",
33
+ "is_form_instruction",
34
+ "scan_form_guidance",
35
+ ]
36
+
37
+ # 도형 순회는 markdown_export와 동일 정책: rect/ellipse/polygon만.
38
+ _SHAPE_TAGS = ("rect", "ellipse", "polygon")
39
+
40
+ # ── placeholder / 안내 패턴 ─────────────────────────────────────────
41
+ _PLACEHOLDER_PATTERNS: dict[str, re.Pattern[str]] = {
42
+ "circle_blank": re.compile(r"[◯○〇]{2,}"),
43
+ "square_blank": re.compile(r"□{2,}"),
44
+ "double_star": re.compile(r"\*\*"),
45
+ "example_marker": re.compile(r"[(〈[\[]\s*예시?\s*[)〉]\]]"),
46
+ "howto_marker": re.compile(r"(작성|기재)\s*요령"),
47
+ }
48
+ _GUIDANCE_KEYWORD = re.compile(
49
+ r"(유의\)|※|삭제\s*바랍니다|삭제합니다|삭제!!|문의주세요|작성해\s*주세요|맞추어\s*작성)"
50
+ )
51
+ _CONDITIONAL_CHOICE = re.compile(
52
+ r"중에서.{0,20}(만\s*남기|남기고|선택)|해당하는\s*것만\s*남기"
53
+ )
54
+
55
+ # 검정(무색) 안내 문단까지 걸러내는 확장 렉시콘. _GUIDANCE_KEYWORD는 색 후보
56
+ # 스캔용이라 "교과별로 수정"·색 범례 문장 등 검정 지시문을 놓친다. 아래는 한국어
57
+ # 양식의 작성-안내 상투 문구(과목/학년 비의존)로, 색이 아니라 텍스트로만 지시문을
58
+ # 판정해야 하는 문단(빨강 지시는 색 strip이 처리)에 쓴다.
59
+ # 검정(무색) 안내 문단 판정용 지시-문구 렉시콘. "유의)"·"※" 같은 *모호한* 마커는
60
+ # 정상 유의사항 본문에도 쓰이므로 일부러 제외한다(over-삭제 방지) — 색으로 표시된
61
+ # 지시는 body_patch.strip_runs_by_color가 별도로 지운다.
62
+ _FORM_INSTRUCTION = re.compile(
63
+ r"삭제\s*바랍니다|삭제합니다|삭제!!|문의\s*주세요|"
64
+ r"작성해\s*주세요|작성해주세요|수정해\s*주세요|수정해주세요|맞추어\s*작성|"
65
+ r"교과별로\s*수정|교과별\s*특성이\s*드러나게|재구성하여\s*작성|이하는\s*작성|"
66
+ r"해당하는\s*것만\s*남기|추정분할점수|"
67
+ r"(검정|검은|빨간|빨강|파란|파랑)\s*(색\s*)?글씨|(빨간|파란)\s*글자"
68
+ )
69
+
70
+
71
+ def is_form_instruction(text: str) -> bool:
72
+ """양식 작성-안내 문단(삭제 지시·색 범례·"교과별로 수정" 등)인지 판정한다.
73
+
74
+ 과목/학년 비의존 — 한국어 양식 지시 상투 문구 렉시콘 기반이다. 색 정보 없이
75
+ 텍스트만으로 지시문을 걸러야 하는 검정 안내 문단용이며, 색으로 표시된 지시는
76
+ ``body_patch.strip_runs_by_color``가 별도로 처리한다. 렉시콘 밖의 *신규* 지시
77
+ 표현은 이 예/아니오로 잡히지 않는다 — 그 판단은 스킬(LLM)의 몫이다."""
78
+ return bool(_FORM_INSTRUCTION.search(text or ""))
79
+
80
+
81
+ # ── 색 범례 ────────────────────────────────────────────────────────
82
+ _LEGEND_HINT = re.compile(r"글씨|글자")
83
+ _COLOR_WORDS: dict[str, str] = {
84
+ "검정": "black",
85
+ "검은": "black",
86
+ "빨간": "red",
87
+ "빨강": "red",
88
+ "적색": "red",
89
+ "파란": "blue",
90
+ "파랑": "blue",
91
+ "청색": "blue",
92
+ "초록": "green",
93
+ "녹색": "green",
94
+ }
95
+ _ACTION_WORDS: list[tuple[re.Pattern[str], str]] = [
96
+ (re.compile(r"그대로|유지"), "keep"),
97
+ (re.compile(r"수정"), "modify"),
98
+ (re.compile(r"삭제"), "delete"),
99
+ ]
100
+
101
+
102
+ def _color_family(hex_color: str) -> str:
103
+ """#RRGGBB → 대략적 색 계열. 정확 hex 일치가 아니라 계열 매칭용."""
104
+ m = re.fullmatch(r"#([0-9A-Fa-f]{6})", hex_color or "")
105
+ if not m:
106
+ return "other"
107
+ r, g, b = (int(m.group(1)[i : i + 2], 16) for i in (0, 2, 4))
108
+ if r <= 0x30 and g <= 0x30 and b <= 0x30:
109
+ return "black"
110
+ if r >= 0xE0 and g >= 0xE0 and b >= 0xE0:
111
+ return "white"
112
+ if r >= 0x96 and g <= 0x78 and b <= 0x78:
113
+ return "red"
114
+ if b >= 0x96 and r <= 0x78:
115
+ return "blue"
116
+ if g >= 0x96 and r <= 0x78 and b <= 0x78:
117
+ return "green"
118
+ if abs(r - g) <= 0x18 and abs(g - b) <= 0x18:
119
+ return "gray"
120
+ return "other"
121
+
122
+
123
+ @dataclass(slots=True)
124
+ class CellLoc:
125
+ """표 셀 좌표 (table_patch 규약)."""
126
+
127
+ table_index: int
128
+ row: int
129
+ col: int
130
+
131
+ def label(self) -> str:
132
+ if self.row == -1 and self.col == -1:
133
+ return f"표{self.table_index} caption"
134
+ return f"표{self.table_index} r{self.row}c{self.col}"
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class ScannedParagraph:
139
+ """스캔된 문단 하나 — 본문(¶N) 또는 셀 내부."""
140
+
141
+ section_index: int
142
+ body_index: int # 소속 최상위 문단의 섹션 내 순번
143
+ cell: CellLoc | None
144
+ spans: list[tuple[str, str, str]] # (char_pr_id, hex_color, text)
145
+ first_char_pr_id: str | None = (
146
+ None # 텍스트가 비어도 서식 컨텍스트를 잃지 않기 위해
147
+ )
148
+
149
+ @property
150
+ def text(self) -> str:
151
+ return "".join(s[2] for s in self.spans)
152
+
153
+ def colors(self) -> set[str]:
154
+ return {s[1] for s in self.spans if s[2].strip()}
155
+
156
+ def location(self) -> str:
157
+ base = f"§{self.section_index}¶{self.body_index}"
158
+ return f"{base} {self.cell.label()}" if self.cell else f"{base} 본문"
159
+
160
+
161
+ @dataclass(slots=True)
162
+ class LegendBinding:
163
+ color_word: str # 범례가 말한 색 이름
164
+ family: str # red/blue/black …
165
+ exact_hex: str | None # 범례 문장 자체 채색에서 추출한 정확 hex(가능 시)
166
+ action: str # keep / modify / delete
167
+ source_text: str
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class Candidate:
172
+ location: str
173
+ signals: list[str]
174
+ confidence: str # high / medium / low
175
+ text_preview: str
176
+ cell: CellLoc | None = None
177
+
178
+
179
+ @dataclass(slots=True)
180
+ class GuidanceReport:
181
+ source: str
182
+ color_inventory: dict[str, dict] # hex → {runs, family, samples}
183
+ legend: list[LegendBinding]
184
+ delete_candidates: list[Candidate]
185
+ modify_candidates_by_table: dict[str, dict] # 위치 → 집계(파랑 등 수정 대상)
186
+ placeholder_candidates: list[Candidate]
187
+ conditional_choices: list[Candidate]
188
+ questions: list[str]
189
+ empty_cell_candidates: list[Candidate] = field(default_factory=list)
190
+ stats: dict[str, int] = field(default_factory=dict)
191
+ limitations: list[str] = field(
192
+ default_factory=lambda: [
193
+ "색 신호는 run charPr textColor 기준 — 배경색/테두리색 신호는 미커버.",
194
+ "여기 나온 것은 후보다. 삭제/수정 확정은 사용자 승인 후에만 한다.",
195
+ ]
196
+ )
197
+
198
+ def to_markdown(self) -> str:
199
+ lines = [f"# 양식 정찰 리포트 — {Path(self.source).name}", ""]
200
+ lines.append(
201
+ f"문단 {self.stats.get('paragraphs', 0)}·표 {self.stats.get('tables', 0)}"
202
+ f"·유색 run {self.stats.get('colored_runs', 0)}"
203
+ )
204
+ lines.append("")
205
+ lines.append("## ① 색 범례 (양식 자체 선언)")
206
+ if self.legend:
207
+ for b in self.legend:
208
+ hex_note = f" (본문 실색 {b.exact_hex})" if b.exact_hex else ""
209
+ lines.append(
210
+ f"- **{b.color_word}**({b.family}{hex_note}) → **{b.action}** — “{b.source_text[:60]}”"
211
+ )
212
+ else:
213
+ lines.append("- 범례 문장 미발견 — 색 의미는 질문 목록으로.")
214
+ lines.append("")
215
+ lines.append("## ② 색 인벤토리")
216
+ for hex_color, info in sorted(
217
+ self.color_inventory.items(), key=lambda kv: -kv[1]["runs"]
218
+ ):
219
+ samples = " / ".join(info["samples"][:3])
220
+ lines.append(
221
+ f"- `{hex_color}` ({info['family']}) — run {info['runs']}개 — 예: {samples[:90]}"
222
+ )
223
+ lines.append("")
224
+ lines.append(f"## ③ 지울 것 후보 ({len(self.delete_candidates)})")
225
+ for c in self.delete_candidates:
226
+ lines.append(
227
+ f"- [{c.confidence}] {c.location} — “{c.text_preview}” 〔{', '.join(c.signals)}〕"
228
+ )
229
+ lines.append("")
230
+ lines.append("## ④ 수정·채움 후보 (표별 집계)")
231
+ for where, agg in self.modify_candidates_by_table.items():
232
+ fmt = agg.get("format_context", "")
233
+ lines.append(
234
+ f"- {where}: 수정대상 문단 {agg['paragraphs']}개 — 예: {agg['sample'][:60]}"
235
+ + (f" — 서식 {fmt}" if fmt else "")
236
+ )
237
+ lines.append("")
238
+ lines.append(f"## ⑤ 빈 셀(채움 후보, {len(self.empty_cell_candidates)}) — 표별")
239
+ by_table: dict[str, list[Candidate]] = {}
240
+ for c in self.empty_cell_candidates:
241
+ key = c.cell.label().split(" ")[0] if c.cell else "본문"
242
+ by_table.setdefault(key, []).append(c)
243
+ for key, cands in by_table.items():
244
+ examples = []
245
+ for c in cands[:3]:
246
+ if c.cell is None:
247
+ examples.append(f"본문({c.text_preview})")
248
+ continue
249
+ fmt = next(
250
+ (s.split(":", 1)[1] for s in c.signals if s.startswith("format:")),
251
+ "",
252
+ )
253
+ detail = ", ".join(part for part in (c.text_preview, fmt) if part)
254
+ examples.append(f"r{c.cell.row}c{c.cell.col}({detail})")
255
+ more = f" 외 {len(cands) - 3}건" if len(cands) > 3 else ""
256
+ lines.append(
257
+ f"- {key}: 빈 셀 {len(cands)}개 — {' · '.join(examples)}{more}"
258
+ )
259
+ lines.append("")
260
+ lines.append(f"## ⑥ placeholder 후보 ({len(self.placeholder_candidates)})")
261
+ for c in self.placeholder_candidates:
262
+ lines.append(
263
+ f"- [{c.confidence}] {c.location} — “{c.text_preview}” 〔{', '.join(c.signals)}〕"
264
+ )
265
+ lines.append("")
266
+ lines.append(f"## ⑦ 조건부 선택 블록 ({len(self.conditional_choices)})")
267
+ for c in self.conditional_choices:
268
+ lines.append(f"- {c.location} — “{c.text_preview}”")
269
+ lines.append("")
270
+ lines.append("## ⑧ 질문 목록 (확신 없음 — 사용자 결정 필요)")
271
+ for q in self.questions:
272
+ lines.append(f"- {q}")
273
+ lines.append("")
274
+ lines.append("## 한계(정직)")
275
+ for lim in self.limitations:
276
+ lines.append(f"- {lim}")
277
+ return "\n".join(lines)
278
+
279
+
280
+ # ──────────────────────────────────────────────────────────────────
281
+ # 순회 — 셀 내부 포함 스타일-aware 문단 수집
282
+ # ──────────────────────────────────────────────────────────────────
283
+ def _local(el) -> str:
284
+ return tag_local_name(str(el.tag))
285
+
286
+
287
+ class _Walker:
288
+ def __init__(self, chars: dict) -> None:
289
+ self.chars = chars
290
+ self.table_counter = -1
291
+ self.out: list[ScannedParagraph] = []
292
+
293
+ def _hex_of(self, cpr: str) -> str:
294
+ style = self.chars.get(str(cpr))
295
+ if style is None:
296
+ return "#000000"
297
+ return (style.attributes.get("textColor") or "#000000").upper()
298
+
299
+ def walk_paragraph(
300
+ self, p_el, section_index: int, body_index: int, cell: CellLoc | None
301
+ ) -> None:
302
+ spans: list[tuple[str, str, str]] = []
303
+ first_cpr: str | None = None
304
+ for run in (c for c in p_el if _local(c) == "run"):
305
+ cpr = run.get("charPrIDRef", "0")
306
+ if first_cpr is None:
307
+ first_cpr = cpr
308
+ hex_color = self._hex_of(cpr)
309
+ for child in run:
310
+ tag = _local(child)
311
+ if tag == "t":
312
+ text = "".join(child.itertext())
313
+ if text:
314
+ spans.append((cpr, hex_color, text))
315
+ elif tag == "tbl":
316
+ self.walk_table(child, section_index, body_index)
317
+ elif tag in _SHAPE_TAGS or tag == "container":
318
+ self.walk_container(child, section_index, body_index, cell)
319
+ self.out.append(
320
+ ScannedParagraph(
321
+ section_index=section_index,
322
+ body_index=body_index,
323
+ cell=cell,
324
+ spans=spans,
325
+ first_char_pr_id=first_cpr,
326
+ )
327
+ )
328
+
329
+ def walk_container(
330
+ self, el, section_index: int, body_index: int, cell: CellLoc | None
331
+ ) -> None:
332
+ for child in el:
333
+ tag = _local(child)
334
+ if tag == "p":
335
+ self.walk_paragraph(child, section_index, body_index, cell)
336
+ elif tag == "tbl":
337
+ self.walk_table(child, section_index, body_index)
338
+ else:
339
+ self.walk_container(child, section_index, body_index, cell)
340
+
341
+ def walk_table(self, tbl_el, section_index: int, body_index: int) -> None:
342
+ self.table_counter += 1
343
+ table_index = self.table_counter
344
+ # 캡션(hp:caption > subList > p)도 표 소속 텍스트다 — row/col = -1 로 표기.
345
+ for cap in (c for c in tbl_el if _local(c) == "caption"):
346
+ self.walk_container(
347
+ cap,
348
+ section_index,
349
+ body_index,
350
+ CellLoc(table_index=table_index, row=-1, col=-1),
351
+ )
352
+ for tr in self._direct_rows(tbl_el):
353
+ for tc in (c for c in tr if _local(c) == "tc"):
354
+ row, col = self._cell_addr(tc)
355
+ loc = CellLoc(table_index=table_index, row=row, col=col)
356
+ for sub in (c for c in tc if _local(c) == "subList"):
357
+ for child in sub:
358
+ if _local(child) == "p":
359
+ self.walk_paragraph(child, section_index, body_index, loc)
360
+
361
+ @staticmethod
362
+ def _direct_rows(tbl_el) -> Iterable:
363
+ return (c for c in tbl_el if _local(c) == "tr")
364
+
365
+ @staticmethod
366
+ def _cell_addr(tc_el) -> tuple[int, int]:
367
+ for child in tc_el:
368
+ if _local(child) == "cellAddr":
369
+ return int(child.get("rowAddr", -1)), int(child.get("colAddr", -1))
370
+ return -1, -1
371
+
372
+
373
+ # ──────────────────────────────────────────────────────────────────
374
+ # 분석
375
+ # ──────────────────────────────────────────────────────────────────
376
+ def _parse_legend(paragraphs: list[ScannedParagraph]) -> list[LegendBinding]:
377
+ bindings: list[LegendBinding] = []
378
+ for para in paragraphs:
379
+ text = para.text
380
+ if not (_LEGEND_HINT.search(text) and any(w in text for w in _COLOR_WORDS)):
381
+ continue
382
+ has_action = any(pat.search(text) for pat, _ in _ACTION_WORDS)
383
+ if not has_action:
384
+ continue
385
+ # 절 단위로 색 단어→가장 가까운 액션 매칭
386
+ clauses = re.split(r"[/,]", text)
387
+ for clause in clauses:
388
+ found_color = next((w for w in _COLOR_WORDS if w in clause), None)
389
+ if found_color is None:
390
+ continue
391
+ action = next((a for pat, a in _ACTION_WORDS if pat.search(clause)), None)
392
+ if action is None:
393
+ continue
394
+ family = _COLOR_WORDS[found_color]
395
+ # 범례 문장 자체 채색에서 정확 hex 추출: 절 텍스트를 포함하는 span 중 계열 일치 색
396
+ exact = None
397
+ for _, hex_color, span_text in para.spans:
398
+ if (
399
+ span_text.strip()
400
+ and span_text in clause
401
+ and _color_family(hex_color) == family
402
+ ):
403
+ exact = hex_color
404
+ break
405
+ if not any(b.family == family and b.action == action for b in bindings):
406
+ bindings.append(
407
+ LegendBinding(
408
+ color_word=found_color,
409
+ family=family,
410
+ exact_hex=exact,
411
+ action=action,
412
+ source_text=clause.strip(),
413
+ )
414
+ )
415
+ return bindings
416
+
417
+
418
+ def _preview(text: str, limit: int = 80) -> str:
419
+ flat = re.sub(r"\s+", " ", text).strip()
420
+ return flat[: limit - 1] + "…" if len(flat) > limit else flat
421
+
422
+
423
+ def _format_context(para: ScannedParagraph, chars: dict) -> str:
424
+ for cpr, _, span_text in para.spans:
425
+ if not span_text.strip():
426
+ continue
427
+ style = chars.get(str(cpr))
428
+ if style is None:
429
+ return ""
430
+ height = style.attributes.get("height")
431
+ size = f"{int(height) / 100:g}pt" if height and height.isdigit() else "?"
432
+ bold = "bold" in style.child_attributes
433
+ return f"{size}{'·bold' if bold else ''}"
434
+ return ""
435
+
436
+
437
+ def _scan_resolve_source(
438
+ source: str | Path | HwpxDocument,
439
+ ) -> tuple[HwpxDocument, str]:
440
+ if isinstance(source, HwpxDocument):
441
+ return source, "<document>"
442
+ return HwpxDocument.open(str(source)), str(source)
443
+
444
+
445
+ def _scan_collect_paragraphs(
446
+ doc: HwpxDocument, chars: dict
447
+ ) -> tuple[list[ScannedParagraph], int]:
448
+ paragraphs: list[ScannedParagraph] = []
449
+ tables_total = 0
450
+ for s_idx, section in enumerate(doc.sections):
451
+ walker = _Walker(chars)
452
+ for b_idx, p in enumerate(section.paragraphs):
453
+ walker.walk_paragraph(p.element, s_idx, b_idx, None)
454
+ paragraphs.extend(walker.out)
455
+ tables_total += walker.table_counter + 1
456
+ return paragraphs, tables_total
457
+
458
+
459
+ def _scan_color_inventory(
460
+ paragraphs: list[ScannedParagraph],
461
+ ) -> tuple[dict[str, dict], int]:
462
+ inventory: dict[str, dict] = {}
463
+ colored_runs = 0
464
+ for para in paragraphs:
465
+ for _, hex_color, text in para.spans:
466
+ if not text.strip():
467
+ continue
468
+ fam = _color_family(hex_color)
469
+ if fam in ("black", "white"):
470
+ continue
471
+ colored_runs += 1
472
+ entry = inventory.setdefault(
473
+ hex_color, {"runs": 0, "family": fam, "samples": []}
474
+ )
475
+ entry["runs"] += 1
476
+ flat = _preview(text, 40)
477
+ if flat and len(entry["samples"]) < 5 and flat not in entry["samples"]:
478
+ entry["samples"].append(flat)
479
+ return inventory, colored_runs
480
+
481
+
482
+ def _scan_delete_candidate(
483
+ para: ScannedParagraph, text: str, families: set[str], delete_family: set[str]
484
+ ) -> Candidate | None:
485
+ signals: list[str] = []
486
+ if delete_family & families:
487
+ signals.append("legend:삭제색")
488
+ kw = _GUIDANCE_KEYWORD.search(text)
489
+ if kw:
490
+ signals.append(f"keyword:{kw.group(0)}")
491
+ if signals:
492
+ confidence = "high" if "legend:삭제색" in signals else "medium"
493
+ # 삭제색 신호는 색 run만 있어도 후보(문장 일부만 빨강인 경우 포함)
494
+ if "legend:삭제색" in signals or kw:
495
+ return Candidate(
496
+ location=para.location(),
497
+ signals=signals,
498
+ confidence=confidence,
499
+ text_preview=_preview(text),
500
+ cell=para.cell,
501
+ )
502
+ return None
503
+
504
+
505
+ def _scan_placeholder_candidates(para: ScannedParagraph, text: str) -> list[Candidate]:
506
+ out: list[Candidate] = []
507
+ for name, pat in _PLACEHOLDER_PATTERNS.items():
508
+ m = pat.search(text)
509
+ if m:
510
+ out.append(
511
+ Candidate(
512
+ location=para.location(),
513
+ signals=[f"placeholder:{name}"],
514
+ confidence="high"
515
+ if name in ("circle_blank", "square_blank")
516
+ else "medium",
517
+ text_preview=_preview(text),
518
+ cell=para.cell,
519
+ )
520
+ )
521
+ return out
522
+
523
+
524
+ def _scan_accumulate_modify(
525
+ modify_by_table: dict[str, dict], para: ScannedParagraph, text: str, chars: dict
526
+ ) -> None:
527
+ key = f"표{para.cell.table_index}" if para.cell else f"§{para.section_index} 본문"
528
+ agg = modify_by_table.setdefault(
529
+ key,
530
+ {
531
+ "paragraphs": 0,
532
+ "sample": _preview(text, 60),
533
+ "format_context": _format_context(para, chars),
534
+ },
535
+ )
536
+ agg["paragraphs"] += 1
537
+
538
+
539
+ def _scan_candidates(
540
+ paragraphs: list[ScannedParagraph], legend: list[LegendBinding], chars: dict
541
+ ) -> tuple[list[Candidate], list[Candidate], list[Candidate], dict[str, dict]]:
542
+ delete_family = {b.family for b in legend if b.action == "delete"}
543
+ modify_family = {b.family for b in legend if b.action == "modify"}
544
+
545
+ delete_candidates: list[Candidate] = []
546
+ placeholder_candidates: list[Candidate] = []
547
+ conditional_choices: list[Candidate] = []
548
+ modify_by_table: dict[str, dict] = {}
549
+
550
+ for para in paragraphs:
551
+ text = para.text
552
+ if not text.strip():
553
+ continue
554
+ families = {_color_family(h) for h in para.colors()}
555
+ cand = _scan_delete_candidate(para, text, families, delete_family)
556
+ if cand is not None:
557
+ delete_candidates.append(cand)
558
+ placeholder_candidates.extend(_scan_placeholder_candidates(para, text))
559
+ if _CONDITIONAL_CHOICE.search(text):
560
+ conditional_choices.append(
561
+ Candidate(
562
+ location=para.location(),
563
+ signals=["conditional_choice"],
564
+ confidence="high",
565
+ text_preview=_preview(text),
566
+ cell=para.cell,
567
+ )
568
+ )
569
+ if modify_family & families:
570
+ _scan_accumulate_modify(modify_by_table, para, text, chars)
571
+
572
+ return (
573
+ delete_candidates,
574
+ placeholder_candidates,
575
+ conditional_choices,
576
+ modify_by_table,
577
+ )
578
+
579
+
580
+ def _scan_cell_joined(
581
+ cell_paras: dict[tuple[int, int, int], list[ScannedParagraph]],
582
+ key: tuple[int, int, int],
583
+ ) -> str:
584
+ return "".join(p.text for p in cell_paras.get(key, [])).strip()
585
+
586
+
587
+ def _scan_empty_cell_candidate(
588
+ cell_paras: dict[tuple[int, int, int], list[ScannedParagraph]],
589
+ tbl: int,
590
+ row: int,
591
+ col: int,
592
+ plist: list[ScannedParagraph],
593
+ chars: dict,
594
+ ) -> Candidate:
595
+ label = ""
596
+ for nkey, tag in (((tbl, row, col - 1), "좌측"), ((tbl, row - 1, col), "상단")):
597
+ ntext = _scan_cell_joined(cell_paras, nkey)
598
+ if ntext:
599
+ label = f"{tag}: {_preview(ntext, 24)}"
600
+ break
601
+ signals = ["empty_cell"]
602
+ cprid = plist[0].first_char_pr_id
603
+ if cprid is not None:
604
+ style = chars.get(str(cprid))
605
+ if style is not None:
606
+ height = style.attributes.get("height")
607
+ size = f"{int(height) / 100:g}pt" if height and height.isdigit() else "?"
608
+ bold = "·bold" if "bold" in style.child_attributes else ""
609
+ signals.append(f"format:{size}{bold}")
610
+ return Candidate(
611
+ location=plist[0].location(),
612
+ signals=signals,
613
+ confidence="medium",
614
+ text_preview=label or "(라벨 없음)",
615
+ cell=plist[0].cell,
616
+ )
617
+
618
+
619
+ def _scan_empty_cells(
620
+ paragraphs: list[ScannedParagraph], chars: dict
621
+ ) -> list[Candidate]:
622
+ cell_paras: dict[tuple[int, int, int], list[ScannedParagraph]] = {}
623
+ for para in paragraphs:
624
+ if para.cell is not None and para.cell.row >= 0:
625
+ key = (para.cell.table_index, para.cell.row, para.cell.col)
626
+ cell_paras.setdefault(key, []).append(para)
627
+
628
+ empty_cell_candidates: list[Candidate] = []
629
+ for (tbl, row, col), plist in sorted(cell_paras.items()):
630
+ if _scan_cell_joined(cell_paras, (tbl, row, col)):
631
+ continue
632
+ empty_cell_candidates.append(
633
+ _scan_empty_cell_candidate(cell_paras, tbl, row, col, plist, chars)
634
+ )
635
+ return empty_cell_candidates
636
+
637
+
638
+ def _scan_unbound_color_questions(
639
+ inventory: dict, legend: list[LegendBinding]
640
+ ) -> list[str]:
641
+ questions: list[str] = []
642
+ bound = {b.family for b in legend}
643
+ for hex_color, info in sorted(inventory.items(), key=lambda kv: -kv[1]["runs"]):
644
+ if info["family"] not in bound and info["runs"] >= 3:
645
+ questions.append(
646
+ f"`{hex_color}`({info['family']}) run {info['runs']}개의 의미가 범례에 없음 — 유지/수정/삭제 중 무엇인가? 예: {info['samples'][0] if info['samples'] else ''}"
647
+ )
648
+ return questions
649
+
650
+
651
+ def _scan_questions(
652
+ inventory: dict,
653
+ legend: list[LegendBinding],
654
+ conditional_choices: list[Candidate],
655
+ placeholder_candidates: list[Candidate],
656
+ ) -> list[str]:
657
+ questions = _scan_unbound_color_questions(inventory, legend)
658
+ for c in conditional_choices:
659
+ questions.append(
660
+ f"{c.location} 조건부 블록 — 어느 쪽을 남길지 결정 필요: “{c.text_preview[:60]}”"
661
+ )
662
+ for c in placeholder_candidates:
663
+ if "placeholder:circle_blank" in c.signals:
664
+ questions.append(
665
+ f"{c.location} 빈칸(◯◯◯) — 들어갈 실제 값은? “{c.text_preview[:40]}”"
666
+ )
667
+ if not legend:
668
+ questions.append(
669
+ "색 범례 문장을 찾지 못함 — 색의 의미(유지/수정/삭제)를 알려달라."
670
+ )
671
+ return questions
672
+
673
+
674
+ def scan_form_guidance(source: str | Path | HwpxDocument) -> GuidanceReport:
675
+ """양식을 정찰해 지울 것/수정할 것/placeholder/질문 후보 리포트를 만든다(비변형)."""
676
+ doc, src_name = _scan_resolve_source(source)
677
+ chars = doc._root.char_properties
678
+ paragraphs, tables_total = _scan_collect_paragraphs(doc, chars)
679
+ inventory, colored_runs = _scan_color_inventory(paragraphs)
680
+ legend = _parse_legend(paragraphs)
681
+ delete_candidates, placeholder_candidates, conditional_choices, modify_by_table = (
682
+ _scan_candidates(paragraphs, legend, chars)
683
+ )
684
+ empty_cell_candidates = _scan_empty_cells(paragraphs, chars)
685
+ questions = _scan_questions(
686
+ inventory, legend, conditional_choices, placeholder_candidates
687
+ )
688
+
689
+ return GuidanceReport(
690
+ source=src_name,
691
+ color_inventory=inventory,
692
+ legend=legend,
693
+ delete_candidates=delete_candidates,
694
+ modify_candidates_by_table=modify_by_table,
695
+ placeholder_candidates=placeholder_candidates,
696
+ conditional_choices=conditional_choices,
697
+ questions=questions,
698
+ empty_cell_candidates=empty_cell_candidates,
699
+ stats={
700
+ "paragraphs": len(paragraphs),
701
+ "tables": tables_total,
702
+ "colored_runs": colored_runs,
703
+ },
704
+ )