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,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Re-export of the neutral fit decision rule that python-hwpx owns.
|
|
3
|
+
|
|
4
|
+
This module used to be a copy. The two versions were behaviourally identical —
|
|
5
|
+
comparing normalised syntax trees showed no semantic difference across the five
|
|
6
|
+
fit-contract modules, only comment alignment and whether a forward reference was
|
|
7
|
+
quoted — so maintaining both bought nothing and cost a drift risk that had
|
|
8
|
+
already come due elsewhere in this stack.
|
|
9
|
+
|
|
10
|
+
Slot measurement and the keep/wrap/shrink ladder are format reasoning, not
|
|
11
|
+
application policy: core's own table and field APIs call them. What this layer
|
|
12
|
+
owns is the application half — seal placement, PDF extraction, institutional
|
|
13
|
+
rules — which stays here and is not re-exported from core.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from hwpx.form_fit.policy import ( # noqa: F401 - re-exported public surface
|
|
19
|
+
FitMode,
|
|
20
|
+
FitPolicy,
|
|
21
|
+
OverflowAction,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FitMode",
|
|
26
|
+
"FitPolicy",
|
|
27
|
+
"OverflowAction",
|
|
28
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Re-export of the neutral fit result contract that python-hwpx owns.
|
|
3
|
+
|
|
4
|
+
This module used to be a copy. The two versions were behaviourally identical —
|
|
5
|
+
comparing normalised syntax trees showed no semantic difference across the five
|
|
6
|
+
fit-contract modules, only comment alignment and whether a forward reference was
|
|
7
|
+
quoted — so maintaining both bought nothing and cost a drift risk that had
|
|
8
|
+
already come due elsewhere in this stack.
|
|
9
|
+
|
|
10
|
+
Slot measurement and the keep/wrap/shrink ladder are format reasoning, not
|
|
11
|
+
application policy: core's own table and field APIs call them. What this layer
|
|
12
|
+
owns is the application half — seal placement, PDF extraction, institutional
|
|
13
|
+
rules — which stays here and is not re-exported from core.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from hwpx.form_fit.report import ( # noqa: F401 - re-exported public surface
|
|
19
|
+
FIELD_OVERFLOW,
|
|
20
|
+
FitResult,
|
|
21
|
+
to_form_report,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FIELD_OVERFLOW",
|
|
26
|
+
"FitResult",
|
|
27
|
+
"to_form_report",
|
|
28
|
+
]
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""직인/관인 placement compliance (M2 P3 / FR-003).
|
|
3
|
+
|
|
4
|
+
A Korean official document's seal (직인/관인) is placed *by rule*: its **center**
|
|
5
|
+
must sit on the **last glyph of the 발신명의** line (the issuer-name line, e.g.
|
|
6
|
+
``"행정안전부장관 홍길동"``). This module decides that rule **geometrically**
|
|
7
|
+
against the Hancom render boxes — reusing the word-box oracle
|
|
8
|
+
(:mod:`hwpx.form_fit.wordbox`) rather than guessing from the document model, since
|
|
9
|
+
the truth is where Hancom *drew* the glyph, not where the XML nominally puts it.
|
|
10
|
+
|
|
11
|
+
Pure-geometry decision entry points (slice 1), offline-testable:
|
|
12
|
+
|
|
13
|
+
* :func:`find_seal_anchor` — locate the last glyph of the 발신명의 line in a set of
|
|
14
|
+
glyph boxes (the seal's intended center).
|
|
15
|
+
* :func:`check_seal_placement` — given a placed seal's box, decide pass/fail: the
|
|
16
|
+
seal center within ``tol`` of the anchor, and no *unintended* occlusion of other
|
|
17
|
+
text. The check **discriminates** (a centered seal passes, a mis-placed one
|
|
18
|
+
fails) so an evaluator can run it — FR-003 / acceptance #3.
|
|
19
|
+
|
|
20
|
+
Placement entry points (slice 2) — turn the decision into an actual seal:
|
|
21
|
+
|
|
22
|
+
* :func:`seal_pos_offsets` — map an anchor center (PDF pt) to the PAPER-relative
|
|
23
|
+
``<hp:pos>`` offsets (HWPUNIT) that land a seal's *center* on it.
|
|
24
|
+
* :func:`place_seal` — embed a seal image and attach it as a **floating** picture
|
|
25
|
+
to the source 발신명의 paragraph (``add_picture`` is inline-only), so it lands on
|
|
26
|
+
that paragraph's page at the computed absolute position.
|
|
27
|
+
|
|
28
|
+
All render coordinates are PDF points (the unit the wordbox oracle reports), origin
|
|
29
|
+
top-left (y grows downward). HWPX positions are HWPUNIT (7200 per inch), so a PDF
|
|
30
|
+
point is exactly ``100`` HWPUNIT (``7200 / 72``).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import math
|
|
36
|
+
from collections.abc import Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from .wordbox import Rect, WordBox
|
|
41
|
+
|
|
42
|
+
# 1 PDF point = 7200/72 = 100 HWPUNIT (HWPUNIT is 7200 per inch).
|
|
43
|
+
_PT_TO_HWPUNIT = 100.0
|
|
44
|
+
# 1 mm = 7200/25.4 HWPUNIT.
|
|
45
|
+
_MM_TO_HWPUNIT = 7200.0 / 25.4
|
|
46
|
+
# A typical 관인/직인 is ~2.5 cm square.
|
|
47
|
+
_DEFAULT_SEAL_MM = 25.0
|
|
48
|
+
|
|
49
|
+
# Default seal-center tolerance (pt). The seal center must land within this of the
|
|
50
|
+
# anchor glyph's center to count as "centered on the last glyph".
|
|
51
|
+
_SEAL_CENTER_TOL_PT = 6.0
|
|
52
|
+
# A glyph counts as occluded by the seal when this fraction of its area is covered.
|
|
53
|
+
_OCCLUSION_FRAC = 0.5
|
|
54
|
+
# Lines are grouped by y-center proximity within this fraction of glyph height.
|
|
55
|
+
_LINE_Y_FRAC = 0.6
|
|
56
|
+
# A 발신명의 that wraps in a narrow cell spans at most this many consecutive lines
|
|
57
|
+
# (the multi-line anchor fallback window).
|
|
58
|
+
_MAX_WRAP_LINES = 3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _norm(text: str) -> str:
|
|
62
|
+
"""Whitespace-insensitive form for matching a sender line."""
|
|
63
|
+
|
|
64
|
+
return "".join(text.split())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _center(box: WordBox | Rect) -> tuple[float, float]:
|
|
68
|
+
return ((box.x0 + box.x1) / 2.0, (box.y0 + box.y1) / 2.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class SealAnchor:
|
|
73
|
+
"""Where the seal should be centered: the last glyph of the 발신명의 line."""
|
|
74
|
+
|
|
75
|
+
glyph: WordBox
|
|
76
|
+
line_text: str
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def center(self) -> tuple[float, float]:
|
|
80
|
+
return _center(self.glyph)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _group_lines(boxes: Sequence[WordBox]) -> list[list[WordBox]]:
|
|
84
|
+
"""Group glyph boxes into reading-order lines (same page, shared y-band)."""
|
|
85
|
+
|
|
86
|
+
finite = [b for b in boxes if b.finite and b.height > 0]
|
|
87
|
+
# Sort by (page, y-center, x0) so a simple sweep forms lines.
|
|
88
|
+
ordered = sorted(finite, key=lambda b: (b.page, (b.y0 + b.y1) / 2.0, b.x0))
|
|
89
|
+
lines: list[list[WordBox]] = []
|
|
90
|
+
for box in ordered:
|
|
91
|
+
cy = (box.y0 + box.y1) / 2.0
|
|
92
|
+
placed = False
|
|
93
|
+
for line in lines:
|
|
94
|
+
ref = line[-1]
|
|
95
|
+
if ref.page != box.page:
|
|
96
|
+
continue
|
|
97
|
+
ref_cy = (ref.y0 + ref.y1) / 2.0
|
|
98
|
+
if abs(ref_cy - cy) <= _LINE_Y_FRAC * max(ref.height, box.height):
|
|
99
|
+
line.append(box)
|
|
100
|
+
placed = True
|
|
101
|
+
break
|
|
102
|
+
if not placed:
|
|
103
|
+
lines.append([box])
|
|
104
|
+
# Glyphs within a line in x order.
|
|
105
|
+
for line in lines:
|
|
106
|
+
line.sort(key=lambda b: b.x0)
|
|
107
|
+
return lines
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_seal_anchor(
|
|
111
|
+
boxes: Sequence[WordBox], sender_text: str, *, page: int | None = None
|
|
112
|
+
) -> SealAnchor | None:
|
|
113
|
+
"""Find the last glyph of the line matching ``sender_text`` (the seal target).
|
|
114
|
+
|
|
115
|
+
Lines are reconstructed from the glyph boxes; the line whose whitespace-stripped
|
|
116
|
+
text **contains** the whitespace-stripped ``sender_text`` is the 발신명의 line. If
|
|
117
|
+
several match, the **last** one in document order wins (the issuer line sits near
|
|
118
|
+
the document foot). The returned anchor is that line's last non-space glyph —
|
|
119
|
+
where the seal center belongs. Returns ``None`` when no line matches (so the
|
|
120
|
+
caller reports ``anchor_found=False`` rather than mis-placing a seal).
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
needle = _norm(sender_text)
|
|
124
|
+
if not needle:
|
|
125
|
+
return None
|
|
126
|
+
candidates = boxes if page is None else [b for b in boxes if b.page == page]
|
|
127
|
+
lines = _group_lines(candidates)
|
|
128
|
+
|
|
129
|
+
# Pass 1 — the issuer line on a single visual line (the common case).
|
|
130
|
+
match: SealAnchor | None = None
|
|
131
|
+
for line in lines:
|
|
132
|
+
line_text = "".join(b.text for b in line)
|
|
133
|
+
if needle in _norm(line_text):
|
|
134
|
+
# last glyph that carries ink (skip trailing whitespace glyphs)
|
|
135
|
+
inked = [b for b in line if b.text and not b.text.isspace()]
|
|
136
|
+
if inked:
|
|
137
|
+
match = SealAnchor(glyph=inked[-1], line_text=line_text)
|
|
138
|
+
if match is not None:
|
|
139
|
+
return match
|
|
140
|
+
|
|
141
|
+
# Pass 2 (fallback) — a 발신명의 that *wrapped* across consecutive lines in a
|
|
142
|
+
# narrow 발신·결재 cell. Grow a small same-page window; the anchor is the last
|
|
143
|
+
# inked glyph of the line where the needle completes. Bounded window avoids
|
|
144
|
+
# matching a stray first part against a coincidental later completion.
|
|
145
|
+
for start in range(len(lines)):
|
|
146
|
+
acc = ""
|
|
147
|
+
for end in range(start, min(start + _MAX_WRAP_LINES, len(lines))):
|
|
148
|
+
if lines[end][0].page != lines[start][0].page:
|
|
149
|
+
break
|
|
150
|
+
acc += _norm("".join(b.text for b in lines[end]))
|
|
151
|
+
if end > start and needle in acc:
|
|
152
|
+
inked = [b for b in lines[end] if b.text and not b.text.isspace()]
|
|
153
|
+
if inked:
|
|
154
|
+
match = SealAnchor(glyph=inked[-1], line_text=acc)
|
|
155
|
+
break # smallest window for this start; later starts (doc order) win
|
|
156
|
+
return match
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class SealVerdict:
|
|
161
|
+
"""Pass/fail verdict for a placed 직인/관인 (FR-003)."""
|
|
162
|
+
|
|
163
|
+
ok: bool
|
|
164
|
+
anchor_found: bool
|
|
165
|
+
centered: bool
|
|
166
|
+
offset_pt: float | None = None
|
|
167
|
+
occluded_glyphs: int = 0
|
|
168
|
+
anchor_text: str = ""
|
|
169
|
+
note: str = ""
|
|
170
|
+
|
|
171
|
+
def to_dict(self) -> dict[str, Any]:
|
|
172
|
+
return {
|
|
173
|
+
"ok": self.ok,
|
|
174
|
+
"anchorFound": self.anchor_found,
|
|
175
|
+
"centered": self.centered,
|
|
176
|
+
"offsetPt": None if self.offset_pt is None else round(self.offset_pt, 2),
|
|
177
|
+
"occludedGlyphs": self.occluded_glyphs,
|
|
178
|
+
"anchorText": self.anchor_text,
|
|
179
|
+
"note": self.note,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _occlusion_fraction(seal: Rect, glyph: WordBox) -> float:
|
|
184
|
+
if glyph.page != seal.page:
|
|
185
|
+
return 0.0
|
|
186
|
+
dx = min(seal.x1, glyph.x1) - max(seal.x0, glyph.x0)
|
|
187
|
+
dy = min(seal.y1, glyph.y1) - max(seal.y0, glyph.y0)
|
|
188
|
+
if dx <= 0.0 or dy <= 0.0:
|
|
189
|
+
return 0.0
|
|
190
|
+
area = glyph.width * glyph.height
|
|
191
|
+
if area <= 0.0:
|
|
192
|
+
return 0.0
|
|
193
|
+
return (dx * dy) / area
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_seal_placement(
|
|
197
|
+
boxes: Sequence[WordBox],
|
|
198
|
+
seal: Rect,
|
|
199
|
+
sender_text: str,
|
|
200
|
+
*,
|
|
201
|
+
tol_pt: float = _SEAL_CENTER_TOL_PT,
|
|
202
|
+
max_occluded: int = 0,
|
|
203
|
+
occlusion_frac: float = _OCCLUSION_FRAC,
|
|
204
|
+
) -> SealVerdict:
|
|
205
|
+
"""Decide whether a placed seal honors the 직인 rule (FR-003, discriminating).
|
|
206
|
+
|
|
207
|
+
Pass requires: (a) the 발신명의 anchor is found; (b) the seal *center* lands within
|
|
208
|
+
``tol_pt`` of the anchor glyph's center; (c) the seal does not *unintentionally*
|
|
209
|
+
occlude other text — glyphs **on the anchor's own line** are expected under the
|
|
210
|
+
seal and are exempt, but covering ``> max_occluded`` glyphs elsewhere fails.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
anchor = find_seal_anchor(boxes, sender_text, page=seal.page)
|
|
214
|
+
if anchor is None:
|
|
215
|
+
return SealVerdict(
|
|
216
|
+
ok=False,
|
|
217
|
+
anchor_found=False,
|
|
218
|
+
centered=False,
|
|
219
|
+
note="발신명의 anchor not found",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
scx, scy = _center(seal)
|
|
223
|
+
acx, acy = anchor.center
|
|
224
|
+
offset = math.hypot(scx - acx, scy - acy)
|
|
225
|
+
centered = offset <= tol_pt
|
|
226
|
+
|
|
227
|
+
# Glyphs the seal sits on top of, excluding the anchor's own baseline.
|
|
228
|
+
anchor_cy = anchor.center[1]
|
|
229
|
+
occluded = 0
|
|
230
|
+
for g in boxes:
|
|
231
|
+
if g is anchor.glyph:
|
|
232
|
+
continue
|
|
233
|
+
if _occlusion_fraction(seal, g) < occlusion_frac:
|
|
234
|
+
continue
|
|
235
|
+
# exempt glyphs on (approximately) the anchor's baseline — the seal legitimately
|
|
236
|
+
# overlaps the 발신명의 line around the last glyph.
|
|
237
|
+
if g.page == seal.page and abs(
|
|
238
|
+
(g.y0 + g.y1) / 2.0 - anchor_cy
|
|
239
|
+
) <= _LINE_Y_FRAC * max(g.height, anchor.glyph.height):
|
|
240
|
+
continue
|
|
241
|
+
occluded += 1
|
|
242
|
+
|
|
243
|
+
occlusion_ok = occluded <= max_occluded
|
|
244
|
+
ok = centered and occlusion_ok
|
|
245
|
+
bits: list[str] = []
|
|
246
|
+
if not centered:
|
|
247
|
+
bits.append(
|
|
248
|
+
f"seal center {offset:.1f}pt from 발신명의 last glyph (tol {tol_pt}pt)"
|
|
249
|
+
)
|
|
250
|
+
if not occlusion_ok:
|
|
251
|
+
bits.append(f"seal occludes {occluded} non-anchor glyph(s)")
|
|
252
|
+
return SealVerdict(
|
|
253
|
+
ok=ok,
|
|
254
|
+
anchor_found=True,
|
|
255
|
+
centered=centered,
|
|
256
|
+
offset_pt=offset,
|
|
257
|
+
occluded_glyphs=occluded,
|
|
258
|
+
anchor_text=anchor.glyph.text,
|
|
259
|
+
note="; ".join(bits),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Placement (slice 2) — put the seal where slice 1 says it belongs.
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def seal_pos_offsets(
|
|
269
|
+
anchor_center_pt: tuple[float, float],
|
|
270
|
+
seal_width_hu: int,
|
|
271
|
+
seal_height_hu: int,
|
|
272
|
+
*,
|
|
273
|
+
pt_to_hwpunit: float = _PT_TO_HWPUNIT,
|
|
274
|
+
) -> tuple[int, int]:
|
|
275
|
+
"""Map a seal-center target (PDF pt) to PAPER ``<hp:pos>`` offsets (HWPUNIT).
|
|
276
|
+
|
|
277
|
+
A floating ``<hp:pic>`` positioned ``horzRelTo="PAPER" vertRelTo="PAPER"`` with
|
|
278
|
+
``horzAlign="LEFT" vertAlign="TOP"`` places its **top-left** corner at
|
|
279
|
+
``(horzOffset, vertOffset)`` from the paper's top-left. To center a seal of
|
|
280
|
+
``seal_width_hu × seal_height_hu`` on ``anchor_center_pt`` the offset is the
|
|
281
|
+
anchor (converted to HWPUNIT) minus half the seal. Offsets are clamped to ``≥ 0``
|
|
282
|
+
because the schema types them ``xs:nonNegativeInteger`` (a seal hanging off the
|
|
283
|
+
top/left edge would otherwise be rejected).
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
cx_pt, cy_pt = anchor_center_pt
|
|
287
|
+
horz = round(cx_pt * pt_to_hwpunit - seal_width_hu / 2.0)
|
|
288
|
+
vert = round(cy_pt * pt_to_hwpunit - seal_height_hu / 2.0)
|
|
289
|
+
return max(0, horz), max(0, vert)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass
|
|
293
|
+
class SealPlacement:
|
|
294
|
+
"""Result of attaching a 직인 image to a document (FR-003, slice 2).
|
|
295
|
+
|
|
296
|
+
``page`` is the render page the caller said the anchor came from (or ``None``);
|
|
297
|
+
a caller can cross-check it against the seal's page from ``extract_image_boxes``.
|
|
298
|
+
``clamped`` is ``True`` when the seal sat so close to the paper's top/left edge
|
|
299
|
+
that the offset was clamped to 0 — the realized center then differs from the
|
|
300
|
+
requested anchor (honest signal rather than a silent mis-stamp).
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
placed: bool
|
|
304
|
+
paragraph_index: int = -1
|
|
305
|
+
binary_item_id: str = ""
|
|
306
|
+
horz_offset: int = 0
|
|
307
|
+
vert_offset: int = 0
|
|
308
|
+
seal_width_hu: int = 0
|
|
309
|
+
seal_height_hu: int = 0
|
|
310
|
+
page: int | None = None
|
|
311
|
+
clamped: bool = False
|
|
312
|
+
note: str = ""
|
|
313
|
+
|
|
314
|
+
def to_dict(self) -> dict[str, Any]:
|
|
315
|
+
return {
|
|
316
|
+
"placed": self.placed,
|
|
317
|
+
"paragraphIndex": self.paragraph_index,
|
|
318
|
+
"binaryItemId": self.binary_item_id,
|
|
319
|
+
"horzOffset": self.horz_offset,
|
|
320
|
+
"vertOffset": self.vert_offset,
|
|
321
|
+
"sealWidthHU": self.seal_width_hu,
|
|
322
|
+
"sealHeightHU": self.seal_height_hu,
|
|
323
|
+
"page": self.page,
|
|
324
|
+
"clamped": self.clamped,
|
|
325
|
+
"note": self.note,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _iter_all_paragraphs(document: Any) -> list[Any]:
|
|
330
|
+
"""Body paragraphs **and** table-cell paragraphs (recursively), in order.
|
|
331
|
+
|
|
332
|
+
``document.paragraphs`` only surfaces a section's direct-child paragraphs, so a
|
|
333
|
+
발신명의 inside a 발신·결재 table box would be invisible to :func:`place_seal`
|
|
334
|
+
(and the seal silently un-placeable). This descends into ``paragraph.tables`` →
|
|
335
|
+
``table.cells`` → ``cell.paragraphs`` so the issuer line is found wherever Hancom
|
|
336
|
+
actually renders it. Duck-typed and defensive: any model missing the table API
|
|
337
|
+
simply yields its body paragraphs.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
out: list[Any] = []
|
|
341
|
+
|
|
342
|
+
def _walk(paragraph: Any) -> None:
|
|
343
|
+
out.append(paragraph)
|
|
344
|
+
for table in getattr(paragraph, "tables", None) or []:
|
|
345
|
+
for row in getattr(table, "rows", None) or []:
|
|
346
|
+
for cell in getattr(row, "cells", None) or []:
|
|
347
|
+
for cell_para in getattr(cell, "paragraphs", None) or []:
|
|
348
|
+
_walk(cell_para)
|
|
349
|
+
|
|
350
|
+
for paragraph in document.paragraphs:
|
|
351
|
+
_walk(paragraph)
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def place_seal(
|
|
356
|
+
document: Any,
|
|
357
|
+
*,
|
|
358
|
+
image_data: bytes,
|
|
359
|
+
image_format: str,
|
|
360
|
+
sender_text: str,
|
|
361
|
+
anchor_center_pt: tuple[float, float],
|
|
362
|
+
seal_width_mm: float = _DEFAULT_SEAL_MM,
|
|
363
|
+
seal_height_mm: float | None = None,
|
|
364
|
+
pt_to_hwpunit: float = _PT_TO_HWPUNIT,
|
|
365
|
+
allow_overlap: bool = True,
|
|
366
|
+
page: int | None = None,
|
|
367
|
+
) -> SealPlacement:
|
|
368
|
+
"""Embed *image_data* and place it as a floating 직인 on the 발신명의 page.
|
|
369
|
+
|
|
370
|
+
The seal is attached to the **source paragraph** whose text contains
|
|
371
|
+
``sender_text`` (last occurrence wins — the issuer line sits near the document
|
|
372
|
+
foot). The search descends into table cells (:func:`_iter_all_paragraphs`) so a
|
|
373
|
+
발신명의 inside a 발신·결재 box is found. Anchoring the floating picture to that
|
|
374
|
+
paragraph makes it land on that paragraph's page; ``anchor_center_pt`` (from the
|
|
375
|
+
render oracle) is the **sole geometric authority** for the position, via
|
|
376
|
+
:func:`seal_pos_offsets` — the paragraph match only selects the page.
|
|
377
|
+
|
|
378
|
+
**Single-page / co-located-page assumption.** The PAPER offset is *page-relative*,
|
|
379
|
+
so ``anchor_center_pt`` must come from the same render page the source paragraph
|
|
380
|
+
lands on. For a single-page letter (the common 공문) this holds. For a multi-page
|
|
381
|
+
document, pass ``page`` (the anchor's render page) — it is recorded on the result
|
|
382
|
+
so the caller can cross-check against the seal's page from
|
|
383
|
+
:func:`hwpx.form_fit.wordbox.extract_image_boxes`. ``place_seal`` does not itself
|
|
384
|
+
re-render, so it cannot detect a page mismatch; the oracle verdict is the gate.
|
|
385
|
+
|
|
386
|
+
``add_picture`` is inline-only, so this uses its floating path
|
|
387
|
+
(``treat_as_char=False`` + PAPER ``pos_overrides``). Returns a
|
|
388
|
+
:class:`SealPlacement`; when no 발신명의 paragraph is found it fails **closed**
|
|
389
|
+
(``placed=False``, nothing inserted) rather than guessing a position.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
needle = _norm(sender_text)
|
|
393
|
+
if not needle:
|
|
394
|
+
return SealPlacement(placed=False, note="empty sender_text")
|
|
395
|
+
|
|
396
|
+
paragraphs = _iter_all_paragraphs(document)
|
|
397
|
+
target_index: int | None = None
|
|
398
|
+
for index, paragraph in enumerate(paragraphs):
|
|
399
|
+
if needle in _norm(paragraph.text):
|
|
400
|
+
target_index = index # last occurrence wins
|
|
401
|
+
if target_index is None:
|
|
402
|
+
return SealPlacement(placed=False, note="발신명의 paragraph not found")
|
|
403
|
+
|
|
404
|
+
seal_w_hu = round(seal_width_mm * _MM_TO_HWPUNIT)
|
|
405
|
+
height_mm = seal_width_mm if seal_height_mm is None else seal_height_mm
|
|
406
|
+
seal_h_hu = round(height_mm * _MM_TO_HWPUNIT)
|
|
407
|
+
horz, vert = seal_pos_offsets(
|
|
408
|
+
anchor_center_pt, seal_w_hu, seal_h_hu, pt_to_hwpunit=pt_to_hwpunit
|
|
409
|
+
)
|
|
410
|
+
# honest signal: a near-edge anchor whose top-left would be negative is clamped to
|
|
411
|
+
# 0, so the realized center no longer equals the requested anchor.
|
|
412
|
+
raw_h = round(anchor_center_pt[0] * pt_to_hwpunit - seal_w_hu / 2.0)
|
|
413
|
+
raw_v = round(anchor_center_pt[1] * pt_to_hwpunit - seal_h_hu / 2.0)
|
|
414
|
+
clamped = raw_h < 0 or raw_v < 0
|
|
415
|
+
|
|
416
|
+
binary_item_id = document.add_image(image_data, image_format)
|
|
417
|
+
paragraphs[target_index].add_picture(
|
|
418
|
+
binary_item_id,
|
|
419
|
+
width=seal_w_hu,
|
|
420
|
+
height=seal_h_hu,
|
|
421
|
+
treat_as_char=False,
|
|
422
|
+
# IN_FRONT_OF_TEXT: the seal is stamped *over* the 발신명의 line; without it
|
|
423
|
+
# Hancom wraps the overlapped glyphs and shoves them aside (oracle-confirmed).
|
|
424
|
+
text_wrap="IN_FRONT_OF_TEXT",
|
|
425
|
+
pos_overrides={
|
|
426
|
+
"horzRelTo": "PAPER",
|
|
427
|
+
"vertRelTo": "PAPER",
|
|
428
|
+
"horzAlign": "LEFT",
|
|
429
|
+
"vertAlign": "TOP",
|
|
430
|
+
"horzOffset": horz,
|
|
431
|
+
"vertOffset": vert,
|
|
432
|
+
"allowOverlap": "1" if allow_overlap else "0",
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
return SealPlacement(
|
|
436
|
+
placed=True,
|
|
437
|
+
paragraph_index=target_index,
|
|
438
|
+
binary_item_id=str(binary_item_id),
|
|
439
|
+
horz_offset=horz,
|
|
440
|
+
vert_offset=vert,
|
|
441
|
+
seal_width_hu=seal_w_hu,
|
|
442
|
+
seal_height_hu=seal_h_hu,
|
|
443
|
+
page=page,
|
|
444
|
+
clamped=clamped,
|
|
445
|
+
note="seal clamped to paper edge" if clamped else "",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
__all__ = [
|
|
450
|
+
"SealAnchor",
|
|
451
|
+
"SealPlacement",
|
|
452
|
+
"SealVerdict",
|
|
453
|
+
"check_seal_placement",
|
|
454
|
+
"find_seal_anchor",
|
|
455
|
+
"place_seal",
|
|
456
|
+
"seal_pos_offsets",
|
|
457
|
+
]
|