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,1295 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Read and export automation handlers for the optional MCP adapter."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import html
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.error import HTTPError, URLError
|
|
16
|
+
from urllib.request import Request
|
|
17
|
+
|
|
18
|
+
from hwpx.ingest import DocumentIngestError, DocumentIngestor
|
|
19
|
+
from hwpx.tools import read_fidelity as _read_fidelity
|
|
20
|
+
|
|
21
|
+
from ..configuration import env_float, env_int
|
|
22
|
+
from ..core.content import (
|
|
23
|
+
collect_full_text,
|
|
24
|
+
get_paragraph_text_from_doc,
|
|
25
|
+
get_table_data,
|
|
26
|
+
get_table_map_in_doc,
|
|
27
|
+
)
|
|
28
|
+
from ..core.document import open_doc
|
|
29
|
+
from ..core.formatting import (
|
|
30
|
+
outline_style_levels,
|
|
31
|
+
)
|
|
32
|
+
from ..core.search import find_in_doc
|
|
33
|
+
from ..network_policy import NetworkPolicy, NetworkPolicyError, open_url
|
|
34
|
+
from ..office.compliance import DEFAULT_POLICY, mask_pii
|
|
35
|
+
from ..runtime_services import RUNTIME_SERVICES
|
|
36
|
+
from ..upstream import (
|
|
37
|
+
HP_NS,
|
|
38
|
+
open_document,
|
|
39
|
+
)
|
|
40
|
+
from ..utils.helpers import default_max_chars, resolve_path, truncate_response
|
|
41
|
+
from ._shared import _with_document_state
|
|
42
|
+
|
|
43
|
+
_OUTPUT_MODES = {"full", "chunks"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_CHUNK_STRATEGIES = {"section", "paragraph"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_DEFAULT_MAX_CHARS_PER_CHUNK = 8000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_DEFAULT_MAX_INPUT_BYTES = 20 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_DEFAULT_FETCH_TIMEOUT_SECONDS = 20.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_FIGURE_CAPTION_RE = re.compile(r"^\s*(?:Figure|Fig\.|그림)\s*\d*", re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _mask_pii_text(value: str, mask: bool = True) -> str:
|
|
62
|
+
"""Mask machine-set PII (rrn/phone/email/card) in user-facing extract output.
|
|
63
|
+
|
|
64
|
+
On by default (safe-by-default per 개인정보 보호법); contextual types stay
|
|
65
|
+
label-gated low-confidence inside ``mask_pii`` so free text isn't over-masked.
|
|
66
|
+
"""
|
|
67
|
+
if not mask or not value:
|
|
68
|
+
return value
|
|
69
|
+
return mask_pii(value, DEFAULT_POLICY)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deep_mask_pii(obj: Any, mask: bool = True) -> Any:
|
|
73
|
+
"""Recursively mask PII in string VALUES of a nested JSON-able structure.
|
|
74
|
+
|
|
75
|
+
Dict keys are left untouched; only string values are masked (``mask_pii`` only
|
|
76
|
+
rewrites machine-set PII + label-gated contextual, so normal text is unchanged).
|
|
77
|
+
"""
|
|
78
|
+
if not mask:
|
|
79
|
+
return obj
|
|
80
|
+
if isinstance(obj, str):
|
|
81
|
+
return mask_pii(obj, DEFAULT_POLICY)
|
|
82
|
+
if isinstance(obj, list):
|
|
83
|
+
return [_deep_mask_pii(item, True) for item in obj]
|
|
84
|
+
if isinstance(obj, dict):
|
|
85
|
+
return {key: _deep_mask_pii(val, True) for key, val in obj.items()}
|
|
86
|
+
return obj
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_output_mode(output: str | None) -> str:
|
|
90
|
+
value = (output or "full").strip().lower()
|
|
91
|
+
if value not in _OUTPUT_MODES:
|
|
92
|
+
expected = ", ".join(sorted(_OUTPUT_MODES))
|
|
93
|
+
raise ValueError(f"output must be one of: {expected}")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_chunk_strategy(chunk_strategy: str | None) -> str:
|
|
98
|
+
value = (chunk_strategy or "section").strip().lower()
|
|
99
|
+
if value not in _CHUNK_STRATEGIES:
|
|
100
|
+
expected = ", ".join(sorted(_CHUNK_STRATEGIES))
|
|
101
|
+
raise ValueError(f"chunk_strategy must be one of: {expected}")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_chunk_size(max_chars_per_chunk: int | None) -> int:
|
|
106
|
+
if max_chars_per_chunk is None:
|
|
107
|
+
return max(1, env_int("MAX_CHARS_PER_CHUNK", _DEFAULT_MAX_CHARS_PER_CHUNK))
|
|
108
|
+
if max_chars_per_chunk <= 0:
|
|
109
|
+
raise ValueError("max_chars_per_chunk must be greater than 0")
|
|
110
|
+
return max_chars_per_chunk
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize_heading_text(text: str) -> str:
|
|
114
|
+
stripped = (text or "").strip()
|
|
115
|
+
if stripped.startswith("#"):
|
|
116
|
+
return stripped.lstrip("#").strip()
|
|
117
|
+
return stripped
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _looks_like_figure_caption(text: str) -> bool:
|
|
121
|
+
return bool(_FIGURE_CAPTION_RE.match((text or "").strip()))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _download_hwpx_from_url(url: str, *, max_input_bytes: int) -> bytes:
|
|
125
|
+
request = Request(url, headers={"User-Agent": "python-hwpx-automation/6"})
|
|
126
|
+
timeout = env_float("FETCH_TIMEOUT_SECONDS", _DEFAULT_FETCH_TIMEOUT_SECONDS)
|
|
127
|
+
try:
|
|
128
|
+
with open_url(
|
|
129
|
+
request,
|
|
130
|
+
policy=NetworkPolicy.from_environment(),
|
|
131
|
+
timeout=timeout,
|
|
132
|
+
) as response:
|
|
133
|
+
payload = response.read(max_input_bytes + 1)
|
|
134
|
+
except NetworkPolicyError:
|
|
135
|
+
raise
|
|
136
|
+
except HTTPError as exc:
|
|
137
|
+
raise ValueError(f"failed to download url: HTTP {exc.code}") from exc
|
|
138
|
+
except URLError as exc:
|
|
139
|
+
raise ValueError(f"failed to download url: {exc.reason}") from exc
|
|
140
|
+
|
|
141
|
+
if len(payload) > max_input_bytes:
|
|
142
|
+
raise ValueError(f"input is too large: limit is {max_input_bytes} bytes")
|
|
143
|
+
if not payload:
|
|
144
|
+
raise ValueError("downloaded payload is empty")
|
|
145
|
+
return payload
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _load_hwpx_payload(
|
|
149
|
+
hwpx_base64: str | None, url: str | None
|
|
150
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
151
|
+
use_base64 = bool(hwpx_base64 and hwpx_base64.strip())
|
|
152
|
+
use_url = bool(url and url.strip())
|
|
153
|
+
if use_base64 == use_url:
|
|
154
|
+
raise ValueError("provide exactly one of hwpx_base64 or url")
|
|
155
|
+
|
|
156
|
+
max_input_bytes = max(1, env_int("MAX_INPUT_BYTES", _DEFAULT_MAX_INPUT_BYTES))
|
|
157
|
+
if use_base64:
|
|
158
|
+
try:
|
|
159
|
+
payload = base64.b64decode((hwpx_base64 or "").strip(), validate=True)
|
|
160
|
+
except (binascii.Error, ValueError) as exc:
|
|
161
|
+
raise ValueError("invalid hwpx_base64 payload") from exc
|
|
162
|
+
if len(payload) > max_input_bytes:
|
|
163
|
+
raise ValueError(f"input is too large: limit is {max_input_bytes} bytes")
|
|
164
|
+
if not payload:
|
|
165
|
+
raise ValueError("hwpx_base64 decoded to empty payload")
|
|
166
|
+
return payload, {"source_type": "base64", "size_bytes": len(payload)}
|
|
167
|
+
|
|
168
|
+
source_url = (url or "").strip()
|
|
169
|
+
payload = _download_hwpx_from_url(source_url, max_input_bytes=max_input_bytes)
|
|
170
|
+
return payload, {
|
|
171
|
+
"source_type": "url",
|
|
172
|
+
"source_url": source_url,
|
|
173
|
+
"size_bytes": len(payload),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _open_hwpx_from_payload(hwpx_base64: str | None, url: str | None):
|
|
178
|
+
payload, source_meta = _load_hwpx_payload(hwpx_base64, url)
|
|
179
|
+
try:
|
|
180
|
+
doc = open_document(BytesIO(payload))
|
|
181
|
+
except Exception as exc: # pragma: no cover - delegated to parser
|
|
182
|
+
raise ValueError(f"failed to parse hwpx payload: {exc}") from exc
|
|
183
|
+
return doc, source_meta
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _table_rows(table: Any) -> list[list[str]]:
|
|
187
|
+
rows: list[list[str]] = []
|
|
188
|
+
for row in getattr(table, "rows", []):
|
|
189
|
+
rows.append([(cell.text or "") for cell in getattr(row, "cells", [])])
|
|
190
|
+
return rows
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _table_to_markdown(rows: list[list[str]]) -> str:
|
|
194
|
+
if not rows:
|
|
195
|
+
return ""
|
|
196
|
+
width = max((len(row) for row in rows), default=0)
|
|
197
|
+
if width <= 0:
|
|
198
|
+
return ""
|
|
199
|
+
|
|
200
|
+
def _pad(row: list[str]) -> list[str]:
|
|
201
|
+
return row + [""] * (width - len(row))
|
|
202
|
+
|
|
203
|
+
normalized = [_pad([str(cell) for cell in row]) for row in rows]
|
|
204
|
+
header = normalized[0]
|
|
205
|
+
divider = ["---"] * width
|
|
206
|
+
|
|
207
|
+
def _render(cells: list[str]) -> str:
|
|
208
|
+
escaped = [cell.replace("|", r"\|").replace("\n", "<br>") for cell in cells]
|
|
209
|
+
return f"| {' | '.join(escaped)} |"
|
|
210
|
+
|
|
211
|
+
lines = [_render(header), _render(divider)]
|
|
212
|
+
for row in normalized[1:]:
|
|
213
|
+
lines.append(_render(row))
|
|
214
|
+
return "\n".join(lines)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _table_to_html(rows: list[list[str]]) -> str:
|
|
218
|
+
if not rows:
|
|
219
|
+
return "<table></table>"
|
|
220
|
+
width = max((len(row) for row in rows), default=0)
|
|
221
|
+
if width <= 0:
|
|
222
|
+
return "<table></table>"
|
|
223
|
+
|
|
224
|
+
def _pad(row: list[str]) -> list[str]:
|
|
225
|
+
return row + [""] * (width - len(row))
|
|
226
|
+
|
|
227
|
+
normalized = [_pad([str(cell) for cell in row]) for row in rows]
|
|
228
|
+
header = normalized[0]
|
|
229
|
+
body_rows = normalized[1:]
|
|
230
|
+
|
|
231
|
+
head_html = "".join(f"<th>{html.escape(cell)}</th>" for cell in header)
|
|
232
|
+
body_html = []
|
|
233
|
+
for row in body_rows:
|
|
234
|
+
cells = "".join(f"<td>{html.escape(cell)}</td>" for cell in row)
|
|
235
|
+
body_html.append(f"<tr>{cells}</tr>")
|
|
236
|
+
|
|
237
|
+
if body_html:
|
|
238
|
+
body = "<tbody>" + "".join(body_html) + "</tbody>"
|
|
239
|
+
else:
|
|
240
|
+
body = ""
|
|
241
|
+
return f"<table><thead><tr>{head_html}</tr></thead>{body}</table>"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_format_detail(
|
|
245
|
+
run: Any, fontfaces: dict[str, dict[str, str]] | None = None
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
"""Resolved inline formatting for one run.
|
|
248
|
+
|
|
249
|
+
Named fields (bold/italic/underline/strikeout/color/fontSize/fontName/
|
|
250
|
+
super-subscript) come from the canonical :mod:`hwpx.tools.read_fidelity`
|
|
251
|
+
resolver so this surface and the fidelity harness agree by construction.
|
|
252
|
+
``strikeout`` is shape-normalised (the always-present
|
|
253
|
+
``<hh:strikeout shape="NONE"/>`` previously read as always-on); ``underline``
|
|
254
|
+
is the type or ``None`` when off. Legacy keys are preserved for compat.
|
|
255
|
+
"""
|
|
256
|
+
style = getattr(run, "style", None)
|
|
257
|
+
span = _read_fidelity.run_span(
|
|
258
|
+
getattr(run, "text", "") or "", style, fontfaces or {}
|
|
259
|
+
)
|
|
260
|
+
return {
|
|
261
|
+
"text": span.text,
|
|
262
|
+
"charPrIDRef": getattr(run, "char_pr_id_ref", None),
|
|
263
|
+
"bold": span.bold,
|
|
264
|
+
"italic": span.italic,
|
|
265
|
+
"underline": span.underline,
|
|
266
|
+
"strikeout": span.strikeout,
|
|
267
|
+
"color": span.color,
|
|
268
|
+
"fontSize": span.size_pt,
|
|
269
|
+
"fontName": span.font,
|
|
270
|
+
"superscript": span.superscript,
|
|
271
|
+
"subscript": span.subscript,
|
|
272
|
+
# legacy back-compat keys
|
|
273
|
+
"textColor": span.color,
|
|
274
|
+
"underlineType": style.underline_type() if style is not None else None,
|
|
275
|
+
"underlineColor": style.underline_color() if style is not None else None,
|
|
276
|
+
"attributes": dict(getattr(style, "attributes", {}) or {}),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _paragraph_format_detail(
|
|
281
|
+
paragraph: Any, fontfaces: dict[str, dict[str, str]] | None = None
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
return {
|
|
284
|
+
"paraPrIDRef": getattr(paragraph, "para_pr_id_ref", None),
|
|
285
|
+
"styleIDRef": getattr(paragraph, "style_id_ref", None),
|
|
286
|
+
"charPrIDRef": getattr(paragraph, "char_pr_id_ref", None),
|
|
287
|
+
"runs": [
|
|
288
|
+
_run_format_detail(run, fontfaces) for run in getattr(paragraph, "runs", [])
|
|
289
|
+
],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _cell_format_detail(cell: Any) -> dict[str, Any]:
|
|
294
|
+
return {
|
|
295
|
+
"width": getattr(cell, "width", None),
|
|
296
|
+
"height": getattr(cell, "height", None),
|
|
297
|
+
"span": list(getattr(cell, "span", ()) or ()),
|
|
298
|
+
"address": list(getattr(cell, "address", ()) or ()),
|
|
299
|
+
"borderFillIDRef": getattr(
|
|
300
|
+
getattr(cell, "element", None), "get", lambda _name, _default=None: _default
|
|
301
|
+
)("borderFillIDRef"),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _table_format_detail(table: Any) -> dict[str, Any]:
|
|
306
|
+
rows = []
|
|
307
|
+
for row in getattr(table, "rows", []):
|
|
308
|
+
cells = [_cell_format_detail(cell) for cell in getattr(row, "cells", [])]
|
|
309
|
+
rows.append(cells)
|
|
310
|
+
return {
|
|
311
|
+
"columnCount": getattr(table, "column_count", None),
|
|
312
|
+
"rowCount": getattr(table, "row_count", None),
|
|
313
|
+
"cells": rows,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _build_read_model(doc: Any, *, format_detail: bool = False) -> dict[str, Any]:
|
|
318
|
+
toc: list[dict[str, Any]] = []
|
|
319
|
+
sections: list[dict[str, Any]] = []
|
|
320
|
+
tables: list[dict[str, Any]] = []
|
|
321
|
+
figures: list[dict[str, Any]] = []
|
|
322
|
+
items: list[dict[str, Any]] = []
|
|
323
|
+
|
|
324
|
+
current_section: dict[str, Any] = {
|
|
325
|
+
"index": 0,
|
|
326
|
+
"title": None,
|
|
327
|
+
"level": 0,
|
|
328
|
+
"paragraphs": [],
|
|
329
|
+
"tables": [],
|
|
330
|
+
"figures": [],
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def _flush_current_section() -> None:
|
|
334
|
+
if (
|
|
335
|
+
not current_section["paragraphs"]
|
|
336
|
+
and not current_section["tables"]
|
|
337
|
+
and not current_section["figures"]
|
|
338
|
+
):
|
|
339
|
+
return
|
|
340
|
+
sections.append(
|
|
341
|
+
{
|
|
342
|
+
"index": len(sections),
|
|
343
|
+
"title": current_section["title"],
|
|
344
|
+
"level": current_section["level"],
|
|
345
|
+
"paragraphs": list(current_section["paragraphs"]),
|
|
346
|
+
"tables": list(current_section["tables"]),
|
|
347
|
+
"figures": list(current_section["figures"]),
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
table_index = 0
|
|
352
|
+
style_levels = outline_style_levels(doc)
|
|
353
|
+
fontfaces = _read_fidelity.fontface_maps(doc) if format_detail else {}
|
|
354
|
+
for paragraph_index, paragraph in enumerate(doc.paragraphs):
|
|
355
|
+
text = (paragraph.text or "").strip()
|
|
356
|
+
level = _paragraph_outline_level(paragraph, text, style_levels)
|
|
357
|
+
paragraph_payload = {"index": paragraph_index, "text": text}
|
|
358
|
+
if format_detail:
|
|
359
|
+
paragraph_payload["format"] = _paragraph_format_detail(paragraph, fontfaces)
|
|
360
|
+
|
|
361
|
+
if level > 0 and text:
|
|
362
|
+
_flush_current_section()
|
|
363
|
+
current_section = {
|
|
364
|
+
"index": len(sections),
|
|
365
|
+
"title": _normalize_heading_text(text),
|
|
366
|
+
"level": level,
|
|
367
|
+
"paragraphs": [],
|
|
368
|
+
"tables": [],
|
|
369
|
+
"figures": [],
|
|
370
|
+
}
|
|
371
|
+
heading_text = _normalize_heading_text(text)
|
|
372
|
+
toc.append(
|
|
373
|
+
{
|
|
374
|
+
"level": level,
|
|
375
|
+
"text": heading_text,
|
|
376
|
+
"paragraph_index": paragraph_index,
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
item = {
|
|
380
|
+
"type": "heading",
|
|
381
|
+
"level": level,
|
|
382
|
+
"text": heading_text,
|
|
383
|
+
"paragraph_index": paragraph_index,
|
|
384
|
+
}
|
|
385
|
+
if format_detail:
|
|
386
|
+
item["format"] = _paragraph_format_detail(paragraph, fontfaces)
|
|
387
|
+
items.append(item)
|
|
388
|
+
elif text:
|
|
389
|
+
item = {
|
|
390
|
+
"type": "paragraph",
|
|
391
|
+
"text": text,
|
|
392
|
+
"paragraph_index": paragraph_index,
|
|
393
|
+
}
|
|
394
|
+
if format_detail:
|
|
395
|
+
item["format"] = _paragraph_format_detail(paragraph, fontfaces)
|
|
396
|
+
items.append(item)
|
|
397
|
+
|
|
398
|
+
if text:
|
|
399
|
+
current_section["paragraphs"].append(paragraph_payload)
|
|
400
|
+
if _looks_like_figure_caption(text):
|
|
401
|
+
figure = {
|
|
402
|
+
"figure_index": len(figures),
|
|
403
|
+
"paragraph_index": paragraph_index,
|
|
404
|
+
"caption": text,
|
|
405
|
+
}
|
|
406
|
+
figures.append(figure)
|
|
407
|
+
current_section["figures"].append(figure)
|
|
408
|
+
|
|
409
|
+
for table in getattr(paragraph, "tables", []):
|
|
410
|
+
rows = _table_rows(table)
|
|
411
|
+
table_payload = {
|
|
412
|
+
"table_index": table_index,
|
|
413
|
+
"paragraph_index": paragraph_index,
|
|
414
|
+
"rows": len(rows),
|
|
415
|
+
"cols": max((len(row) for row in rows), default=0),
|
|
416
|
+
"data": rows,
|
|
417
|
+
}
|
|
418
|
+
if format_detail:
|
|
419
|
+
table_payload["format"] = _table_format_detail(table)
|
|
420
|
+
tables.append(table_payload)
|
|
421
|
+
current_section["tables"].append(table_payload)
|
|
422
|
+
item = {
|
|
423
|
+
"type": "table",
|
|
424
|
+
"table_index": table_index,
|
|
425
|
+
"paragraph_index": paragraph_index,
|
|
426
|
+
"data": rows,
|
|
427
|
+
}
|
|
428
|
+
if format_detail:
|
|
429
|
+
item["format"] = table_payload["format"]
|
|
430
|
+
items.append(item)
|
|
431
|
+
table_index += 1
|
|
432
|
+
|
|
433
|
+
_flush_current_section()
|
|
434
|
+
try:
|
|
435
|
+
notes = [note.to_dict() for note in _read_fidelity.collect_notes(doc)]
|
|
436
|
+
except Exception: # pragma: no cover - defensive: never break a read
|
|
437
|
+
notes = []
|
|
438
|
+
return {
|
|
439
|
+
"title": toc[0]["text"] if toc else None,
|
|
440
|
+
"toc": toc,
|
|
441
|
+
"sections": sections,
|
|
442
|
+
"tables": tables,
|
|
443
|
+
"figures": figures,
|
|
444
|
+
"items": items,
|
|
445
|
+
"notes": notes,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _append_notes_markdown(
|
|
450
|
+
markdown: str, notes: list[dict[str, Any]], mask: bool
|
|
451
|
+
) -> str:
|
|
452
|
+
"""Append a footnote/endnote definition appendix (reference-style).
|
|
453
|
+
|
|
454
|
+
The reading surfaces used to drop note bodies entirely; this preserves them
|
|
455
|
+
at the installed surface as ``[^fn1]: body`` lines under a rule.
|
|
456
|
+
"""
|
|
457
|
+
if not notes:
|
|
458
|
+
return markdown
|
|
459
|
+
fn_i = en_i = 0
|
|
460
|
+
lines: list[str] = []
|
|
461
|
+
for note in notes:
|
|
462
|
+
if note.get("kind") == "footNote":
|
|
463
|
+
fn_i += 1
|
|
464
|
+
marker, label = f"[^fn{fn_i}]", "각주"
|
|
465
|
+
else:
|
|
466
|
+
en_i += 1
|
|
467
|
+
marker, label = f"[^en{en_i}]", "미주"
|
|
468
|
+
body = _mask_pii_text(note.get("bodyText", "") or "", mask)
|
|
469
|
+
lines.append(f"{marker}: ({label}) {body}")
|
|
470
|
+
appendix = "\n".join(lines)
|
|
471
|
+
return f"{markdown}\n\n---\n\n{appendix}" if markdown else appendix
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _render_markdown(model: dict[str, Any]) -> str:
|
|
475
|
+
blocks: list[str] = []
|
|
476
|
+
for item in model["items"]:
|
|
477
|
+
kind = item["type"]
|
|
478
|
+
if kind == "heading":
|
|
479
|
+
level = max(1, min(6, int(item["level"])))
|
|
480
|
+
blocks.append(f"{'#' * level} {item['text']}")
|
|
481
|
+
elif kind == "paragraph":
|
|
482
|
+
blocks.append(item["text"])
|
|
483
|
+
elif kind == "table":
|
|
484
|
+
table_markdown = _table_to_markdown(item["data"])
|
|
485
|
+
if table_markdown:
|
|
486
|
+
blocks.append(table_markdown)
|
|
487
|
+
return "\n\n".join(blocks).strip()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _render_html(model: dict[str, Any]) -> str:
|
|
491
|
+
body: list[str] = ['<article class="hwpx-document">']
|
|
492
|
+
for item in model["items"]:
|
|
493
|
+
kind = item["type"]
|
|
494
|
+
if kind == "heading":
|
|
495
|
+
level = max(1, min(6, int(item["level"])))
|
|
496
|
+
body.append(f"<h{level}>{html.escape(item['text'])}</h{level}>")
|
|
497
|
+
elif kind == "paragraph":
|
|
498
|
+
body.append(f"<p>{html.escape(item['text'])}</p>")
|
|
499
|
+
elif kind == "table":
|
|
500
|
+
body.append(_table_to_html(item["data"]))
|
|
501
|
+
body.append("</article>")
|
|
502
|
+
return (
|
|
503
|
+
"<!doctype html>"
|
|
504
|
+
"<html><head><meta charset='utf-8'><title>HWPX Document</title></head>"
|
|
505
|
+
f"<body>{''.join(body)}</body></html>"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _section_markdown(section: dict[str, Any]) -> str:
|
|
510
|
+
blocks: list[str] = []
|
|
511
|
+
title = section.get("title")
|
|
512
|
+
level = max(1, min(6, int(section.get("level") or 1)))
|
|
513
|
+
paragraphs = list(section.get("paragraphs") or [])
|
|
514
|
+
if title:
|
|
515
|
+
blocks.append(f"{'#' * level} {title}")
|
|
516
|
+
if paragraphs and paragraphs[0].get("text") == title:
|
|
517
|
+
paragraphs = paragraphs[1:]
|
|
518
|
+
for paragraph in paragraphs:
|
|
519
|
+
text = (paragraph.get("text") or "").strip()
|
|
520
|
+
if text:
|
|
521
|
+
blocks.append(text)
|
|
522
|
+
for table in section.get("tables") or []:
|
|
523
|
+
markdown_table = _table_to_markdown(table.get("data") or [])
|
|
524
|
+
if markdown_table:
|
|
525
|
+
blocks.append(markdown_table)
|
|
526
|
+
return "\n\n".join(blocks).strip()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _section_html(section: dict[str, Any]) -> str:
|
|
530
|
+
parts: list[str] = ["<section>"]
|
|
531
|
+
title = section.get("title")
|
|
532
|
+
level = max(1, min(6, int(section.get("level") or 1)))
|
|
533
|
+
paragraphs = list(section.get("paragraphs") or [])
|
|
534
|
+
if title:
|
|
535
|
+
parts.append(f"<h{level}>{html.escape(title)}</h{level}>")
|
|
536
|
+
if paragraphs and paragraphs[0].get("text") == title:
|
|
537
|
+
paragraphs = paragraphs[1:]
|
|
538
|
+
for paragraph in paragraphs:
|
|
539
|
+
text = (paragraph.get("text") or "").strip()
|
|
540
|
+
if text:
|
|
541
|
+
parts.append(f"<p>{html.escape(text)}</p>")
|
|
542
|
+
for table in section.get("tables") or []:
|
|
543
|
+
parts.append(_table_to_html(table.get("data") or []))
|
|
544
|
+
parts.append("</section>")
|
|
545
|
+
return "".join(parts)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _chunk_paragraphs(paragraphs: list[str], max_chars_per_chunk: int) -> list[str]:
|
|
549
|
+
chunks: list[str] = []
|
|
550
|
+
buffer: list[str] = []
|
|
551
|
+
used = 0
|
|
552
|
+
for paragraph in paragraphs:
|
|
553
|
+
text = (paragraph or "").strip()
|
|
554
|
+
if not text:
|
|
555
|
+
continue
|
|
556
|
+
additional = len(text) + (2 if buffer else 0)
|
|
557
|
+
if buffer and used + additional > max_chars_per_chunk:
|
|
558
|
+
chunks.append("\n\n".join(buffer))
|
|
559
|
+
buffer = []
|
|
560
|
+
used = 0
|
|
561
|
+
if len(text) > max_chars_per_chunk:
|
|
562
|
+
if buffer:
|
|
563
|
+
chunks.append("\n\n".join(buffer))
|
|
564
|
+
buffer = []
|
|
565
|
+
used = 0
|
|
566
|
+
for start in range(0, len(text), max_chars_per_chunk):
|
|
567
|
+
chunks.append(text[start : start + max_chars_per_chunk])
|
|
568
|
+
continue
|
|
569
|
+
buffer.append(text)
|
|
570
|
+
used += additional
|
|
571
|
+
if buffer:
|
|
572
|
+
chunks.append("\n\n".join(buffer))
|
|
573
|
+
return chunks
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _markdown_chunks(
|
|
577
|
+
model: dict[str, Any], *, chunk_strategy: str, max_chars_per_chunk: int
|
|
578
|
+
) -> list[str]:
|
|
579
|
+
if chunk_strategy == "section":
|
|
580
|
+
return [
|
|
581
|
+
chunk
|
|
582
|
+
for section in model["sections"]
|
|
583
|
+
if (chunk := _section_markdown(section))
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
paragraphs = [
|
|
587
|
+
item["text"]
|
|
588
|
+
for item in model["items"]
|
|
589
|
+
if item["type"] in {"heading", "paragraph"}
|
|
590
|
+
]
|
|
591
|
+
return _chunk_paragraphs(paragraphs, max_chars_per_chunk)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _ingest_markdown_chunks(markdown: str, *, max_chars_per_chunk: int) -> list[str]:
|
|
595
|
+
paragraphs = [part for part in re.split(r"\n{2,}", markdown or "") if part.strip()]
|
|
596
|
+
return _chunk_paragraphs(paragraphs, max_chars_per_chunk)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _html_chunks(
|
|
600
|
+
model: dict[str, Any], *, chunk_strategy: str, max_chars_per_chunk: int
|
|
601
|
+
) -> list[str]:
|
|
602
|
+
if chunk_strategy == "section":
|
|
603
|
+
chunks = [_section_html(section) for section in model["sections"]]
|
|
604
|
+
return [chunk for chunk in chunks if chunk]
|
|
605
|
+
|
|
606
|
+
paragraphs = [
|
|
607
|
+
f"<p>{html.escape(item['text'])}</p>"
|
|
608
|
+
for item in model["items"]
|
|
609
|
+
if item["type"] in {"heading", "paragraph"}
|
|
610
|
+
]
|
|
611
|
+
plain_chunks = _chunk_paragraphs(paragraphs, max_chars_per_chunk)
|
|
612
|
+
return [
|
|
613
|
+
f"<article class='hwpx-document'>{chunk}</article>" for chunk in plain_chunks
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _json_chunks(
|
|
618
|
+
model: dict[str, Any], *, chunk_strategy: str, max_chars_per_chunk: int
|
|
619
|
+
) -> list[dict[str, Any]]:
|
|
620
|
+
if chunk_strategy == "section":
|
|
621
|
+
return [
|
|
622
|
+
{"chunk_index": index, "strategy": "section", "section": section}
|
|
623
|
+
for index, section in enumerate(model["sections"])
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
paragraphs = []
|
|
627
|
+
for section in model["sections"]:
|
|
628
|
+
for paragraph in section.get("paragraphs") or []:
|
|
629
|
+
paragraphs.append(
|
|
630
|
+
{
|
|
631
|
+
"section_index": section.get("index"),
|
|
632
|
+
"paragraph_index": paragraph.get("index"),
|
|
633
|
+
"text": paragraph.get("text") or "",
|
|
634
|
+
}
|
|
635
|
+
)
|
|
636
|
+
groups = _chunk_paragraphs(
|
|
637
|
+
[item["text"] for item in paragraphs], max_chars_per_chunk
|
|
638
|
+
)
|
|
639
|
+
chunks: list[dict[str, Any]] = []
|
|
640
|
+
offset = 0
|
|
641
|
+
for chunk_index, chunk in enumerate(groups):
|
|
642
|
+
consumed = len(chunk.split("\n\n")) if chunk else 0
|
|
643
|
+
selected = paragraphs[offset : offset + consumed]
|
|
644
|
+
offset += consumed
|
|
645
|
+
chunks.append(
|
|
646
|
+
{
|
|
647
|
+
"chunk_index": chunk_index,
|
|
648
|
+
"strategy": "paragraph",
|
|
649
|
+
"paragraphs": selected,
|
|
650
|
+
}
|
|
651
|
+
)
|
|
652
|
+
return chunks
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _build_conversion_meta(
|
|
656
|
+
model: dict[str, Any], source_meta: dict[str, Any]
|
|
657
|
+
) -> dict[str, Any]:
|
|
658
|
+
return {
|
|
659
|
+
"source_type": source_meta.get("source_type"),
|
|
660
|
+
"source_url": source_meta.get("source_url"),
|
|
661
|
+
"size_bytes": source_meta.get("size_bytes"),
|
|
662
|
+
"section_count": len(model["sections"]),
|
|
663
|
+
"paragraph_count": sum(
|
|
664
|
+
1 for item in model["items"] if item["type"] in {"heading", "paragraph"}
|
|
665
|
+
),
|
|
666
|
+
"table_count": len(model["tables"]),
|
|
667
|
+
"figure_caption_count": len(model["figures"]),
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _ingest_local_document(filename: str):
|
|
672
|
+
if DocumentIngestor is None:
|
|
673
|
+
raise RuntimeError("installed python-hwpx does not provide document ingest")
|
|
674
|
+
from ..ingest_adapters import MarkItDownAdapter, MissingMarkItDownDependency
|
|
675
|
+
|
|
676
|
+
path = resolve_path(filename)
|
|
677
|
+
ingestor = DocumentIngestor.default()
|
|
678
|
+
ingestor.register_converter(MarkItDownAdapter(), priority=100.0)
|
|
679
|
+
try:
|
|
680
|
+
return path, ingestor.convert(path)
|
|
681
|
+
except DocumentIngestError as exc:
|
|
682
|
+
for attempt in getattr(exc, "attempts", []) or []:
|
|
683
|
+
if getattr(attempt, "error_type", None) == "MissingMarkItDownDependency":
|
|
684
|
+
raise MissingMarkItDownDependency(
|
|
685
|
+
str(attempt.message), attempts=exc.attempts
|
|
686
|
+
) from exc
|
|
687
|
+
raise
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _document_ingest_error_payload(exc: Exception, filename: str) -> dict[str, Any]:
|
|
691
|
+
if DocumentIngestError is not None and isinstance(exc, DocumentIngestError):
|
|
692
|
+
payload = exc.as_dict()
|
|
693
|
+
else:
|
|
694
|
+
payload = {"error": type(exc).__name__, "message": str(exc), "attempts": []}
|
|
695
|
+
payload.update({"ok": False, "filename": filename})
|
|
696
|
+
return payload
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _document_ingest_meta(result: Any) -> dict[str, Any]:
|
|
700
|
+
source_info = getattr(result, "source_info", None)
|
|
701
|
+
meta = {
|
|
702
|
+
"source_format": getattr(result, "source_format", None),
|
|
703
|
+
"engine": getattr(result, "engine", None),
|
|
704
|
+
"engine_version": getattr(result, "engine_version", None),
|
|
705
|
+
"lossiness": getattr(result, "lossiness", None),
|
|
706
|
+
}
|
|
707
|
+
if source_info is not None:
|
|
708
|
+
meta["source_info"] = {
|
|
709
|
+
"mimetype": getattr(source_info, "mimetype", None),
|
|
710
|
+
"extension": getattr(source_info, "extension", None),
|
|
711
|
+
"charset": getattr(source_info, "charset", None),
|
|
712
|
+
"filename": getattr(source_info, "filename", None),
|
|
713
|
+
"local_path": getattr(source_info, "local_path", None),
|
|
714
|
+
"url": getattr(source_info, "url", None),
|
|
715
|
+
}
|
|
716
|
+
metadata = getattr(result, "metadata", None)
|
|
717
|
+
if isinstance(metadata, dict):
|
|
718
|
+
meta.update(metadata)
|
|
719
|
+
return {key: value for key, value in meta.items() if value is not None}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _attempts_payload(result: Any) -> list[dict[str, Any]]:
|
|
723
|
+
attempts = getattr(result, "attempts", []) or []
|
|
724
|
+
return [
|
|
725
|
+
attempt.as_dict() if hasattr(attempt, "as_dict") else dict(attempt)
|
|
726
|
+
for attempt in attempts
|
|
727
|
+
]
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _paragraph_count(doc) -> int:
|
|
731
|
+
return len(doc.paragraphs)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _table_count(doc) -> int:
|
|
735
|
+
table_tag = f"{HP_NS}tbl"
|
|
736
|
+
count = 0
|
|
737
|
+
for section in getattr(doc, "sections", []):
|
|
738
|
+
section_element = getattr(section, "element", None)
|
|
739
|
+
if section_element is None or not hasattr(section_element, "iter"):
|
|
740
|
+
continue
|
|
741
|
+
count += sum(1 for _ in section_element.iter(table_tag))
|
|
742
|
+
return count
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _outline_level(text: str) -> int:
|
|
746
|
+
stripped = (text or "").strip()
|
|
747
|
+
if not stripped:
|
|
748
|
+
return 0
|
|
749
|
+
if stripped.startswith("#"):
|
|
750
|
+
return min(6, len(stripped) - len(stripped.lstrip("#")))
|
|
751
|
+
if stripped[:2].isdigit() and "." in stripped[:6]:
|
|
752
|
+
return 2
|
|
753
|
+
if stripped[:1].isdigit() and "." in stripped[:4]:
|
|
754
|
+
return 1
|
|
755
|
+
return 1 if len(stripped) < 60 else 0
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _paragraph_outline_level(
|
|
759
|
+
paragraph: Any, text: str, style_levels: dict[str, int]
|
|
760
|
+
) -> int:
|
|
761
|
+
"""개요 문단 스타일을 우선하고, 구버전 '#' 헤딩만 fallback으로 인식한다."""
|
|
762
|
+
if not (text or "").strip():
|
|
763
|
+
return 0
|
|
764
|
+
ref = getattr(paragraph, "style_id_ref", None)
|
|
765
|
+
if ref is not None and str(ref) in style_levels:
|
|
766
|
+
return style_levels[str(ref)]
|
|
767
|
+
if text.strip().startswith("#"):
|
|
768
|
+
return _outline_level(text)
|
|
769
|
+
if style_levels:
|
|
770
|
+
return 0
|
|
771
|
+
return _outline_level(text)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def get_document_info(filename: str) -> dict:
|
|
775
|
+
"""문서 메타데이터와 구조 요약을 조회합니다."""
|
|
776
|
+
path = resolve_path(filename)
|
|
777
|
+
doc = open_doc(path)
|
|
778
|
+
file_size = Path(path).stat().st_size
|
|
779
|
+
return _with_document_state(
|
|
780
|
+
{
|
|
781
|
+
"filename": filename,
|
|
782
|
+
"sections": len(doc.sections),
|
|
783
|
+
"paragraphs": _paragraph_count(doc),
|
|
784
|
+
"tables": _table_count(doc),
|
|
785
|
+
"file_size": str(file_size),
|
|
786
|
+
},
|
|
787
|
+
path,
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def get_document_text(
|
|
792
|
+
filename: str, max_chars: int | None = None, mask: bool = True
|
|
793
|
+
) -> dict:
|
|
794
|
+
"""문서 전체 텍스트를 조회합니다. (기본: 기계검증 PII 마스킹 ON — `mask=False`로 원본)"""
|
|
795
|
+
path = resolve_path(filename)
|
|
796
|
+
doc = open_doc(path)
|
|
797
|
+
text = _mask_pii_text(collect_full_text(doc), mask)
|
|
798
|
+
return _with_document_state(truncate_response(text, max_chars=max_chars), path)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def get_document_outline(filename: str) -> dict:
|
|
802
|
+
"""문단 기준 제목/개요 구조를 조회합니다."""
|
|
803
|
+
path = resolve_path(filename)
|
|
804
|
+
doc = open_doc(path)
|
|
805
|
+
outline: list[dict] = []
|
|
806
|
+
style_levels = outline_style_levels(doc)
|
|
807
|
+
for index, para in enumerate(doc.paragraphs):
|
|
808
|
+
text = (para.text or "").strip()
|
|
809
|
+
level = _paragraph_outline_level(para, text, style_levels)
|
|
810
|
+
if level > 0 and text:
|
|
811
|
+
outline.append(
|
|
812
|
+
{
|
|
813
|
+
"level": level,
|
|
814
|
+
"text": _normalize_heading_text(text),
|
|
815
|
+
"paragraph_index": index,
|
|
816
|
+
}
|
|
817
|
+
)
|
|
818
|
+
return _with_document_state({"outline": outline}, path)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _summary_table_map(path) -> dict:
|
|
822
|
+
"""Compact per-table map (FR-006) via python-hwpx table_summary — bounded."""
|
|
823
|
+
try:
|
|
824
|
+
from hwpx.table_patch import table_summary
|
|
825
|
+
except Exception as exc: # pragma: no cover - dependency compatibility
|
|
826
|
+
return {"tables": [], "count": 0, "note": f"summary unavailable: {exc}"}
|
|
827
|
+
tables = table_summary(path)
|
|
828
|
+
return {"tables": tables, "count": len(tables), "detail": "summary"}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def get_document_map(
|
|
832
|
+
filename: str,
|
|
833
|
+
max_preview_chars: int = 80,
|
|
834
|
+
detail: str = "full",
|
|
835
|
+
) -> dict:
|
|
836
|
+
"""문서 개요, 표, 양식 필드, 앵커를 한 번에 조회합니다.
|
|
837
|
+
|
|
838
|
+
detail="summary" (FR-006): 표는 셀 덤프 없이 표당 {tableIndex, rows, cols,
|
|
839
|
+
merges, heading, firstRow} 요약만 반환 — 37표 양식도 토큰한도 내 1콜. 헤딩은
|
|
840
|
+
apply_table_ops/fill_cells의 tableAnchor로 그대로 쓸 수 있는 텍스트.
|
|
841
|
+
detail="full"(기본)은 종전대로 셀 단위 표 지도를 반환한다."""
|
|
842
|
+
path = resolve_path(filename)
|
|
843
|
+
doc = open_doc(path)
|
|
844
|
+
model = _build_read_model(doc)
|
|
845
|
+
preview_limit = max(0, int(max_preview_chars))
|
|
846
|
+
summary_mode = str(detail).lower() == "summary"
|
|
847
|
+
|
|
848
|
+
paragraph_anchors = []
|
|
849
|
+
for item in model["items"]:
|
|
850
|
+
if item.get("type") not in {"heading", "paragraph"}:
|
|
851
|
+
continue
|
|
852
|
+
text = str(item.get("text") or "")
|
|
853
|
+
paragraph_index = item.get("paragraph_index")
|
|
854
|
+
paragraph_anchors.append(
|
|
855
|
+
{
|
|
856
|
+
"kind": item.get("type"),
|
|
857
|
+
"paragraphIndex": paragraph_index,
|
|
858
|
+
"textPreview": text[:preview_limit],
|
|
859
|
+
"anchor": {
|
|
860
|
+
"kind": "body_paragraph",
|
|
861
|
+
"paragraphIndex": paragraph_index,
|
|
862
|
+
},
|
|
863
|
+
}
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
table_anchors = [
|
|
867
|
+
{
|
|
868
|
+
"kind": "table",
|
|
869
|
+
"tableIndex": table.get("table_index"),
|
|
870
|
+
"paragraphIndex": table.get("paragraph_index"),
|
|
871
|
+
"rows": table.get("rows"),
|
|
872
|
+
"cols": table.get("cols"),
|
|
873
|
+
"anchor": {
|
|
874
|
+
"kind": "table",
|
|
875
|
+
"tableIndex": table.get("table_index"),
|
|
876
|
+
"paragraphIndex": table.get("paragraph_index"),
|
|
877
|
+
},
|
|
878
|
+
}
|
|
879
|
+
for table in model["tables"]
|
|
880
|
+
]
|
|
881
|
+
|
|
882
|
+
try:
|
|
883
|
+
form_fields = RUNTIME_SERVICES.ops.list_form_fields(path)
|
|
884
|
+
except Exception as exc: # pragma: no cover - diagnostic fallback
|
|
885
|
+
form_fields = {"fields": [], "error": str(exc)}
|
|
886
|
+
|
|
887
|
+
result = {
|
|
888
|
+
"filename": filename,
|
|
889
|
+
"info": {
|
|
890
|
+
"sections": len(doc.sections),
|
|
891
|
+
"paragraphs": _paragraph_count(doc),
|
|
892
|
+
"tables": _table_count(doc),
|
|
893
|
+
},
|
|
894
|
+
"outline": model["toc"],
|
|
895
|
+
"sections": [
|
|
896
|
+
{
|
|
897
|
+
"index": section.get("index"),
|
|
898
|
+
"title": section.get("title"),
|
|
899
|
+
"level": section.get("level"),
|
|
900
|
+
"paragraphCount": len(section.get("paragraphs") or []),
|
|
901
|
+
"tableCount": len(section.get("tables") or []),
|
|
902
|
+
"figureCount": len(section.get("figures") or []),
|
|
903
|
+
}
|
|
904
|
+
for section in model["sections"]
|
|
905
|
+
],
|
|
906
|
+
"tables": _summary_table_map(path)
|
|
907
|
+
if summary_mode
|
|
908
|
+
else get_table_map_in_doc(doc),
|
|
909
|
+
"formFields": form_fields,
|
|
910
|
+
"anchors": {
|
|
911
|
+
"paragraphs": paragraph_anchors,
|
|
912
|
+
"tables": table_anchors,
|
|
913
|
+
"figures": model["figures"],
|
|
914
|
+
},
|
|
915
|
+
"sourceTools": [
|
|
916
|
+
"get_document_info",
|
|
917
|
+
"get_document_outline",
|
|
918
|
+
"get_table_map",
|
|
919
|
+
"list_form_fields",
|
|
920
|
+
"hwpx_extract_json",
|
|
921
|
+
],
|
|
922
|
+
}
|
|
923
|
+
return _with_document_state(result, path)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def hwpx_to_markdown(
|
|
927
|
+
hwpx_base64: str | None = None,
|
|
928
|
+
url: str | None = None,
|
|
929
|
+
output: str = "full",
|
|
930
|
+
chunk_strategy: str = "section",
|
|
931
|
+
max_chars_per_chunk: int | None = None,
|
|
932
|
+
mask: bool = True,
|
|
933
|
+
) -> dict:
|
|
934
|
+
"""HWPX payload 또는 URL을 Markdown으로 변환합니다. (기본: 기계검증 PII 마스킹 ON)"""
|
|
935
|
+
mode = _normalize_output_mode(output)
|
|
936
|
+
strategy = _normalize_chunk_strategy(chunk_strategy)
|
|
937
|
+
chunk_size = _resolve_chunk_size(max_chars_per_chunk)
|
|
938
|
+
|
|
939
|
+
doc, source_meta = _open_hwpx_from_payload(hwpx_base64, url)
|
|
940
|
+
model = _build_read_model(doc)
|
|
941
|
+
markdown = _mask_pii_text(_render_markdown(model), mask)
|
|
942
|
+
markdown = _append_notes_markdown(markdown, model.get("notes", []), mask)
|
|
943
|
+
|
|
944
|
+
result: dict[str, Any] = {
|
|
945
|
+
"markdown": markdown,
|
|
946
|
+
"meta": _build_conversion_meta(model, source_meta),
|
|
947
|
+
}
|
|
948
|
+
if mode == "chunks":
|
|
949
|
+
result["chunks"] = _deep_mask_pii(
|
|
950
|
+
_markdown_chunks(
|
|
951
|
+
model,
|
|
952
|
+
chunk_strategy=strategy,
|
|
953
|
+
max_chars_per_chunk=chunk_size,
|
|
954
|
+
),
|
|
955
|
+
mask,
|
|
956
|
+
)
|
|
957
|
+
result["meta"]["chunk_strategy"] = strategy
|
|
958
|
+
result["meta"]["max_chars_per_chunk"] = chunk_size
|
|
959
|
+
return result
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def document_to_markdown(
|
|
963
|
+
filename: str,
|
|
964
|
+
output: str = "full",
|
|
965
|
+
chunk_strategy: str = "section",
|
|
966
|
+
max_chars_per_chunk: int | None = None,
|
|
967
|
+
mask: bool = True,
|
|
968
|
+
) -> dict:
|
|
969
|
+
"""로컬 문서를 Markdown으로 변환합니다. 현재 HWPX는 python-hwpx ingest 엔진으로 처리합니다."""
|
|
970
|
+
mode = _normalize_output_mode(output)
|
|
971
|
+
strategy = _normalize_chunk_strategy(chunk_strategy)
|
|
972
|
+
chunk_size = _resolve_chunk_size(max_chars_per_chunk)
|
|
973
|
+
|
|
974
|
+
try:
|
|
975
|
+
path, ingest_result = _ingest_local_document(filename)
|
|
976
|
+
except Exception as exc:
|
|
977
|
+
return _document_ingest_error_payload(exc, filename)
|
|
978
|
+
|
|
979
|
+
markdown = _mask_pii_text(ingest_result.markdown, mask)
|
|
980
|
+
payload: dict[str, Any] = {
|
|
981
|
+
"ok": True,
|
|
982
|
+
"filename": str(path),
|
|
983
|
+
"markdown": markdown,
|
|
984
|
+
"meta": _document_ingest_meta(ingest_result),
|
|
985
|
+
"warnings": list(getattr(ingest_result, "warnings", []) or []),
|
|
986
|
+
"attempts": _attempts_payload(ingest_result),
|
|
987
|
+
}
|
|
988
|
+
if mode == "chunks":
|
|
989
|
+
payload["chunks"] = _deep_mask_pii(
|
|
990
|
+
_ingest_markdown_chunks(markdown, max_chars_per_chunk=chunk_size),
|
|
991
|
+
mask,
|
|
992
|
+
)
|
|
993
|
+
payload["meta"]["chunk_strategy"] = strategy
|
|
994
|
+
payload["meta"]["max_chars_per_chunk"] = chunk_size
|
|
995
|
+
return payload
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def hwpx_to_html(
|
|
999
|
+
hwpx_base64: str | None = None,
|
|
1000
|
+
url: str | None = None,
|
|
1001
|
+
output: str = "full",
|
|
1002
|
+
chunk_strategy: str = "section",
|
|
1003
|
+
max_chars_per_chunk: int | None = None,
|
|
1004
|
+
) -> dict:
|
|
1005
|
+
"""HWPX payload 또는 URL을 HTML로 변환합니다."""
|
|
1006
|
+
mode = _normalize_output_mode(output)
|
|
1007
|
+
strategy = _normalize_chunk_strategy(chunk_strategy)
|
|
1008
|
+
chunk_size = _resolve_chunk_size(max_chars_per_chunk)
|
|
1009
|
+
|
|
1010
|
+
doc, source_meta = _open_hwpx_from_payload(hwpx_base64, url)
|
|
1011
|
+
model = _build_read_model(doc)
|
|
1012
|
+
payload: dict[str, Any] = {
|
|
1013
|
+
"html": _render_html(model),
|
|
1014
|
+
"meta": _build_conversion_meta(model, source_meta),
|
|
1015
|
+
}
|
|
1016
|
+
payload["meta"]["image_policy"] = "omitted"
|
|
1017
|
+
|
|
1018
|
+
if mode == "chunks":
|
|
1019
|
+
payload["chunks"] = _html_chunks(
|
|
1020
|
+
model,
|
|
1021
|
+
chunk_strategy=strategy,
|
|
1022
|
+
max_chars_per_chunk=chunk_size,
|
|
1023
|
+
)
|
|
1024
|
+
payload["meta"]["chunk_strategy"] = strategy
|
|
1025
|
+
payload["meta"]["max_chars_per_chunk"] = chunk_size
|
|
1026
|
+
return payload
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def hwpx_extract_json(
|
|
1030
|
+
hwpx_base64: str | None = None,
|
|
1031
|
+
url: str | None = None,
|
|
1032
|
+
output: str = "full",
|
|
1033
|
+
chunk_strategy: str = "section",
|
|
1034
|
+
max_chars_per_chunk: int | None = None,
|
|
1035
|
+
format_detail: bool = False,
|
|
1036
|
+
mask: bool = True,
|
|
1037
|
+
) -> dict:
|
|
1038
|
+
"""HWPX payload 또는 URL에서 구조화된 JSON을 추출합니다. (기본: 기계검증 PII 마스킹 ON)"""
|
|
1039
|
+
mode = _normalize_output_mode(output)
|
|
1040
|
+
strategy = _normalize_chunk_strategy(chunk_strategy)
|
|
1041
|
+
chunk_size = _resolve_chunk_size(max_chars_per_chunk)
|
|
1042
|
+
|
|
1043
|
+
doc, source_meta = _open_hwpx_from_payload(hwpx_base64, url)
|
|
1044
|
+
model = _build_read_model(doc, format_detail=bool(format_detail))
|
|
1045
|
+
doc_payload = _deep_mask_pii(
|
|
1046
|
+
{
|
|
1047
|
+
"title": model["title"],
|
|
1048
|
+
"toc": model["toc"],
|
|
1049
|
+
"sections": model["sections"],
|
|
1050
|
+
"tables": model["tables"],
|
|
1051
|
+
"figures": model["figures"],
|
|
1052
|
+
"notes": model.get("notes", []),
|
|
1053
|
+
},
|
|
1054
|
+
mask,
|
|
1055
|
+
)
|
|
1056
|
+
result: dict[str, Any] = {
|
|
1057
|
+
"doc": doc_payload,
|
|
1058
|
+
"meta": _build_conversion_meta(model, source_meta),
|
|
1059
|
+
}
|
|
1060
|
+
if format_detail:
|
|
1061
|
+
result["meta"]["format_detail"] = True
|
|
1062
|
+
if mode == "chunks":
|
|
1063
|
+
result["chunks"] = _deep_mask_pii(
|
|
1064
|
+
_json_chunks(
|
|
1065
|
+
model,
|
|
1066
|
+
chunk_strategy=strategy,
|
|
1067
|
+
max_chars_per_chunk=chunk_size,
|
|
1068
|
+
),
|
|
1069
|
+
mask,
|
|
1070
|
+
)
|
|
1071
|
+
result["meta"]["chunk_strategy"] = strategy
|
|
1072
|
+
result["meta"]["max_chars_per_chunk"] = chunk_size
|
|
1073
|
+
return result
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def document_extract_json(
|
|
1077
|
+
filename: str,
|
|
1078
|
+
output: str = "full",
|
|
1079
|
+
chunk_strategy: str = "section",
|
|
1080
|
+
max_chars_per_chunk: int | None = None,
|
|
1081
|
+
format_detail: bool = False,
|
|
1082
|
+
mask: bool = True,
|
|
1083
|
+
) -> dict:
|
|
1084
|
+
"""로컬 문서에서 Markdown과 구조화된 JSON을 함께 추출합니다. 현재 HWPX ingest를 우선 사용합니다."""
|
|
1085
|
+
del format_detail
|
|
1086
|
+
mode = _normalize_output_mode(output)
|
|
1087
|
+
strategy = _normalize_chunk_strategy(chunk_strategy)
|
|
1088
|
+
chunk_size = _resolve_chunk_size(max_chars_per_chunk)
|
|
1089
|
+
|
|
1090
|
+
try:
|
|
1091
|
+
path, ingest_result = _ingest_local_document(filename)
|
|
1092
|
+
except Exception as exc:
|
|
1093
|
+
return _document_ingest_error_payload(exc, filename)
|
|
1094
|
+
|
|
1095
|
+
markdown = _mask_pii_text(ingest_result.markdown, mask)
|
|
1096
|
+
doc_payload = _deep_mask_pii(
|
|
1097
|
+
{
|
|
1098
|
+
"title": getattr(ingest_result, "title", None),
|
|
1099
|
+
"markdown": markdown,
|
|
1100
|
+
"sections": getattr(ingest_result, "sections", []) or [],
|
|
1101
|
+
"tables": getattr(ingest_result, "tables", []) or [],
|
|
1102
|
+
"metadata": getattr(ingest_result, "metadata", {}) or {},
|
|
1103
|
+
},
|
|
1104
|
+
mask,
|
|
1105
|
+
)
|
|
1106
|
+
payload: dict[str, Any] = {
|
|
1107
|
+
"ok": True,
|
|
1108
|
+
"filename": str(path),
|
|
1109
|
+
"doc": doc_payload,
|
|
1110
|
+
"meta": _document_ingest_meta(ingest_result),
|
|
1111
|
+
"warnings": list(getattr(ingest_result, "warnings", []) or []),
|
|
1112
|
+
"attempts": _attempts_payload(ingest_result),
|
|
1113
|
+
}
|
|
1114
|
+
if mode == "chunks":
|
|
1115
|
+
payload["chunks"] = _deep_mask_pii(
|
|
1116
|
+
_ingest_markdown_chunks(markdown, max_chars_per_chunk=chunk_size),
|
|
1117
|
+
mask,
|
|
1118
|
+
)
|
|
1119
|
+
payload["meta"]["chunk_strategy"] = strategy
|
|
1120
|
+
payload["meta"]["max_chars_per_chunk"] = chunk_size
|
|
1121
|
+
return payload
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def get_paragraph_text(
|
|
1125
|
+
filename: str,
|
|
1126
|
+
paragraph_index: int | None = None,
|
|
1127
|
+
location: dict[str, Any] | None = None,
|
|
1128
|
+
) -> dict:
|
|
1129
|
+
"""본문 문단 또는 표 셀 문단 텍스트를 조회합니다."""
|
|
1130
|
+
path = resolve_path(filename)
|
|
1131
|
+
doc = open_doc(path)
|
|
1132
|
+
result = get_paragraph_text_from_doc(
|
|
1133
|
+
doc, paragraph_index=paragraph_index, location=location
|
|
1134
|
+
)
|
|
1135
|
+
if result["location"].get("kind") == "body_paragraph":
|
|
1136
|
+
result["paragraph_index"] = result["location"]["paragraph_index"]
|
|
1137
|
+
return _with_document_state(result, path)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def get_location_text(filename: str, location: dict[str, Any]) -> dict:
|
|
1141
|
+
"""get_table_map/find_text가 반환한 location으로 텍스트를 조회합니다."""
|
|
1142
|
+
return get_paragraph_text(filename, location=location)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def get_paragraphs_text(
|
|
1146
|
+
filename: str,
|
|
1147
|
+
start_index: int = 0,
|
|
1148
|
+
end_index: int = None,
|
|
1149
|
+
max_chars: int | None = None,
|
|
1150
|
+
) -> dict:
|
|
1151
|
+
"""문단 범위 텍스트를 조회합니다."""
|
|
1152
|
+
path = resolve_path(filename)
|
|
1153
|
+
doc = open_doc(path)
|
|
1154
|
+
if max_chars is None:
|
|
1155
|
+
max_chars = default_max_chars()
|
|
1156
|
+
|
|
1157
|
+
total = len(doc.paragraphs)
|
|
1158
|
+
end = total if end_index is None else min(end_index, total)
|
|
1159
|
+
start = max(0, start_index)
|
|
1160
|
+
picked = []
|
|
1161
|
+
used = 0
|
|
1162
|
+
truncated = False
|
|
1163
|
+
for index in range(start, end):
|
|
1164
|
+
text = doc.paragraphs[index].text or ""
|
|
1165
|
+
next_size = used + len(text)
|
|
1166
|
+
if next_size > max_chars:
|
|
1167
|
+
remaining = max(0, max_chars - used)
|
|
1168
|
+
picked.append({"index": index, "text": text[:remaining]})
|
|
1169
|
+
truncated = True
|
|
1170
|
+
break
|
|
1171
|
+
picked.append({"index": index, "text": text})
|
|
1172
|
+
used = next_size
|
|
1173
|
+
return _with_document_state({"paragraphs": picked, "truncated": truncated}, path)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def find_text(
|
|
1177
|
+
filename: str, text_to_find: str, match_case: bool = True, max_results: int = 50
|
|
1178
|
+
) -> dict:
|
|
1179
|
+
"""문서에서 텍스트를 검색합니다. 원본은 수정하지 않습니다."""
|
|
1180
|
+
path = resolve_path(filename)
|
|
1181
|
+
doc = open_doc(path)
|
|
1182
|
+
return _with_document_state(
|
|
1183
|
+
find_in_doc(
|
|
1184
|
+
doc,
|
|
1185
|
+
text_to_find=text_to_find,
|
|
1186
|
+
match_case=match_case,
|
|
1187
|
+
max_results=max_results,
|
|
1188
|
+
),
|
|
1189
|
+
path,
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def get_table_text(filename: str, table_index: int = 0) -> dict:
|
|
1194
|
+
"""표 셀 텍스트를 2D 배열로 조회합니다."""
|
|
1195
|
+
path = resolve_path(filename)
|
|
1196
|
+
doc = open_doc(path)
|
|
1197
|
+
result = get_table_data(doc, table_index)
|
|
1198
|
+
return _with_document_state(
|
|
1199
|
+
{
|
|
1200
|
+
"table_index": table_index,
|
|
1201
|
+
"rows": result["rows"],
|
|
1202
|
+
"cols": result["cols"],
|
|
1203
|
+
"data": result["data"],
|
|
1204
|
+
},
|
|
1205
|
+
path,
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def get_table_map(filename: str) -> dict:
|
|
1210
|
+
"""문서 내 표 위치, 크기, 문맥 요약을 조회합니다."""
|
|
1211
|
+
path = resolve_path(filename)
|
|
1212
|
+
doc = open_doc(path)
|
|
1213
|
+
return _with_document_state(get_table_map_in_doc(doc), path)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def list_available_documents(directory: str = ".") -> dict:
|
|
1217
|
+
"""지정 디렉토리의 .hwpx 파일 목록을 조회합니다."""
|
|
1218
|
+
import glob
|
|
1219
|
+
|
|
1220
|
+
path = resolve_path(directory)
|
|
1221
|
+
files = glob.glob(os.path.join(path, "*.hwpx"))
|
|
1222
|
+
docs = []
|
|
1223
|
+
for file_path in sorted(files):
|
|
1224
|
+
stat = os.stat(file_path)
|
|
1225
|
+
docs.append(
|
|
1226
|
+
{
|
|
1227
|
+
"filename": os.path.basename(file_path),
|
|
1228
|
+
"size": f"{stat.st_size / 1024:.1f}KB",
|
|
1229
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
1230
|
+
}
|
|
1231
|
+
)
|
|
1232
|
+
return {"directory": directory, "documents": docs, "count": len(docs)}
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def package_parts(filename: str) -> dict:
|
|
1236
|
+
"""[고급] HWPX 패키지 파트 목록을 조회합니다."""
|
|
1237
|
+
path = resolve_path(filename)
|
|
1238
|
+
return RUNTIME_SERVICES.ops.package_parts(path)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def package_get_xml(filename: str, part_name: str, max_chars: int = 5000) -> dict:
|
|
1242
|
+
"""[고급] 특정 패키지 파트의 XML을 조회합니다."""
|
|
1243
|
+
path = resolve_path(filename)
|
|
1244
|
+
result = RUNTIME_SERVICES.ops.package_get_xml(path, part_name)
|
|
1245
|
+
return truncate_response(result.get("xmlString", ""), max_chars=max_chars)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def package_get_text(filename: str, part_name: str, max_chars: int = 5000) -> dict:
|
|
1249
|
+
"""[고급] 특정 패키지 파트의 텍스트를 조회합니다."""
|
|
1250
|
+
path = resolve_path(filename)
|
|
1251
|
+
result = RUNTIME_SERVICES.ops.package_get_text(path, part_name)
|
|
1252
|
+
return truncate_response(result.get("text", ""), max_chars=max_chars)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def object_find_by_tag(filename: str, tag_name: str, max_results: int = 20) -> dict:
|
|
1256
|
+
"""[고급] 문서 XML에서 태그를 검색합니다."""
|
|
1257
|
+
path = resolve_path(filename)
|
|
1258
|
+
return RUNTIME_SERVICES.ops.object_find_by_tag(
|
|
1259
|
+
path, tag_name, max_results=max_results
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def object_find_by_attr(
|
|
1264
|
+
filename: str, attr_name: str, attr_value: str = None, max_results: int = 20
|
|
1265
|
+
) -> dict:
|
|
1266
|
+
"""[고급] 문서 XML에서 속성을 검색합니다."""
|
|
1267
|
+
path = resolve_path(filename)
|
|
1268
|
+
return RUNTIME_SERVICES.ops.object_find_by_attr(
|
|
1269
|
+
path, None, attr_name, attr_value, max_results=max_results
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
__all__ = [
|
|
1274
|
+
"get_document_text",
|
|
1275
|
+
"get_document_info",
|
|
1276
|
+
"get_document_outline",
|
|
1277
|
+
"get_document_map",
|
|
1278
|
+
"get_paragraph_text",
|
|
1279
|
+
"get_paragraphs_text",
|
|
1280
|
+
"get_location_text",
|
|
1281
|
+
"get_table_text",
|
|
1282
|
+
"get_table_map",
|
|
1283
|
+
"find_text",
|
|
1284
|
+
"list_available_documents",
|
|
1285
|
+
"hwpx_to_markdown",
|
|
1286
|
+
"document_to_markdown",
|
|
1287
|
+
"hwpx_to_html",
|
|
1288
|
+
"hwpx_extract_json",
|
|
1289
|
+
"document_extract_json",
|
|
1290
|
+
"package_parts",
|
|
1291
|
+
"package_get_text",
|
|
1292
|
+
"package_get_xml",
|
|
1293
|
+
"object_find_by_attr",
|
|
1294
|
+
"object_find_by_tag",
|
|
1295
|
+
]
|