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.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. 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)