hwpx-kit-cli 0.1.1__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_kit/__init__.py +1 -0
- hwpx_kit/adapter/__init__.py +0 -0
- hwpx_kit/adapter/base.py +43 -0
- hwpx_kit/adapter/hwpx_engine.py +545 -0
- hwpx_kit/adapter/kordoc_engine.py +79 -0
- hwpx_kit/adapter/office_readers.py +46 -0
- hwpx_kit/cli.py +259 -0
- hwpx_kit/commands/__init__.py +0 -0
- hwpx_kit/commands/analyze.py +126 -0
- hwpx_kit/commands/convert.py +52 -0
- hwpx_kit/commands/export.py +44 -0
- hwpx_kit/commands/fill.py +91 -0
- hwpx_kit/commands/fmt.py +29 -0
- hwpx_kit/commands/generate.py +12 -0
- hwpx_kit/commands/outline.py +13 -0
- hwpx_kit/commands/page_break.py +17 -0
- hwpx_kit/commands/read.py +42 -0
- hwpx_kit/commands/render.py +11 -0
- hwpx_kit/commands/row_height.py +39 -0
- hwpx_kit/commands/table_clear.py +22 -0
- hwpx_kit/commands/table_copy.py +24 -0
- hwpx_kit/commands/table_map_cmd.py +18 -0
- hwpx_kit/commands/table_new.py +29 -0
- hwpx_kit/commands/table_set.py +49 -0
- hwpx_kit/commands/validate.py +12 -0
- hwpx_kit/format.py +94 -0
- hwpx_kit/output.py +48 -0
- hwpx_kit_cli-0.1.1.dist-info/METADATA +190 -0
- hwpx_kit_cli-0.1.1.dist-info/RECORD +32 -0
- hwpx_kit_cli-0.1.1.dist-info/WHEEL +4 -0
- hwpx_kit_cli-0.1.1.dist-info/entry_points.txt +2 -0
- hwpx_kit_cli-0.1.1.dist-info/licenses/LICENSE +209 -0
hwpx_kit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
hwpx_kit/adapter/base.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""엔진 독립 인터페이스.
|
|
2
|
+
|
|
3
|
+
commands/ 는 이 모듈의 타입과 Protocol만 알아야 한다.
|
|
4
|
+
python-hwpx 교체 시 hwpx_engine.py만 다시 쓴다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
# 엔진의 PLACEHOLDER_RE는 ASCII 전용 — 한글 키를 위해 자체 정의
|
|
13
|
+
MARKER_RE = re.compile(r"\{\{\s*([^{}\s][^{}]*?)\s*\}\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FormField:
|
|
18
|
+
index: int
|
|
19
|
+
name: str
|
|
20
|
+
current: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Marker:
|
|
25
|
+
key: str
|
|
26
|
+
paragraph_index: int
|
|
27
|
+
context: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EngineAdapter(Protocol):
|
|
31
|
+
def form_fields(self) -> list[FormField]: ...
|
|
32
|
+
def markers(self) -> list[Marker]: ...
|
|
33
|
+
def table_map(self) -> dict: ...
|
|
34
|
+
def fill_form_field(self, name: str, value: str) -> dict: ...
|
|
35
|
+
def replace_marker(self, key: str, value: str) -> int: ...
|
|
36
|
+
def replace_text(self, search: str, value: str) -> int: ...
|
|
37
|
+
def replace_paragraph_text(self, search: str, value: str) -> int: ...
|
|
38
|
+
def delete_paragraph_text(self, search: str) -> int: ...
|
|
39
|
+
def fill_by_label(self, label_path: str, value: str) -> dict: ...
|
|
40
|
+
def export_markdown(self) -> str: ...
|
|
41
|
+
def export_text(self) -> str: ...
|
|
42
|
+
def validate(self) -> dict: ...
|
|
43
|
+
def save_copy(self, out_path: str) -> str: ...
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from hwpx.document import HwpxDocument
|
|
6
|
+
|
|
7
|
+
from hwpx_kit.adapter.base import MARKER_RE, FormField, Marker
|
|
8
|
+
from hwpx_kit.output import quiet_engine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HwpxEngineAdapter:
|
|
12
|
+
def __init__(self, doc: HwpxDocument, source_path: str):
|
|
13
|
+
self._doc = doc
|
|
14
|
+
self._source_path = os.path.abspath(source_path)
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def open(cls, path: str) -> "HwpxEngineAdapter":
|
|
18
|
+
with quiet_engine():
|
|
19
|
+
doc = HwpxDocument.open(path)
|
|
20
|
+
return cls(doc, path)
|
|
21
|
+
|
|
22
|
+
def form_fields(self) -> list[FormField]:
|
|
23
|
+
with quiet_engine():
|
|
24
|
+
raw = self._doc.list_form_fields()
|
|
25
|
+
return [
|
|
26
|
+
FormField(
|
|
27
|
+
index=i,
|
|
28
|
+
name=str(f.get("name", "") or ""),
|
|
29
|
+
current=str(f.get("text", f.get("value", "")) or ""),
|
|
30
|
+
)
|
|
31
|
+
for i, f in enumerate(raw)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def markers(self) -> list[Marker]:
|
|
35
|
+
found: list[Marker] = []
|
|
36
|
+
with quiet_engine():
|
|
37
|
+
paragraphs = list(self._doc.paragraphs)
|
|
38
|
+
for idx, para in enumerate(paragraphs):
|
|
39
|
+
text = para.text or ""
|
|
40
|
+
for key in MARKER_RE.findall(text):
|
|
41
|
+
found.append(Marker(key=key, paragraph_index=idx, context=text))
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
def table_map(self) -> dict:
|
|
45
|
+
"""엔진 표 맵에 셀별 is_anchor를 덧붙여 반환.
|
|
46
|
+
|
|
47
|
+
병합 셀은 격자 전체에 복제되어 보이는데(엔진 특성), anchor가 아닌
|
|
48
|
+
복제 좌표를 라벨 후보로 세면 #N 번호가 오염되고 같은 논리 셀에
|
|
49
|
+
이중 기입된다 — 소비자(analyze)가 복제를 거를 수 있게 표식 제공.
|
|
50
|
+
"""
|
|
51
|
+
with quiet_engine():
|
|
52
|
+
result = self._doc.get_table_map()
|
|
53
|
+
from hwpx.tools import table_navigation as tn
|
|
54
|
+
|
|
55
|
+
tables = tn._collect_document_tables(self._doc)
|
|
56
|
+
for entry in result.get("tables", []):
|
|
57
|
+
table = tables[entry["table_index"]].table
|
|
58
|
+
for cell in entry.get("cells", []):
|
|
59
|
+
cell["is_anchor"] = self._cell_is_anchor(table, cell["row"], cell["col"])
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _cell_is_anchor(table, row: int, col: int) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
return bool(table._grid_entry(row, col).is_anchor)
|
|
66
|
+
except Exception:
|
|
67
|
+
return True # 판정 불가면 후보 유지 (누락보다 잡음이 안전)
|
|
68
|
+
|
|
69
|
+
def fill_form_field(self, name: str, value: str) -> dict:
|
|
70
|
+
with quiet_engine():
|
|
71
|
+
return self._doc.fill_form_field(value, name=name)
|
|
72
|
+
|
|
73
|
+
def replace_marker(self, key: str, value: str) -> int:
|
|
74
|
+
with quiet_engine():
|
|
75
|
+
return self._doc.replace_text_in_runs("{{" + key + "}}", value)
|
|
76
|
+
|
|
77
|
+
def replace_text(self, search: str, value: str) -> int:
|
|
78
|
+
with quiet_engine():
|
|
79
|
+
return self._doc.replace_text_in_runs(search, value)
|
|
80
|
+
|
|
81
|
+
def _iter_all_paragraphs(self):
|
|
82
|
+
"""본문 + 표 셀(중첩 포함) 문단 전부 순회."""
|
|
83
|
+
|
|
84
|
+
def walk(paragraphs):
|
|
85
|
+
for p in paragraphs:
|
|
86
|
+
yield p
|
|
87
|
+
for table in getattr(p, "tables", []) or []:
|
|
88
|
+
for r in range(table.row_count):
|
|
89
|
+
for c in range(table.column_count):
|
|
90
|
+
try:
|
|
91
|
+
cell = table.cell(r, c)
|
|
92
|
+
except Exception:
|
|
93
|
+
continue
|
|
94
|
+
yield from walk(cell.paragraphs)
|
|
95
|
+
|
|
96
|
+
yield from walk(list(self._doc.paragraphs))
|
|
97
|
+
|
|
98
|
+
def delete_paragraph_text(self, search: str) -> int:
|
|
99
|
+
"""문단 전체 텍스트가 search와 일치하면 문단을 제거.
|
|
100
|
+
|
|
101
|
+
양식의 안내문(예: '←해당시' 지시 블록) 삭제용. 셀의 마지막 문단처럼
|
|
102
|
+
제거가 불가능한 위치면 텍스트만 비운다.
|
|
103
|
+
"""
|
|
104
|
+
count = 0
|
|
105
|
+
with quiet_engine():
|
|
106
|
+
targets = [
|
|
107
|
+
p for p in self._iter_all_paragraphs()
|
|
108
|
+
if (p.text or "").strip() == search.strip()
|
|
109
|
+
]
|
|
110
|
+
for p in targets:
|
|
111
|
+
# 비우기 먼저: remove()가 위치에 따라 예외 없이 무효될 수 있어
|
|
112
|
+
# (표 셀 문단 등) 어느 경로든 내용이 사라지도록 보장
|
|
113
|
+
p.text = ""
|
|
114
|
+
try:
|
|
115
|
+
p.remove()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
count += 1
|
|
119
|
+
return count
|
|
120
|
+
|
|
121
|
+
def apply_run_format(self, search: str, *, bold: bool | None = None,
|
|
122
|
+
underline: bool | None = None) -> int:
|
|
123
|
+
"""search를 품은 런에 글자 서식 적용 (본문+표 셀). 적용 런 수 반환.
|
|
124
|
+
|
|
125
|
+
런 단위 적용 — search가 런의 일부여도 그 런 전체에 서식이 걸린다.
|
|
126
|
+
(부분 강조가 필요하면 원문이 런 경계와 일치해야 함 — 스킬에 명시)
|
|
127
|
+
엔진이 run.bold=True 시 새 charPr을 만들어 연결한다 (실증 2026-07-10).
|
|
128
|
+
"""
|
|
129
|
+
count = 0
|
|
130
|
+
with quiet_engine():
|
|
131
|
+
for para in self._iter_all_paragraphs():
|
|
132
|
+
for run in list(para.runs):
|
|
133
|
+
if search and search in (run.text or ""):
|
|
134
|
+
if bold is not None:
|
|
135
|
+
run.bold = bold
|
|
136
|
+
if underline is not None:
|
|
137
|
+
run.underline = underline
|
|
138
|
+
count += 1
|
|
139
|
+
return count
|
|
140
|
+
|
|
141
|
+
def replace_paragraph_text(self, search: str, value: str) -> int:
|
|
142
|
+
"""문단 전체 텍스트가 search와 일치하면 통째로 교체.
|
|
143
|
+
|
|
144
|
+
런이 쪼개져 replace_text가 못 잡는 긴 문장용 폴백. 문단은 첫 런
|
|
145
|
+
서식으로 합쳐진다(제목/부제처럼 단일 서식 문단에 안전).
|
|
146
|
+
"""
|
|
147
|
+
count = 0
|
|
148
|
+
with quiet_engine():
|
|
149
|
+
for p in self._iter_all_paragraphs():
|
|
150
|
+
if (p.text or "").strip() == search.strip():
|
|
151
|
+
p.text = value
|
|
152
|
+
count += 1
|
|
153
|
+
return count
|
|
154
|
+
|
|
155
|
+
def fill_by_label(self, label_path: str, value: str) -> dict:
|
|
156
|
+
with quiet_engine():
|
|
157
|
+
return self._doc.fill_by_path({label_path: value})
|
|
158
|
+
|
|
159
|
+
def _label_candidates(self, label: str) -> list:
|
|
160
|
+
"""엔진이 fill_by_path 내부에서 쓰는 것과 동일한 라벨 열거 (문서 순서, 공백 정규화).
|
|
161
|
+
|
|
162
|
+
공개 API(find_cell_by_label)는 방향 유효성으로 후보를 걸러 번호가 방향에
|
|
163
|
+
따라 달라지고, fill_by_path는 라벨 속 '>'를 경로 구분자로 오파싱한다 —
|
|
164
|
+
그래서 내부 열거를 직접 쓴다 (엔진 교체 시 이 지점 재구현).
|
|
165
|
+
"""
|
|
166
|
+
from hwpx.tools import table_navigation as tn
|
|
167
|
+
|
|
168
|
+
tables = tn._collect_document_tables(self._doc)
|
|
169
|
+
return [
|
|
170
|
+
c
|
|
171
|
+
for c in tn._find_label_candidates(tables, label)
|
|
172
|
+
# 병합 셀의 격자 복제(비-anchor)는 같은 논리 셀 — 출현 수에서 제외
|
|
173
|
+
if self._cell_is_anchor(c.table, c.row, c.col)
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
def normalize_label(self, label: str) -> str:
|
|
177
|
+
"""엔진 라벨 매칭과 동일한 정규화 (공백 축약 + casefold + 끝 콜론 제거)."""
|
|
178
|
+
from hwpx.tools import table_navigation as tn
|
|
179
|
+
|
|
180
|
+
return tn._normalize_label_text(label)
|
|
181
|
+
|
|
182
|
+
def label_positions_map(self) -> dict[str, list[dict]]:
|
|
183
|
+
"""정규화 라벨 텍스트 → anchor 셀 좌표 목록(문서 순서). 격자 한 번 스캔.
|
|
184
|
+
|
|
185
|
+
fill_at_label의 후보 열거와 같은 기준이라, 여기서 매긴 #N은
|
|
186
|
+
fill이 고르는 N번째와 항상 같은 셀이다. analyze가 모든 라벨의
|
|
187
|
+
중복 여부를 fill 관점에서 판정할 때 쓴다.
|
|
188
|
+
"""
|
|
189
|
+
from hwpx.tools import table_navigation as tn
|
|
190
|
+
|
|
191
|
+
out: dict[str, list[dict]] = {}
|
|
192
|
+
with quiet_engine():
|
|
193
|
+
for tref in tn._collect_document_tables(self._doc):
|
|
194
|
+
table = tref.table
|
|
195
|
+
for row in range(table.row_count):
|
|
196
|
+
for col in range(table.column_count):
|
|
197
|
+
if not self._cell_is_anchor(table, row, col):
|
|
198
|
+
continue
|
|
199
|
+
text = tn._normalize_label_text(tn._cell_text(table, row, col))
|
|
200
|
+
if not text:
|
|
201
|
+
continue
|
|
202
|
+
out.setdefault(text, []).append(
|
|
203
|
+
{"table_index": tref.table_index, "row": row, "col": col}
|
|
204
|
+
)
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
def find_label_matches(self, label: str) -> list[dict]:
|
|
208
|
+
"""라벨과 일치하는 셀 전부를 문서 순서로 반환 (중복 라벨 #N 번호 매기기용).
|
|
209
|
+
|
|
210
|
+
fill_at_label과 같은 열거를 쓰므로 analyze가 붙인 #N과
|
|
211
|
+
fill이 고르는 N번째가 항상 일치한다.
|
|
212
|
+
"""
|
|
213
|
+
with quiet_engine():
|
|
214
|
+
candidates = self._label_candidates(label)
|
|
215
|
+
return [
|
|
216
|
+
{"table_index": c.table_index, "row": c.row, "col": c.col}
|
|
217
|
+
for c in candidates
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
def fill_at_label(
|
|
221
|
+
self, label: str, directions: list[str], value: str, nth: int | None = None
|
|
222
|
+
) -> dict:
|
|
223
|
+
"""라벨 셀에서 directions만큼 이동한 셀을 채운다.
|
|
224
|
+
|
|
225
|
+
nth가 None이면 라벨이 유일할 때만 채움(중복이면 ambiguous 거부 —
|
|
226
|
+
엔진 fill_by_path와 같은 안전 동작). nth(1-기준)가 있으면 문서 순서
|
|
227
|
+
N번째 출현을 지정해 채운다.
|
|
228
|
+
"""
|
|
229
|
+
from hwpx.tools import table_navigation as tn
|
|
230
|
+
|
|
231
|
+
def _fail(reason: str) -> dict:
|
|
232
|
+
return {"applied_count": 0, "failed": [{"reason": reason}]}
|
|
233
|
+
|
|
234
|
+
for d in directions:
|
|
235
|
+
if d not in ("left", "right", "up", "down"):
|
|
236
|
+
return _fail(f"지원하지 않는 방향: {d}")
|
|
237
|
+
|
|
238
|
+
with quiet_engine():
|
|
239
|
+
try:
|
|
240
|
+
candidates = self._label_candidates(label)
|
|
241
|
+
except ValueError as exc:
|
|
242
|
+
return _fail(str(exc))
|
|
243
|
+
if not candidates:
|
|
244
|
+
return _fail("label not found")
|
|
245
|
+
if nth is None:
|
|
246
|
+
if len(candidates) > 1:
|
|
247
|
+
return _fail("ambiguous label")
|
|
248
|
+
candidate = candidates[0]
|
|
249
|
+
elif not (1 <= nth <= len(candidates)):
|
|
250
|
+
return _fail(f"라벨 출현 {len(candidates)}곳인데 #{nth} 지정 — 범위 밖")
|
|
251
|
+
else:
|
|
252
|
+
candidate = candidates[nth - 1]
|
|
253
|
+
|
|
254
|
+
row, col = candidate.row, candidate.col
|
|
255
|
+
for d in directions:
|
|
256
|
+
moved = tn._move(candidate.table, row, col, d)
|
|
257
|
+
if moved is None:
|
|
258
|
+
return _fail("navigation out of bounds")
|
|
259
|
+
row, col = moved
|
|
260
|
+
candidate.table.set_cell_text(row, col, str(value), logical=True)
|
|
261
|
+
return {"applied_count": 1, "failed": []}
|
|
262
|
+
|
|
263
|
+
def export_markdown(self) -> str:
|
|
264
|
+
with quiet_engine():
|
|
265
|
+
return self._doc.export_markdown()
|
|
266
|
+
|
|
267
|
+
def export_text(self) -> str:
|
|
268
|
+
with quiet_engine():
|
|
269
|
+
return self._doc.export_text()
|
|
270
|
+
|
|
271
|
+
def validate(self) -> dict:
|
|
272
|
+
with quiet_engine():
|
|
273
|
+
report = self._doc.validate()
|
|
274
|
+
return report if isinstance(report, dict) else vars(report)
|
|
275
|
+
|
|
276
|
+
def copy_row_height(self, table_index: int, like: int, rows: list[int]) -> int:
|
|
277
|
+
"""table_index번째 표에서 like 행의 높이를 rows 행들에 복사. 적용 높이 반환.
|
|
278
|
+
|
|
279
|
+
사용자가 한글에서 행을 추가하면 기본 높이로 붙는 경우가 있어,
|
|
280
|
+
구조(행 개수)는 사용자가 맞추고 높이 정돈은 도구가 맡는 분업용.
|
|
281
|
+
기준 높이는 like 행 첫 셀 기준 — 세로 병합이 낀 행은 셀별 높이가
|
|
282
|
+
다를 수 있으니 기준 행은 병합 없는 행으로 지정할 것.
|
|
283
|
+
"""
|
|
284
|
+
with quiet_engine():
|
|
285
|
+
from hwpx.tools import table_navigation as tn
|
|
286
|
+
|
|
287
|
+
tables = tn._collect_document_tables(self._doc)
|
|
288
|
+
if not 0 <= table_index < len(tables):
|
|
289
|
+
raise ValueError(f"표 인덱스 범위 밖: {table_index} (표 {len(tables)}개)")
|
|
290
|
+
table = tables[table_index].table
|
|
291
|
+
all_rows = list(table.rows)
|
|
292
|
+
if not 0 <= like < len(all_rows):
|
|
293
|
+
raise ValueError(f"기준 행 범위 밖: {like} (행 {len(all_rows)}개)")
|
|
294
|
+
bad = [r for r in rows if not 0 <= r < len(all_rows)]
|
|
295
|
+
if bad:
|
|
296
|
+
raise ValueError(f"대상 행 범위 밖: {bad} (행 {len(all_rows)}개)")
|
|
297
|
+
height = list(all_rows[like].cells)[0].height
|
|
298
|
+
for r in rows:
|
|
299
|
+
for cell in all_rows[r].cells:
|
|
300
|
+
cell.set_size(height=height)
|
|
301
|
+
return height
|
|
302
|
+
|
|
303
|
+
def clear_table_rows(self, table_index: int, rows: list[int] | None) -> int:
|
|
304
|
+
"""table_index번째 표에서 지정 행들(None=전체)의 셀 내용을 비운다.
|
|
305
|
+
|
|
306
|
+
병합 셀은 anchor 좌표만 비운다 (복제 좌표에 쓰면 이중 처리).
|
|
307
|
+
비운 셀 수를 반환. 행 구조는 그대로 — 구조 변경은 사용자가 한글에서.
|
|
308
|
+
"""
|
|
309
|
+
with quiet_engine():
|
|
310
|
+
from hwpx.tools import table_navigation as tn
|
|
311
|
+
|
|
312
|
+
tables = tn._collect_document_tables(self._doc)
|
|
313
|
+
if not 0 <= table_index < len(tables):
|
|
314
|
+
raise ValueError(f"표 인덱스 범위 밖: {table_index} (표 {len(tables)}개)")
|
|
315
|
+
table = tables[table_index].table
|
|
316
|
+
row_count = len(list(table.rows))
|
|
317
|
+
col_count = max(len(list(r.cells)) for r in table.rows)
|
|
318
|
+
targets = list(range(row_count)) if rows is None else rows
|
|
319
|
+
bad = [r for r in targets if not 0 <= r < row_count]
|
|
320
|
+
if bad:
|
|
321
|
+
raise ValueError(f"행 범위 밖: {bad} (행 {row_count}개)")
|
|
322
|
+
cleared = 0
|
|
323
|
+
for r in targets:
|
|
324
|
+
for c in range(col_count):
|
|
325
|
+
if not self._cell_is_anchor(table, r, c):
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
table.set_cell_text(r, c, "", logical=True)
|
|
329
|
+
cleared += 1
|
|
330
|
+
except Exception:
|
|
331
|
+
continue # 격자 밖(짧은 행) 좌표는 건너뜀
|
|
332
|
+
return cleared
|
|
333
|
+
|
|
334
|
+
def set_table_cells(self, table_index: int, assignments: list[tuple[int, int, str]]) -> int:
|
|
335
|
+
"""table_index번째 표의 (row, col) 셀들에 값을 기입. 기입 수 반환."""
|
|
336
|
+
with quiet_engine():
|
|
337
|
+
from hwpx.tools import table_navigation as tn
|
|
338
|
+
|
|
339
|
+
tables = tn._collect_document_tables(self._doc)
|
|
340
|
+
if not 0 <= table_index < len(tables):
|
|
341
|
+
raise ValueError(f"표 인덱스 범위 밖: {table_index} (표 {len(tables)}개)")
|
|
342
|
+
table = tables[table_index].table
|
|
343
|
+
row_count = len(list(table.rows))
|
|
344
|
+
for r, c, _ in assignments:
|
|
345
|
+
if not 0 <= r < row_count:
|
|
346
|
+
raise ValueError(f"행 범위 밖: {r} (행 {row_count}개)")
|
|
347
|
+
for r, c, value in assignments:
|
|
348
|
+
table.set_cell_text(r, c, value, logical=True)
|
|
349
|
+
return len(assignments)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _find_anchor_paragraph(self, anchor_text: str | None = None,
|
|
353
|
+
after_table: int | None = None):
|
|
354
|
+
"""앵커 문단 찾기 — 문단 원문(공백 정규화 전체 일치) 또는 표 인덱스
|
|
355
|
+
(그 표가 든 문단). 표끼리 붙어 있어 사이 문단이 없을 때는 after_table이
|
|
356
|
+
유일한 길이다."""
|
|
357
|
+
import re as _re
|
|
358
|
+
|
|
359
|
+
norm = lambda s: _re.sub(r"\s+", " ", (s or "")).strip() # noqa: E731
|
|
360
|
+
if (anchor_text is None) == (after_table is None):
|
|
361
|
+
raise ValueError("anchor_text 또는 after_table 중 정확히 하나를 지정하세요.")
|
|
362
|
+
if after_table is not None:
|
|
363
|
+
from hwpx.tools import table_navigation as tn
|
|
364
|
+
|
|
365
|
+
tables = tn._collect_document_tables(self._doc)
|
|
366
|
+
if not 0 <= after_table < len(tables):
|
|
367
|
+
raise ValueError(f"표 인덱스 범위 밖: {after_table} (표 {len(tables)}개)")
|
|
368
|
+
el = tables[after_table].table.element
|
|
369
|
+
while el is not None and not el.tag.endswith("}p"):
|
|
370
|
+
el = el.getparent()
|
|
371
|
+
if el is None:
|
|
372
|
+
raise ValueError("표의 문단을 찾지 못했습니다.")
|
|
373
|
+
tree = el.getroottree()
|
|
374
|
+
target_path = tree.getpath(el)
|
|
375
|
+
for para in self._doc.paragraphs:
|
|
376
|
+
if para.element.getroottree().getpath(para.element) == target_path:
|
|
377
|
+
return para
|
|
378
|
+
raise ValueError("표의 문단 객체를 찾지 못했습니다.")
|
|
379
|
+
for para in self._doc.paragraphs:
|
|
380
|
+
if norm(para.text) == norm(anchor_text):
|
|
381
|
+
return para
|
|
382
|
+
raise ValueError(f"해당 원문의 문단을 찾지 못함: {anchor_text!r}")
|
|
383
|
+
|
|
384
|
+
def outline(self) -> list[dict]:
|
|
385
|
+
"""본문 문단 지도 — 인덱스·텍스트·표 위치(문서 순서 표 인덱스).
|
|
386
|
+
|
|
387
|
+
앵커 후보 탐색용: 원시 XML을 뒤지지 않고 문단/표 배치를 한 번에 본다.
|
|
388
|
+
"""
|
|
389
|
+
out = []
|
|
390
|
+
table_counter = 0
|
|
391
|
+
with quiet_engine():
|
|
392
|
+
for i, para in enumerate(self._doc.paragraphs):
|
|
393
|
+
entry: dict = {"paragraph": i, "text": (para.text or "")}
|
|
394
|
+
tables_here = []
|
|
395
|
+
for t in para.tables:
|
|
396
|
+
tables_here.append({
|
|
397
|
+
"table_index": table_counter,
|
|
398
|
+
"rows": t.row_count,
|
|
399
|
+
"cols": t.column_count,
|
|
400
|
+
})
|
|
401
|
+
table_counter += 1
|
|
402
|
+
if tables_here:
|
|
403
|
+
entry["tables"] = tables_here
|
|
404
|
+
out.append(entry)
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
def copy_table(self, table_index: int, anchor_text: str | None = None,
|
|
408
|
+
after_table: int | None = None) -> dict:
|
|
409
|
+
"""table_index번째 표를 통째 복제해 anchor_text 문단에 삽입.
|
|
410
|
+
|
|
411
|
+
원시 XML 노드 삽입은 엔진 저장이 무시한다(실험 확인) — 엔진 등록
|
|
412
|
+
경유(add_table)로 새 표를 만든 뒤 내용을 원본의 deepcopy로 바꿔치기.
|
|
413
|
+
서식·병합·행 높이까지 그대로 복제되고 저장에 살아남는다.
|
|
414
|
+
"""
|
|
415
|
+
import copy as _copy
|
|
416
|
+
|
|
417
|
+
import re as _re
|
|
418
|
+
|
|
419
|
+
norm = lambda s: _re.sub(r"\s+", " ", (s or "")).strip() # noqa: E731
|
|
420
|
+
with quiet_engine():
|
|
421
|
+
from hwpx.tools import table_navigation as tn
|
|
422
|
+
|
|
423
|
+
tables = tn._collect_document_tables(self._doc)
|
|
424
|
+
if not 0 <= table_index < len(tables):
|
|
425
|
+
raise ValueError(f"표 인덱스 범위 밖: {table_index} (표 {len(tables)}개)")
|
|
426
|
+
src = tables[table_index].table
|
|
427
|
+
rows = len(list(src.rows))
|
|
428
|
+
cols = max(len(list(r.cells)) for r in src.rows)
|
|
429
|
+
|
|
430
|
+
target = self._find_anchor_paragraph(anchor_text=anchor_text,
|
|
431
|
+
after_table=after_table)
|
|
432
|
+
|
|
433
|
+
new_table = target.add_table(rows, cols)
|
|
434
|
+
new_el, src_el = new_table.element, src.element
|
|
435
|
+
for child in list(new_el):
|
|
436
|
+
new_el.remove(child)
|
|
437
|
+
for key, value in src_el.attrib.items():
|
|
438
|
+
if key != "id": # 표 id는 새로 발급된 것 유지 (중복 방지)
|
|
439
|
+
new_el.set(key, value)
|
|
440
|
+
for child in src_el:
|
|
441
|
+
new_el.append(_copy.deepcopy(child))
|
|
442
|
+
return {"rows": rows, "cols": cols}
|
|
443
|
+
|
|
444
|
+
def set_page_break(self, at_text: str | None = None,
|
|
445
|
+
table_index: int | None = None) -> int:
|
|
446
|
+
"""문단(원문 일치) 또는 표가 앵커된 문단에 pageBreak=1 설정. 적용 수 반환.
|
|
447
|
+
|
|
448
|
+
hwpx 문단은 pageBreak 속성(기본 '0')을 원래 갖고 있음 — 실증 2026-07-10.
|
|
449
|
+
"""
|
|
450
|
+
import re as _re
|
|
451
|
+
|
|
452
|
+
norm = lambda s: _re.sub(r"\s+", " ", (s or "")).strip() # noqa: E731
|
|
453
|
+
|
|
454
|
+
def _apply(paragraph) -> None:
|
|
455
|
+
# 원시 element.set은 파일에서 연 문서의 저장 시 증발(모델 재직렬화) —
|
|
456
|
+
# 반드시 모델 경유 (to_model → page_break → apply_model)
|
|
457
|
+
model = paragraph.to_model()
|
|
458
|
+
model.page_break = True
|
|
459
|
+
paragraph.apply_model(model)
|
|
460
|
+
|
|
461
|
+
with quiet_engine():
|
|
462
|
+
if table_index is not None:
|
|
463
|
+
from hwpx.tools import table_navigation as tn
|
|
464
|
+
|
|
465
|
+
tables = tn._collect_document_tables(self._doc)
|
|
466
|
+
if not 0 <= table_index < len(tables):
|
|
467
|
+
raise ValueError(f"표 인덱스 범위 밖: {table_index} (표 {len(tables)}개)")
|
|
468
|
+
tbl_el = tables[table_index].table.element
|
|
469
|
+
p_el = tbl_el
|
|
470
|
+
while p_el is not None and not p_el.tag.endswith("}p"):
|
|
471
|
+
p_el = p_el.getparent()
|
|
472
|
+
if p_el is None:
|
|
473
|
+
raise ValueError("표의 문단을 찾지 못했습니다.")
|
|
474
|
+
tree = p_el.getroottree()
|
|
475
|
+
target_path = tree.getpath(p_el)
|
|
476
|
+
for para in self._doc.paragraphs:
|
|
477
|
+
if para.element.getroottree().getpath(para.element) == target_path:
|
|
478
|
+
_apply(para)
|
|
479
|
+
return 1
|
|
480
|
+
raise ValueError("표의 문단 객체를 찾지 못했습니다.")
|
|
481
|
+
for para in self._doc.paragraphs:
|
|
482
|
+
if norm(para.text) == norm(at_text):
|
|
483
|
+
_apply(para)
|
|
484
|
+
return 1
|
|
485
|
+
raise ValueError(f"해당 원문의 문단을 찾지 못함: {at_text!r}")
|
|
486
|
+
|
|
487
|
+
def new_table(self, rows: int, cols: int, anchor_text: str | None = None,
|
|
488
|
+
like_table: int | None = None,
|
|
489
|
+
after_table: int | None = None) -> None:
|
|
490
|
+
"""anchor_text 문단에 rows×cols 새 표 생성. like_table 지정 시 그 표의
|
|
491
|
+
테두리 서식 참조(borderFillIDRef — tbl·셀 최빈값)를 빌려 입힌다."""
|
|
492
|
+
import re as _re
|
|
493
|
+
from collections import Counter
|
|
494
|
+
|
|
495
|
+
norm = lambda s: _re.sub(r"\s+", " ", (s or "")).strip() # noqa: E731
|
|
496
|
+
with quiet_engine():
|
|
497
|
+
from hwpx.tools import table_navigation as tn
|
|
498
|
+
|
|
499
|
+
tbl_ref = header_ref = body_ref = None
|
|
500
|
+
header_h = body_h = None
|
|
501
|
+
if like_table is not None:
|
|
502
|
+
tables = tn._collect_document_tables(self._doc)
|
|
503
|
+
if not 0 <= like_table < len(tables):
|
|
504
|
+
raise ValueError(f"like_table 범위 밖: {like_table} (표 {len(tables)}개)")
|
|
505
|
+
src = tables[like_table].table
|
|
506
|
+
tbl_ref = src.element.get("borderFillIDRef")
|
|
507
|
+
|
|
508
|
+
def _row_ref(row):
|
|
509
|
+
refs = [c.element.get("borderFillIDRef") for c in row.cells]
|
|
510
|
+
refs = [r for r in refs if r]
|
|
511
|
+
return Counter(refs).most_common(1)[0][0] if refs else None
|
|
512
|
+
|
|
513
|
+
src_rows = list(src.rows)
|
|
514
|
+
if src_rows:
|
|
515
|
+
header_ref = _row_ref(src_rows[0])
|
|
516
|
+
header_h = list(src_rows[0].cells)[0].height
|
|
517
|
+
if len(src_rows) > 1:
|
|
518
|
+
body_ref = _row_ref(src_rows[1])
|
|
519
|
+
body_h = list(src_rows[1].cells)[0].height
|
|
520
|
+
body_ref = body_ref or header_ref
|
|
521
|
+
body_h = body_h or header_h
|
|
522
|
+
|
|
523
|
+
target = self._find_anchor_paragraph(anchor_text=anchor_text,
|
|
524
|
+
after_table=after_table)
|
|
525
|
+
|
|
526
|
+
new_t = target.add_table(rows, cols)
|
|
527
|
+
if tbl_ref:
|
|
528
|
+
new_t.element.set("borderFillIDRef", tbl_ref)
|
|
529
|
+
# 행별 차용: 헤더 행(0)은 기준 표 헤더 서식+높이, 본문 행은 본문 것
|
|
530
|
+
for r_i, row in enumerate(new_t.rows):
|
|
531
|
+
ref = header_ref if r_i == 0 else body_ref
|
|
532
|
+
height = header_h if r_i == 0 else body_h
|
|
533
|
+
for cell in row.cells:
|
|
534
|
+
if ref:
|
|
535
|
+
cell.element.set("borderFillIDRef", ref)
|
|
536
|
+
if height:
|
|
537
|
+
cell.set_size(height=height)
|
|
538
|
+
|
|
539
|
+
def save_copy(self, out_path: str) -> str:
|
|
540
|
+
out_abs = os.path.abspath(out_path)
|
|
541
|
+
if out_abs == self._source_path:
|
|
542
|
+
raise ValueError("원본 파일에 덮어쓸 수 없습니다. 다른 출력 경로를 지정하세요.")
|
|
543
|
+
with quiet_engine():
|
|
544
|
+
self._doc.save_to_path(out_abs)
|
|
545
|
+
return out_abs
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""kordoc(MIT, chrisryugj/kordoc) 래퍼 — 레거시/타 포맷 읽기·렌더·생성 엔진.
|
|
2
|
+
|
|
3
|
+
python-hwpx가 못 하는 것을 맡는다:
|
|
4
|
+
- .hwp(HWP3/5)·PDF·DOCX·XLSX → Markdown (한글 프로그램 불필요)
|
|
5
|
+
- HWPX → SVG 렌더 (눈 검증용)
|
|
6
|
+
- Markdown → 공문서 HWPX 생성
|
|
7
|
+
|
|
8
|
+
공급망 보험: github.com/6aneffy/kordoc 포크 유지, 검증 버전 KNOWN_GOOD_VERSION.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
KNOWN_GOOD_VERSION = "3.18.0"
|
|
18
|
+
|
|
19
|
+
# kordoc가 읽을 수 있는 확장자 (hwpx는 python-hwpx가 담당)
|
|
20
|
+
KORDOC_READ_EXTS = {".hwp", ".hwpml", ".pdf", ".docx", ".xlsx", ".xls"}
|
|
21
|
+
|
|
22
|
+
_INSTALL_HINT = (
|
|
23
|
+
"kordoc가 필요합니다 (Node.js 18+ 필요). 설치: npm install -g kordoc"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def kordoc_available() -> bool:
|
|
28
|
+
return shutil.which("kordoc") is not None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(args: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
|
|
32
|
+
exe = shutil.which("kordoc")
|
|
33
|
+
if exe is None:
|
|
34
|
+
raise RuntimeError(_INSTALL_HINT)
|
|
35
|
+
proc = subprocess.run(
|
|
36
|
+
[exe, *args], capture_output=True, timeout=timeout,
|
|
37
|
+
)
|
|
38
|
+
if proc.returncode != 0:
|
|
39
|
+
detail = proc.stderr.decode("utf-8", errors="replace").strip()[:300]
|
|
40
|
+
raise RuntimeError(f"kordoc 실행 실패: {detail or '알 수 없는 오류'}")
|
|
41
|
+
return proc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class KordocAdapter:
|
|
45
|
+
"""서브프로세스 기반이라 상태 없음 — 전부 정적 메서드."""
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def convert_to_markdown(path: str) -> str:
|
|
49
|
+
with tempfile.TemporaryDirectory() as td:
|
|
50
|
+
out = os.path.join(td, "out.md")
|
|
51
|
+
_run([os.path.abspath(path), "-o", out, "--silent"])
|
|
52
|
+
with open(out, encoding="utf-8") as fh:
|
|
53
|
+
return fh.read()
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def render_svg(path: str, out_path: str) -> str:
|
|
57
|
+
"""조판 캐시가 있으면 원본 조판 렌더, 없으면(비한컴 저장본) reflow 합성 렌더."""
|
|
58
|
+
out_abs = os.path.abspath(out_path)
|
|
59
|
+
src = os.path.abspath(path)
|
|
60
|
+
try:
|
|
61
|
+
_run(["render", src, "-o", out_abs, "--silent"])
|
|
62
|
+
except RuntimeError as exc:
|
|
63
|
+
if "linesegarray" not in str(exc) and "조판 캐시" not in str(exc):
|
|
64
|
+
raise
|
|
65
|
+
_run(["render", src, "--reflow", "-o", out_abs, "--silent"])
|
|
66
|
+
if not os.path.exists(out_abs):
|
|
67
|
+
raise RuntimeError("kordoc render가 SVG를 생성하지 못했습니다.")
|
|
68
|
+
return out_abs
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def generate_hwpx(markdown_path: str, out_path: str, preset: str | None = None) -> str:
|
|
72
|
+
out_abs = os.path.abspath(out_path)
|
|
73
|
+
args = ["generate", os.path.abspath(markdown_path), "-o", out_abs, "--silent"]
|
|
74
|
+
if preset:
|
|
75
|
+
args += ["--preset", preset]
|
|
76
|
+
_run(args)
|
|
77
|
+
if not os.path.exists(out_abs):
|
|
78
|
+
raise RuntimeError("kordoc generate가 파일을 생성하지 못했습니다.")
|
|
79
|
+
return out_abs
|