Skip to content

Commit 662cb11

Browse files
committed
Release 2.8.2
1 parent 1464fe0 commit 662cb11

5 files changed

Lines changed: 57 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
모든 중요한 변경 사항은 이 문서에 기록됩니다. 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)[Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다.
44

5+
## [2.8.2] - 2026-03-08
6+
### 변경
7+
- README를 현재 공개 API와 CLI 범위에 맞춰 정리했습니다. Quick start, 텍스트 추출, 객체 검색 예시를 실제 호출 방식 기준으로 수정했습니다.
8+
- `add_memo()`/`add_memo_with_anchor()``HwpxDocument.new()`로 만든 실제 `lxml` 기반 문서에서도 동작하도록 memo XML 생성 경로를 엔진 호환 방식으로 정리했습니다.
9+
- 실제 빈 문서 템플릿에서 메모 추가 후 roundtrip 되는 회귀 테스트를 추가했습니다.
10+
511
## [2.8.1] - 2026-03-08
612
### 추가
713
- 템플릿 자동화 회귀 스위트를 추가했습니다 (`tests/template_automation/`). 단순 토큰, 반복 토큰, split-run, 공백 정규화, 표/머리글/바닥글/다중 섹션, 체크박스 토글, extract-repack, 비표준 rootfile 패턴을 대표 fixture + 시나리오 계약으로 점검합니다.
@@ -12,7 +18,6 @@
1218
- 섹션 속성(`secPr`)이 비어 있을 때 보강 생성하는 경로를 XML 엔진 호환 방식으로 정리했습니다.
1319
- `add_section()`이 새 섹션을 잘못된 네임스페이스로 만들던 문제를 수정했습니다.
1420
- mypy/pyright gradual scope에 이번에 추가한 template automation helper/generator 모듈을 포함했습니다.
15-
1621
## [2.8] - 2026-03-08
1722
### 변경
1823
- `HwpxPackage`와 OXML 로딩/저장이 rootfile/manifest-relative 경로를 실제로 따르도록 정렬했습니다.

README.md

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313

1414
---
1515

16-
`python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리입니다.
16+
`python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리이자 CLI 도구 모음입니다.
1717
한/글 설치 없이, OS에 관계없이 HWPX 문서의 구조를 파싱하고 콘텐츠를 조작할 수 있습니다.
18+
문서 편집 API뿐 아니라 스키마/패키지 검증, unpack/pack, 템플릿 분석 같은 XML-first 워크플로도 함께 제공합니다.
1819

1920
> **pyhwpx / pyhwp와 다른 점?**
2021
> | | python-hwpx | pyhwpx | pyhwp |
@@ -26,7 +27,7 @@
2627
2728
## 🌍 크로스 플랫폼 지원
2829

29-
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 완벽하게 읽고 수 있습니다.
30+
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 읽고 편집하는 워크플로를 구성할 수 있습니다.
3031

3132
| 플랫폼 | 읽기 | 쓰기 | 비고 |
3233
|--------|------|------|------|
@@ -48,24 +49,23 @@ pip install python-hwpx
4849
```python
4950
from hwpx import HwpxDocument
5051

51-
# 기존 문서 열기
52-
doc = HwpxDocument.open("보고서.hwpx")
53-
5452
# 빈 문서 새로 만들기
5553
doc = HwpxDocument.new()
5654

55+
# 기존 문서를 수정하려면:
56+
# doc = HwpxDocument.open("보고서.hwpx")
57+
5758
# 문단 추가
58-
doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
59+
paragraph = doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
5960

6061
# 표 추가 (2×3)
6162
table = doc.add_table(rows=2, cols=3)
6263
table.set_cell_text(0, 0, "이름")
6364
table.set_cell_text(0, 1, "부서")
6465
table.set_cell_text(0, 2, "연락처")
6566

66-
# 메모 추가 (한/글에서 바로 표시)
67-
paragraph = doc.paragraphs[0]
68-
doc.add_memo_with_anchor("검토 필요", paragraph=paragraph)
67+
# 메모 추가 (기본 템플릿의 memo shape 사용)
68+
doc.add_memo_with_anchor("검토 필요", paragraph=paragraph, memo_shape_id_ref="0")
6969

7070
# 저장
7171
doc.save_to_path("결과물.hwpx")
@@ -99,15 +99,15 @@ doc.save_to_path("결과물.hwpx")
9999
| 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
100100
| 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
101101
|**유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
102-
| 🧰 **작업 도구** | unpack/pack/분석/비교 | 패키지 점검과 재구성 작업 보조 |
102+
| 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
103103
| 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
104104
| 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 20162011 자동 변환 |
105105
106106
## 기능 상세
107107
108108
### 📄 문서 편집
109109
110-
문단, 표, 메모, 머리말/꼬리말을 Python 객체로 다룹니다.
110+
문단, 표, 메모, 머리글/바닥글을 Python 객체로 다룹니다.
111111
112112
```python
113113
# 단락 추가·삭제
@@ -119,9 +119,9 @@ new_sec = doc.add_section() # 문서 끝에 섹션 추가
119119
new_sec.add_paragraph("두 번째 섹션 내용")
120120
doc.remove_section(1) # 인덱스로 섹션 삭제
121121
122-
# 머리말·꼬리말
122+
# 머리글·바닥글
123123
doc.set_header_text("기밀 문서", page_type="BOTH")
124-
doc.set_footer_text("— 1 —", page_type="BOTH")
124+
doc.set_footer_text("1 / 10", page_type="BOTH")
125125
126126
# 표 셀 병합·분할
127127
table.merge_cells(0, 0, 1, 1) # (0,0)~(1,1) 병합
@@ -134,13 +134,14 @@ table.set_cell_text(0, 0, "병합된 셀", logical=True, split_merged=True)
134134
from hwpx import TextExtractor, ObjectFinder
135135

136136
# 텍스트 추출
137-
for section in TextExtractor("문서.hwpx"):
138-
for para in section.paragraphs:
139-
print(para.text)
137+
with TextExtractor("문서.hwpx") as extractor:
138+
for section in extractor.iter_sections():
139+
for para in extractor.iter_paragraphs(section):
140+
print(para.text())
140141

141142
# 특정 객체 탐색
142-
for obj in ObjectFinder("문서.hwpx").find("tbl"):
143-
print(obj.tag, obj.attributes)
143+
for obj in ObjectFinder("문서.hwpx").find_all(tag="tbl"):
144+
print(obj.tag, obj.path)
144145
```
145146

146147
### 🎨 스타일 기반 텍스트 치환

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-hwpx"
7-
version = "2.8.1"
7+
version = "2.8.2"
88
description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
99
readme = { file = "README.md", content-type = "text/markdown" }
1010
license = { file = "LICENSE" }

src/hwpx/oxml/document.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def _create_paragraph_element(
129129
style_id_ref: str | int | None = None,
130130
paragraph_attributes: Optional[dict[str, str]] = None,
131131
run_attributes: Optional[dict[str, str]] = None,
132+
parent: ET.Element | None = None,
132133
) -> ET.Element:
133134
"""Return a paragraph element populated with a single run and text node."""
134135

@@ -140,16 +141,21 @@ def _create_paragraph_element(
140141
if style_id_ref is not None:
141142
attrs["styleIDRef"] = str(style_id_ref)
142143

143-
paragraph = ET.Element(f"{_HP}p", attrs)
144+
if parent is None:
145+
paragraph = ET.Element(f"{_HP}p", attrs)
146+
else:
147+
paragraph = parent.makeelement(f"{_HP}p", attrs)
144148

145149
run_attrs: dict[str, str] = dict(run_attributes or {})
146150
if char_pr_id_ref is not None:
147151
run_attrs.setdefault("charPrIDRef", str(char_pr_id_ref))
148152
else:
149153
run_attrs.setdefault("charPrIDRef", "0")
150154

151-
run = ET.SubElement(paragraph, f"{_HP}run", run_attrs)
152-
text_element = ET.SubElement(run, f"{_HP}t")
155+
run = paragraph.makeelement(f"{_HP}run", run_attrs)
156+
paragraph.append(run)
157+
text_element = run.makeelement(f"{_HP}t", {})
158+
run.append(text_element)
153159
text_element.text = text
154160
return paragraph
155161

@@ -1368,7 +1374,7 @@ def add_memo(
13681374
memo_attrs.setdefault("id", memo_id or _memo_id())
13691375
if memo_shape_id_ref is not None:
13701376
memo_attrs.setdefault("memoShapeIDRef", str(memo_shape_id_ref))
1371-
memo_element = ET.SubElement(self.element, f"{_HP}memo", memo_attrs)
1377+
memo_element = _append_child(self.element, f"{_HP}memo", memo_attrs)
13721378
memo = HwpxOxmlMemo(memo_element, self)
13731379
memo.set_text(text, char_pr_id_ref=char_pr_id_ref)
13741380
self.section.mark_dirty()
@@ -1472,10 +1478,11 @@ def set_text(
14721478
for child in list(self.element):
14731479
if _element_local_name(child) in {"paraList", "p"}:
14741480
self.element.remove(child)
1475-
para_list = ET.SubElement(self.element, f"{_HP}paraList")
1481+
para_list = _append_child(self.element, f"{_HP}paraList", {})
14761482
paragraph = _create_paragraph_element(
14771483
desired,
14781484
char_pr_id_ref=existing_char if existing_char is not None else "0",
1485+
parent=para_list,
14791486
)
14801487
para_list.append(paragraph)
14811488
self.group.section.mark_dirty()
@@ -3542,7 +3549,7 @@ def paragraphs(self) -> list[HwpxOxmlParagraph]:
35423549
def _memo_group_element(self, create: bool = False) -> ET.Element | None:
35433550
element = self._element.find(f"{_HP}memogroup")
35443551
if element is None and create:
3545-
element = ET.SubElement(self._element, f"{_HP}memogroup")
3552+
element = _append_child(self._element, f"{_HP}memogroup", {})
35463553
self.mark_dirty()
35473554
return element
35483555

tests/test_memo_and_style_editing.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,24 @@ def test_add_memo_with_anchor_creates_paragraph_when_missing() -> None:
238238
assert field_end.get("beginIDRef") == "field-02"
239239

240240

241+
def test_add_memo_with_anchor_roundtrips_on_real_document() -> None:
242+
document = HwpxDocument.new()
243+
paragraph = document.add_paragraph("Quick start anchor")
244+
245+
memo, anchored, field_id = document.add_memo_with_anchor(
246+
"Quick start memo",
247+
paragraph=paragraph,
248+
memo_shape_id_ref="0",
249+
)
250+
251+
assert anchored is paragraph
252+
assert memo.text == "Quick start memo"
253+
assert field_id
254+
255+
reopened = HwpxDocument.open(document.to_bytes())
256+
assert any(item.text == "Quick start memo" for item in reopened.memos)
257+
258+
241259
def test_document_ensure_run_style_creates_bold_entry() -> None:
242260
document, _, header = _build_document()
243261

0 commit comments

Comments
 (0)