python-hwpx 2.22.0__py3-none-any.whl → 2.23.0__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/table_patch.py +111 -7
- python_hwpx-2.23.0.dist-info/METADATA +213 -0
- {python_hwpx-2.22.0.dist-info → python_hwpx-2.23.0.dist-info}/RECORD +8 -8
- python_hwpx-2.22.0.dist-info/METADATA +0 -467
- {python_hwpx-2.22.0.dist-info → python_hwpx-2.23.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.22.0.dist-info → python_hwpx-2.23.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.22.0.dist-info → python_hwpx-2.23.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.22.0.dist-info → python_hwpx-2.23.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.22.0.dist-info → python_hwpx-2.23.0.dist-info}/top_level.txt +0 -0
hwpx/table_patch.py
CHANGED
|
@@ -196,6 +196,80 @@ def _all_paragraph_spans(cell: bytes) -> list[tuple[int, int]]:
|
|
|
196
196
|
return [(m.start(), m.end()) for m in _P_SPAN_RE.finditer(masked)]
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
# --- font shrink-to-fit helpers (byte-preserving charPr materialisation) ------
|
|
200
|
+
|
|
201
|
+
def _header_part_name(parts: Mapping[str, bytes]) -> str | None:
|
|
202
|
+
return next((n for n in parts if n.endswith("header.xml")), None)
|
|
203
|
+
|
|
204
|
+
def _charpr_height(header: bytes, cid: str) -> int | None:
|
|
205
|
+
m = re.search(rb'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + re.escape(cid.encode()) + rb'"[^>]*?\bheight="(\d+)"', header)
|
|
206
|
+
return int(m.group(1)) if m else None
|
|
207
|
+
|
|
208
|
+
def _cell_run_charpr(cell: bytes) -> str | None:
|
|
209
|
+
m = re.search(rb'<(?:[A-Za-z_][\w.-]*:)?run\b[^>]*?\bcharPrIDRef="(\d+)"', cell)
|
|
210
|
+
return m.group(1).decode() if m else None
|
|
211
|
+
|
|
212
|
+
def _cell_inner_width(cell: bytes) -> int:
|
|
213
|
+
w = _iattr(cell, "cellSz", "width") or 0
|
|
214
|
+
m = re.search(rb'<(?:[A-Za-z_][\w.-]*:)?cellMargin\b[^>]*?\bleft="(\d+)"[^>]*?\bright="(\d+)"', cell)
|
|
215
|
+
left, right = (int(m.group(1)), int(m.group(2))) if m else (0, 0)
|
|
216
|
+
return max(w - left - right, 0)
|
|
217
|
+
|
|
218
|
+
def _materialize_charpr(header: bytes, base_id: str, new_height: int, cache: dict[tuple[str, int], str]) -> tuple[bytes, str]:
|
|
219
|
+
"""Clone charPr *base_id* with *new_height*, append it to the charProperties
|
|
220
|
+
list (itemCnt bumped), return (new_header, new_charpr_id). Deduped via *cache*."""
|
|
221
|
+
key = (base_id, new_height)
|
|
222
|
+
if key in cache:
|
|
223
|
+
return header, cache[key]
|
|
224
|
+
bid = re.escape(base_id.encode())
|
|
225
|
+
m = re.search(rb'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + bid + rb'".*?</(?:[A-Za-z_][\w.-]*:)?charPr>', header, re.DOTALL)
|
|
226
|
+
if m is None:
|
|
227
|
+
m = re.search(rb'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="' + bid + rb'"[^>]*/>', header)
|
|
228
|
+
if m is None:
|
|
229
|
+
raise KeyError(f"base charPr {base_id} not found")
|
|
230
|
+
ids = [int(x) for x in re.findall(rb'<(?:[A-Za-z_][\w.-]*:)?charPr\b[^>]*?\bid="(\d+)"', header)]
|
|
231
|
+
new_id = str((max(ids) + 1) if ids else 0)
|
|
232
|
+
clone = re.sub(rb'(\bid=")\d+(")', rb'\g<1>' + new_id.encode() + rb'\g<2>', m.group(0), count=1)
|
|
233
|
+
if re.search(rb'\bheight="\d+"', clone):
|
|
234
|
+
clone = re.sub(rb'(\bheight=")\d+(")', rb'\g<1>' + str(new_height).encode() + rb'\g<2>', clone, count=1)
|
|
235
|
+
close = re.search(rb'</(?:[A-Za-z_][\w.-]*:)?charProperties>', header)
|
|
236
|
+
if close is None:
|
|
237
|
+
raise KeyError("charProperties close tag not found")
|
|
238
|
+
header = header[:close.start()] + clone + header[close.start():]
|
|
239
|
+
header = re.sub(
|
|
240
|
+
rb'(<(?:[A-Za-z_][\w.-]*:)?charProperties\b[^>]*?\bitemCnt=")(\d+)(")',
|
|
241
|
+
lambda mm: mm.group(1) + str(int(mm.group(2)) + 1).encode() + mm.group(3),
|
|
242
|
+
header, count=1,
|
|
243
|
+
)
|
|
244
|
+
cache[key] = new_id
|
|
245
|
+
return header, new_id
|
|
246
|
+
|
|
247
|
+
def _shrunk_font_id(header: bytes, cell_bytes: bytes, text: str, target_lines: int, min_font_pt: float,
|
|
248
|
+
cache: dict[tuple[str, int], str]) -> tuple[bytes, str, str] | None:
|
|
249
|
+
"""If *text* needs more than *target_lines* at the cell's base font, decide a
|
|
250
|
+
shrink (form_fit FitEngine) and materialise it. Returns (new_header, base_id,
|
|
251
|
+
new_id) or None (no shrink needed / not resolvable)."""
|
|
252
|
+
from .form_fit.engine import FitEngine
|
|
253
|
+
from .form_fit.measure import SlotMetrics
|
|
254
|
+
from .form_fit.policy import FitPolicy
|
|
255
|
+
|
|
256
|
+
base_id = _cell_run_charpr(cell_bytes)
|
|
257
|
+
if base_id is None:
|
|
258
|
+
return None
|
|
259
|
+
inner = _cell_inner_width(cell_bytes)
|
|
260
|
+
base_h = _charpr_height(header, base_id)
|
|
261
|
+
if not inner or not base_h:
|
|
262
|
+
return None
|
|
263
|
+
base_pt = base_h / 100.0
|
|
264
|
+
slot = SlotMetrics(available_width=inner * 0.94, font_pt=base_pt, max_lines=target_lines)
|
|
265
|
+
result = FitEngine().fit(text, slot, FitPolicy(mode="wrap_then_shrink", max_lines=target_lines, min_font_pt=min_font_pt))
|
|
266
|
+
new_pt = result.font_pt
|
|
267
|
+
if not new_pt or new_pt >= base_pt - 1e-6:
|
|
268
|
+
return None
|
|
269
|
+
header, new_id = _materialize_charpr(header, base_id, int(round(new_pt * 100)), cache)
|
|
270
|
+
return header, base_id, new_id
|
|
271
|
+
|
|
272
|
+
|
|
199
273
|
# --- public API ---------------------------------------------------------------
|
|
200
274
|
|
|
201
275
|
@dataclass(frozen=True)
|
|
@@ -256,16 +330,17 @@ class CellFillResult:
|
|
|
256
330
|
}
|
|
257
331
|
|
|
258
332
|
|
|
259
|
-
def _normalize(cell: Mapping[str, Any] | Any) -> tuple[str, int, int, int, str]:
|
|
333
|
+
def _normalize(cell: Mapping[str, Any] | Any) -> tuple[str, int, int, int, str, int | None]:
|
|
260
334
|
get = cell.get if isinstance(cell, Mapping) else (lambda k, d=None: getattr(cell, k, d))
|
|
261
335
|
section = str(get("section_path") or get("sectionPath") or "Contents/section0.xml")
|
|
262
336
|
table_index = get("table_index", get("tableIndex"))
|
|
263
337
|
row = get("row")
|
|
264
338
|
col = get("col")
|
|
265
339
|
text = get("text")
|
|
340
|
+
max_lines = get("max_lines", get("maxLines"))
|
|
266
341
|
if table_index is None or row is None or col is None or text is None:
|
|
267
342
|
raise ValueError("cell fill requires table_index, row, col, text")
|
|
268
|
-
return section, int(table_index), int(row), int(col), str(text)
|
|
343
|
+
return section, int(table_index), int(row), int(col), str(text), (int(max_lines) if max_lines else None)
|
|
269
344
|
|
|
270
345
|
|
|
271
346
|
def fill_cells(
|
|
@@ -273,6 +348,8 @@ def fill_cells(
|
|
|
273
348
|
cells: Sequence[Mapping[str, Any] | Any],
|
|
274
349
|
*,
|
|
275
350
|
output_path: str | Path | None = None,
|
|
351
|
+
fit_max_lines: int | None = None,
|
|
352
|
+
min_font_pt: float = 8.0,
|
|
276
353
|
) -> CellFillResult:
|
|
277
354
|
"""Byte-preserving fill of table cells by ``(table_index, row, col)`` address.
|
|
278
355
|
|
|
@@ -281,6 +358,12 @@ def fill_cells(
|
|
|
281
358
|
parts and untouched tables round-trip byte-identical. Empty / self-closing
|
|
282
359
|
cells are filled (text inserted), never silently reported as done. An
|
|
283
360
|
unresolvable address is reported in ``skipped`` and mutates nothing.
|
|
361
|
+
|
|
362
|
+
**Font shrink-to-fit** (optional): when ``fit_max_lines`` is set (or a cell
|
|
363
|
+
carries ``max_lines``), a cell whose text would wrap past that many lines at
|
|
364
|
+
its template font is shrunk — the smallest font ``>= min_font_pt`` that fits
|
|
365
|
+
(form_fit FitEngine) is materialised as a real ``charPr`` and the cell's run
|
|
366
|
+
points at it. Column widths and the rest of the document are untouched.
|
|
284
367
|
"""
|
|
285
368
|
source_bytes = _read_source_bytes(source)
|
|
286
369
|
specs = [_normalize(c) for c in cells]
|
|
@@ -292,23 +375,28 @@ def fill_cells(
|
|
|
292
375
|
with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as zf:
|
|
293
376
|
parts = {i.filename: zf.read(i.filename) for i in zf.infolist() if not i.is_dir()}
|
|
294
377
|
|
|
378
|
+
header_name = _header_part_name(parts)
|
|
379
|
+
header_xml = parts.get(header_name) if header_name else None
|
|
380
|
+
charpr_cache: dict[tuple[str, int], str] = {}
|
|
381
|
+
header_changed = False
|
|
382
|
+
|
|
295
383
|
applied: list[CellApplied] = []
|
|
296
384
|
skipped: list[CellSkipped] = []
|
|
297
|
-
by_section: dict[str, list[tuple[int, int, int, str]]] = {}
|
|
298
|
-
for section, ti, row, col, text in specs:
|
|
299
|
-
by_section.setdefault(section, []).append((ti, row, col, text))
|
|
385
|
+
by_section: dict[str, list[tuple[int, int, int, str, int | None]]] = {}
|
|
386
|
+
for section, ti, row, col, text, mx in specs:
|
|
387
|
+
by_section.setdefault(section, []).append((ti, row, col, text, mx))
|
|
300
388
|
|
|
301
389
|
changed_parts: dict[str, bytes] = {}
|
|
302
390
|
for section_path, section_specs in by_section.items():
|
|
303
391
|
section_xml = parts.get(section_path)
|
|
304
392
|
if section_xml is None:
|
|
305
|
-
skipped.extend(CellSkipped(section_path, ti, r, c, "section part not found") for ti, r, c,
|
|
393
|
+
skipped.extend(CellSkipped(section_path, ti, r, c, "section part not found") for ti, r, c, _t, _m in section_specs)
|
|
306
394
|
continue
|
|
307
395
|
table_spans = _iter_table_spans(section_xml)
|
|
308
396
|
# accumulate byte edits over the whole section (table region splices)
|
|
309
397
|
section_edits: list[tuple[int, int, bytes]] = []
|
|
310
398
|
occupied: list[tuple[int, int]] = [] # filled paragraph spans (merge-collision guard)
|
|
311
|
-
for ti, row, col, text in section_specs:
|
|
399
|
+
for ti, row, col, text, mx in section_specs:
|
|
312
400
|
if ti < 0 or ti >= len(table_spans):
|
|
313
401
|
skipped.append(CellSkipped(section_path, ti, row, col, "table_index out of range"))
|
|
314
402
|
continue
|
|
@@ -324,6 +412,16 @@ def fill_cells(
|
|
|
324
412
|
if not p_spans:
|
|
325
413
|
skipped.append(CellSkipped(section_path, ti, row, col, "cell has no paragraph"))
|
|
326
414
|
continue
|
|
415
|
+
# Font shrink-to-fit: a cell with a max-lines target whose text would
|
|
416
|
+
# wrap past it gets its run pointed at a smaller (materialised) charPr.
|
|
417
|
+
shrink: tuple[str, str] | None = None
|
|
418
|
+
target = mx or fit_max_lines
|
|
419
|
+
if target and header_xml is not None and text.strip():
|
|
420
|
+
res = _shrunk_font_id(header_xml, cell_bytes, text, target, min_font_pt, charpr_cache)
|
|
421
|
+
if res is not None:
|
|
422
|
+
header_xml, base_id, new_id = res
|
|
423
|
+
header_changed = True
|
|
424
|
+
shrink = (base_id, new_id)
|
|
327
425
|
# Replace the WHOLE cell's visible text with `text`: line k -> paragraph k,
|
|
328
426
|
# trailing paragraphs are emptied (so stale multi-line template content —
|
|
329
427
|
# e.g. stacked 성취기준 codes — does not survive). newline-separated text
|
|
@@ -348,6 +446,9 @@ def fill_cells(
|
|
|
348
446
|
collided = True
|
|
349
447
|
break
|
|
350
448
|
new_para = _strip_paragraph_layout_cache(replacement if (_s, _e) == (0, len(para)) else _apply_edits(para, [(_s, _e, replacement)]))
|
|
449
|
+
if shrink is not None:
|
|
450
|
+
base_id, new_id = shrink
|
|
451
|
+
new_para = re.sub(rb'(charPrIDRef=")' + re.escape(base_id.encode()) + rb'(")', rb"\g<1>" + new_id.encode() + rb"\g<2>", new_para)
|
|
351
452
|
occupied.append((a0, a1))
|
|
352
453
|
cell_edits.append((a0, a1, new_para))
|
|
353
454
|
if collided:
|
|
@@ -362,6 +463,9 @@ def fill_cells(
|
|
|
362
463
|
if new_section != section_xml:
|
|
363
464
|
changed_parts[section_path] = new_section
|
|
364
465
|
|
|
466
|
+
if header_changed and header_name and header_xml is not None:
|
|
467
|
+
changed_parts[header_name] = header_xml
|
|
468
|
+
|
|
365
469
|
if not changed_parts:
|
|
366
470
|
open_safety, _ = _finalize(source_bytes, output_path, source=source)
|
|
367
471
|
return CellFillResult(source_bytes, tuple(applied), tuple(skipped), (), True, "none", open_safety)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-hwpx
|
|
3
|
+
Version: 2.23.0
|
|
4
|
+
Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
|
|
5
|
+
Author: python-hwpx Maintainers
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/airmang/python-hwpx
|
|
8
|
+
Project-URL: Documentation, https://airmang.github.io/python-hwpx/
|
|
9
|
+
Project-URL: Issues, https://github.com/airmang/python-hwpx/issues
|
|
10
|
+
Keywords: hwp,hwpx,hancom,opc,xml,document-automation,validation,template
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
License-File: NOTICE
|
|
23
|
+
Requires-Dist: lxml<6,>=4.9
|
|
24
|
+
Provides-Extra: visual
|
|
25
|
+
Requires-Dist: pymupdf>=1.24; extra == "visual"
|
|
26
|
+
Requires-Dist: pillow>=10.0; extra == "visual"
|
|
27
|
+
Requires-Dist: numpy>=1.26; extra == "visual"
|
|
28
|
+
Provides-Extra: xlsx
|
|
29
|
+
Requires-Dist: openpyxl>=3.1; extra == "xlsx"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
Requires-Dist: build>=1.0; extra == "test"
|
|
36
|
+
Requires-Dist: pytest>=7.4; extra == "test"
|
|
37
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
38
|
+
Provides-Extra: typecheck
|
|
39
|
+
Requires-Dist: mypy>=1.10; extra == "typecheck"
|
|
40
|
+
Requires-Dist: pyright>=1.1.390; extra == "typecheck"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<h1 align="center">python-hwpx</h1>
|
|
45
|
+
<p align="center">
|
|
46
|
+
<strong>한글 없이 HWPX 문서를 Python으로 읽고, 편집하고, 생성하고, 검증합니다.</strong>
|
|
47
|
+
</p>
|
|
48
|
+
<p align="center">
|
|
49
|
+
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/v/python-hwpx?color=blue&label=PyPI" alt="PyPI"></a>
|
|
50
|
+
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/pyversions/python-hwpx" alt="Python"></a>
|
|
51
|
+
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue" alt="License"></a>
|
|
52
|
+
<a href="https://airmang.github.io/python-hwpx/"><img src="https://img.shields.io/badge/docs-Sphinx-8CA1AF" alt="Docs"></a>
|
|
53
|
+
</p>
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 🧩 HWPX Stack (3종)
|
|
59
|
+
|
|
60
|
+
| 계층 | 레포 | 역할 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| 📦 라이브러리 | **[`python-hwpx`](https://github.com/airmang/python-hwpx)** | 순수 파이썬 HWPX 파싱·편집·생성 코어 |
|
|
63
|
+
| 🔌 MCP 서버 | [`hwpx-mcp-server`](https://github.com/airmang/hwpx-mcp-server) | MCP 클라이언트(Claude Desktop, VS Code 등)에서 HWPX 조작 |
|
|
64
|
+
| 🎯 에이전트 스킬 | [`hwpx-skill`](https://github.com/airmang/hwpx-plugins) | 에이전트가 HWPX를 바로 쓰게 해주는 공식 온보딩 스킬 |
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 왜 python-hwpx인가
|
|
69
|
+
|
|
70
|
+
- **한컴오피스 설치 불필요** — HWPX는 ZIP+XML(OWPML/OPC) 구조라, 순수 파이썬으로 Windows·macOS·Linux·CI 어디서나 읽고 씁니다.
|
|
71
|
+
- **읽기부터 생성까지 한 코어** — 텍스트/서식 추출, 문단·표·양식 편집, 새 문서 생성, XSD 스키마 검증을 하나의 API로 처리합니다.
|
|
72
|
+
- **에이전트·자동화 친화** — 같은 스택 위에서 `hwpx-mcp-server`와 공식 스킬이 직결됩니다.
|
|
73
|
+
|
|
74
|
+
## 빠른 시작
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install python-hwpx # Python 3.10+ · lxml ≥ 4.9
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from hwpx import HwpxDocument
|
|
82
|
+
|
|
83
|
+
# 기존 문서 열기 → 편집 → 저장
|
|
84
|
+
doc = HwpxDocument.open("보고서.hwpx")
|
|
85
|
+
doc.add_paragraph("자동화로 추가한 문단입니다.")
|
|
86
|
+
doc.save_to_path("보고서-수정.hwpx")
|
|
87
|
+
|
|
88
|
+
# 새 문서 만들기
|
|
89
|
+
new = HwpxDocument.new()
|
|
90
|
+
new.add_paragraph("python-hwpx로 만든 새 문서")
|
|
91
|
+
new.save_to_path("새문서.hwpx")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> 💡 컨텍스트 매니저도 지원합니다 — `with` 블록을 벗어나면 리소스가 자동 정리됩니다:
|
|
95
|
+
> ```python
|
|
96
|
+
> with HwpxDocument.open("보고서.hwpx") as doc:
|
|
97
|
+
> doc.add_paragraph("자동으로 리소스가 정리됩니다.")
|
|
98
|
+
> doc.save_to_path("결과물.hwpx")
|
|
99
|
+
> ```
|
|
100
|
+
|
|
101
|
+
`open`/`new` → `edit`/`extract` → `save_to_path` 흐름만 잡으면 나머지는 필요할 때 확장하면 됩니다.
|
|
102
|
+
|
|
103
|
+
## 무엇을 하나
|
|
104
|
+
|
|
105
|
+
### 🔍 읽기 · 추출
|
|
106
|
+
- 텍스트/HTML/Markdown 내보내기 — `export_text()` · `export_html()` · `export_markdown()`
|
|
107
|
+
- **풍부한 Markdown** — `export_rich_markdown()`은 인라인 서식(`**굵게**`·`*기울임*`·`~~취소선~~`), 중첩 표(colspan/rowspan 안전), 도형 텍스트, 이미지, 각주/미주, 하이퍼링크, 제목(`#`/`##`) 자동 감지까지 보존
|
|
108
|
+
- `TextExtractor` / `ObjectFinder` — 섹션·문단 순회, 태그·속성·XPath로 객체 탐색 (`hp:tab`은 `\t`로 보존, roundtrip 안전)
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
doc = HwpxDocument.open("보고서.hwpx")
|
|
112
|
+
md = doc.export_rich_markdown(
|
|
113
|
+
image_dir="out/images", # BinData 이미지를 디스크에 추출
|
|
114
|
+
image_ref_prefix="images/", # 마크다운 내  경로 접두
|
|
115
|
+
detect_headings=True, # Ⅰ./1. 패턴 기반 #/## 자동
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### ✏️ 편집
|
|
120
|
+
- 문단 추가/삭제/서식, Run 단위 볼드·이탤릭·밑줄·색상
|
|
121
|
+
- 섹션 추가/삭제(`add_section(after=)`·`remove_section()`, manifest 자동 관리)
|
|
122
|
+
- 표 생성·셀 텍스트·병합/분할·중첩 테이블, 이미지 임베드, 머리글/바닥글, 메모(앵커 기반), 각주/미주, 북마크/하이퍼링크, 다단 편집
|
|
123
|
+
- **기존 문서 서식 편집** — 정렬·줄간격·들여쓰기·문단 간격, 용지·여백·방향, 쪽번호, 불릿/번호
|
|
124
|
+
- **스타일 기반 치환** — 색상·밑줄·`charPrIDRef`로 Run을 필터링해 선택 교체(`replace_text_in_runs`·`find_runs_by_style`)
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# 빨간색 텍스트만 찾아서 치환
|
|
128
|
+
doc.replace_text_in_runs("임시", "확정", text_color="#FF0000")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 🖊️ 양식 채우기 (byte-preserving)
|
|
132
|
+
- 누름틀(클릭히어) 필드 조회·서식 보존 채움, 라벨 기반 셀 탐색(`find_cell_by_label`)·경로 채우기(`fill_by_path`)
|
|
133
|
+
- **바이트 보존 구조 편집** — 셀 채우기 / 행·열·표 삭제·삽입 / 열 너비 오토핏 / 폰트 shrink-to-fit 을 문서 재조립 없이 수행해 양식 서식을 그대로 보존. 미수정 영역은 `hwpx.patch`가 section XML 바이트를 splice해 손대지 않음
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
doc = HwpxDocument.open("신청서.hwpx")
|
|
137
|
+
result = doc.fill_by_path({
|
|
138
|
+
"성명 > right": "홍길동",
|
|
139
|
+
"소속 > right": "플랫폼팀",
|
|
140
|
+
})
|
|
141
|
+
doc.save_to_path("신청서-작성완료.hwpx")
|
|
142
|
+
print(result["applied_count"], result["failed_count"])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 🏗️ 생성 · 공문서 도구
|
|
146
|
+
- `hwpx.builder` — Section/Heading/Table/Image/Header 조립형 생성 + 하드게이트 저장 리포트
|
|
147
|
+
- 공문서 도구 — `official_lint`(항목기호 위계·"끝." 표시·붙임·날짜 lint), 결재란 프리셋
|
|
148
|
+
- `advanced_generators` — 사진대지(image_grid)·회의 명패·표 기반 조직도
|
|
149
|
+
- `mail_merge` — 템플릿+데이터 N부 대량 생성, 표 합계·평균 계산
|
|
150
|
+
- `doc_diff` — 문단 LCS diff·신구대조표·참조 정합 lint
|
|
151
|
+
- `style_profile` — 참조 문서 프로파일 추출·적용, 템플릿 레지스트리
|
|
152
|
+
|
|
153
|
+
### ✅ 검증 · 안전 · 저수준
|
|
154
|
+
- XSD 스키마 + 패키지 구조 검증 — CLI `hwpx-validate` · `hwpx-validate-package`, `hwpx-analyze-template`
|
|
155
|
+
- `validate_editor_open_safety` — 저장/팩/리페어/빌더 출력 게이트, `openSafety` 증거 반환
|
|
156
|
+
- `hwpx.tools.fuzz`(시드 결정적 시나리오·3중 오라클) · `hwpx.tools.layout_preview`(페이지 박스 근사 HTML/PNG 자기검증) · `opc.security`(XML entity·ZIP 압축 폭탄 가드)
|
|
157
|
+
- `hwpx.oxml` 데이터클래스로 OWPML 스키마 ↔ Python 객체 직접 조작, HWPML 2016→2011 네임스페이스 자동 정규화
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
hwpx-validate-package 보고서.hwpx
|
|
161
|
+
hwpx-analyze-template 보고서.hwpx
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
> 전체 기능·클래스·메서드 목록은 [사용 가이드](docs/usage.md)와 [API 레퍼런스](https://airmang.github.io/python-hwpx/api_reference.html)를 참고하세요.
|
|
165
|
+
|
|
166
|
+
## 대항 라이브러리 비교
|
|
167
|
+
|
|
168
|
+
| | python-hwpx | pyhwpx | pyhwp |
|
|
169
|
+
|---|---|---|---|
|
|
170
|
+
| **대상 포맷** | `.hwpx` (OWPML/OPC) | `.hwpx` | `.hwp` (v5 바이너리) |
|
|
171
|
+
| **한/글 설치** | 불필요 | 필요 (Windows COM) | 불필요 |
|
|
172
|
+
| **크로스 플랫폼** | ✅ Linux / macOS / Windows / CI | ❌ Windows 전용 | ✅ |
|
|
173
|
+
| **편집/생성 API** | ✅ | ✅ (COM) | ❌ 대부분 읽기 |
|
|
174
|
+
| **스키마 검증** | ✅ | ❌ | ❌ |
|
|
175
|
+
| **AI 에이전트 연동 (MCP)** | ✅ `hwpx-mcp-server` | ❌ | ❌ |
|
|
176
|
+
|
|
177
|
+
> HWP(v5 바이너리) 파일은 지원하지 않습니다. 한컴오피스에서 HWPX로 변환 후 사용하세요.
|
|
178
|
+
|
|
179
|
+
## 알려진 제약
|
|
180
|
+
|
|
181
|
+
- `add_shape()` / `add_control()`은 한/글이 요구하는 모든 하위 요소를 생성하지 않습니다. 복잡한 개체 추가 시 한/글에서 열어 검증하세요.
|
|
182
|
+
- 이미지 바이너리 임베드는 지원하지만 `<hp:pic>` 요소의 완전 자동 생성은 제공하지 않습니다.
|
|
183
|
+
- 암호화된 HWPX 파일의 암복호화는 지원하지 않습니다.
|
|
184
|
+
|
|
185
|
+
## 더 보기
|
|
186
|
+
|
|
187
|
+
- **[🚀 빠른 시작](docs/quickstart.md)** · **[📚 사용 가이드](docs/usage.md)** — 첫 파일 열기부터 문단·표·메모·섹션 편집, 텍스트 추출·검증까지
|
|
188
|
+
- **[💡 예제 모음](docs/examples.md)** · [`examples/`](examples/) — `build_release_checklist.py`(메모·스타일 편집 HWPX 생성), `extract_text.py`(CLI 텍스트 추출), `find_objects.py`(OWPML 노드 추적) 등
|
|
189
|
+
- **[📐 스키마 개요](docs/schema-overview.md)** · **[🔧 설치 검증](docs/installation.md)**
|
|
190
|
+
- **[📖 전체 문서 (Sphinx)](https://airmang.github.io/python-hwpx/)** — API 레퍼런스·50+ 실전 패턴·FAQ
|
|
191
|
+
- **[📝 CHANGELOG](CHANGELOG.md)** · **[🤝 CONTRIBUTING](CONTRIBUTING.md)** · **[👥 CONTRIBUTORS](CONTRIBUTORS.md)**
|
|
192
|
+
|
|
193
|
+
## 기여하기
|
|
194
|
+
|
|
195
|
+
버그 리포트, 기능 제안, PR 모두 환영합니다.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
git clone https://github.com/airmang/python-hwpx.git
|
|
199
|
+
cd python-hwpx
|
|
200
|
+
pip install -e ".[dev]"
|
|
201
|
+
pytest
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
Apache License 2.0. See LICENSE and NOTICE.
|
|
207
|
+
|
|
208
|
+
## Maintainer
|
|
209
|
+
|
|
210
|
+
Primary maintainer/contact: **고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
211
|
+
|
|
212
|
+
- ✉️ [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
|
|
213
|
+
- 🐙 [@airmang](https://github.com/airmang)
|
|
@@ -5,7 +5,7 @@ hwpx/form_fill.py,sha256=VUIU53Qa9Ho2aP72biDvJwnDW7ngdAzu3PSd5A7d1JM,9908
|
|
|
5
5
|
hwpx/package.py,sha256=0rKjGCJbPQvrVBIy07Jpjsu3fI7HhbqFCGWTiTDsJpo,1141
|
|
6
6
|
hwpx/patch.py,sha256=8G1wgGVgCp0KyhQb-y0ZI2FJEatCwLt_JpepHJmYhYQ,23799
|
|
7
7
|
hwpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
hwpx/table_patch.py,sha256=
|
|
8
|
+
hwpx/table_patch.py,sha256=KVIZe4pBoV7zXfmPsVYMF7dp7ei_IV5zLSxPofWqWas,40305
|
|
9
9
|
hwpx/template_formfit.py,sha256=dlxf98FatT5Nl4hN15TuMXqj5NlDTGSnLaqXVo_PoBo,23424
|
|
10
10
|
hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
|
|
11
11
|
hwpx/builder/__init__.py,sha256=8tcxClbeuNle_R_2nDAauyy518OCegG8qVPjwIKRUZA,849
|
|
@@ -153,10 +153,10 @@ hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
|
|
|
153
153
|
hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
|
|
154
154
|
hwpx/visual/oracle.py,sha256=VNO-tAB-lzTdW5XtSELxg_Aau369qRFBsJvNryHF_D8,31319
|
|
155
155
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
156
|
-
python_hwpx-2.
|
|
157
|
-
python_hwpx-2.
|
|
158
|
-
python_hwpx-2.
|
|
159
|
-
python_hwpx-2.
|
|
160
|
-
python_hwpx-2.
|
|
161
|
-
python_hwpx-2.
|
|
162
|
-
python_hwpx-2.
|
|
156
|
+
python_hwpx-2.23.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
157
|
+
python_hwpx-2.23.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
|
|
158
|
+
python_hwpx-2.23.0.dist-info/METADATA,sha256=rtn8xh_EIAAyjusBUmwdbynyhbFQIlBf2-w50xpoCZU,10589
|
|
159
|
+
python_hwpx-2.23.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
160
|
+
python_hwpx-2.23.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
|
|
161
|
+
python_hwpx-2.23.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
162
|
+
python_hwpx-2.23.0.dist-info/RECORD,,
|
|
@@ -1,467 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: python-hwpx
|
|
3
|
-
Version: 2.22.0
|
|
4
|
-
Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
|
|
5
|
-
Author: python-hwpx Maintainers
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Homepage, https://github.com/airmang/python-hwpx
|
|
8
|
-
Project-URL: Documentation, https://airmang.github.io/python-hwpx/
|
|
9
|
-
Project-URL: Issues, https://github.com/airmang/python-hwpx/issues
|
|
10
|
-
Keywords: hwp,hwpx,hancom,opc,xml,document-automation,validation,template
|
|
11
|
-
Classifier: Development Status :: 3 - Alpha
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
-
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
19
|
-
Requires-Python: >=3.10
|
|
20
|
-
Description-Content-Type: text/markdown
|
|
21
|
-
License-File: LICENSE
|
|
22
|
-
License-File: NOTICE
|
|
23
|
-
Requires-Dist: lxml<6,>=4.9
|
|
24
|
-
Provides-Extra: visual
|
|
25
|
-
Requires-Dist: pymupdf>=1.24; extra == "visual"
|
|
26
|
-
Requires-Dist: pillow>=10.0; extra == "visual"
|
|
27
|
-
Requires-Dist: numpy>=1.26; extra == "visual"
|
|
28
|
-
Provides-Extra: xlsx
|
|
29
|
-
Requires-Dist: openpyxl>=3.1; extra == "xlsx"
|
|
30
|
-
Provides-Extra: dev
|
|
31
|
-
Requires-Dist: build>=1.0; extra == "dev"
|
|
32
|
-
Requires-Dist: twine>=4.0; extra == "dev"
|
|
33
|
-
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
34
|
-
Provides-Extra: test
|
|
35
|
-
Requires-Dist: build>=1.0; extra == "test"
|
|
36
|
-
Requires-Dist: pytest>=7.4; extra == "test"
|
|
37
|
-
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
38
|
-
Provides-Extra: typecheck
|
|
39
|
-
Requires-Dist: mypy>=1.10; extra == "typecheck"
|
|
40
|
-
Requires-Dist: pyright>=1.1.390; extra == "typecheck"
|
|
41
|
-
Dynamic: license-file
|
|
42
|
-
|
|
43
|
-
<p align="center">
|
|
44
|
-
<h1 align="center">python-hwpx</h1>
|
|
45
|
-
<p align="center">
|
|
46
|
-
<strong>한글 없이 HWPX 문서를 Python으로 읽고, 편집하고, 생성하고, 검증합니다.</strong>
|
|
47
|
-
</p>
|
|
48
|
-
<p align="center">
|
|
49
|
-
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/v/python-hwpx?color=blue&label=PyPI" alt="PyPI"></a>
|
|
50
|
-
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/pyversions/python-hwpx" alt="Python"></a>
|
|
51
|
-
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue" alt="License"></a>
|
|
52
|
-
<a href="https://airmang.github.io/python-hwpx/"><img src="https://img.shields.io/badge/docs-Sphinx-8CA1AF" alt="Docs"></a>
|
|
53
|
-
</p>
|
|
54
|
-
</p>
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## 🧩 HWPX Stack (3종)
|
|
59
|
-
|
|
60
|
-
| 계층 | 레포 | 역할 |
|
|
61
|
-
|---|---|---|
|
|
62
|
-
| 📦 라이브러리 | **[`python-hwpx`](https://github.com/airmang/python-hwpx)** | 순수 파이썬 HWPX 파싱·편집·생성 코어 |
|
|
63
|
-
| 🔌 MCP 서버 | [`hwpx-mcp-server`](https://github.com/airmang/hwpx-mcp-server) | MCP 클라이언트(Claude Desktop, VS Code 등)에서 HWPX 조작 |
|
|
64
|
-
| 🎯 에이전트 스킬 | [`hwpx-skill`](https://github.com/airmang/hwpx-skill) | 에이전트가 HWPX를 바로 쓰게 해주는 공식 온보딩 스킬 |
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
## 왜 python-hwpx인가
|
|
68
|
-
|
|
69
|
-
- 한컴오피스 설치 불필요 — 순수 파이썬으로 어디서나 동작
|
|
70
|
-
- XML-first 워크플로 — 스키마 검증·unpack/pack까지 포함
|
|
71
|
-
- 에이전트·자동화 친화 — MCP 서버·Skill이 같은 스택 위에서 직결
|
|
72
|
-
|
|
73
|
-
## 대항 라이브러리 비교
|
|
74
|
-
|
|
75
|
-
| 항목 | python-hwpx | pyhwp(x) 류 | ole+bin 수작업 |
|
|
76
|
-
|---|---|---|---|
|
|
77
|
-
| HWPX Open XML 지원 | ✅ | ⚠️ 부분 | ❌ |
|
|
78
|
-
| 한컴오피스 설치 불필요 | ✅ | ✅ | ✅ |
|
|
79
|
-
| 편집/생성 API | ✅ | ❌ 대부분 읽기 | ❌ |
|
|
80
|
-
| 스키마 검증 | ✅ | ❌ | ❌ |
|
|
81
|
-
| AI 에이전트 연동 (MCP) | ✅ (hwpx-mcp-server) | ❌ | ❌ |
|
|
82
|
-
|
|
83
|
-
## ⚡ 30초 안에 가치 확인
|
|
84
|
-
|
|
85
|
-
### 1. 기존 문서를 열고 수정
|
|
86
|
-
|
|
87
|
-
```python
|
|
88
|
-
from hwpx import HwpxDocument
|
|
89
|
-
|
|
90
|
-
document = HwpxDocument.open("보고서.hwpx")
|
|
91
|
-
document.add_paragraph("자동화로 추가한 문단입니다.")
|
|
92
|
-
document.save_to_path("보고서-수정.hwpx")
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
### 2. 양식형 표를 코드로 채우기
|
|
96
|
-
|
|
97
|
-
```python
|
|
98
|
-
from hwpx import HwpxDocument
|
|
99
|
-
|
|
100
|
-
doc = HwpxDocument.open("신청서.hwpx")
|
|
101
|
-
result = doc.fill_by_path({
|
|
102
|
-
"성명 > right": "홍길동",
|
|
103
|
-
"소속 > right": "플랫폼팀",
|
|
104
|
-
})
|
|
105
|
-
doc.save_to_path("신청서-작성완료.hwpx")
|
|
106
|
-
|
|
107
|
-
print(result["applied_count"], result["failed_count"])
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
### 3. 텍스트 추출과 구조 검증
|
|
111
|
-
|
|
112
|
-
```python
|
|
113
|
-
from hwpx import HwpxDocument
|
|
114
|
-
|
|
115
|
-
text = HwpxDocument.open("보고서.hwpx").export_markdown()
|
|
116
|
-
print(text[:500])
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
hwpx-validate-package 보고서.hwpx
|
|
121
|
-
hwpx-analyze-template 보고서.hwpx
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### 4. 풍부한 Markdown 변환 (서식·표·각주·이미지 보존)
|
|
125
|
-
|
|
126
|
-
`export_markdown()`는 단순 평문 추출이고, `export_rich_markdown()`는 인라인 서식(`**굵게**`, `*기울임*`, `~~취소선~~`),
|
|
127
|
-
표(중첩 포함, colspan/rowspan 안전), 도형 텍스트, 이미지, 각주/미주, 하이퍼링크, 제목(`#`/`##`) 자동 감지까지 보존한다.
|
|
128
|
-
|
|
129
|
-
```python
|
|
130
|
-
from hwpx import HwpxDocument
|
|
131
|
-
|
|
132
|
-
doc = HwpxDocument.open("보고서.hwpx")
|
|
133
|
-
|
|
134
|
-
md = doc.export_rich_markdown(
|
|
135
|
-
image_dir="out/images", # BinData 이미지를 디스크에 추출
|
|
136
|
-
image_ref_prefix="images/", # 마크다운 내  경로 접두
|
|
137
|
-
detect_headings=True, # Ⅰ./1. 패턴 기반 #/## 자동
|
|
138
|
-
)
|
|
139
|
-
print(md)
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
문자열·경로·바이트도 그대로 받는다:
|
|
143
|
-
|
|
144
|
-
```python
|
|
145
|
-
from hwpx.tools.markdown_export import export_markdown
|
|
146
|
-
|
|
147
|
-
md = export_markdown("보고서.hwpx") # 경로
|
|
148
|
-
md = export_markdown(open("a.hwpx", "rb").read()) # bytes
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### 5. 각주 본문에 혼합 서식 / 하이퍼링크 추가
|
|
152
|
-
|
|
153
|
-
`HwpxOxmlNote`에 `body_paragraph`, `add_run`, `add_hyperlink` helper가 있어 각주 본문을
|
|
154
|
-
직접 paragraph로 다루지 않고도 인라인 서식·링크를 손쉽게 채울 수 있다.
|
|
155
|
-
|
|
156
|
-
```python
|
|
157
|
-
para = section.paragraphs[0]
|
|
158
|
-
note = para.add_footnote("") # 빈 각주 생성 후 본문 구성
|
|
159
|
-
note.add_run("자세한 내용은 ", )
|
|
160
|
-
note.add_run("정부 공식 사이트", bold=True)
|
|
161
|
-
note.add_run("를 참고하라: ")
|
|
162
|
-
note.add_hyperlink("https://www.kasa.go.kr", "우주항공청")
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
처음에는 `open/new -> edit/extract -> save_to_path` 흐름만 잡으면 된다. 패키지 구조, XML 파트, 템플릿 회귀 점검은 필요할 때만 확장하면 된다.
|
|
166
|
-
|
|
167
|
-
## 어디부터 읽으면 되나
|
|
168
|
-
|
|
169
|
-
필요한 작업부터 바로 들어가면 된다.
|
|
170
|
-
|
|
171
|
-
- **첫 파일을 열고 저장하는 최소 경로** → [`docs/quickstart.md`](docs/quickstart.md)
|
|
172
|
-
- **문단, 표, 메모, 섹션 편집 패턴** → [`docs/usage.md`](docs/usage.md)
|
|
173
|
-
- **텍스트 추출, 구조 조회, 검증/패키지 작업** → [`docs/usage.md`](docs/usage.md)
|
|
174
|
-
- **실행 가능한 예제 모음** → [`docs/examples.md`](docs/examples.md)
|
|
175
|
-
- **패키지 구조와 스키마 심화** → [`docs/schema-overview.md`](docs/schema-overview.md)
|
|
176
|
-
- **설치 검증과 개발 환경 확인** → [`docs/installation.md`](docs/installation.md)
|
|
177
|
-
|
|
178
|
-
## examples 하이라이트
|
|
179
|
-
|
|
180
|
-
<table>
|
|
181
|
-
<tr>
|
|
182
|
-
<td valign="top">
|
|
183
|
-
<strong><a href="examples/build_release_checklist.py">build_release_checklist.py</a></strong><br>
|
|
184
|
-
메모와 스타일 편집이 포함된 릴리스 체크리스트용 HWPX를 생성한다.
|
|
185
|
-
</td>
|
|
186
|
-
<td valign="top">
|
|
187
|
-
<strong><a href="examples/extract_text.py">extract_text.py</a></strong><br>
|
|
188
|
-
본문과 중첩 객체 텍스트를 CLI로 빠르게 추출한다.
|
|
189
|
-
</td>
|
|
190
|
-
<td valign="top">
|
|
191
|
-
<strong><a href="examples/find_objects.py">find_objects.py</a></strong><br>
|
|
192
|
-
태그·속성 기준으로 OWPML XML 노드를 추적한다.
|
|
193
|
-
</td>
|
|
194
|
-
</tr>
|
|
195
|
-
</table>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
## Quick Start
|
|
199
|
-
|
|
200
|
-
새 문서를 바로 만들고 싶다면 이렇게 시작하면 된다.
|
|
201
|
-
|
|
202
|
-
```python
|
|
203
|
-
from hwpx import HwpxDocument
|
|
204
|
-
|
|
205
|
-
document = HwpxDocument.new()
|
|
206
|
-
document.add_paragraph("python-hwpx로 만든 새 문서")
|
|
207
|
-
document.save_to_path("새문서.hwpx")
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
> 💡 컨텍스트 매니저도 지원합니다:
|
|
211
|
-
> ```python
|
|
212
|
-
> with HwpxDocument.open("보고서.hwpx") as doc:
|
|
213
|
-
> doc.add_paragraph("자동으로 리소스가 정리됩니다.")
|
|
214
|
-
> doc.save_to_path("결과물.hwpx")
|
|
215
|
-
> ```
|
|
216
|
-
|
|
217
|
-
표, 메모, 텍스트 추출, 검증, 패키지/XML 심화는 [`docs/quickstart.md`](docs/quickstart.md)와 [`docs/usage.md`](docs/usage.md)에서 바로 이어진다.
|
|
218
|
-
|
|
219
|
-
> **pyhwpx / pyhwp와 다른 점?**
|
|
220
|
-
> | | python-hwpx | pyhwpx | pyhwp |
|
|
221
|
-
> |---|---|---|---|
|
|
222
|
-
> | **대상 포맷** | `.hwpx` (OWPML/OPC) | `.hwpx` | `.hwp` (v5 바이너리) |
|
|
223
|
-
> | **한/글 설치** | 불필요 | 필요 (Windows COM) | 불필요 |
|
|
224
|
-
> | **크로스 플랫폼** | ✅ Linux / macOS / Windows / CI | ❌ Windows 전용 | ✅ |
|
|
225
|
-
> | **방식** | 직접 XML 파싱 | COM 자동화 | OLE 파싱 |
|
|
226
|
-
|
|
227
|
-
## HWPX plugin usage
|
|
228
|
-
|
|
229
|
-
The per-host bundles in the `hwpx-plugins` repository consume `python-hwpx` through
|
|
230
|
-
`hwpx-mcp-server` and the local quickcheck scripts. During local development, set
|
|
231
|
-
`PYTHON_HWPX_REPO=/absolute/path/to/python-hwpx` so the plugin launcher uses this checkout as an
|
|
232
|
-
editable dependency.
|
|
233
|
-
|
|
234
|
-
## 🌍 크로스 플랫폼 지원
|
|
235
|
-
|
|
236
|
-
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 읽고 편집하는 워크플로를 구성할 수 있습니다.
|
|
237
|
-
|
|
238
|
-
| 플랫폼 | 읽기 | 쓰기 | 비고 |
|
|
239
|
-
|--------|------|------|------|
|
|
240
|
-
| ✅ Windows | ✅ | ✅ | 한컴오피스 |
|
|
241
|
-
| ✅ macOS | ✅ | ✅ | 한컴오피스 Mac |
|
|
242
|
-
| ✅ Linux | ✅ | ✅ | 한컴오피스 Linux |
|
|
243
|
-
| ✅ CI/CD | ✅ | ✅ | Docker, GitHub Actions 등 |
|
|
244
|
-
|
|
245
|
-
## 주요 기능 한눈에 보기
|
|
246
|
-
|
|
247
|
-
| 카테고리 | 기능 | 설명 |
|
|
248
|
-
|----------|------|------|
|
|
249
|
-
| 📄 **문서 I/O** | 열기/저장/생성 | 파일, 바이트, 스트림 입출력 · 원자적 저장 · ZIP 무결성 검증 |
|
|
250
|
-
| 📝 **단락** | 추가/삭제/편집/서식 | 텍스트 설정, 단락 삭제(`remove_paragraph`), 스타일 참조 |
|
|
251
|
-
| ✏️ **Run** | 텍스트 조각 | 추가, 교체, 볼드/이탤릭/밑줄/색상 서식 |
|
|
252
|
-
| 📊 **표(Table)** | 생성/편집/병합 | N×M 표 생성, 셀 텍스트, 셀 병합/분할, 중첩 테이블 |
|
|
253
|
-
| 🧭 **표 자동화** | 탐색/채우기 | 테이블 맵, 라벨 기반 셀 탐색, 경로 기반 배치 채우기 |
|
|
254
|
-
| 📑 **섹션** | 추가/삭제 | `add_section(after=)`, `remove_section()`, manifest 자동 관리 |
|
|
255
|
-
| 🖼️ **이미지** | 임베드/삭제 | 바이너리 데이터 관리, manifest 자동 등록 |
|
|
256
|
-
| ✏️ **도형** | 선/사각형/타원 | OWPML 명세 준수 도형 삽입 |
|
|
257
|
-
| 📑 **머리글/바닥글** | 설정/제거 | 홀수/짝수/양쪽 페이지 구분 |
|
|
258
|
-
| 💬 **메모** | 추가/삭제 | 앵커 기반 메모, 메모 셰이프 참조 |
|
|
259
|
-
| 📌 **각주/미주** | 추가 | 텍스트 접근 |
|
|
260
|
-
| 🔗 **북마크/하이퍼링크** | 삽입/조회 | URL 링크, 내부 북마크 |
|
|
261
|
-
| 📰 **다단 편집** | 컬럼 정의 | 다단 레이아웃 제어 |
|
|
262
|
-
| 🔍 **텍스트 추출** | 파이프라인 | 섹션/단락 순회, 주석 렌더링, 중첩 객체 제어 |
|
|
263
|
-
| 🔎 **객체 검색** | 태그/속성/XPath | 특정 요소 탐색, 주석 이터레이터 |
|
|
264
|
-
| 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
|
|
265
|
-
| 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
|
|
266
|
-
| ✅ **유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
|
|
267
|
-
| 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
|
|
268
|
-
| 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
|
|
269
|
-
| 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
|
|
270
|
-
| 🏗️ **빌더** | 조립형 생성 | `hwpx.builder` — Section/Heading/Table/Image/Header 조립, 하드게이트 저장 리포트 |
|
|
271
|
-
| ✅ **편집기 오픈 안전** | `validate_editor_open_safety` | 저장/팩/리페어/빌더 출력 게이트, `openSafety` 증거 반환 |
|
|
272
|
-
| 🧪 **퍼징 수렴 루프** | `hwpx.tools.fuzz` | 시드 결정적 시나리오 생성 · 3중 오라클 러너 · 회귀 fixture 박제 |
|
|
273
|
-
| 🖥️ **레이아웃 프리뷰** | `hwpx.tools.layout_preview` | 페이지 박스·표·여백 근사 HTML/PNG (에이전트 자기검증용) |
|
|
274
|
-
| 🧷 **바이트 보존 패치** | `hwpx.patch` | section XML 바이트 splice — 미수정 영역 바이트 보존 |
|
|
275
|
-
| 📐 **기존 문서 서식 편집** | 문단·페이지 | 정렬·줄간격·들여쓰기·문단 간격, 용지·여백·방향, 머리말/쪽번호, 불릿/번호 |
|
|
276
|
-
| 🖊️ **누름틀** | 양식 필드 | 클릭히어 필드 조회·서식 보존 채움 |
|
|
277
|
-
| 🏛️ **공문서 도구** | `official_lint` · 결재란 | 항목기호 위계·"끝." 표시·붙임·날짜 표기 lint, 결재란 프리셋 |
|
|
278
|
-
| 📷 **고급 생성기** | `advanced_generators` | 사진대지(image_grid)·회의 명패·표 기반 조직도 |
|
|
279
|
-
| 🆚 **신구대조** | `doc_diff` | 문단 LCS diff·신구대조표 생성·참조 정합 lint |
|
|
280
|
-
| 📨 **메일머지·표 계산** | `mail_merge` | 템플릿+데이터 N부 대량 생성, 표 합계·평균 |
|
|
281
|
-
| 🪄 **서식 이식** | `style_profile` | 참조 문서 프로파일 추출·적용, 템플릿 레지스트리 |
|
|
282
|
-
| 🛡️ **입력 강건화** | `opc.security` | XML entity 폭탄·ZIP 압축 폭탄 가드 |
|
|
283
|
-
|
|
284
|
-
## 기능 상세
|
|
285
|
-
|
|
286
|
-
### 📄 문서 편집
|
|
287
|
-
|
|
288
|
-
문단, 표, 메모, 머리글/바닥글을 Python 객체로 다룹니다.
|
|
289
|
-
|
|
290
|
-
```python
|
|
291
|
-
# 단락 추가·삭제
|
|
292
|
-
doc.add_paragraph("새 문단")
|
|
293
|
-
doc.remove_paragraph(doc.paragraphs[-1]) # 마지막 단락 삭제
|
|
294
|
-
|
|
295
|
-
# 섹션 추가·삭제
|
|
296
|
-
new_sec = doc.add_section() # 문서 끝에 섹션 추가
|
|
297
|
-
new_sec.add_paragraph("두 번째 섹션 내용")
|
|
298
|
-
doc.remove_section(1) # 인덱스로 섹션 삭제
|
|
299
|
-
|
|
300
|
-
# 머리글·바닥글
|
|
301
|
-
doc.set_header_text("기밀 문서", page_type="BOTH")
|
|
302
|
-
doc.set_footer_text("1 / 10", page_type="BOTH")
|
|
303
|
-
|
|
304
|
-
# 표 셀 병합·분할
|
|
305
|
-
table.merge_cells(0, 0, 1, 1) # (0,0)~(1,1) 병합
|
|
306
|
-
table.set_cell_text(0, 0, "병합된 셀", logical=True, split_merged=True)
|
|
307
|
-
table.set_cell_text(0, 0, "line 1\nline 2", split_paragraphs=True)
|
|
308
|
-
|
|
309
|
-
# 양식형 표 자동 채우기
|
|
310
|
-
form = doc.add_table(2, 2)
|
|
311
|
-
form.cell(0, 0).text = "성명:"
|
|
312
|
-
form.cell(1, 0).text = "소속"
|
|
313
|
-
|
|
314
|
-
doc.find_cell_by_label("성명") # {"matches": [...], "count": 1}
|
|
315
|
-
doc.fill_by_path({
|
|
316
|
-
"성명 > right": "홍길동",
|
|
317
|
-
"소속 > right": "플랫폼팀",
|
|
318
|
-
})
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
`doc.paragraphs`의 인덱스는 본문 직속 문단 0-based 기준입니다. 표 안 문단은
|
|
322
|
-
본문 `paragraph_index`에 섞지 않고 `get_table_map()`의 cell `location`
|
|
323
|
-
(`table_index`, `row`, `col`, `cell_paragraph_index`)으로 다룹니다.
|
|
324
|
-
`get_table_map()`은 `caption_text`와 `preceding_paragraph_text`를 분리해
|
|
325
|
-
반환하고, 셀 미리보기의 여러 문단은 `\n`으로 유지합니다.
|
|
326
|
-
|
|
327
|
-
### 🔍 텍스트 추출 & 검색
|
|
328
|
-
|
|
329
|
-
```python
|
|
330
|
-
from hwpx import TextExtractor, ObjectFinder
|
|
331
|
-
|
|
332
|
-
# 텍스트 추출
|
|
333
|
-
with TextExtractor("문서.hwpx") as extractor:
|
|
334
|
-
for section in extractor.iter_sections():
|
|
335
|
-
for para in extractor.iter_paragraphs(section):
|
|
336
|
-
print(para.text())
|
|
337
|
-
|
|
338
|
-
# 특정 객체 탐색
|
|
339
|
-
for obj in ObjectFinder("문서.hwpx").find_all(tag="tbl"):
|
|
340
|
-
print(obj.tag, obj.path)
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
`hp:tab`과 `ctrl id="tab"`은 탭 문자(`\t`)로 보존됩니다. 따라서 `Paragraph.text`, `TextExtractor`, `export_text()`/`export_html()`/`export_markdown()` 경로에서 같은 탭 의미를 유지한 채 roundtrip 할 수 있습니다. 필요하면 `preserve_breaks=False`로 줄바꿈/탭을 공백 기반으로 평탄화할 수 있습니다.
|
|
344
|
-
|
|
345
|
-
### 🎨 스타일 기반 텍스트 치환
|
|
346
|
-
|
|
347
|
-
서식(색상, 밑줄, charPrIDRef)으로 런을 필터링해 선택적으로 교체합니다.
|
|
348
|
-
|
|
349
|
-
```python
|
|
350
|
-
# 빨간색 텍스트만 찾아서 치환
|
|
351
|
-
doc.replace_text_in_runs(
|
|
352
|
-
"임시", "확정",
|
|
353
|
-
text_color="#FF0000",
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
# 특정 서식의 런 검색
|
|
357
|
-
runs = doc.find_runs_by_style(underline_type="SINGLE")
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
### 📤 내보내기
|
|
361
|
-
|
|
362
|
-
```python
|
|
363
|
-
# 텍스트, HTML, Markdown으로 변환
|
|
364
|
-
text = doc.export_text()
|
|
365
|
-
html = doc.export_html()
|
|
366
|
-
md = doc.export_markdown()
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
### 🏗️ 저수준 XML 제어
|
|
370
|
-
|
|
371
|
-
OWPML 스키마에 매핑된 데이터클래스로 XML 구조를 직접 다룹니다.
|
|
372
|
-
|
|
373
|
-
```python
|
|
374
|
-
# 헤더 참조 목록
|
|
375
|
-
doc.border_fills # 테두리 채우기
|
|
376
|
-
doc.bullets # 글머리표
|
|
377
|
-
doc.styles # 스타일
|
|
378
|
-
doc.track_changes # 변경 추적
|
|
379
|
-
|
|
380
|
-
# 바탕쪽·이력·버전 파트
|
|
381
|
-
doc.master_pages
|
|
382
|
-
doc.histories
|
|
383
|
-
doc.version
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
## 아키텍처
|
|
387
|
-
|
|
388
|
-
```
|
|
389
|
-
python-hwpx
|
|
390
|
-
├── hwpx.document # 고수준 편집 API (HwpxDocument)
|
|
391
|
-
├── hwpx.opc # OPC 컨테이너 읽기/쓰기 (원자적 저장, ZIP 무결성 검증)
|
|
392
|
-
├── hwpx.oxml # OWPML XML ↔ 데이터클래스 매핑
|
|
393
|
-
│ ├── document.py # 섹션, 문단, 표, 런, 메모, 도형, 노트
|
|
394
|
-
│ ├── header.py # 헤더 참조 목록 (스타일, 글머리표, 변경추적 등)
|
|
395
|
-
│ ├── body.py # 타입이 지정된 본문 모델
|
|
396
|
-
│ └── common.py # 범용 XML ↔ 데이터클래스
|
|
397
|
-
├── hwpx.tools
|
|
398
|
-
│ ├── archive_cli # unpack/pack CLI 및 재패킹 메타데이터
|
|
399
|
-
│ ├── text_extractor # 텍스트 추출 파이프라인
|
|
400
|
-
│ ├── text_extract_cli # 텍스트 추출 CLI
|
|
401
|
-
│ ├── object_finder # 객체 탐색 유틸리티
|
|
402
|
-
│ ├── exporter # 텍스트/HTML/Markdown 내보내기
|
|
403
|
-
│ ├── validator # 스키마 유효성 검사 (hwpx-validate CLI)
|
|
404
|
-
│ ├── package_validator# ZIP/OPC/HWPX 구조 검사
|
|
405
|
-
│ ├── page_guard # 구조 변화 징후 점검
|
|
406
|
-
│ └── template_analyzer# 레퍼런스 문서 분석/추출
|
|
407
|
-
└── hwpx.templates # 내장 빈 문서 템플릿
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
## 문서
|
|
412
|
-
|
|
413
|
-
| | |
|
|
414
|
-
|---|---|
|
|
415
|
-
| **[📖 전체 문서](https://airmang.github.io/python-hwpx/)** | Sphinx 기반 API 레퍼런스, 사용 가이드, FAQ |
|
|
416
|
-
| **[🚀 빠른 시작](https://airmang.github.io/python-hwpx/quickstart.html)** | 5분 안에 HWPX 문서 다루기 |
|
|
417
|
-
| **[📚 사용 가이드](https://airmang.github.io/python-hwpx/usage.html)** | 50+ 실전 사용 패턴 |
|
|
418
|
-
| **[🔧 API 레퍼런스](https://airmang.github.io/python-hwpx/api_reference.html)** | 클래스·메서드 상세 명세 |
|
|
419
|
-
| **[📐 스키마 개요](https://airmang.github.io/python-hwpx/schema-overview.html)** | OWPML 스키마 구조 설명 |
|
|
420
|
-
| **[🧪 스택 통합 자료](shared/hwpx/README.md)** | fixture, smoke, validation, compatibility 운영 자료 |
|
|
421
|
-
|
|
422
|
-
## 지원 포맷
|
|
423
|
-
|
|
424
|
-
| 포맷 | 확장자 | 읽기 | 쓰기 |
|
|
425
|
-
|------|--------|------|------|
|
|
426
|
-
| HWPX | `.hwpx` | ✅ | ✅ |
|
|
427
|
-
| HWP | `.hwp` | ❌ | ❌ |
|
|
428
|
-
|
|
429
|
-
> **Note:** HWP(v5 바이너리) 파일은 지원하지 않습니다. 한컴오피스에서 HWPX로 변환 후 사용하세요.
|
|
430
|
-
|
|
431
|
-
## 요구 사항
|
|
432
|
-
|
|
433
|
-
- Python 3.10+
|
|
434
|
-
- lxml ≥ 4.9
|
|
435
|
-
|
|
436
|
-
## 알려진 제약
|
|
437
|
-
|
|
438
|
-
- `add_shape()` / `add_control()`은 한/글이 요구하는 모든 하위 요소를 생성하지 않습니다.
|
|
439
|
-
복잡한 개체를 추가할 때는 한/글에서 열어 검증해 주세요.
|
|
440
|
-
- 이미지 삽입 시 바이너리 임베드는 지원하지만, `<hp:pic>` 요소의 완전한 자동 생성은 제공하지 않습니다.
|
|
441
|
-
- 암호화된 HWPX 파일의 암복호화는 지원하지 않습니다.
|
|
442
|
-
|
|
443
|
-
## 기여하기
|
|
444
|
-
|
|
445
|
-
버그 리포트, 기능 제안, PR 모두 환영합니다.
|
|
446
|
-
개발 환경 설정과 테스트 방법은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
|
447
|
-
|
|
448
|
-
```bash
|
|
449
|
-
git clone https://github.com/airmang/python-hwpx.git
|
|
450
|
-
cd python-hwpx
|
|
451
|
-
pip install -e ".[dev]"
|
|
452
|
-
pytest
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
머지된 기여자 목록은 [CONTRIBUTORS.md](CONTRIBUTORS.md)에서 확인할 수 있습니다.
|
|
456
|
-
|
|
457
|
-
## License
|
|
458
|
-
Apache License 2.0. See LICENSE and NOTICE.
|
|
459
|
-
|
|
460
|
-
<br>
|
|
461
|
-
|
|
462
|
-
## Maintainer
|
|
463
|
-
|
|
464
|
-
Primary maintainer/contact: **고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
465
|
-
|
|
466
|
-
- ✉️ [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
|
|
467
|
-
- 🐙 [@airmang](https://github.com/airmang)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|