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,762 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""문단, 표, 메모 CRUD 로직."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
from xml.etree import ElementTree as ET
|
|
15
|
+
|
|
16
|
+
from ..compat import patch_python_hwpx
|
|
17
|
+
from ..storage import build_hwpx_open_safety_report
|
|
18
|
+
from ..upstream import HP_NS as _HP_NS, HwpxDocument, repair_pathological_text_spacing
|
|
19
|
+
from .formatting import resolve_style_id
|
|
20
|
+
from .locations import resolve_paragraph_reference
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _iter_tables(doc: HwpxDocument):
|
|
26
|
+
for paragraph in doc.paragraphs:
|
|
27
|
+
for table in getattr(paragraph, "tables", []):
|
|
28
|
+
yield table
|
|
29
|
+
yield from _iter_nested_tables(table)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _iter_nested_tables(table: Any):
|
|
33
|
+
for row in getattr(table, "rows", []) or []:
|
|
34
|
+
for cell in getattr(row, "cells", []) or []:
|
|
35
|
+
for paragraph in getattr(cell, "paragraphs", []) or []:
|
|
36
|
+
for nested in getattr(paragraph, "tables", []) or []:
|
|
37
|
+
yield nested
|
|
38
|
+
yield from _iter_nested_tables(nested)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_style(doc: HwpxDocument, style: str | None) -> str | None:
|
|
42
|
+
if style is None:
|
|
43
|
+
return None
|
|
44
|
+
return resolve_style_id(doc, style)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _style_char_pr(doc: HwpxDocument, style_id: str | int | None) -> str | None:
|
|
48
|
+
"""문단 스타일(styleIDRef)이 참조하는 글자속성(charPrIDRef)을 돌려준다.
|
|
49
|
+
|
|
50
|
+
스타일이 없거나 글자속성이 지정되지 않으면 ``None``. ``add_paragraph`` 계열이
|
|
51
|
+
새 run에 스타일이 규정한 글자 크기·글꼴을 실어 주도록 하기 위한 조회 헬퍼다.
|
|
52
|
+
"""
|
|
53
|
+
if style_id is None:
|
|
54
|
+
return None
|
|
55
|
+
from .formatting import list_styles_in_doc
|
|
56
|
+
|
|
57
|
+
target = str(style_id)
|
|
58
|
+
for style in list_styles_in_doc(doc):
|
|
59
|
+
if str(style.get("id")) == target:
|
|
60
|
+
ref = style.get("char_pr_id_ref")
|
|
61
|
+
return None if ref in (None, "") else str(ref)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _default_body_char_pr(doc: HwpxDocument) -> str | None:
|
|
66
|
+
"""본문(바탕글/Normal) 스타일의 글자속성을 돌려준다.
|
|
67
|
+
|
|
68
|
+
스타일 인자가 없는 표 셀 등에 제목용 큰 글자(charPr 0)가 새어 들어가지
|
|
69
|
+
않도록, 문서 기본 본문 크기를 적용하기 위한 fallback 값이다.
|
|
70
|
+
"""
|
|
71
|
+
from .formatting import list_styles_in_doc
|
|
72
|
+
|
|
73
|
+
fallback: str | None = None
|
|
74
|
+
for style in list_styles_in_doc(doc):
|
|
75
|
+
name = (style.get("name") or "").strip()
|
|
76
|
+
eng = (style.get("eng_name") or "").strip()
|
|
77
|
+
ref = style.get("char_pr_id_ref")
|
|
78
|
+
if ref in (None, ""):
|
|
79
|
+
continue
|
|
80
|
+
if name in ("바탕글", "본문") or eng.lower() in ("normal", "body"):
|
|
81
|
+
return str(ref)
|
|
82
|
+
if fallback is None and str(style.get("id")) == "0":
|
|
83
|
+
fallback = str(ref)
|
|
84
|
+
return fallback
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _enforce_run_char_pr(paragraph: Any, char_ref: str | None) -> None:
|
|
88
|
+
"""방금 만든 문단의 run이 스타일 글자속성을 쓰도록 보정한다(회귀 가드).
|
|
89
|
+
|
|
90
|
+
python-hwpx는 글자속성을 명시하지 않은 새 run에 ``charPrIDRef="0"``을
|
|
91
|
+
박는다. 이 0번 글자속성이 본문 크기이면 문제없지만, 제목용 큰 글자
|
|
92
|
+
(예: 17pt)를 0번에 둔 투고 양식에서는 본문 전체가 그 크기로 렌더된다.
|
|
93
|
+
스타일을 지정해 문단을 넣었는데도 run이 스타일과 다른 글자속성을 가지면
|
|
94
|
+
스타일 값으로 교정한다. 기본값(0/None) 교정은 정상 경로라 DEBUG 로깅,
|
|
95
|
+
그 밖의 예상 못 한 불일치는 회귀 신호로 WARNING을 남긴다.
|
|
96
|
+
"""
|
|
97
|
+
if char_ref is None or paragraph is None:
|
|
98
|
+
return
|
|
99
|
+
want = str(char_ref)
|
|
100
|
+
for run in getattr(paragraph, "runs", None) or []:
|
|
101
|
+
current = getattr(run, "char_pr_id_ref", None)
|
|
102
|
+
if current is not None and str(current) == want:
|
|
103
|
+
continue
|
|
104
|
+
if current in (None, 0, "0"):
|
|
105
|
+
logger.debug(
|
|
106
|
+
"styled run 기본 charPr(%s) → 스타일 charPr=%s 적용", current, want
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
logger.warning(
|
|
110
|
+
"styled run charPrIDRef=%s 가 스타일 charPr=%s 와 불일치 → 교정",
|
|
111
|
+
current,
|
|
112
|
+
want,
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
run.char_pr_id_ref = int(want) if want.isdigit() else want
|
|
116
|
+
except Exception: # pragma: no cover - 방어적
|
|
117
|
+
logger.debug("run charPrIDRef 교정 실패", exc_info=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _clear_paragraph_layout_cache(paragraph: Any) -> None:
|
|
121
|
+
element = getattr(paragraph, "element", None)
|
|
122
|
+
if element is None:
|
|
123
|
+
return
|
|
124
|
+
for child in list(element):
|
|
125
|
+
if child.tag.rsplit("}", 1)[-1].lower() == "linesegarray":
|
|
126
|
+
element.remove(child)
|
|
127
|
+
section = getattr(paragraph, "section", None)
|
|
128
|
+
if section is not None and hasattr(section, "mark_dirty"):
|
|
129
|
+
section.mark_dirty()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── 문단 ──────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def _outline_style_for_level(doc: HwpxDocument, level: int) -> dict[str, Any] | None:
|
|
135
|
+
from .formatting import list_styles_in_doc
|
|
136
|
+
|
|
137
|
+
for style in list_styles_in_doc(doc):
|
|
138
|
+
name = str(style.get("name") or "")
|
|
139
|
+
eng_name = str(style.get("eng_name") or "")
|
|
140
|
+
if name == f"개요 {level}" or eng_name == f"Outline {level}":
|
|
141
|
+
return style
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def add_heading_to_doc(doc: HwpxDocument, text: str, level: int = 1) -> int:
|
|
146
|
+
"""문서 끝에 제목(헤딩) 문단을 추가한다. 추가된 paragraph_index를 반환.
|
|
147
|
+
|
|
148
|
+
제목 텍스트는 마크다운 프리픽스 없이 저장하고, 개요 수준은 템플릿 내장
|
|
149
|
+
"개요 N" 문단 스타일로 표현한다. 구버전이 본문에 남긴 '#' 리터럴 헤딩은
|
|
150
|
+
읽기 경로(_outline_level)가 계속 인식한다.
|
|
151
|
+
"""
|
|
152
|
+
safe_level = min(10, max(1, int(level)))
|
|
153
|
+
stripped = (text or "").strip()
|
|
154
|
+
if stripped.startswith("#"):
|
|
155
|
+
stripped = stripped.lstrip("#").strip()
|
|
156
|
+
|
|
157
|
+
from ..office.authoring import DocumentStylePreset
|
|
158
|
+
|
|
159
|
+
tokens = DocumentStylePreset().ensure_tokens(doc)
|
|
160
|
+
char_ref = tokens.get(f"gov_heading_{safe_level}") or tokens.get("heading")
|
|
161
|
+
|
|
162
|
+
outline_style = _outline_style_for_level(doc, safe_level)
|
|
163
|
+
if outline_style is not None:
|
|
164
|
+
doc.add_paragraph(
|
|
165
|
+
stripped,
|
|
166
|
+
style_id_ref=outline_style.get("id"),
|
|
167
|
+
para_pr_id_ref=outline_style.get("para_pr_id_ref"),
|
|
168
|
+
char_pr_id_ref=char_ref,
|
|
169
|
+
inherit_style=False,
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
doc.add_paragraph(stripped, char_pr_id_ref=char_ref, inherit_style=False)
|
|
173
|
+
return len(doc.paragraphs) - 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _last_paragraph_is_outline(doc: HwpxDocument) -> bool:
|
|
177
|
+
paragraphs = doc.paragraphs
|
|
178
|
+
if not paragraphs:
|
|
179
|
+
return False
|
|
180
|
+
ref = getattr(paragraphs[-1], "style_id_ref", None)
|
|
181
|
+
if ref is None:
|
|
182
|
+
return False
|
|
183
|
+
from .formatting import outline_style_levels
|
|
184
|
+
|
|
185
|
+
return str(ref) in outline_style_levels(doc)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def add_paragraph_to_doc(doc: HwpxDocument, text: str, style: str = None) -> int:
|
|
189
|
+
"""문서 끝에 일반 문단을 추가한다. 추가된 paragraph_index를 반환."""
|
|
190
|
+
style_id = _resolve_style(doc, style)
|
|
191
|
+
# 스타일이 규정한 글자속성(크기·글꼴)을 run에 실어 준다. 이를 넘기지 않으면
|
|
192
|
+
# python-hwpx가 charPrIDRef="0"을 기본으로 박아, 0번이 제목 크기인 양식에서
|
|
193
|
+
# 본문이 통째로 커진다(add_heading_to_doc는 이미 char_pr_id_ref를 넘긴다).
|
|
194
|
+
char_ref = _style_char_pr(doc, style_id)
|
|
195
|
+
# 직전 문단이 개요(헤딩) 스타일이면 상속을 끊는다 — 헤딩 뒤 본문이
|
|
196
|
+
# 개요 수준·강조 서식을 물려받는 사고 방지.
|
|
197
|
+
inherit = not (style_id is None and _last_paragraph_is_outline(doc))
|
|
198
|
+
paragraph = doc.add_paragraph(
|
|
199
|
+
text or "",
|
|
200
|
+
style_id_ref=style_id,
|
|
201
|
+
char_pr_id_ref=char_ref,
|
|
202
|
+
inherit_style=inherit,
|
|
203
|
+
)
|
|
204
|
+
_enforce_run_char_pr(paragraph, char_ref)
|
|
205
|
+
repair_pathological_text_spacing(
|
|
206
|
+
doc,
|
|
207
|
+
paragraph=paragraph,
|
|
208
|
+
fallback_char_pr_id=char_ref or _default_body_char_pr(doc),
|
|
209
|
+
)
|
|
210
|
+
return len(doc.paragraphs) - 1
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def insert_paragraph_to_doc(doc: HwpxDocument, paragraph_index: int, text: str, style: str = None) -> int:
|
|
214
|
+
"""지정 위치 앞에 문단을 삽입한다. 삽입된 paragraph_index를 반환."""
|
|
215
|
+
total = len(doc.paragraphs)
|
|
216
|
+
if paragraph_index < 0 or paragraph_index > total:
|
|
217
|
+
raise ValueError(f"유효하지 않은 paragraph_index: {paragraph_index}")
|
|
218
|
+
|
|
219
|
+
if paragraph_index == total:
|
|
220
|
+
return add_paragraph_to_doc(doc, text, style)
|
|
221
|
+
|
|
222
|
+
target = doc.paragraphs[paragraph_index]
|
|
223
|
+
section = target.section
|
|
224
|
+
style_id = _resolve_style(doc, style)
|
|
225
|
+
para_pr_id_ref = None
|
|
226
|
+
if style_id is None:
|
|
227
|
+
# 삽입 위치와 무관한 섹션 마지막 문단이 아니라 바로 뒤 대상 문단의
|
|
228
|
+
# 문단/글자 스타일을 이어받는다.
|
|
229
|
+
style_id = getattr(target, "style_id_ref", None)
|
|
230
|
+
para_pr_id_ref = getattr(target, "para_pr_id_ref", None)
|
|
231
|
+
char_ref = _style_char_pr(doc, style_id)
|
|
232
|
+
if char_ref is None and style is None:
|
|
233
|
+
target_runs = list(getattr(target, "runs", None) or [])
|
|
234
|
+
if target_runs:
|
|
235
|
+
char_ref = getattr(target_runs[0], "char_pr_id_ref", None)
|
|
236
|
+
inserted = section.add_paragraph(
|
|
237
|
+
text or "",
|
|
238
|
+
style_id_ref=style_id,
|
|
239
|
+
para_pr_id_ref=para_pr_id_ref,
|
|
240
|
+
char_pr_id_ref=char_ref,
|
|
241
|
+
inherit_style=False,
|
|
242
|
+
)
|
|
243
|
+
_enforce_run_char_pr(inserted, char_ref)
|
|
244
|
+
repair_pathological_text_spacing(
|
|
245
|
+
doc,
|
|
246
|
+
paragraph=inserted,
|
|
247
|
+
fallback_char_pr_id=_style_char_pr(doc, style_id) or _default_body_char_pr(doc),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
section_element = section.element
|
|
251
|
+
try:
|
|
252
|
+
target_position = list(section_element).index(target.element)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
raise RuntimeError("대상 문단 요소를 섹션에서 찾을 수 없습니다.") from exc
|
|
255
|
+
|
|
256
|
+
# add_paragraph는 항상 끝에 붙으므로, 생성한 요소를 제거한 뒤 목표 위치에 재삽입한다.
|
|
257
|
+
section_element.remove(inserted.element)
|
|
258
|
+
section_element.insert(target_position, inserted.element)
|
|
259
|
+
return paragraph_index
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def delete_paragraph_from_doc(doc: HwpxDocument, paragraph_index: int) -> int:
|
|
263
|
+
"""지정 문단을 실제 제거한다. 남은 문단 수를 반환."""
|
|
264
|
+
paragraphs = doc.paragraphs
|
|
265
|
+
total = len(paragraphs)
|
|
266
|
+
if paragraph_index < 0 or paragraph_index >= total:
|
|
267
|
+
raise ValueError(f"유효하지 않은 paragraph_index: {paragraph_index}")
|
|
268
|
+
if total <= 1:
|
|
269
|
+
# 최소 1개 문단은 유지해 문서 구조를 보존한다.
|
|
270
|
+
target = paragraphs[paragraph_index]
|
|
271
|
+
for run in target.runs:
|
|
272
|
+
run.text = ""
|
|
273
|
+
_clear_paragraph_layout_cache(target)
|
|
274
|
+
return total
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
doc.remove_paragraph(paragraph_index)
|
|
278
|
+
except (ValueError, IndexError) as exc:
|
|
279
|
+
raise RuntimeError("삭제할 문단 요소를 섹션에서 찾을 수 없습니다.") from exc
|
|
280
|
+
return total - 1
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ── 표 ────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def add_table_to_doc(doc: HwpxDocument, rows: int, cols: int, data: list[list[str]] = None) -> int:
|
|
286
|
+
"""문서 끝에 표를 추가한다. 추가된 table_index를 반환."""
|
|
287
|
+
if rows <= 0 or cols <= 0:
|
|
288
|
+
raise ValueError("rows와 cols는 1 이상이어야 합니다.")
|
|
289
|
+
table = doc.add_table(rows=rows, cols=cols)
|
|
290
|
+
# 표 셀도 스타일 인자가 없으면 charPrIDRef="0"이 박혀 제목 크기가 샐 수 있다.
|
|
291
|
+
# 문서 본문(바탕글) 글자속성을 셀 run에 적용해 크기 누수를 막는다.
|
|
292
|
+
cell_char_ref = _default_body_char_pr(doc)
|
|
293
|
+
payload = data or []
|
|
294
|
+
for r in range(rows):
|
|
295
|
+
row_cells = table.rows[r].cells
|
|
296
|
+
row_data = (payload[r] or []) if r < len(payload) else []
|
|
297
|
+
for c in range(cols):
|
|
298
|
+
cell = row_cells[c]
|
|
299
|
+
if c < len(row_data):
|
|
300
|
+
cell.text = str(row_data[c])
|
|
301
|
+
for cell_para in getattr(cell, "paragraphs", None) or []:
|
|
302
|
+
_enforce_run_char_pr(cell_para, cell_char_ref)
|
|
303
|
+
return len(list(_iter_tables(doc))) - 1
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def get_table_data(doc: HwpxDocument, table_index: int) -> dict:
|
|
307
|
+
"""표의 모든 셀 텍스트를 2D 배열로 반환한다."""
|
|
308
|
+
tables = list(_iter_tables(doc))
|
|
309
|
+
if table_index < 0 or table_index >= len(tables):
|
|
310
|
+
raise ValueError(f"유효하지 않은 table_index: {table_index}")
|
|
311
|
+
table = tables[table_index]
|
|
312
|
+
data = [[cell.text or "" for cell in row.cells] for row in table.rows]
|
|
313
|
+
rows = len(data)
|
|
314
|
+
cols = len(data[0]) if data else 0
|
|
315
|
+
return {"rows": rows, "cols": cols, "data": data}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_table_map_in_doc(doc: HwpxDocument) -> dict:
|
|
319
|
+
"""문서의 표 메타데이터를 LLM 친화적인 JSON 형태로 반환한다."""
|
|
320
|
+
result = doc.get_table_map()
|
|
321
|
+
tables = list(result.get("tables", []))
|
|
322
|
+
return {"tables": tables, "count": len(tables)}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def find_cell_by_label_in_doc(doc: HwpxDocument, label_text: str, direction: str = "right") -> dict:
|
|
326
|
+
"""라벨 셀 기준으로 대상 셀을 찾는다."""
|
|
327
|
+
return doc.find_cell_by_label(label_text, direction=direction)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def fill_by_path_in_doc(doc: HwpxDocument, mappings: dict[str, str]) -> dict:
|
|
331
|
+
"""라벨 기반 경로 구문으로 표 셀을 채운다."""
|
|
332
|
+
return doc.fill_by_path(mappings)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def set_cell_text(
|
|
336
|
+
doc: HwpxDocument,
|
|
337
|
+
table_index: int,
|
|
338
|
+
row: int,
|
|
339
|
+
col: int,
|
|
340
|
+
text: str,
|
|
341
|
+
*,
|
|
342
|
+
preserve_format: bool = True,
|
|
343
|
+
split_paragraphs: bool = False,
|
|
344
|
+
) -> None:
|
|
345
|
+
"""표의 특정 셀 텍스트를 변경한다."""
|
|
346
|
+
tables = list(_iter_tables(doc))
|
|
347
|
+
if table_index < 0 or table_index >= len(tables):
|
|
348
|
+
raise ValueError(f"유효하지 않은 table_index: {table_index}")
|
|
349
|
+
table = tables[table_index]
|
|
350
|
+
if row < 0 or row >= len(table.rows):
|
|
351
|
+
raise ValueError(f"유효하지 않은 row: {row}")
|
|
352
|
+
if col < 0 or col >= len(table.rows[row].cells):
|
|
353
|
+
raise ValueError(f"유효하지 않은 col: {col}")
|
|
354
|
+
try:
|
|
355
|
+
table.set_cell_text(
|
|
356
|
+
row,
|
|
357
|
+
col,
|
|
358
|
+
text or "",
|
|
359
|
+
preserve_format=preserve_format,
|
|
360
|
+
split_paragraphs=split_paragraphs,
|
|
361
|
+
)
|
|
362
|
+
except TypeError:
|
|
363
|
+
table.rows[row].cells[col].text = text or ""
|
|
364
|
+
|
|
365
|
+
cell = table.rows[row].cells[col]
|
|
366
|
+
for paragraph in getattr(cell, "paragraphs", None) or []:
|
|
367
|
+
repair_pathological_text_spacing(
|
|
368
|
+
doc,
|
|
369
|
+
paragraph=paragraph,
|
|
370
|
+
fallback_char_pr_id=_default_body_char_pr(doc),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def merge_cells_in_table(
|
|
375
|
+
doc: HwpxDocument,
|
|
376
|
+
table_index: int,
|
|
377
|
+
start_row: int,
|
|
378
|
+
start_col: int,
|
|
379
|
+
end_row: int,
|
|
380
|
+
end_col: int,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""표의 셀을 병합한다. python-hwpx 네이티브 API 사용."""
|
|
383
|
+
tables = list(_iter_tables(doc))
|
|
384
|
+
if table_index < 0 or table_index >= len(tables):
|
|
385
|
+
raise ValueError(f"유효하지 않은 table_index: {table_index}")
|
|
386
|
+
if start_row > end_row or start_col > end_col:
|
|
387
|
+
raise ValueError("시작 좌표는 종료 좌표보다 작거나 같아야 합니다.")
|
|
388
|
+
|
|
389
|
+
table = tables[table_index]
|
|
390
|
+
table.merge_cells(start_row, start_col, end_row, end_col)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def split_cell_in_table(doc: HwpxDocument, table_index: int, row: int, col: int) -> dict:
|
|
394
|
+
"""병합된 셀을 분할한다. 원래 span 정보를 반환한다. python-hwpx 네이티브 API 사용."""
|
|
395
|
+
tables = list(_iter_tables(doc))
|
|
396
|
+
if table_index < 0 or table_index >= len(tables):
|
|
397
|
+
raise ValueError(f"유효하지 않은 table_index: {table_index}")
|
|
398
|
+
table = tables[table_index]
|
|
399
|
+
if row < 0 or row >= len(table.rows):
|
|
400
|
+
raise ValueError(f"유효하지 않은 row: {row}")
|
|
401
|
+
if col < 0 or col >= len(table.rows[row].cells):
|
|
402
|
+
raise ValueError(f"유효하지 않은 col: {col}")
|
|
403
|
+
|
|
404
|
+
cell = table.rows[row].cells[col]
|
|
405
|
+
span = cell.element.find(f"{_HP_NS}cellSpan")
|
|
406
|
+
if span is None:
|
|
407
|
+
return {"rowSpan": 1, "colSpan": 1}
|
|
408
|
+
|
|
409
|
+
original = {
|
|
410
|
+
"rowSpan": int(span.get("rowSpan", "1")),
|
|
411
|
+
"colSpan": int(span.get("colSpan", "1")),
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if original["rowSpan"] <= 1 and original["colSpan"] <= 1:
|
|
415
|
+
return original
|
|
416
|
+
|
|
417
|
+
table.split_merged_cell(row, col)
|
|
418
|
+
return original
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def format_table_in_doc(doc: HwpxDocument, table_index: int, has_header_row: bool = None) -> None:
|
|
422
|
+
"""표 서식을 변경한다. 헤더 행 강조 등."""
|
|
423
|
+
if has_header_row is None:
|
|
424
|
+
return
|
|
425
|
+
tables = list(_iter_tables(doc))
|
|
426
|
+
if table_index < 0 or table_index >= len(tables):
|
|
427
|
+
raise ValueError(f"유효하지 않은 table_index: {table_index}")
|
|
428
|
+
table = tables[table_index]
|
|
429
|
+
if not table.rows:
|
|
430
|
+
return
|
|
431
|
+
for cell in table.rows[0].cells:
|
|
432
|
+
for paragraph in getattr(cell, "paragraphs", []):
|
|
433
|
+
for run in paragraph.runs:
|
|
434
|
+
run.bold = bool(has_header_row)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def copy_document_file(source: str, destination: str = None) -> str:
|
|
438
|
+
"""문서를 복사한다. destination이 None이면 자동 이름 생성."""
|
|
439
|
+
if destination is None:
|
|
440
|
+
stem, ext = source.rsplit(".", 1) if "." in source else (source, "hwpx")
|
|
441
|
+
destination = f"{stem}_copy.{ext}"
|
|
442
|
+
source_path = Path(source)
|
|
443
|
+
destination_path = Path(destination)
|
|
444
|
+
if source_path.suffix.lower() != ".hwpx" and destination_path.suffix.lower() != ".hwpx":
|
|
445
|
+
shutil.copy2(source_path, destination_path)
|
|
446
|
+
return str(destination_path)
|
|
447
|
+
|
|
448
|
+
_require_open_safe_hwpx(source_path, "source")
|
|
449
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
450
|
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
451
|
+
suffix=destination_path.suffix or ".hwpx",
|
|
452
|
+
dir=str(destination_path.parent),
|
|
453
|
+
)
|
|
454
|
+
tmp_path = Path(tmp_name)
|
|
455
|
+
try:
|
|
456
|
+
os.close(tmp_fd)
|
|
457
|
+
shutil.copy2(source_path, tmp_path)
|
|
458
|
+
_require_open_safe_hwpx(tmp_path, "copied")
|
|
459
|
+
os.replace(tmp_path, destination_path)
|
|
460
|
+
except Exception:
|
|
461
|
+
tmp_path.unlink(missing_ok=True)
|
|
462
|
+
raise
|
|
463
|
+
return str(destination_path)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _require_open_safe_hwpx(path: Path, role: str) -> None:
|
|
467
|
+
report = build_hwpx_open_safety_report(path)
|
|
468
|
+
if not report["ok"]:
|
|
469
|
+
raise ValueError(f"{role} HWPX failed open-safety verification: {report['summary']}")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ── 메모 ──────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
def _looks_like_mixed_xml_type_error(exc: BaseException) -> bool:
|
|
475
|
+
current: BaseException | None = exc
|
|
476
|
+
while current is not None:
|
|
477
|
+
message = str(current)
|
|
478
|
+
if "lxml.etree._Element" in message and "ElementTree.Element" in message:
|
|
479
|
+
return True
|
|
480
|
+
if "SubElement() argument 1 must be" in message and "xml.etree.ElementTree.Element" in message:
|
|
481
|
+
return True
|
|
482
|
+
current = current.__cause__
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _append_child_element(parent: Any, tag: str, attrs: dict[str, str] | None = None) -> Any:
|
|
487
|
+
payload = dict(attrs or {})
|
|
488
|
+
try:
|
|
489
|
+
return ET.SubElement(parent, tag, payload)
|
|
490
|
+
except TypeError:
|
|
491
|
+
maker = getattr(parent, "makeelement", None)
|
|
492
|
+
if not callable(maker):
|
|
493
|
+
raise
|
|
494
|
+
child = maker(tag, payload)
|
|
495
|
+
parent.append(child)
|
|
496
|
+
return child
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _make_element_like(parent: Any, tag: str, attrs: dict[str, str] | None = None) -> Any:
|
|
500
|
+
payload = dict(attrs or {})
|
|
501
|
+
maker = getattr(parent, "makeelement", None)
|
|
502
|
+
if callable(maker):
|
|
503
|
+
return maker(tag, payload)
|
|
504
|
+
return ET.Element(tag, payload)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _add_memo_with_anchor_fallback(paragraph: Any, text: str) -> None:
|
|
508
|
+
section = paragraph.section
|
|
509
|
+
section_element = section.element
|
|
510
|
+
memo_group = section_element.find(f"{_HP_NS}memogroup")
|
|
511
|
+
if memo_group is None:
|
|
512
|
+
memo_group = _append_child_element(section_element, f"{_HP_NS}memogroup")
|
|
513
|
+
|
|
514
|
+
memo_id = uuid4().hex[:10]
|
|
515
|
+
memo_element = _append_child_element(memo_group, f"{_HP_NS}memo", {"id": memo_id})
|
|
516
|
+
para_list = _append_child_element(memo_element, f"{_HP_NS}paraList")
|
|
517
|
+
memo_para = _append_child_element(
|
|
518
|
+
para_list,
|
|
519
|
+
f"{_HP_NS}p",
|
|
520
|
+
{
|
|
521
|
+
"id": f"memo-{memo_id}-p",
|
|
522
|
+
"paraPrIDRef": "0",
|
|
523
|
+
"styleIDRef": "0",
|
|
524
|
+
"pageBreak": "0",
|
|
525
|
+
"columnBreak": "0",
|
|
526
|
+
"merged": "0",
|
|
527
|
+
},
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
char_ref = str(paragraph.char_pr_id_ref or "0")
|
|
531
|
+
memo_run = _append_child_element(memo_para, f"{_HP_NS}run", {"charPrIDRef": char_ref})
|
|
532
|
+
_append_child_element(memo_run, f"{_HP_NS}t").text = text
|
|
533
|
+
|
|
534
|
+
field_id = uuid4().hex
|
|
535
|
+
created = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
536
|
+
run_begin = _make_element_like(paragraph.element, f"{_HP_NS}run", {"charPrIDRef": char_ref})
|
|
537
|
+
ctrl_begin = _append_child_element(run_begin, f"{_HP_NS}ctrl")
|
|
538
|
+
field_begin = _append_child_element(
|
|
539
|
+
ctrl_begin,
|
|
540
|
+
f"{_HP_NS}fieldBegin",
|
|
541
|
+
{
|
|
542
|
+
"id": field_id,
|
|
543
|
+
"type": "MEMO",
|
|
544
|
+
"editable": "true",
|
|
545
|
+
"dirty": "false",
|
|
546
|
+
"fieldid": field_id,
|
|
547
|
+
"command": f"memoId={memo_id};",
|
|
548
|
+
},
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
parameters = _append_child_element(field_begin, f"{_HP_NS}parameters", {"count": "5", "name": ""})
|
|
552
|
+
_append_child_element(parameters, f"{_HP_NS}stringParam", {"name": "ID"}).text = memo_id
|
|
553
|
+
_append_child_element(parameters, f"{_HP_NS}integerParam", {"name": "Number"}).text = "1"
|
|
554
|
+
_append_child_element(parameters, f"{_HP_NS}stringParam", {"name": "CreateDateTime"}).text = created
|
|
555
|
+
_append_child_element(parameters, f"{_HP_NS}stringParam", {"name": "Author"}).text = ""
|
|
556
|
+
_append_child_element(parameters, f"{_HP_NS}stringParam", {"name": "MemoShapeID"}).text = ""
|
|
557
|
+
|
|
558
|
+
sub_list = _append_child_element(
|
|
559
|
+
field_begin,
|
|
560
|
+
f"{_HP_NS}subList",
|
|
561
|
+
{
|
|
562
|
+
"id": f"memo-field-{memo_id}",
|
|
563
|
+
"textDirection": "HORIZONTAL",
|
|
564
|
+
"lineWrap": "BREAK",
|
|
565
|
+
"vertAlign": "TOP",
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
sub_para = _append_child_element(
|
|
569
|
+
sub_list,
|
|
570
|
+
f"{_HP_NS}p",
|
|
571
|
+
{
|
|
572
|
+
"id": f"memo-field-{memo_id}-p",
|
|
573
|
+
"paraPrIDRef": "0",
|
|
574
|
+
"styleIDRef": "0",
|
|
575
|
+
"pageBreak": "0",
|
|
576
|
+
"columnBreak": "0",
|
|
577
|
+
"merged": "0",
|
|
578
|
+
},
|
|
579
|
+
)
|
|
580
|
+
sub_run = _append_child_element(sub_para, f"{_HP_NS}run", {"charPrIDRef": char_ref})
|
|
581
|
+
_append_child_element(sub_run, f"{_HP_NS}t").text = memo_id
|
|
582
|
+
|
|
583
|
+
run_end = _make_element_like(paragraph.element, f"{_HP_NS}run", {"charPrIDRef": char_ref})
|
|
584
|
+
ctrl_end = _append_child_element(run_end, f"{_HP_NS}ctrl")
|
|
585
|
+
_append_child_element(ctrl_end, f"{_HP_NS}fieldEnd", {"beginIDRef": field_id, "fieldid": field_id})
|
|
586
|
+
|
|
587
|
+
paragraph.element.insert(0, run_begin)
|
|
588
|
+
paragraph.element.append(run_end)
|
|
589
|
+
section.mark_dirty()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _extract_memo_id_from_field_begin(field_begin: Any) -> str | None:
|
|
593
|
+
command = (field_begin.get("command") or "")
|
|
594
|
+
if "memoId=" in command:
|
|
595
|
+
memo_id = command.split("memoId=", 1)[1].split(";", 1)[0].strip()
|
|
596
|
+
if memo_id:
|
|
597
|
+
return memo_id
|
|
598
|
+
|
|
599
|
+
parameters = field_begin.find(f"{_HP_NS}parameters")
|
|
600
|
+
if parameters is None:
|
|
601
|
+
return None
|
|
602
|
+
for item in parameters.findall(f"{_HP_NS}stringParam"):
|
|
603
|
+
if (item.get("name") or "").strip().lower() != "id":
|
|
604
|
+
continue
|
|
605
|
+
memo_id = (item.text or "").strip()
|
|
606
|
+
if memo_id:
|
|
607
|
+
return memo_id
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _memo_anchor_runs(paragraph: Any, memo_ids: set[str]) -> list[Any]:
|
|
612
|
+
if not memo_ids:
|
|
613
|
+
return []
|
|
614
|
+
|
|
615
|
+
field_ids: set[str] = set()
|
|
616
|
+
runs_to_remove: list[Any] = []
|
|
617
|
+
|
|
618
|
+
for run_element in list(paragraph.element.findall(f"{_HP_NS}run")):
|
|
619
|
+
for ctrl in run_element.findall(f"{_HP_NS}ctrl"):
|
|
620
|
+
field_begin = ctrl.find(f"{_HP_NS}fieldBegin")
|
|
621
|
+
if field_begin is None:
|
|
622
|
+
continue
|
|
623
|
+
memo_id = _extract_memo_id_from_field_begin(field_begin)
|
|
624
|
+
if memo_id not in memo_ids:
|
|
625
|
+
continue
|
|
626
|
+
field_id = (field_begin.get("id") or field_begin.get("fieldid") or "").strip()
|
|
627
|
+
if field_id:
|
|
628
|
+
field_ids.add(field_id)
|
|
629
|
+
runs_to_remove.append(run_element)
|
|
630
|
+
break
|
|
631
|
+
|
|
632
|
+
if not field_ids:
|
|
633
|
+
return runs_to_remove
|
|
634
|
+
|
|
635
|
+
for run_element in list(paragraph.element.findall(f"{_HP_NS}run")):
|
|
636
|
+
for ctrl in run_element.findall(f"{_HP_NS}ctrl"):
|
|
637
|
+
field_end = ctrl.find(f"{_HP_NS}fieldEnd")
|
|
638
|
+
if field_end is None:
|
|
639
|
+
continue
|
|
640
|
+
begin_id = (field_end.get("beginIDRef") or field_end.get("fieldid") or "").strip()
|
|
641
|
+
if begin_id not in field_ids:
|
|
642
|
+
continue
|
|
643
|
+
runs_to_remove.append(run_element)
|
|
644
|
+
break
|
|
645
|
+
|
|
646
|
+
unique_runs: list[Any] = []
|
|
647
|
+
seen: set[int] = set()
|
|
648
|
+
for run_element in runs_to_remove:
|
|
649
|
+
marker = id(run_element)
|
|
650
|
+
if marker in seen:
|
|
651
|
+
continue
|
|
652
|
+
seen.add(marker)
|
|
653
|
+
unique_runs.append(run_element)
|
|
654
|
+
return unique_runs
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def get_paragraph_text_from_doc(
|
|
658
|
+
doc: HwpxDocument,
|
|
659
|
+
paragraph_index: int | None = None,
|
|
660
|
+
location: dict[str, Any] | None = None,
|
|
661
|
+
) -> dict[str, Any]:
|
|
662
|
+
"""본문 문단 또는 표 셀 문단 텍스트를 조회한다."""
|
|
663
|
+
resolved = resolve_paragraph_reference(doc, paragraph_index=paragraph_index, location=location)
|
|
664
|
+
return {"location": resolved.location, "text": resolved.paragraph.text or ""}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def add_memo_to_doc(
|
|
668
|
+
doc: HwpxDocument,
|
|
669
|
+
paragraph_index: int | None,
|
|
670
|
+
text: str,
|
|
671
|
+
location: dict[str, Any] | None = None,
|
|
672
|
+
) -> dict[str, Any]:
|
|
673
|
+
"""문단에 메모를 추가한다."""
|
|
674
|
+
patch_python_hwpx()
|
|
675
|
+
resolved = resolve_paragraph_reference(
|
|
676
|
+
doc,
|
|
677
|
+
paragraph_index=paragraph_index,
|
|
678
|
+
location=location,
|
|
679
|
+
create=True,
|
|
680
|
+
)
|
|
681
|
+
paragraph = resolved.paragraph
|
|
682
|
+
memo_count_before = len(doc.memos)
|
|
683
|
+
try:
|
|
684
|
+
doc.add_memo_with_anchor(text or "", paragraph=paragraph)
|
|
685
|
+
except Exception as exc: # noqa: BLE001
|
|
686
|
+
if not _looks_like_mixed_xml_type_error(exc):
|
|
687
|
+
raise
|
|
688
|
+
# Clean up any partially-created memo from the failed native call
|
|
689
|
+
current_memos = doc.memos
|
|
690
|
+
while len(current_memos) > memo_count_before:
|
|
691
|
+
try:
|
|
692
|
+
doc.remove_memo(current_memos[-1])
|
|
693
|
+
except Exception: # noqa: BLE001
|
|
694
|
+
break
|
|
695
|
+
current_memos = doc.memos
|
|
696
|
+
logger.warning("메모 추가 중 혼합 XML 타입 충돌 감지, fallback 경로 사용: %s", exc)
|
|
697
|
+
_add_memo_with_anchor_fallback(paragraph, text or "")
|
|
698
|
+
return {"memo_added": True, "location": resolved.location}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def remove_memo_from_doc(
|
|
702
|
+
doc: HwpxDocument,
|
|
703
|
+
paragraph_index: int | None,
|
|
704
|
+
location: dict[str, Any] | None = None,
|
|
705
|
+
) -> dict[str, Any]:
|
|
706
|
+
"""문단의 메모를 제거한다."""
|
|
707
|
+
patch_python_hwpx()
|
|
708
|
+
resolved = resolve_paragraph_reference(doc, paragraph_index=paragraph_index, location=location)
|
|
709
|
+
paragraph = resolved.paragraph
|
|
710
|
+
|
|
711
|
+
memo_ids: set[str] = set()
|
|
712
|
+
for run in paragraph.runs:
|
|
713
|
+
for ctrl in run.element.findall(f"{_HP_NS}ctrl"):
|
|
714
|
+
field_begin = ctrl.find(f"{_HP_NS}fieldBegin")
|
|
715
|
+
if field_begin is None:
|
|
716
|
+
continue
|
|
717
|
+
memo_id = _extract_memo_id_from_field_begin(field_begin)
|
|
718
|
+
if memo_id:
|
|
719
|
+
memo_ids.add(memo_id)
|
|
720
|
+
|
|
721
|
+
for memo in list(doc.memos):
|
|
722
|
+
if memo.id in memo_ids:
|
|
723
|
+
doc.remove_memo(memo)
|
|
724
|
+
|
|
725
|
+
removed_anchor = False
|
|
726
|
+
for run_element in _memo_anchor_runs(paragraph, memo_ids):
|
|
727
|
+
paragraph.element.remove(run_element)
|
|
728
|
+
removed_anchor = True
|
|
729
|
+
if removed_anchor and paragraph.section is not None:
|
|
730
|
+
paragraph.section.mark_dirty()
|
|
731
|
+
return {"memo_removed": True, "location": resolved.location}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ── 페이지 ────────────────────────────────────────────
|
|
735
|
+
|
|
736
|
+
def add_page_break_to_doc(doc: HwpxDocument) -> None:
|
|
737
|
+
"""문서 끝에 페이지 나누기를 추가한다."""
|
|
738
|
+
doc.add_paragraph("", pageBreak="1")
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ── 텍스트 수집 ───────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
def iter_all_paragraphs(doc: Any):
|
|
744
|
+
for paragraph in doc.paragraphs:
|
|
745
|
+
yield paragraph
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def iter_table_texts(doc: Any):
|
|
749
|
+
for table in _iter_tables(doc):
|
|
750
|
+
for row in table.rows:
|
|
751
|
+
for cell in row.cells:
|
|
752
|
+
yield cell.text or ""
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def collect_full_text(doc: Any) -> str:
|
|
756
|
+
chunks: list[str] = []
|
|
757
|
+
for paragraph in iter_all_paragraphs(doc):
|
|
758
|
+
chunks.append(paragraph.text or "")
|
|
759
|
+
for text in iter_table_texts(doc):
|
|
760
|
+
if text:
|
|
761
|
+
chunks.append(text)
|
|
762
|
+
return "\n".join(chunks)
|