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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -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