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,478 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Administrative-document style lint checks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from hwpx.document import HwpxDocument
|
|
11
|
+
|
|
12
|
+
OFFICIAL_DOCUMENT_STYLE_REPORT_VERSION = "official-document-style-v1"
|
|
13
|
+
_RULE_SOURCE_DOCUMENT = "hwpx-skill/references/official-document-rules.md"
|
|
14
|
+
_RULES_CHECKED = (
|
|
15
|
+
"item-marker-hierarchy",
|
|
16
|
+
"end-marker",
|
|
17
|
+
"attachment-notation",
|
|
18
|
+
"date-notation",
|
|
19
|
+
"amount-notation",
|
|
20
|
+
"colon-question-spacing",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_MARKER_PATTERNS: tuple[tuple[re.Pattern[str], int, str], ...] = (
|
|
24
|
+
(re.compile(r"^\s*\d+\.\s+"), 0, "1."),
|
|
25
|
+
(re.compile(r"^\s*[가-힣]\.\s+"), 1, "가."),
|
|
26
|
+
(re.compile(r"^\s*\d+\)\s+"), 2, "1)"),
|
|
27
|
+
(re.compile(r"^\s*[가-힣]\)\s+"), 3, "가)"),
|
|
28
|
+
(re.compile(r"^\s*\(\d+\)\s+"), 4, "(1)"),
|
|
29
|
+
(re.compile(r"^\s*\([가-힣]\)\s+"), 5, "(가)"),
|
|
30
|
+
)
|
|
31
|
+
_BAD_DELIMITED_DATE_RE = re.compile(r"\b(20\d{2})[-/](\d{1,2})[-/](\d{1,2})\b")
|
|
32
|
+
_DOT_DATE_RE = re.compile(r"\b(20\d{2})\.\s*(\d{1,2})\.\s*(\d{1,2})\.?")
|
|
33
|
+
_AMOUNT_RE = re.compile(r"(?<![\d,])(\d{4,})(원)")
|
|
34
|
+
_ATTACHMENT_PREFIX_RE = re.compile(r"^\s*(붙임|첨부)\b")
|
|
35
|
+
_ATTACHMENT_RE = re.compile(r"^\s*(?:붙임|첨부)\s+(?:\d+\.\s*)?.+\s+\d+\s*부\.\s*$")
|
|
36
|
+
_SPACE_BEFORE_PUNCTUATION_RE = re.compile(r"\s+[:??]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def inspect_official_document_style(
|
|
40
|
+
source: Any, *, document_type: Any = None
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Inspect official-document conventions in text, plans, or HWPX files.
|
|
43
|
+
|
|
44
|
+
When *document_type* resolves to a 공문 (official outgoing document) the
|
|
45
|
+
structural spine — 두문(수신)·결문(발신명의·시행·공개구분)·끝. — is enforced at
|
|
46
|
+
ERROR severity (the hard-gate, ``structure_pass``). Without *document_type*
|
|
47
|
+
the behaviour is unchanged (backward compatible).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
paragraphs = _paragraphs_from_source(source)
|
|
51
|
+
is_gongmun = _is_gongmun(document_type)
|
|
52
|
+
violations: list[dict[str, Any]] = []
|
|
53
|
+
violations.extend(_inspect_marker_hierarchy(paragraphs))
|
|
54
|
+
if not is_gongmun:
|
|
55
|
+
# A 시행문 places its 결문(발신명의·시행) AFTER the 끝. marker, so the strict
|
|
56
|
+
# "끝. must be the final paragraph" rule does not apply to 공문; the
|
|
57
|
+
# structure gate enforces 끝. presence instead.
|
|
58
|
+
violations.extend(_inspect_end_marker(paragraphs))
|
|
59
|
+
violations.extend(_inspect_attachment_notation(paragraphs))
|
|
60
|
+
violations.extend(_inspect_dates(paragraphs))
|
|
61
|
+
violations.extend(_inspect_amounts(paragraphs))
|
|
62
|
+
violations.extend(_inspect_spacing(paragraphs))
|
|
63
|
+
if is_gongmun:
|
|
64
|
+
violations.extend(_inspect_gongmun_structure(paragraphs))
|
|
65
|
+
|
|
66
|
+
violation_count = len(violations)
|
|
67
|
+
error_count = sum(1 for v in violations if v.get("severity") == "error")
|
|
68
|
+
ok = violation_count == 0
|
|
69
|
+
rules = list(_RULES_CHECKED) + (list(_GONGMUN_STRUCTURE_RULES) if is_gongmun else [])
|
|
70
|
+
return {
|
|
71
|
+
"report_version": OFFICIAL_DOCUMENT_STYLE_REPORT_VERSION,
|
|
72
|
+
"pass": ok,
|
|
73
|
+
"structure_pass": error_count == 0,
|
|
74
|
+
"document_type": str(document_type) if document_type else None,
|
|
75
|
+
"score": max(0.0, round(1.0 - (violation_count / 10), 2)),
|
|
76
|
+
"summary": {
|
|
77
|
+
"paragraph_count": len(paragraphs),
|
|
78
|
+
"violation_count": violation_count,
|
|
79
|
+
"error_count": error_count,
|
|
80
|
+
"rules_checked": rules,
|
|
81
|
+
},
|
|
82
|
+
"violations": violations,
|
|
83
|
+
"repair_hints": [
|
|
84
|
+
{
|
|
85
|
+
"rule": violation["rule"],
|
|
86
|
+
"paragraph_index": violation.get("paragraph_index"),
|
|
87
|
+
"suggestion": violation["suggestion"],
|
|
88
|
+
}
|
|
89
|
+
for violation in violations
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_GONGMUN_DOCTYPES = {"공문", "공문서", "official_notice", "시행문"}
|
|
95
|
+
_GONGMUN_STRUCTURE_RULES = (
|
|
96
|
+
"missing-susin",
|
|
97
|
+
"missing-balsinmyeongui",
|
|
98
|
+
"missing-sihaeng",
|
|
99
|
+
"missing-disclosure",
|
|
100
|
+
"missing-end-marker",
|
|
101
|
+
)
|
|
102
|
+
_ISSUER_SUFFIX_RE = re.compile(r"(장|관|감)$")
|
|
103
|
+
_DISCLOSURE_RE = re.compile(r"(부분공개|비공개|공개)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_gongmun(document_type: Any) -> bool:
|
|
107
|
+
return bool(document_type) and str(document_type).strip() in _GONGMUN_DOCTYPES
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _norm_spaces(text: str) -> str:
|
|
111
|
+
return re.sub(r"\s+", "", text)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _inspect_gongmun_structure(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
115
|
+
"""ERROR-severity 공문 spine checks (the hard-gate), anchored by a real 시행문.
|
|
116
|
+
|
|
117
|
+
Reliably machine-checkable from real 시행문: 수신(두문), 시행/공개구분(결문),
|
|
118
|
+
끝.(본문 종결), and 발신명의 — detected via the literal label OR a 기관장 명의
|
|
119
|
+
line (space-normalised, ending 장/관/감, not the 수신 recipient line).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
nonempty = [t.strip() for t in paragraphs if t.strip()]
|
|
123
|
+
norm = [_norm_spaces(t) for t in nonempty]
|
|
124
|
+
full_norm = "".join(norm)
|
|
125
|
+
violations: list[dict[str, Any]] = []
|
|
126
|
+
|
|
127
|
+
def err(rule: str, message: str, suggestion: str) -> None:
|
|
128
|
+
violations.append(
|
|
129
|
+
_violation(
|
|
130
|
+
rule=rule,
|
|
131
|
+
paragraph_index=0,
|
|
132
|
+
text="",
|
|
133
|
+
message=message,
|
|
134
|
+
suggestion=suggestion,
|
|
135
|
+
severity="error",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if "수신" not in full_norm:
|
|
140
|
+
err("missing-susin", "공문 두문에 수신(수신자)이 없습니다",
|
|
141
|
+
"두문에 '수신 <수신자>'를 추가하세요.")
|
|
142
|
+
if "시행" not in full_norm:
|
|
143
|
+
err("missing-sihaeng", "공문 결문에 시행 정보가 없습니다",
|
|
144
|
+
"결문에 '시행 <처리과-일련번호> (<시행일자>)'를 추가하세요.")
|
|
145
|
+
if not _DISCLOSURE_RE.search(full_norm):
|
|
146
|
+
err("missing-disclosure", "공문 결문에 공개구분이 없습니다",
|
|
147
|
+
"결문에 공개구분(공개/부분공개/비공개)을 추가하세요.")
|
|
148
|
+
if "끝." not in full_norm:
|
|
149
|
+
err("missing-end-marker", "공문 본문에 끝 표시(끝.)가 없습니다",
|
|
150
|
+
"본문/붙임 마지막에 '끝.'을 두세요.")
|
|
151
|
+
has_label = "발신명의" in full_norm
|
|
152
|
+
has_issuer = any(
|
|
153
|
+
_ISSUER_SUFFIX_RE.search(t) and len(t) >= 3 and "수신" not in t and not t.endswith(")")
|
|
154
|
+
for t in norm
|
|
155
|
+
)
|
|
156
|
+
if not (has_label or has_issuer):
|
|
157
|
+
err("missing-balsinmyeongui", "공문 결문에 발신명의(기관장 명의)가 없습니다",
|
|
158
|
+
"결문에 발신명의(예: ○○교육지원청교육장)를 추가하세요.")
|
|
159
|
+
return violations
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _document_paragraph_texts(paragraphs: Any) -> list[str]:
|
|
163
|
+
"""Flatten paragraph text including nested table-cell text.
|
|
164
|
+
|
|
165
|
+
Real 시행문 carry the 두문(수신·경유) and 결문(발신명의·시행·공개구분) inside
|
|
166
|
+
tables, which top-level ``document.paragraphs`` does not descend into.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
texts: list[str] = []
|
|
170
|
+
for paragraph in paragraphs:
|
|
171
|
+
texts.append(paragraph.text)
|
|
172
|
+
for table in getattr(paragraph, "tables", ()):
|
|
173
|
+
for row in table.rows:
|
|
174
|
+
for cell in row.cells:
|
|
175
|
+
texts.extend(_document_paragraph_texts(cell.paragraphs))
|
|
176
|
+
return texts
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _paragraphs_from_source(source: Any) -> list[str]:
|
|
180
|
+
if isinstance(source, HwpxDocument):
|
|
181
|
+
return _document_paragraph_texts(source.paragraphs)
|
|
182
|
+
if isinstance(source, Path):
|
|
183
|
+
return _paragraphs_from_path(source)
|
|
184
|
+
if isinstance(source, str):
|
|
185
|
+
candidate = Path(source)
|
|
186
|
+
if candidate.exists():
|
|
187
|
+
return _paragraphs_from_path(candidate)
|
|
188
|
+
return source.splitlines() or [source]
|
|
189
|
+
if isinstance(source, Mapping):
|
|
190
|
+
if "paragraphs" in source:
|
|
191
|
+
return _paragraphs_from_sequence(source.get("paragraphs"))
|
|
192
|
+
if "text" in source:
|
|
193
|
+
return str(source.get("text") or "").splitlines()
|
|
194
|
+
if "sections" in source:
|
|
195
|
+
return _paragraphs_from_document_plan(source)
|
|
196
|
+
if isinstance(source, Sequence) and not isinstance(source, (bytes, bytearray)):
|
|
197
|
+
return _paragraphs_from_sequence(source)
|
|
198
|
+
raise TypeError("source must be a HWPX path, HwpxDocument, mapping, text, or paragraph sequence")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _paragraphs_from_path(path: Path) -> list[str]:
|
|
202
|
+
document = HwpxDocument.open(path)
|
|
203
|
+
try:
|
|
204
|
+
return _document_paragraph_texts(document.paragraphs)
|
|
205
|
+
finally:
|
|
206
|
+
document.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _paragraphs_from_sequence(value: Any) -> list[str]:
|
|
210
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
|
211
|
+
raise TypeError("paragraphs must be a sequence")
|
|
212
|
+
paragraphs: list[str] = []
|
|
213
|
+
for item in value:
|
|
214
|
+
if isinstance(item, Mapping):
|
|
215
|
+
paragraphs.append(str(item.get("text") or ""))
|
|
216
|
+
else:
|
|
217
|
+
paragraphs.append(str(item))
|
|
218
|
+
return paragraphs
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _paragraphs_from_document_plan(plan: Mapping[str, Any]) -> list[str]:
|
|
222
|
+
paragraphs: list[str] = []
|
|
223
|
+
for section in plan.get("sections") or ():
|
|
224
|
+
if not isinstance(section, Mapping):
|
|
225
|
+
continue
|
|
226
|
+
for block in section.get("blocks", section.get("children")) or ():
|
|
227
|
+
if not isinstance(block, Mapping):
|
|
228
|
+
continue
|
|
229
|
+
block_type = str(block.get("type") or "").strip()
|
|
230
|
+
if block_type in {"heading", "paragraph"}:
|
|
231
|
+
text = str(block.get("text") or "").strip()
|
|
232
|
+
if text:
|
|
233
|
+
paragraphs.append(text)
|
|
234
|
+
for child in block.get("children") or ():
|
|
235
|
+
if isinstance(child, Mapping) and child.get("text"):
|
|
236
|
+
paragraphs.append(str(child.get("text") or ""))
|
|
237
|
+
elif block_type in {"bullets", "bullet", "numbered_list", "numberedList"}:
|
|
238
|
+
paragraphs.extend(str(item) for item in block.get("items") or ())
|
|
239
|
+
elif block_type == "table":
|
|
240
|
+
header = block.get("header") or ()
|
|
241
|
+
rows = block.get("rows") or ()
|
|
242
|
+
if header:
|
|
243
|
+
paragraphs.append(" ".join(str(item) for item in header))
|
|
244
|
+
for row in rows:
|
|
245
|
+
if isinstance(row, Sequence) and not isinstance(row, (str, bytes, bytearray)):
|
|
246
|
+
paragraphs.append(" ".join(str(item) for item in row))
|
|
247
|
+
return paragraphs
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _violation(
|
|
251
|
+
*,
|
|
252
|
+
rule: str,
|
|
253
|
+
paragraph_index: int,
|
|
254
|
+
text: str,
|
|
255
|
+
message: str,
|
|
256
|
+
suggestion: str,
|
|
257
|
+
severity: str = "warning",
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
return {
|
|
260
|
+
"rule": rule,
|
|
261
|
+
"severity": severity,
|
|
262
|
+
"paragraph_index": paragraph_index,
|
|
263
|
+
"text": text,
|
|
264
|
+
"message": message,
|
|
265
|
+
"suggestion": suggestion,
|
|
266
|
+
"source": {
|
|
267
|
+
"document": _RULE_SOURCE_DOCUMENT,
|
|
268
|
+
"rule": rule,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _marker_depth(text: str) -> tuple[int, str] | None:
|
|
274
|
+
for pattern, depth, label in _MARKER_PATTERNS:
|
|
275
|
+
if pattern.search(text):
|
|
276
|
+
return depth, label
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _inspect_marker_hierarchy(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
281
|
+
violations: list[dict[str, Any]] = []
|
|
282
|
+
previous_depth: int | None = None
|
|
283
|
+
seen_depths: set[int] = set()
|
|
284
|
+
for index, text in enumerate(paragraphs):
|
|
285
|
+
marker = _marker_depth(text)
|
|
286
|
+
if marker is None:
|
|
287
|
+
continue
|
|
288
|
+
depth, label = marker
|
|
289
|
+
if previous_depth is not None and depth > previous_depth + 1:
|
|
290
|
+
violations.append(
|
|
291
|
+
_violation(
|
|
292
|
+
rule="item-marker-hierarchy",
|
|
293
|
+
paragraph_index=index,
|
|
294
|
+
text=text,
|
|
295
|
+
message=f"item marker {label!r} skips an intermediate hierarchy level",
|
|
296
|
+
suggestion="Use the order 1. -> 가. -> 1) -> 가) -> (1) -> (가) without skipping levels.",
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
if depth > 0 and depth - 1 not in seen_depths:
|
|
300
|
+
violations.append(
|
|
301
|
+
_violation(
|
|
302
|
+
rule="item-marker-hierarchy",
|
|
303
|
+
paragraph_index=index,
|
|
304
|
+
text=text,
|
|
305
|
+
message=f"item marker {label!r} appears before its parent level",
|
|
306
|
+
suggestion="Introduce the parent item level before using this marker.",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
seen_depths.add(depth)
|
|
310
|
+
previous_depth = depth
|
|
311
|
+
return violations
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _inspect_end_marker(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
315
|
+
nonempty = [(index, text.rstrip()) for index, text in enumerate(paragraphs) if text.strip()]
|
|
316
|
+
if not nonempty:
|
|
317
|
+
return []
|
|
318
|
+
violations: list[dict[str, Any]] = []
|
|
319
|
+
attachment_indexes = [
|
|
320
|
+
index
|
|
321
|
+
for index, text in nonempty
|
|
322
|
+
if _ATTACHMENT_PREFIX_RE.search(text)
|
|
323
|
+
]
|
|
324
|
+
end_indexes = [
|
|
325
|
+
index
|
|
326
|
+
for index, text in nonempty
|
|
327
|
+
if text.strip() == "끝." or text.rstrip().endswith("끝.")
|
|
328
|
+
]
|
|
329
|
+
if not end_indexes:
|
|
330
|
+
index, text = nonempty[-1]
|
|
331
|
+
return [
|
|
332
|
+
_violation(
|
|
333
|
+
rule="end-marker",
|
|
334
|
+
paragraph_index=index,
|
|
335
|
+
text=text,
|
|
336
|
+
message="official documents should close with an end marker",
|
|
337
|
+
suggestion='Add "끝." at the required final position.',
|
|
338
|
+
severity="error",
|
|
339
|
+
)
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
last_index, last_text = nonempty[-1]
|
|
343
|
+
end_index = end_indexes[-1]
|
|
344
|
+
end_text = paragraphs[end_index].rstrip()
|
|
345
|
+
if end_index != last_index:
|
|
346
|
+
violations.append(
|
|
347
|
+
_violation(
|
|
348
|
+
rule="end-marker",
|
|
349
|
+
paragraph_index=end_index,
|
|
350
|
+
text=end_text,
|
|
351
|
+
message='the "끝." marker is not the final non-empty paragraph',
|
|
352
|
+
suggestion='Move "끝." to the final non-empty paragraph.',
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
if attachment_indexes:
|
|
356
|
+
if end_text.strip() != "끝.":
|
|
357
|
+
violations.append(
|
|
358
|
+
_violation(
|
|
359
|
+
rule="end-marker",
|
|
360
|
+
paragraph_index=end_index,
|
|
361
|
+
text=end_text,
|
|
362
|
+
message='when attachments are listed, "끝." should be a standalone final paragraph',
|
|
363
|
+
suggestion='Place attachment lines first, then a standalone final "끝." paragraph.',
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
if max(attachment_indexes) > end_index:
|
|
367
|
+
violations.append(
|
|
368
|
+
_violation(
|
|
369
|
+
rule="end-marker",
|
|
370
|
+
paragraph_index=end_index,
|
|
371
|
+
text=end_text,
|
|
372
|
+
message='the "끝." marker appears before the attachment notation',
|
|
373
|
+
suggestion='Place all attachment notation before the final "끝." paragraph.',
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
elif end_text.strip() != "끝.":
|
|
377
|
+
if not re.search(r"\s{2}끝\.$", end_text):
|
|
378
|
+
violations.append(
|
|
379
|
+
_violation(
|
|
380
|
+
rule="end-marker",
|
|
381
|
+
paragraph_index=end_index,
|
|
382
|
+
text=end_text,
|
|
383
|
+
message='inline "끝." should be preceded by two spaces',
|
|
384
|
+
suggestion='Use two spaces before the inline "끝." marker, for example "본문 끝.".',
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
return violations
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _inspect_attachment_notation(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
391
|
+
violations: list[dict[str, Any]] = []
|
|
392
|
+
for index, text in enumerate(paragraphs):
|
|
393
|
+
if not _ATTACHMENT_PREFIX_RE.search(text):
|
|
394
|
+
continue
|
|
395
|
+
if not _ATTACHMENT_RE.search(text.strip()):
|
|
396
|
+
violations.append(
|
|
397
|
+
_violation(
|
|
398
|
+
rule="attachment-notation",
|
|
399
|
+
paragraph_index=index,
|
|
400
|
+
text=text,
|
|
401
|
+
message="attachment notation should include item text, copy count, and a period",
|
|
402
|
+
suggestion='Use notation such as "붙임 1. 세부계획서 1부.".',
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
return violations
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _inspect_dates(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
409
|
+
violations: list[dict[str, Any]] = []
|
|
410
|
+
for index, text in enumerate(paragraphs):
|
|
411
|
+
for match in _BAD_DELIMITED_DATE_RE.finditer(text):
|
|
412
|
+
replacement = _date_suggestion(match.group(1), match.group(2), match.group(3))
|
|
413
|
+
violations.append(
|
|
414
|
+
_violation(
|
|
415
|
+
rule="date-notation",
|
|
416
|
+
paragraph_index=index,
|
|
417
|
+
text=text,
|
|
418
|
+
message=f"date {match.group(0)!r} uses a non-official delimiter style",
|
|
419
|
+
suggestion=f"Use dotted Korean administrative notation: {replacement}",
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
for match in _DOT_DATE_RE.finditer(text):
|
|
423
|
+
replacement = _date_suggestion(match.group(1), match.group(2), match.group(3))
|
|
424
|
+
if match.group(0) != replacement:
|
|
425
|
+
violations.append(
|
|
426
|
+
_violation(
|
|
427
|
+
rule="date-notation",
|
|
428
|
+
paragraph_index=index,
|
|
429
|
+
text=text,
|
|
430
|
+
message=f"date {match.group(0)!r} should use spaces and no zero padding",
|
|
431
|
+
suggestion=f"Use {replacement}",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
return violations
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _date_suggestion(year: str, month: str, day: str) -> str:
|
|
438
|
+
return f"{int(year)}. {int(month)}. {int(day)}."
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _inspect_amounts(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
442
|
+
violations: list[dict[str, Any]] = []
|
|
443
|
+
for index, text in enumerate(paragraphs):
|
|
444
|
+
for match in _AMOUNT_RE.finditer(text):
|
|
445
|
+
formatted = f"{int(match.group(1)):,}원"
|
|
446
|
+
violations.append(
|
|
447
|
+
_violation(
|
|
448
|
+
rule="amount-notation",
|
|
449
|
+
paragraph_index=index,
|
|
450
|
+
text=text,
|
|
451
|
+
message=f"amount {match.group(0)!r} should use thousands separators",
|
|
452
|
+
suggestion=f"Use {formatted}.",
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return violations
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _inspect_spacing(paragraphs: Sequence[str]) -> list[dict[str, Any]]:
|
|
459
|
+
violations: list[dict[str, Any]] = []
|
|
460
|
+
for index, text in enumerate(paragraphs):
|
|
461
|
+
if not _SPACE_BEFORE_PUNCTUATION_RE.search(text):
|
|
462
|
+
continue
|
|
463
|
+
violations.append(
|
|
464
|
+
_violation(
|
|
465
|
+
rule="colon-question-spacing",
|
|
466
|
+
paragraph_index=index,
|
|
467
|
+
text=text,
|
|
468
|
+
message="do not insert a space before colons or question marks",
|
|
469
|
+
suggestion="Remove the space before ':' or '?'.",
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return violations
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
__all__ = [
|
|
476
|
+
"OFFICIAL_DOCUMENT_STYLE_REPORT_VERSION",
|
|
477
|
+
"inspect_official_document_style",
|
|
478
|
+
]
|