python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|