python-hwpx 2.7.1__tar.gz → 2.8.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/PKG-INFO +32 -24
  2. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/README.md +31 -23
  3. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/pyproject.toml +30 -4
  4. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/opc/package.py +62 -97
  5. python_hwpx-2.8.2/src/hwpx/opc/relationships.py +227 -0
  6. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/document.py +34 -18
  7. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/archive_cli.py +35 -11
  8. python_hwpx-2.8.2/src/hwpx/tools/package_validator.py +352 -0
  9. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/page_guard.py +12 -40
  10. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/template_analyzer.py +35 -19
  11. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/text_extractor.py +44 -27
  12. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/PKG-INFO +32 -24
  13. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/SOURCES.txt +1 -0
  14. python_hwpx-2.8.2/tests/test_gap_closure_tools.py +548 -0
  15. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_memo_and_style_editing.py +18 -0
  16. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_section_headers.py +17 -0
  17. python_hwpx-2.7.1/src/hwpx/tools/package_validator.py +0 -219
  18. python_hwpx-2.7.1/tests/test_gap_closure_tools.py +0 -221
  19. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/LICENSE +0 -0
  20. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/setup.cfg +0 -0
  21. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/__init__.py +0 -0
  22. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/data/Skeleton.hwpx +0 -0
  23. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/document.py +0 -0
  24. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/opc/xml_utils.py +0 -0
  25. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/__init__.py +0 -0
  26. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/body.py +0 -0
  27. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/common.py +0 -0
  28. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/header.py +0 -0
  29. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/header_part.py +0 -0
  30. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/memo.py +0 -0
  31. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/namespaces.py +0 -0
  32. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/paragraph.py +0 -0
  33. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/parser.py +0 -0
  34. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/schema.py +0 -0
  35. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/section.py +0 -0
  36. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/table.py +0 -0
  37. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/utils.py +0 -0
  38. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/package.py +0 -0
  39. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/py.typed +0 -0
  40. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/templates.py +0 -0
  41. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/__init__.py +0 -0
  42. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/_schemas/header.xsd +0 -0
  43. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/_schemas/section.xsd +0 -0
  44. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/exporter.py +0 -0
  45. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/object_finder.py +0 -0
  46. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/text_extract_cli.py +0 -0
  47. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/validator.py +0 -0
  48. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
  49. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/entry_points.txt +0 -0
  50. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/requires.txt +0 -0
  51. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/top_level.txt +0 -0
  52. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_coverage_targets.py +0 -0
  53. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_document_context_manager.py +0 -0
  54. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_document_formatting.py +0 -0
  55. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_document_save_api.py +0 -0
  56. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_inline_models.py +0 -0
  57. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_integration_hwpx_compatibility.py +0 -0
  58. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_integration_roundtrip.py +0 -0
  59. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_new_features.py +0 -0
  60. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_opc_package.py +0 -0
  61. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_oxml_parsing.py +0 -0
  62. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_packaging_py_typed.py +0 -0
  63. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_paragraph_section_management.py +0 -0
  64. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_repr_snapshots.py +0 -0
  65. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_split_merged_cell.py +0 -0
  66. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_tables_default_border.py +0 -0
  67. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_text_extractor_annotations.py +0 -0
  68. {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_version_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 2.7.1
3
+ Version: 2.8.2
4
4
  Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
5
5
  Author: python-hwpx Maintainers
6
6
  License: Non-Commercial License
@@ -80,8 +80,9 @@ Dynamic: license-file
80
80
 
81
81
  ---
82
82
 
83
- `python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리입니다.
83
+ `python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리이자 CLI 도구 모음입니다.
84
84
  한/글 설치 없이, OS에 관계없이 HWPX 문서의 구조를 파싱하고 콘텐츠를 조작할 수 있습니다.
85
+ 문서 편집 API뿐 아니라 스키마/패키지 검증, unpack/pack, 템플릿 분석 같은 XML-first 워크플로도 함께 제공합니다.
85
86
 
86
87
  > **pyhwpx / pyhwp와 다른 점?**
87
88
  > | | python-hwpx | pyhwpx | pyhwp |
@@ -93,7 +94,7 @@ Dynamic: license-file
93
94
 
94
95
  ## 🌍 크로스 플랫폼 지원
95
96
 
96
- HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 완벽하게 읽고 수 있습니다.
97
+ HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 읽고 편집하는 워크플로를 구성할 수 있습니다.
97
98
 
98
99
  | 플랫폼 | 읽기 | 쓰기 | 비고 |
99
100
  |--------|------|------|------|
@@ -115,14 +116,14 @@ pip install python-hwpx
115
116
  ```python
116
117
  from hwpx import HwpxDocument
117
118
 
118
- # 기존 문서 열기
119
- doc = HwpxDocument.open("보고서.hwpx")
120
-
121
119
  # 빈 문서 새로 만들기
122
120
  doc = HwpxDocument.new()
123
121
 
122
+ # 기존 문서를 수정하려면:
123
+ # doc = HwpxDocument.open("보고서.hwpx")
124
+
124
125
  # 문단 추가
125
- doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
126
+ paragraph = doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
126
127
 
127
128
  # 표 추가 (2×3)
128
129
  table = doc.add_table(rows=2, cols=3)
@@ -130,9 +131,8 @@ table.set_cell_text(0, 0, "이름")
130
131
  table.set_cell_text(0, 1, "부서")
131
132
  table.set_cell_text(0, 2, "연락처")
132
133
 
133
- # 메모 추가 (한/글에서 바로 표시)
134
- paragraph = doc.paragraphs[0]
135
- doc.add_memo_with_anchor("검토 필요", paragraph=paragraph)
134
+ # 메모 추가 (기본 템플릿의 memo shape 사용)
135
+ doc.add_memo_with_anchor("검토 필요", paragraph=paragraph, memo_shape_id_ref="0")
136
136
 
137
137
  # 저장
138
138
  doc.save_to_path("결과물.hwpx")
@@ -166,7 +166,7 @@ doc.save_to_path("결과물.hwpx")
166
166
  | 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
167
167
  | 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
168
168
  | ✅ **유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
169
- | 🧰 **워크플로 도구** | unpack/pack/template analyze/page guard | 템플릿 보존형 XML-first 작업 보조 |
169
+ | 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
170
170
  | 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
171
171
  | 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
172
172
 
@@ -174,7 +174,7 @@ doc.save_to_path("결과물.hwpx")
174
174
 
175
175
  ### 📄 문서 편집
176
176
 
177
- 문단, 표, 메모, 머리말/꼬리말을 Python 객체로 다룹니다.
177
+ 문단, 표, 메모, 머리글/바닥글을 Python 객체로 다룹니다.
178
178
 
179
179
  ```python
180
180
  # 단락 추가·삭제
@@ -186,9 +186,9 @@ new_sec = doc.add_section() # 문서 끝에 섹션 추가
186
186
  new_sec.add_paragraph("두 번째 섹션 내용")
187
187
  doc.remove_section(1) # 인덱스로 섹션 삭제
188
188
 
189
- # 머리말·꼬리말
189
+ # 머리글·바닥글
190
190
  doc.set_header_text("기밀 문서", page_type="BOTH")
191
- doc.set_footer_text("1 ", page_type="BOTH")
191
+ doc.set_footer_text("1 / 10", page_type="BOTH")
192
192
 
193
193
  # 표 셀 병합·분할
194
194
  table.merge_cells(0, 0, 1, 1) # (0,0)~(1,1) 병합
@@ -201,13 +201,14 @@ table.set_cell_text(0, 0, "병합된 셀", logical=True, split_merged=True)
201
201
  from hwpx import TextExtractor, ObjectFinder
202
202
 
203
203
  # 텍스트 추출
204
- for section in TextExtractor("문서.hwpx"):
205
- for para in section.paragraphs:
206
- print(para.text)
204
+ with TextExtractor("문서.hwpx") as extractor:
205
+ for section in extractor.iter_sections():
206
+ for para in extractor.iter_paragraphs(section):
207
+ print(para.text())
207
208
 
208
209
  # 특정 객체 탐색
209
- for obj in ObjectFinder("문서.hwpx").find("tbl"):
210
- print(obj.tag, obj.attributes)
210
+ for obj in ObjectFinder("문서.hwpx").find_all(tag="tbl"):
211
+ print(obj.tag, obj.path)
211
212
  ```
212
213
 
213
214
  ### 🎨 스타일 기반 텍스트 치환
@@ -270,7 +271,7 @@ python-hwpx
270
271
  │ ├── exporter # 텍스트/HTML/Markdown 내보내기
271
272
  │ ├── validator # 스키마 유효성 검사 (hwpx-validate CLI)
272
273
  │ ├── package_validator# ZIP/OPC/HWPX 구조 검사
273
- │ ├── page_guard # layout-drift proxy
274
+ │ ├── page_guard # 구조 변화 징후 점검
274
275
  │ └── template_analyzer# 레퍼런스 문서 분석/추출
275
276
  └── hwpx.templates # 내장 빈 문서 템플릿
276
277
  ```
@@ -284,21 +285,28 @@ hwpx-validate 문서.hwpx
284
285
  # ZIP/OPC/HWPX 패키지 구조 검사
285
286
  hwpx-validate-package 문서.hwpx
286
287
 
287
- # HWPX 풀기 / 다시 묶기
288
+ # HWPX 풀기 / 다시 묶기 (기본값: XML/HWPF 바이트 보존)
288
289
  hwpx-unpack 문서.hwpx ./unpacked
290
+ hwpx-unpack 문서.hwpx ./pretty-unpacked --pretty-xml
289
291
  hwpx-pack ./unpacked ./repacked.hwpx
290
292
 
291
- # 레퍼런스 템플릿 분석과 파트 추출
293
+ # 레퍼런스 문서 분석과 작업 디렉터리 추출
292
294
  hwpx-analyze-template 문서.hwpx --extract-dir ./template-parts --json
295
+ hwpx-pack ./template-parts ./template-roundtrip.hwpx
296
+ hwpx-validate-package ./template-roundtrip.hwpx
293
297
 
294
298
  # plain / markdown 텍스트 추출
295
299
  hwpx-text-extract 문서.hwpx --format markdown --output 문서.md
296
300
 
297
- # 레이아웃 드리프트 프록시 비교
301
+ # 문서 구조 변화 징후 비교
298
302
  hwpx-page-guard --reference 원본.hwpx --output 결과.hwpx
299
303
  ```
300
304
 
301
- `hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 통계를 비교해 레이아웃 드리프트 위험을 탐지하는 프록시 도구입니다.
305
+ `hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 같은 구조 지표를 비교해 편집 전후 변화 징후를 빠르게 점검합니다.
306
+
307
+ `hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를 전제로 두지 않고, `META-INF/container.xml`과 실제 rootfile/manifest 선언을 따라가며 패키지 구조를 확인합니다. 엔진이 열 수 있는 비표준 패키지는 가능한 경우 경고로 분리해 보여줍니다.
308
+
309
+ `hwpx-analyze-template --extract-dir`는 다시 묶고 점검하기 쉬운 작업 디렉터리를 만듭니다. 재구성과 구조 검증에 필요한 파일을 함께 꺼내는 용도이며, 편집기에서의 최종 렌더링 결과까지 보장한다는 뜻은 아닙니다.
302
310
 
303
311
  ## 문서
304
312
 
@@ -13,8 +13,9 @@
13
13
 
14
14
  ---
15
15
 
16
- `python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리입니다.
16
+ `python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리이자 CLI 도구 모음입니다.
17
17
  한/글 설치 없이, OS에 관계없이 HWPX 문서의 구조를 파싱하고 콘텐츠를 조작할 수 있습니다.
18
+ 문서 편집 API뿐 아니라 스키마/패키지 검증, unpack/pack, 템플릿 분석 같은 XML-first 워크플로도 함께 제공합니다.
18
19
 
19
20
  > **pyhwpx / pyhwp와 다른 점?**
20
21
  > | | python-hwpx | pyhwpx | pyhwp |
@@ -26,7 +27,7 @@
26
27
 
27
28
  ## 🌍 크로스 플랫폼 지원
28
29
 
29
- HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 완벽하게 읽고 수 있습니다.
30
+ HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 읽고 편집하는 워크플로를 구성할 수 있습니다.
30
31
 
31
32
  | 플랫폼 | 읽기 | 쓰기 | 비고 |
32
33
  |--------|------|------|------|
@@ -48,14 +49,14 @@ pip install python-hwpx
48
49
  ```python
49
50
  from hwpx import HwpxDocument
50
51
 
51
- # 기존 문서 열기
52
- doc = HwpxDocument.open("보고서.hwpx")
53
-
54
52
  # 빈 문서 새로 만들기
55
53
  doc = HwpxDocument.new()
56
54
 
55
+ # 기존 문서를 수정하려면:
56
+ # doc = HwpxDocument.open("보고서.hwpx")
57
+
57
58
  # 문단 추가
58
- doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
59
+ paragraph = doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
59
60
 
60
61
  # 표 추가 (2×3)
61
62
  table = doc.add_table(rows=2, cols=3)
@@ -63,9 +64,8 @@ table.set_cell_text(0, 0, "이름")
63
64
  table.set_cell_text(0, 1, "부서")
64
65
  table.set_cell_text(0, 2, "연락처")
65
66
 
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")
69
69
 
70
70
  # 저장
71
71
  doc.save_to_path("결과물.hwpx")
@@ -99,7 +99,7 @@ doc.save_to_path("결과물.hwpx")
99
99
  | 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
100
100
  | 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
101
101
  | ✅ **유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
102
- | 🧰 **워크플로 도구** | unpack/pack/template analyze/page guard | 템플릿 보존형 XML-first 작업 보조 |
102
+ | 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
103
103
  | 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
104
104
  | 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
105
105
 
@@ -107,7 +107,7 @@ doc.save_to_path("결과물.hwpx")
107
107
 
108
108
  ### 📄 문서 편집
109
109
 
110
- 문단, 표, 메모, 머리말/꼬리말을 Python 객체로 다룹니다.
110
+ 문단, 표, 메모, 머리글/바닥글을 Python 객체로 다룹니다.
111
111
 
112
112
  ```python
113
113
  # 단락 추가·삭제
@@ -119,9 +119,9 @@ new_sec = doc.add_section() # 문서 끝에 섹션 추가
119
119
  new_sec.add_paragraph("두 번째 섹션 내용")
120
120
  doc.remove_section(1) # 인덱스로 섹션 삭제
121
121
 
122
- # 머리말·꼬리말
122
+ # 머리글·바닥글
123
123
  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")
125
125
 
126
126
  # 표 셀 병합·분할
127
127
  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)
134
134
  from hwpx import TextExtractor, ObjectFinder
135
135
 
136
136
  # 텍스트 추출
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())
140
141
 
141
142
  # 특정 객체 탐색
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)
144
145
  ```
145
146
 
146
147
  ### 🎨 스타일 기반 텍스트 치환
@@ -203,7 +204,7 @@ python-hwpx
203
204
  │ ├── exporter # 텍스트/HTML/Markdown 내보내기
204
205
  │ ├── validator # 스키마 유효성 검사 (hwpx-validate CLI)
205
206
  │ ├── package_validator# ZIP/OPC/HWPX 구조 검사
206
- │ ├── page_guard # layout-drift proxy
207
+ │ ├── page_guard # 구조 변화 징후 점검
207
208
  │ └── template_analyzer# 레퍼런스 문서 분석/추출
208
209
  └── hwpx.templates # 내장 빈 문서 템플릿
209
210
  ```
@@ -217,21 +218,28 @@ hwpx-validate 문서.hwpx
217
218
  # ZIP/OPC/HWPX 패키지 구조 검사
218
219
  hwpx-validate-package 문서.hwpx
219
220
 
220
- # HWPX 풀기 / 다시 묶기
221
+ # HWPX 풀기 / 다시 묶기 (기본값: XML/HWPF 바이트 보존)
221
222
  hwpx-unpack 문서.hwpx ./unpacked
223
+ hwpx-unpack 문서.hwpx ./pretty-unpacked --pretty-xml
222
224
  hwpx-pack ./unpacked ./repacked.hwpx
223
225
 
224
- # 레퍼런스 템플릿 분석과 파트 추출
226
+ # 레퍼런스 문서 분석과 작업 디렉터리 추출
225
227
  hwpx-analyze-template 문서.hwpx --extract-dir ./template-parts --json
228
+ hwpx-pack ./template-parts ./template-roundtrip.hwpx
229
+ hwpx-validate-package ./template-roundtrip.hwpx
226
230
 
227
231
  # plain / markdown 텍스트 추출
228
232
  hwpx-text-extract 문서.hwpx --format markdown --output 문서.md
229
233
 
230
- # 레이아웃 드리프트 프록시 비교
234
+ # 문서 구조 변화 징후 비교
231
235
  hwpx-page-guard --reference 원본.hwpx --output 결과.hwpx
232
236
  ```
233
237
 
234
- `hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 통계를 비교해 레이아웃 드리프트 위험을 탐지하는 프록시 도구입니다.
238
+ `hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 같은 구조 지표를 비교해 편집 전후 변화 징후를 빠르게 점검합니다.
239
+
240
+ `hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를 전제로 두지 않고, `META-INF/container.xml`과 실제 rootfile/manifest 선언을 따라가며 패키지 구조를 확인합니다. 엔진이 열 수 있는 비표준 패키지는 가능한 경우 경고로 분리해 보여줍니다.
241
+
242
+ `hwpx-analyze-template --extract-dir`는 다시 묶고 점검하기 쉬운 작업 디렉터리를 만듭니다. 재구성과 구조 검증에 필요한 파일을 함께 꺼내는 용도이며, 편집기에서의 최종 렌더링 결과까지 보장한다는 뜻은 아닙니다.
235
243
 
236
244
  ## 문서
237
245
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-hwpx"
7
- version = "2.7.1"
7
+ version = "2.8.2"
8
8
  description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  license = { file = "LICENSE" }
@@ -78,7 +78,20 @@ testpaths = ["tests"]
78
78
 
79
79
  [tool.mypy]
80
80
  python_version = "3.10"
81
- files = ["src/hwpx/document.py", "src/hwpx/oxml/document.py"]
81
+ files = [
82
+ "src/hwpx/document.py",
83
+ "src/hwpx/oxml/document.py",
84
+ "src/hwpx/opc/package.py",
85
+ "src/hwpx/opc/relationships.py",
86
+ "src/hwpx/tools/archive_cli.py",
87
+ "src/hwpx/tools/package_validator.py",
88
+ "src/hwpx/tools/page_guard.py",
89
+ "src/hwpx/tools/template_analyzer.py",
90
+ "src/hwpx/tools/text_extract_cli.py",
91
+ "src/hwpx/tools/text_extractor.py",
92
+ "tests/template_automation/helpers.py",
93
+ "tests/template_automation/generate_fixtures.py",
94
+ ]
82
95
  ignore_missing_imports = true
83
96
 
84
97
  [[tool.mypy.overrides]]
@@ -86,7 +99,20 @@ module = ["hwpx.document", "hwpx.oxml.document"]
86
99
  ignore_errors = true
87
100
 
88
101
  [tool.pyright]
89
- include = ["src/hwpx/document.py", "src/hwpx/oxml/document.py"]
102
+ # Keep basic pyright focused on OPC/tooling modules that currently pass
103
+ # without suppressing their real diagnostics.
104
+ include = [
105
+ "src/hwpx/opc/package.py",
106
+ "src/hwpx/opc/relationships.py",
107
+ "src/hwpx/tools/archive_cli.py",
108
+ "src/hwpx/tools/package_validator.py",
109
+ "src/hwpx/tools/page_guard.py",
110
+ "src/hwpx/tools/template_analyzer.py",
111
+ "src/hwpx/tools/text_extract_cli.py",
112
+ "src/hwpx/tools/text_extractor.py",
113
+ "tests/template_automation/helpers.py",
114
+ "tests/template_automation/generate_fixtures.py",
115
+ ]
90
116
  pythonVersion = "3.10"
91
- typeCheckingMode = "off"
117
+ typeCheckingMode = "basic"
92
118
  reportMissingTypeStubs = false
@@ -7,12 +7,21 @@ import io
7
7
  import os
8
8
  import tempfile
9
9
  from dataclasses import dataclass
10
- from pathlib import Path
10
+ from pathlib import Path, PurePosixPath
11
11
  from typing import BinaryIO, Iterable, Iterator, Mapping, MutableMapping
12
12
  from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
13
13
 
14
- from lxml import etree
14
+ from lxml import etree # type: ignore[reportAttributeAccessIssue]
15
15
 
16
+ from .relationships import (
17
+ MAIN_ROOTFILE_MEDIA_TYPE,
18
+ OPF_NS,
19
+ is_header_part_name,
20
+ is_section_part_name,
21
+ normalize_part_name,
22
+ parse_container_rootfiles,
23
+ parse_manifest_relationships,
24
+ )
16
25
  from .xml_utils import (
17
26
  extract_xml_declaration,
18
27
  iter_declared_namespaces,
@@ -24,8 +33,6 @@ __all__ = ["HwpxPackage", "HwpxPackageError", "HwpxStructureError", "RootFile",
24
33
 
25
34
  logger = logging.getLogger(__name__)
26
35
 
27
- _OPF_NS = "http://www.idpf.org/2007/opf/"
28
-
29
36
 
30
37
  class HwpxPackageError(Exception):
31
38
  """Base error raised for issues related to :class:`HwpxPackage`."""
@@ -169,25 +176,10 @@ class HwpxPackage:
169
176
  except Exception:
170
177
  logger.exception("container.xml 파싱에 실패했습니다.")
171
178
  raise
172
- rootfiles = []
173
- for elem in root.findall(".//{*}rootfile"):
174
- full_path_attr = elem.get("full-path")
175
- full_path = full_path_attr or elem.get("fullPath") or elem.get("full_path")
176
- if full_path and not full_path_attr:
177
- logger.warning(
178
- "container.xml rootfile이 비표준 경로 속성명을 사용했습니다: %s",
179
- elem.attrib,
180
- )
181
- if not full_path:
182
- raise HwpxStructureError("container.xml contains a rootfile without 'full-path'.")
183
- media_type_attr = elem.get("media-type")
184
- media_type = media_type_attr or elem.get("mediaType") or elem.get("media_type")
185
- if media_type and not media_type_attr:
186
- logger.warning(
187
- "container.xml rootfile이 비표준 media-type 속성명을 사용했습니다: %s",
188
- elem.attrib,
189
- )
190
- rootfiles.append(RootFile(full_path, media_type))
179
+ rootfiles = [
180
+ RootFile(ref.full_path, ref.media_type)
181
+ for ref in parse_container_rootfiles(root)
182
+ ]
191
183
  if not rootfiles:
192
184
  raise HwpxStructureError("container.xml does not declare any rootfiles.")
193
185
  return rootfiles
@@ -201,10 +193,6 @@ class HwpxPackage:
201
193
  def _validate_structure(self) -> None:
202
194
  for rootfile in self._rootfiles:
203
195
  rootfile.ensure_exists(self._files)
204
- if not any(path.startswith(("Contents/", "Content/")) for path in self._files):
205
- raise HwpxStructureError(
206
- "HWPX package does not contain a 'Contents' directory."
207
- )
208
196
 
209
197
  @property
210
198
  def mimetype(self) -> str:
@@ -220,7 +208,7 @@ class HwpxPackage:
220
208
  @property
221
209
  def main_content(self) -> RootFile:
222
210
  for rootfile in self._rootfiles:
223
- if rootfile.media_type == "application/hwpml-package+xml":
211
+ if rootfile.media_type == MAIN_ROOTFILE_MEDIA_TYPE:
224
212
  return rootfile
225
213
  selected = self._rootfiles[0]
226
214
  logger.warning(
@@ -254,7 +242,6 @@ class HwpxPackage:
254
242
  elif norm_path == self.VERSION_PATH:
255
243
  pending_version = self._parse_version(data)
256
244
  self._files[norm_path] = data
257
- self._invalidate_caches(norm_path)
258
245
  if norm_path == self.MIMETYPE_PATH:
259
246
  self._mimetype = mimetype
260
247
  elif norm_path == self.CONTAINER_PATH:
@@ -263,6 +250,7 @@ class HwpxPackage:
263
250
  elif norm_path == self.VERSION_PATH:
264
251
  assert pending_version is not None
265
252
  self._version = pending_version
253
+ self._invalidate_caches(norm_path)
266
254
  self._validate_structure()
267
255
 
268
256
  def delete(self, path: str) -> None:
@@ -274,11 +262,12 @@ class HwpxPackage:
274
262
  "Cannot remove mandatory files ('mimetype', 'container.xml', 'version.xml')."
275
263
  )
276
264
  del self._files[norm_path]
265
+ self._invalidate_caches(norm_path)
277
266
  self._validate_structure()
278
267
 
279
268
  @staticmethod
280
269
  def _normalize_path(path: str) -> str:
281
- return path.replace("\\", "/")
270
+ return normalize_part_name(path)
282
271
 
283
272
  def files(self) -> list[str]:
284
273
  return sorted(self._files)
@@ -314,13 +303,12 @@ class HwpxPackage:
314
303
 
315
304
  def manifest_tree(self) -> etree._Element:
316
305
  if self._manifest_tree is None:
317
- self._manifest_tree = self.get_xml(self.MANIFEST_PATH)
306
+ self._manifest_tree = self.get_xml(self.main_content.full_path)
318
307
  return self._manifest_tree
319
308
 
320
309
  def _manifest_items(self) -> list[etree._Element]:
321
310
  manifest = self.manifest_tree()
322
- ns = {"opf": _OPF_NS}
323
- return list(manifest.findall("./opf:manifest/opf:item", ns))
311
+ return list(manifest.findall("./opf:manifest/opf:item", OPF_NS))
324
312
 
325
313
  @staticmethod
326
314
  def _normalized_manifest_value(element: etree._Element) -> str:
@@ -339,52 +327,37 @@ class HwpxPackage:
339
327
 
340
328
  def _resolve_spine_paths(self) -> list[str]:
341
329
  if self._spine_cache is None:
342
- manifest = self.manifest_tree()
343
- ns = {"opf": _OPF_NS}
344
- manifest_items: dict[str, str] = {}
345
- for item in manifest.findall("./opf:manifest/opf:item", ns):
346
- item_id = item.attrib.get("id")
347
- href = item.attrib.get("href", "")
348
- if item_id and href:
349
- manifest_items[item_id] = href
350
- spine_paths: list[str] = []
351
- for itemref in manifest.findall("./opf:spine/opf:itemref", ns):
352
- idref = itemref.attrib.get("idref")
353
- if not idref:
354
- continue
355
- href = manifest_items.get(idref)
356
- if href:
357
- spine_paths.append(href)
358
- self._spine_cache = spine_paths
330
+ relationships = parse_manifest_relationships(
331
+ self.manifest_tree(),
332
+ self.main_content.full_path,
333
+ known_parts=self._files.keys(),
334
+ )
335
+ self._spine_cache = list(relationships.spine_paths)
359
336
  return self._spine_cache
360
337
 
361
338
  def section_paths(self) -> list[str]:
362
339
  if self._section_paths_cache is None:
363
- from pathlib import PurePosixPath
364
-
365
340
  paths = [
366
341
  path
367
342
  for path in self._resolve_spine_paths()
368
- if path and PurePosixPath(path).name.startswith("section")
343
+ if path and is_section_part_name(path)
369
344
  ]
370
345
  if not paths:
371
346
  logger.warning("manifest spine에서 section 경로를 찾지 못해 파일명 기반 fallback을 사용합니다.")
372
347
  paths = [
373
348
  name
374
349
  for name in self._files.keys()
375
- if PurePosixPath(name).name.startswith("section")
350
+ if is_section_part_name(name)
376
351
  ]
377
352
  self._section_paths_cache = paths
378
353
  return list(self._section_paths_cache)
379
354
 
380
355
  def header_paths(self) -> list[str]:
381
356
  if self._header_paths_cache is None:
382
- from pathlib import PurePosixPath
383
-
384
357
  paths = [
385
358
  path
386
359
  for path in self._resolve_spine_paths()
387
- if path and PurePosixPath(path).name.startswith("header")
360
+ if path and is_header_part_name(path)
388
361
  ]
389
362
  if not paths and self.has_part(self.HEADER_PATH):
390
363
  logger.warning(
@@ -397,14 +370,13 @@ class HwpxPackage:
397
370
 
398
371
  def master_page_paths(self) -> list[str]:
399
372
  if self._master_page_paths_cache is None:
400
- from pathlib import PurePosixPath
401
-
402
- paths = [
403
- item.attrib.get("href", "")
404
- for item in self._manifest_items()
405
- if self._manifest_matches(item, "masterpage", "master-page")
406
- and item.attrib.get("href")
407
- ]
373
+ paths = list(
374
+ parse_manifest_relationships(
375
+ self.manifest_tree(),
376
+ self.main_content.full_path,
377
+ known_parts=self._files.keys(),
378
+ ).master_page_paths
379
+ )
408
380
  if not paths:
409
381
  logger.warning("manifest에서 masterPage를 찾지 못해 파일명 탐색 fallback을 사용합니다.")
410
382
  paths = [
@@ -418,13 +390,13 @@ class HwpxPackage:
418
390
 
419
391
  def history_paths(self) -> list[str]:
420
392
  if self._history_paths_cache is None:
421
- from pathlib import PurePosixPath
422
-
423
- paths = [
424
- item.attrib.get("href", "")
425
- for item in self._manifest_items()
426
- if self._manifest_matches(item, "history") and item.attrib.get("href")
427
- ]
393
+ paths = list(
394
+ parse_manifest_relationships(
395
+ self.manifest_tree(),
396
+ self.main_content.full_path,
397
+ known_parts=self._files.keys(),
398
+ ).history_paths
399
+ )
428
400
  if not paths:
429
401
  logger.warning("manifest에서 history를 찾지 못해 파일명 탐색 fallback을 사용합니다.")
430
402
  paths = [
@@ -437,13 +409,11 @@ class HwpxPackage:
437
409
 
438
410
  def version_path(self) -> str | None:
439
411
  if not self._version_path_cache_resolved:
440
- path: str | None = None
441
- for item in self._manifest_items():
442
- if self._manifest_matches(item, "version"):
443
- href = item.attrib.get("href", "").strip()
444
- if href:
445
- path = href
446
- break
412
+ path = parse_manifest_relationships(
413
+ self.manifest_tree(),
414
+ self.main_content.full_path,
415
+ known_parts=self._files.keys(),
416
+ ).version_path
447
417
  if path is None and self.has_part(self.VERSION_PATH):
448
418
  logger.warning(
449
419
  "manifest에서 version 파트를 찾지 못해 기본 경로 fallback을 사용합니다: %s",
@@ -461,8 +431,7 @@ class HwpxPackage:
461
431
  def _manifest_element(self) -> etree._Element | None:
462
432
  """Return the ``<opf:manifest>`` element."""
463
433
  manifest = self.manifest_tree()
464
- ns = {"opf": _OPF_NS}
465
- return manifest.find("opf:manifest", ns)
434
+ return manifest.find("opf:manifest", OPF_NS)
466
435
 
467
436
  def add_manifest_item(
468
437
  self,
@@ -475,13 +444,12 @@ class HwpxPackage:
475
444
  if manifest_el is None:
476
445
  raise HwpxStructureError("Manifest does not contain an <opf:manifest> element.")
477
446
 
478
- ns = {"opf": _OPF_NS}
479
- for existing in manifest_el.findall("opf:item", ns):
447
+ for existing in manifest_el.findall("opf:item", OPF_NS):
480
448
  if existing.get("id") == item_id:
481
449
  return # already present
482
450
 
483
451
  new_item = manifest_el.makeelement(
484
- f"{{{_OPF_NS}}}item",
452
+ f"{{{OPF_NS['opf']}}}item",
485
453
  {"id": item_id, "href": href, "media-type": media_type},
486
454
  )
487
455
  manifest_el.append(new_item)
@@ -493,8 +461,7 @@ class HwpxPackage:
493
461
  if manifest_el is None:
494
462
  return False
495
463
 
496
- ns = {"opf": _OPF_NS}
497
- for existing in manifest_el.findall("opf:item", ns):
464
+ for existing in manifest_el.findall("opf:item", OPF_NS):
498
465
  if existing.get("id") == item_id:
499
466
  manifest_el.remove(existing)
500
467
  self._persist_manifest()
@@ -505,20 +472,18 @@ class HwpxPackage:
505
472
  """Write the in-memory manifest tree back to the package."""
506
473
  tree = self._manifest_tree
507
474
  if tree is not None:
508
- self.set_part(self.MANIFEST_PATH, tree)
475
+ self.set_part(self.main_content.full_path, tree)
509
476
 
510
477
  def _invalidate_caches(self, changed_path: str) -> None:
511
- if changed_path == self.MANIFEST_PATH:
478
+ if changed_path in {self.CONTAINER_PATH, self.main_content.full_path}:
512
479
  self._manifest_tree = None
513
- self._spine_cache = None
514
- self._section_paths_cache = None
515
- self._header_paths_cache = None
516
- self._master_page_paths_cache = None
517
- self._history_paths_cache = None
518
- self._version_path_cache = None
519
- self._version_path_cache_resolved = False
520
- elif changed_path == self.VERSION_PATH:
521
- self._version_path_cache_resolved = False
480
+ self._spine_cache = None
481
+ self._section_paths_cache = None
482
+ self._header_paths_cache = None
483
+ self._master_page_paths_cache = None
484
+ self._history_paths_cache = None
485
+ self._version_path_cache = None
486
+ self._version_path_cache_resolved = False
522
487
 
523
488
  def save(
524
489
  self,