python-hwpx 2.8__tar.gz → 2.8.3__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.
- {python_hwpx-2.8/src/python_hwpx.egg-info → python_hwpx-2.8.3}/PKG-INFO +34 -63
- {python_hwpx-2.8 → python_hwpx-2.8.3}/README.md +31 -28
- {python_hwpx-2.8 → python_hwpx-2.8.3}/pyproject.toml +9 -4
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/document.py +29 -16
- {python_hwpx-2.8 → python_hwpx-2.8.3/src/python_hwpx.egg-info}/PKG-INFO +34 -63
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/python_hwpx.egg-info/SOURCES.txt +1 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/python_hwpx.egg-info/requires.txt +1 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_memo_and_style_editing.py +18 -0
- python_hwpx-2.8.3/tests/test_packaging_license_metadata.py +63 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_section_headers.py +17 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/LICENSE +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/setup.cfg +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/__init__.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/document.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/opc/package.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/opc/relationships.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/opc/xml_utils.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/__init__.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/body.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/header_part.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/memo.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/namespaces.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/paragraph.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/section.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/table.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/package.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/py.typed +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/templates.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/archive_cli.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/exporter.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/object_finder.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/package_validator.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/page_guard.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/template_analyzer.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/text_extract_cli.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/text_extractor.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_coverage_targets.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_document_context_manager.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_document_formatting.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_document_save_api.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_gap_closure_tools.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_inline_models.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_integration_roundtrip.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_new_features.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_opc_package.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_packaging_py_typed.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_paragraph_section_management.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_repr_snapshots.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_split_merged_cell.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_text_extractor_annotations.py +0 -0
- {python_hwpx-2.8 → python_hwpx-2.8.3}/tests/test_version_metadata.py +0 -0
|
@@ -1,48 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 2.8
|
|
3
|
+
Version: 2.8.3
|
|
4
4
|
Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
|
-
License:
|
|
7
|
-
|
|
8
|
-
Copyright (c) 2024 python-hwpx Maintainers
|
|
9
|
-
|
|
10
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
-
of this software and associated documentation files (the "Software"), to use,
|
|
12
|
-
copy, modify, merge, publish, distribute, and sublicense the Software only for
|
|
13
|
-
non-commercial purposes, subject to the following conditions:
|
|
14
|
-
|
|
15
|
-
1. Non-Commercial Use Only. The Software may be used, copied, modified,
|
|
16
|
-
merged, published, distributed, and sublicensed only for non-commercial
|
|
17
|
-
purposes. "Non-Commercial" means use that is not primarily intended for or
|
|
18
|
-
directed toward commercial advantage, monetary compensation, or any form of
|
|
19
|
-
direct or indirect commercial exploitation.
|
|
20
|
-
|
|
21
|
-
2. Attribution. The above copyright notice and this permission notice shall be
|
|
22
|
-
included in all copies or substantial portions of the Software.
|
|
23
|
-
|
|
24
|
-
3. No Warranty of Commercial Support. The maintainers are not obligated to
|
|
25
|
-
provide commercial support, maintenance, or updates.
|
|
26
|
-
|
|
27
|
-
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
28
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
29
|
-
FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
|
30
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
31
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
32
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
33
|
-
SOFTWARE.
|
|
34
|
-
|
|
35
|
-
If you require permissions to use this Software for commercial purposes,
|
|
36
|
-
please contact the copyright holders to negotiate an alternative licensing
|
|
37
|
-
arrangement.
|
|
38
|
-
|
|
6
|
+
License-Expression: LicenseRef-python-hwpx-NonCommercial
|
|
39
7
|
Project-URL: Homepage, https://github.com/airmang/python-hwpx
|
|
40
8
|
Project-URL: Documentation, https://github.com/airmang/python-hwpx/tree/main/docs
|
|
41
9
|
Project-URL: Issues, https://github.com/airmang/python-hwpx/issues
|
|
42
10
|
Keywords: hwp,hwpx,hancom,opc,xml
|
|
43
11
|
Classifier: Development Status :: 3 - Alpha
|
|
44
12
|
Classifier: Intended Audience :: Developers
|
|
45
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
46
13
|
Classifier: Programming Language :: Python :: 3
|
|
47
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
48
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -58,6 +25,7 @@ Requires-Dist: build>=1.0; extra == "dev"
|
|
|
58
25
|
Requires-Dist: twine>=4.0; extra == "dev"
|
|
59
26
|
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
60
27
|
Provides-Extra: test
|
|
28
|
+
Requires-Dist: build>=1.0; extra == "test"
|
|
61
29
|
Requires-Dist: pytest>=7.4; extra == "test"
|
|
62
30
|
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
63
31
|
Provides-Extra: typecheck
|
|
@@ -73,15 +41,16 @@ Dynamic: license-file
|
|
|
73
41
|
<p align="center">
|
|
74
42
|
<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>
|
|
75
43
|
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/pyversions/python-hwpx" alt="Python"></a>
|
|
76
|
-
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-
|
|
44
|
+
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Custom%20Noncommercial-orange" alt="License: Custom Non-Commercial"></a>
|
|
77
45
|
<a href="https://airmang.github.io/python-hwpx/"><img src="https://img.shields.io/badge/docs-Sphinx-8CA1AF" alt="Docs"></a>
|
|
78
46
|
</p>
|
|
79
47
|
</p>
|
|
80
48
|
|
|
81
49
|
---
|
|
82
50
|
|
|
83
|
-
`python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는
|
|
51
|
+
`python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리이자 CLI 도구 모음입니다.
|
|
84
52
|
한/글 설치 없이, OS에 관계없이 HWPX 문서의 구조를 파싱하고 콘텐츠를 조작할 수 있습니다.
|
|
53
|
+
문서 편집 API뿐 아니라 스키마/패키지 검증, unpack/pack, 템플릿 분석 같은 XML-first 워크플로도 함께 제공합니다.
|
|
85
54
|
|
|
86
55
|
> **pyhwpx / pyhwp와 다른 점?**
|
|
87
56
|
> | | python-hwpx | pyhwpx | pyhwp |
|
|
@@ -93,7 +62,7 @@ Dynamic: license-file
|
|
|
93
62
|
|
|
94
63
|
## 🌍 크로스 플랫폼 지원
|
|
95
64
|
|
|
96
|
-
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로
|
|
65
|
+
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 읽고 편집하는 워크플로를 구성할 수 있습니다.
|
|
97
66
|
|
|
98
67
|
| 플랫폼 | 읽기 | 쓰기 | 비고 |
|
|
99
68
|
|--------|------|------|------|
|
|
@@ -115,14 +84,14 @@ pip install python-hwpx
|
|
|
115
84
|
```python
|
|
116
85
|
from hwpx import HwpxDocument
|
|
117
86
|
|
|
118
|
-
# 기존 문서 열기
|
|
119
|
-
doc = HwpxDocument.open("보고서.hwpx")
|
|
120
|
-
|
|
121
87
|
# 빈 문서 새로 만들기
|
|
122
88
|
doc = HwpxDocument.new()
|
|
123
89
|
|
|
90
|
+
# 기존 문서를 수정하려면:
|
|
91
|
+
# doc = HwpxDocument.open("보고서.hwpx")
|
|
92
|
+
|
|
124
93
|
# 문단 추가
|
|
125
|
-
doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
|
|
94
|
+
paragraph = doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
|
|
126
95
|
|
|
127
96
|
# 표 추가 (2×3)
|
|
128
97
|
table = doc.add_table(rows=2, cols=3)
|
|
@@ -130,9 +99,8 @@ table.set_cell_text(0, 0, "이름")
|
|
|
130
99
|
table.set_cell_text(0, 1, "부서")
|
|
131
100
|
table.set_cell_text(0, 2, "연락처")
|
|
132
101
|
|
|
133
|
-
# 메모 추가 (
|
|
134
|
-
paragraph =
|
|
135
|
-
doc.add_memo_with_anchor("검토 필요", paragraph=paragraph)
|
|
102
|
+
# 메모 추가 (기본 템플릿의 memo shape 사용)
|
|
103
|
+
doc.add_memo_with_anchor("검토 필요", paragraph=paragraph, memo_shape_id_ref="0")
|
|
136
104
|
|
|
137
105
|
# 저장
|
|
138
106
|
doc.save_to_path("결과물.hwpx")
|
|
@@ -166,7 +134,7 @@ doc.save_to_path("결과물.hwpx")
|
|
|
166
134
|
| 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
|
|
167
135
|
| 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
|
|
168
136
|
| ✅ **유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
|
|
169
|
-
| 🧰
|
|
137
|
+
| 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
|
|
170
138
|
| 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
|
|
171
139
|
| 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
|
|
172
140
|
|
|
@@ -174,7 +142,7 @@ doc.save_to_path("결과물.hwpx")
|
|
|
174
142
|
|
|
175
143
|
### 📄 문서 편집
|
|
176
144
|
|
|
177
|
-
문단, 표, 메모,
|
|
145
|
+
문단, 표, 메모, 머리글/바닥글을 Python 객체로 다룹니다.
|
|
178
146
|
|
|
179
147
|
```python
|
|
180
148
|
# 단락 추가·삭제
|
|
@@ -186,9 +154,9 @@ new_sec = doc.add_section() # 문서 끝에 섹션 추가
|
|
|
186
154
|
new_sec.add_paragraph("두 번째 섹션 내용")
|
|
187
155
|
doc.remove_section(1) # 인덱스로 섹션 삭제
|
|
188
156
|
|
|
189
|
-
#
|
|
157
|
+
# 머리글·바닥글
|
|
190
158
|
doc.set_header_text("기밀 문서", page_type="BOTH")
|
|
191
|
-
doc.set_footer_text("
|
|
159
|
+
doc.set_footer_text("1 / 10", page_type="BOTH")
|
|
192
160
|
|
|
193
161
|
# 표 셀 병합·분할
|
|
194
162
|
table.merge_cells(0, 0, 1, 1) # (0,0)~(1,1) 병합
|
|
@@ -201,13 +169,14 @@ table.set_cell_text(0, 0, "병합된 셀", logical=True, split_merged=True)
|
|
|
201
169
|
from hwpx import TextExtractor, ObjectFinder
|
|
202
170
|
|
|
203
171
|
# 텍스트 추출
|
|
204
|
-
|
|
205
|
-
for
|
|
206
|
-
|
|
172
|
+
with TextExtractor("문서.hwpx") as extractor:
|
|
173
|
+
for section in extractor.iter_sections():
|
|
174
|
+
for para in extractor.iter_paragraphs(section):
|
|
175
|
+
print(para.text())
|
|
207
176
|
|
|
208
177
|
# 특정 객체 탐색
|
|
209
|
-
for obj in ObjectFinder("문서.hwpx").
|
|
210
|
-
print(obj.tag, obj.
|
|
178
|
+
for obj in ObjectFinder("문서.hwpx").find_all(tag="tbl"):
|
|
179
|
+
print(obj.tag, obj.path)
|
|
211
180
|
```
|
|
212
181
|
|
|
213
182
|
### 🎨 스타일 기반 텍스트 치환
|
|
@@ -270,7 +239,7 @@ python-hwpx
|
|
|
270
239
|
│ ├── exporter # 텍스트/HTML/Markdown 내보내기
|
|
271
240
|
│ ├── validator # 스키마 유효성 검사 (hwpx-validate CLI)
|
|
272
241
|
│ ├── package_validator# ZIP/OPC/HWPX 구조 검사
|
|
273
|
-
│ ├── page_guard #
|
|
242
|
+
│ ├── page_guard # 구조 변화 징후 점검
|
|
274
243
|
│ └── template_analyzer# 레퍼런스 문서 분석/추출
|
|
275
244
|
└── hwpx.templates # 내장 빈 문서 템플릿
|
|
276
245
|
```
|
|
@@ -289,7 +258,7 @@ hwpx-unpack 문서.hwpx ./unpacked
|
|
|
289
258
|
hwpx-unpack 문서.hwpx ./pretty-unpacked --pretty-xml
|
|
290
259
|
hwpx-pack ./unpacked ./repacked.hwpx
|
|
291
260
|
|
|
292
|
-
# 레퍼런스
|
|
261
|
+
# 레퍼런스 문서 분석과 작업 디렉터리 추출
|
|
293
262
|
hwpx-analyze-template 문서.hwpx --extract-dir ./template-parts --json
|
|
294
263
|
hwpx-pack ./template-parts ./template-roundtrip.hwpx
|
|
295
264
|
hwpx-validate-package ./template-roundtrip.hwpx
|
|
@@ -297,15 +266,15 @@ hwpx-validate-package ./template-roundtrip.hwpx
|
|
|
297
266
|
# plain / markdown 텍스트 추출
|
|
298
267
|
hwpx-text-extract 문서.hwpx --format markdown --output 문서.md
|
|
299
268
|
|
|
300
|
-
#
|
|
269
|
+
# 문서 구조 변화 징후 비교
|
|
301
270
|
hwpx-page-guard --reference 원본.hwpx --output 결과.hwpx
|
|
302
271
|
```
|
|
303
272
|
|
|
304
|
-
`hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이
|
|
273
|
+
`hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 같은 구조 지표를 비교해 편집 전후 변화 징후를 빠르게 점검합니다.
|
|
305
274
|
|
|
306
|
-
`hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를
|
|
275
|
+
`hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를 전제로 두지 않고, `META-INF/container.xml`과 실제 rootfile/manifest 선언을 따라가며 패키지 구조를 확인합니다. 엔진이 열 수 있는 비표준 패키지는 가능한 경우 경고로 분리해 보여줍니다.
|
|
307
276
|
|
|
308
|
-
`hwpx-analyze-template --extract-dir`는
|
|
277
|
+
`hwpx-analyze-template --extract-dir`는 다시 묶고 점검하기 쉬운 작업 디렉터리를 만듭니다. 재구성과 구조 검증에 필요한 파일을 함께 꺼내는 용도이며, 편집기에서의 최종 렌더링 결과까지 보장한다는 뜻은 아닙니다.
|
|
309
278
|
|
|
310
279
|
## 문서
|
|
311
280
|
|
|
@@ -352,13 +321,15 @@ pytest
|
|
|
352
321
|
|
|
353
322
|
## License
|
|
354
323
|
|
|
355
|
-
[
|
|
324
|
+
[Custom Non-Commercial License](LICENSE) © python-hwpx Maintainers
|
|
325
|
+
|
|
326
|
+
Commercial use requires separate permission from the copyright holders.
|
|
356
327
|
|
|
357
328
|
<br>
|
|
358
329
|
|
|
359
|
-
##
|
|
330
|
+
## Maintainer
|
|
360
331
|
|
|
361
|
-
**고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
332
|
+
Primary maintainer/contact: **고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
362
333
|
|
|
363
334
|
- ✉️ [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
|
|
364
335
|
- 🐙 [@airmang](https://github.com/airmang)
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
<p align="center">
|
|
7
7
|
<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>
|
|
8
8
|
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/pyversions/python-hwpx" alt="Python"></a>
|
|
9
|
-
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-
|
|
9
|
+
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Custom%20Noncommercial-orange" alt="License: Custom Non-Commercial"></a>
|
|
10
10
|
<a href="https://airmang.github.io/python-hwpx/"><img src="https://img.shields.io/badge/docs-Sphinx-8CA1AF" alt="Docs"></a>
|
|
11
11
|
</p>
|
|
12
12
|
</p>
|
|
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 =
|
|
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
|
-
| 🧰
|
|
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
|
-
문단, 표, 메모,
|
|
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("
|
|
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
|
-
|
|
138
|
-
for
|
|
139
|
-
|
|
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").
|
|
143
|
-
print(obj.tag, obj.
|
|
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 #
|
|
207
|
+
│ ├── page_guard # 구조 변화 징후 점검
|
|
207
208
|
│ └── template_analyzer# 레퍼런스 문서 분석/추출
|
|
208
209
|
└── hwpx.templates # 내장 빈 문서 템플릿
|
|
209
210
|
```
|
|
@@ -222,7 +223,7 @@ hwpx-unpack 문서.hwpx ./unpacked
|
|
|
222
223
|
hwpx-unpack 문서.hwpx ./pretty-unpacked --pretty-xml
|
|
223
224
|
hwpx-pack ./unpacked ./repacked.hwpx
|
|
224
225
|
|
|
225
|
-
# 레퍼런스
|
|
226
|
+
# 레퍼런스 문서 분석과 작업 디렉터리 추출
|
|
226
227
|
hwpx-analyze-template 문서.hwpx --extract-dir ./template-parts --json
|
|
227
228
|
hwpx-pack ./template-parts ./template-roundtrip.hwpx
|
|
228
229
|
hwpx-validate-package ./template-roundtrip.hwpx
|
|
@@ -230,15 +231,15 @@ hwpx-validate-package ./template-roundtrip.hwpx
|
|
|
230
231
|
# plain / markdown 텍스트 추출
|
|
231
232
|
hwpx-text-extract 문서.hwpx --format markdown --output 문서.md
|
|
232
233
|
|
|
233
|
-
#
|
|
234
|
+
# 문서 구조 변화 징후 비교
|
|
234
235
|
hwpx-page-guard --reference 원본.hwpx --output 결과.hwpx
|
|
235
236
|
```
|
|
236
237
|
|
|
237
|
-
`hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이
|
|
238
|
+
`hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 같은 구조 지표를 비교해 편집 전후 변화 징후를 빠르게 점검합니다.
|
|
238
239
|
|
|
239
|
-
`hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를
|
|
240
|
+
`hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를 전제로 두지 않고, `META-INF/container.xml`과 실제 rootfile/manifest 선언을 따라가며 패키지 구조를 확인합니다. 엔진이 열 수 있는 비표준 패키지는 가능한 경우 경고로 분리해 보여줍니다.
|
|
240
241
|
|
|
241
|
-
`hwpx-analyze-template --extract-dir`는
|
|
242
|
+
`hwpx-analyze-template --extract-dir`는 다시 묶고 점검하기 쉬운 작업 디렉터리를 만듭니다. 재구성과 구조 검증에 필요한 파일을 함께 꺼내는 용도이며, 편집기에서의 최종 렌더링 결과까지 보장한다는 뜻은 아닙니다.
|
|
242
243
|
|
|
243
244
|
## 문서
|
|
244
245
|
|
|
@@ -285,13 +286,15 @@ pytest
|
|
|
285
286
|
|
|
286
287
|
## License
|
|
287
288
|
|
|
288
|
-
[
|
|
289
|
+
[Custom Non-Commercial License](LICENSE) © python-hwpx Maintainers
|
|
290
|
+
|
|
291
|
+
Commercial use requires separate permission from the copyright holders.
|
|
289
292
|
|
|
290
293
|
<br>
|
|
291
294
|
|
|
292
|
-
##
|
|
295
|
+
## Maintainer
|
|
293
296
|
|
|
294
|
-
**고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
297
|
+
Primary maintainer/contact: **고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
295
298
|
|
|
296
299
|
- ✉️ [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
|
|
297
300
|
- 🐙 [@airmang](https://github.com/airmang)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools", "wheel"]
|
|
2
|
+
requires = ["setuptools>=77.0.0", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-hwpx"
|
|
7
|
-
version = "2.8"
|
|
7
|
+
version = "2.8.3"
|
|
8
8
|
description = "Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
-
license =
|
|
10
|
+
license = "LicenseRef-python-hwpx-NonCommercial"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
11
12
|
requires-python = ">=3.10"
|
|
12
13
|
authors = [
|
|
13
14
|
{ name = "python-hwpx Maintainers" },
|
|
@@ -16,7 +17,6 @@ keywords = ["hwp", "hwpx", "hancom", "opc", "xml"]
|
|
|
16
17
|
classifiers = [
|
|
17
18
|
"Development Status :: 3 - Alpha",
|
|
18
19
|
"Intended Audience :: Developers",
|
|
19
|
-
"License :: OSI Approved :: MIT License",
|
|
20
20
|
"Programming Language :: Python :: 3",
|
|
21
21
|
"Programming Language :: Python :: 3.10",
|
|
22
22
|
"Programming Language :: Python :: 3.11",
|
|
@@ -35,6 +35,7 @@ dev = [
|
|
|
35
35
|
"pytest>=7.4",
|
|
36
36
|
]
|
|
37
37
|
test = [
|
|
38
|
+
"build>=1.0",
|
|
38
39
|
"pytest>=7.4",
|
|
39
40
|
"pytest-cov>=5.0",
|
|
40
41
|
]
|
|
@@ -89,6 +90,8 @@ files = [
|
|
|
89
90
|
"src/hwpx/tools/template_analyzer.py",
|
|
90
91
|
"src/hwpx/tools/text_extract_cli.py",
|
|
91
92
|
"src/hwpx/tools/text_extractor.py",
|
|
93
|
+
"tests/template_automation/helpers.py",
|
|
94
|
+
"tests/template_automation/generate_fixtures.py",
|
|
92
95
|
]
|
|
93
96
|
ignore_missing_imports = true
|
|
94
97
|
|
|
@@ -108,6 +111,8 @@ include = [
|
|
|
108
111
|
"src/hwpx/tools/template_analyzer.py",
|
|
109
112
|
"src/hwpx/tools/text_extract_cli.py",
|
|
110
113
|
"src/hwpx/tools/text_extractor.py",
|
|
114
|
+
"tests/template_automation/helpers.py",
|
|
115
|
+
"tests/template_automation/generate_fixtures.py",
|
|
111
116
|
]
|
|
112
117
|
pythonVersion = "3.10"
|
|
113
118
|
typeCheckingMode = "basic"
|
|
@@ -47,6 +47,8 @@ logger = logging.getLogger(__name__)
|
|
|
47
47
|
|
|
48
48
|
_HP_NS = "http://www.hancom.co.kr/hwpml/2011/paragraph"
|
|
49
49
|
_HP = f"{{{_HP_NS}}}"
|
|
50
|
+
_HS_NS = "http://www.hancom.co.kr/hwpml/2011/section"
|
|
51
|
+
_HS = f"{{{_HS_NS}}}"
|
|
50
52
|
_HH_NS = "http://www.hancom.co.kr/hwpml/2011/head"
|
|
51
53
|
_HH = f"{{{_HH_NS}}}"
|
|
52
54
|
|
|
@@ -127,6 +129,7 @@ def _create_paragraph_element(
|
|
|
127
129
|
style_id_ref: str | int | None = None,
|
|
128
130
|
paragraph_attributes: Optional[dict[str, str]] = None,
|
|
129
131
|
run_attributes: Optional[dict[str, str]] = None,
|
|
132
|
+
parent: ET.Element | None = None,
|
|
130
133
|
) -> ET.Element:
|
|
131
134
|
"""Return a paragraph element populated with a single run and text node."""
|
|
132
135
|
|
|
@@ -138,7 +141,10 @@ def _create_paragraph_element(
|
|
|
138
141
|
if style_id_ref is not None:
|
|
139
142
|
attrs["styleIDRef"] = str(style_id_ref)
|
|
140
143
|
|
|
141
|
-
|
|
144
|
+
if parent is None:
|
|
145
|
+
paragraph = ET.Element(f"{_HP}p", attrs)
|
|
146
|
+
else:
|
|
147
|
+
paragraph = parent.makeelement(f"{_HP}p", attrs)
|
|
142
148
|
|
|
143
149
|
run_attrs: dict[str, str] = dict(run_attributes or {})
|
|
144
150
|
if char_pr_id_ref is not None:
|
|
@@ -146,8 +152,10 @@ def _create_paragraph_element(
|
|
|
146
152
|
else:
|
|
147
153
|
run_attrs.setdefault("charPrIDRef", "0")
|
|
148
154
|
|
|
149
|
-
run =
|
|
150
|
-
|
|
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)
|
|
151
159
|
text_element.text = text
|
|
152
160
|
return paragraph
|
|
153
161
|
|
|
@@ -535,18 +543,22 @@ class HwpxOxmlSectionHeaderFooter:
|
|
|
535
543
|
def _ensure_text_element(self) -> ET.Element:
|
|
536
544
|
sublist = self.element.find(f"{_HP}subList")
|
|
537
545
|
if sublist is None:
|
|
538
|
-
sublist =
|
|
546
|
+
sublist = _append_child(
|
|
547
|
+
self.element,
|
|
548
|
+
f"{_HP}subList",
|
|
549
|
+
self._initial_sublist_attributes(),
|
|
550
|
+
)
|
|
539
551
|
paragraph = sublist.find(f"{_HP}p")
|
|
540
552
|
if paragraph is None:
|
|
541
553
|
paragraph_attrs = dict(_DEFAULT_PARAGRAPH_ATTRS)
|
|
542
554
|
paragraph_attrs["id"] = _paragraph_id()
|
|
543
|
-
paragraph =
|
|
555
|
+
paragraph = _append_child(sublist, f"{_HP}p", paragraph_attrs)
|
|
544
556
|
run = paragraph.find(f"{_HP}run")
|
|
545
557
|
if run is None:
|
|
546
|
-
run =
|
|
558
|
+
run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": "0"})
|
|
547
559
|
text = run.find(f"{_HP}t")
|
|
548
560
|
if text is None:
|
|
549
|
-
text =
|
|
561
|
+
text = _append_child(run, f"{_HP}t")
|
|
550
562
|
return text
|
|
551
563
|
|
|
552
564
|
@property
|
|
@@ -851,7 +863,7 @@ class HwpxOxmlSectionProperties:
|
|
|
851
863
|
attrs = {"applyPageType": page_type}
|
|
852
864
|
if header_id is not None:
|
|
853
865
|
attrs[self._apply_id_attributes(tag)[0]] = header_id
|
|
854
|
-
apply =
|
|
866
|
+
apply = _append_child(self.element, f"{_HP}{tag}Apply", attrs)
|
|
855
867
|
changed = True
|
|
856
868
|
else:
|
|
857
869
|
if apply.get("applyPageType") != page_type:
|
|
@@ -897,7 +909,7 @@ class HwpxOxmlSectionProperties:
|
|
|
897
909
|
element = self._find_header_footer(tag, page_type)
|
|
898
910
|
changed = False
|
|
899
911
|
if element is None:
|
|
900
|
-
element =
|
|
912
|
+
element = _append_child(
|
|
901
913
|
self.element,
|
|
902
914
|
f"{_HP}{tag}",
|
|
903
915
|
{"id": _object_id(), "applyPageType": page_type},
|
|
@@ -1362,7 +1374,7 @@ class HwpxOxmlMemoGroup:
|
|
|
1362
1374
|
memo_attrs.setdefault("id", memo_id or _memo_id())
|
|
1363
1375
|
if memo_shape_id_ref is not None:
|
|
1364
1376
|
memo_attrs.setdefault("memoShapeIDRef", str(memo_shape_id_ref))
|
|
1365
|
-
memo_element =
|
|
1377
|
+
memo_element = _append_child(self.element, f"{_HP}memo", memo_attrs)
|
|
1366
1378
|
memo = HwpxOxmlMemo(memo_element, self)
|
|
1367
1379
|
memo.set_text(text, char_pr_id_ref=char_pr_id_ref)
|
|
1368
1380
|
self.section.mark_dirty()
|
|
@@ -1466,10 +1478,11 @@ class HwpxOxmlMemo:
|
|
|
1466
1478
|
for child in list(self.element):
|
|
1467
1479
|
if _element_local_name(child) in {"paraList", "p"}:
|
|
1468
1480
|
self.element.remove(child)
|
|
1469
|
-
para_list =
|
|
1481
|
+
para_list = _append_child(self.element, f"{_HP}paraList", {})
|
|
1470
1482
|
paragraph = _create_paragraph_element(
|
|
1471
1483
|
desired,
|
|
1472
1484
|
char_pr_id_ref=existing_char if existing_char is not None else "0",
|
|
1485
|
+
parent=para_list,
|
|
1473
1486
|
)
|
|
1474
1487
|
para_list.append(paragraph)
|
|
1475
1488
|
self.group.section.mark_dirty()
|
|
@@ -3493,11 +3506,11 @@ class HwpxOxmlSection:
|
|
|
3493
3506
|
if paragraph is None:
|
|
3494
3507
|
paragraph_attrs = dict(_DEFAULT_PARAGRAPH_ATTRS)
|
|
3495
3508
|
paragraph_attrs["id"] = _paragraph_id()
|
|
3496
|
-
paragraph =
|
|
3509
|
+
paragraph = _append_child(self._element, f"{_HP}p", paragraph_attrs)
|
|
3497
3510
|
run = paragraph.find(f"{_HP}run")
|
|
3498
3511
|
if run is None:
|
|
3499
|
-
run =
|
|
3500
|
-
element =
|
|
3512
|
+
run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": "0"})
|
|
3513
|
+
element = _append_child(run, f"{_HP}secPr")
|
|
3501
3514
|
self._properties_cache = None
|
|
3502
3515
|
self.mark_dirty()
|
|
3503
3516
|
return element
|
|
@@ -3536,7 +3549,7 @@ class HwpxOxmlSection:
|
|
|
3536
3549
|
def _memo_group_element(self, create: bool = False) -> ET.Element | None:
|
|
3537
3550
|
element = self._element.find(f"{_HP}memogroup")
|
|
3538
3551
|
if element is None and create:
|
|
3539
|
-
element =
|
|
3552
|
+
element = _append_child(self._element, f"{_HP}memogroup", {})
|
|
3540
3553
|
self.mark_dirty()
|
|
3541
3554
|
return element
|
|
3542
3555
|
|
|
@@ -4660,7 +4673,7 @@ class HwpxOxmlDocument:
|
|
|
4660
4673
|
part_name = f"Contents/{section_id}.xml"
|
|
4661
4674
|
|
|
4662
4675
|
# Build minimal section XML
|
|
4663
|
-
section_element = ET.Element(f"{
|
|
4676
|
+
section_element = ET.Element(f"{_HS}sec")
|
|
4664
4677
|
para_attrs = {"id": _paragraph_id(), **_DEFAULT_PARAGRAPH_ATTRS}
|
|
4665
4678
|
para = ET.SubElement(section_element, f"{_HP}p", para_attrs)
|
|
4666
4679
|
run = ET.SubElement(para, f"{_HP}run", {"charPrIDRef": "0"})
|
|
@@ -1,48 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 2.8
|
|
3
|
+
Version: 2.8.3
|
|
4
4
|
Summary: Hancom HWPX 패키지를 로드하고 편집하기 위한 Python 유틸리티 모음
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
|
-
License:
|
|
7
|
-
|
|
8
|
-
Copyright (c) 2024 python-hwpx Maintainers
|
|
9
|
-
|
|
10
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
-
of this software and associated documentation files (the "Software"), to use,
|
|
12
|
-
copy, modify, merge, publish, distribute, and sublicense the Software only for
|
|
13
|
-
non-commercial purposes, subject to the following conditions:
|
|
14
|
-
|
|
15
|
-
1. Non-Commercial Use Only. The Software may be used, copied, modified,
|
|
16
|
-
merged, published, distributed, and sublicensed only for non-commercial
|
|
17
|
-
purposes. "Non-Commercial" means use that is not primarily intended for or
|
|
18
|
-
directed toward commercial advantage, monetary compensation, or any form of
|
|
19
|
-
direct or indirect commercial exploitation.
|
|
20
|
-
|
|
21
|
-
2. Attribution. The above copyright notice and this permission notice shall be
|
|
22
|
-
included in all copies or substantial portions of the Software.
|
|
23
|
-
|
|
24
|
-
3. No Warranty of Commercial Support. The maintainers are not obligated to
|
|
25
|
-
provide commercial support, maintenance, or updates.
|
|
26
|
-
|
|
27
|
-
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
28
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
29
|
-
FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
|
30
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
31
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
32
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
33
|
-
SOFTWARE.
|
|
34
|
-
|
|
35
|
-
If you require permissions to use this Software for commercial purposes,
|
|
36
|
-
please contact the copyright holders to negotiate an alternative licensing
|
|
37
|
-
arrangement.
|
|
38
|
-
|
|
6
|
+
License-Expression: LicenseRef-python-hwpx-NonCommercial
|
|
39
7
|
Project-URL: Homepage, https://github.com/airmang/python-hwpx
|
|
40
8
|
Project-URL: Documentation, https://github.com/airmang/python-hwpx/tree/main/docs
|
|
41
9
|
Project-URL: Issues, https://github.com/airmang/python-hwpx/issues
|
|
42
10
|
Keywords: hwp,hwpx,hancom,opc,xml
|
|
43
11
|
Classifier: Development Status :: 3 - Alpha
|
|
44
12
|
Classifier: Intended Audience :: Developers
|
|
45
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
46
13
|
Classifier: Programming Language :: Python :: 3
|
|
47
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
48
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -58,6 +25,7 @@ Requires-Dist: build>=1.0; extra == "dev"
|
|
|
58
25
|
Requires-Dist: twine>=4.0; extra == "dev"
|
|
59
26
|
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
60
27
|
Provides-Extra: test
|
|
28
|
+
Requires-Dist: build>=1.0; extra == "test"
|
|
61
29
|
Requires-Dist: pytest>=7.4; extra == "test"
|
|
62
30
|
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
63
31
|
Provides-Extra: typecheck
|
|
@@ -73,15 +41,16 @@ Dynamic: license-file
|
|
|
73
41
|
<p align="center">
|
|
74
42
|
<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>
|
|
75
43
|
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/pyversions/python-hwpx" alt="Python"></a>
|
|
76
|
-
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-
|
|
44
|
+
<a href="https://github.com/airmang/python-hwpx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Custom%20Noncommercial-orange" alt="License: Custom Non-Commercial"></a>
|
|
77
45
|
<a href="https://airmang.github.io/python-hwpx/"><img src="https://img.shields.io/badge/docs-Sphinx-8CA1AF" alt="Docs"></a>
|
|
78
46
|
</p>
|
|
79
47
|
</p>
|
|
80
48
|
|
|
81
49
|
---
|
|
82
50
|
|
|
83
|
-
`python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는
|
|
51
|
+
`python-hwpx`는 한컴오피스의 [HWPX 포맷](https://www.hancom.com/)을 순수 Python으로 다루는 라이브러리이자 CLI 도구 모음입니다.
|
|
84
52
|
한/글 설치 없이, OS에 관계없이 HWPX 문서의 구조를 파싱하고 콘텐츠를 조작할 수 있습니다.
|
|
53
|
+
문서 편집 API뿐 아니라 스키마/패키지 검증, unpack/pack, 템플릿 분석 같은 XML-first 워크플로도 함께 제공합니다.
|
|
85
54
|
|
|
86
55
|
> **pyhwpx / pyhwp와 다른 점?**
|
|
87
56
|
> | | python-hwpx | pyhwpx | pyhwp |
|
|
@@ -93,7 +62,7 @@ Dynamic: license-file
|
|
|
93
62
|
|
|
94
63
|
## 🌍 크로스 플랫폼 지원
|
|
95
64
|
|
|
96
|
-
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로
|
|
65
|
+
HWPX 파일은 **ZIP + XML** 구조이므로, 한/글 프로그램 없이 Python만으로 읽고 편집하는 워크플로를 구성할 수 있습니다.
|
|
97
66
|
|
|
98
67
|
| 플랫폼 | 읽기 | 쓰기 | 비고 |
|
|
99
68
|
|--------|------|------|------|
|
|
@@ -115,14 +84,14 @@ pip install python-hwpx
|
|
|
115
84
|
```python
|
|
116
85
|
from hwpx import HwpxDocument
|
|
117
86
|
|
|
118
|
-
# 기존 문서 열기
|
|
119
|
-
doc = HwpxDocument.open("보고서.hwpx")
|
|
120
|
-
|
|
121
87
|
# 빈 문서 새로 만들기
|
|
122
88
|
doc = HwpxDocument.new()
|
|
123
89
|
|
|
90
|
+
# 기존 문서를 수정하려면:
|
|
91
|
+
# doc = HwpxDocument.open("보고서.hwpx")
|
|
92
|
+
|
|
124
93
|
# 문단 추가
|
|
125
|
-
doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
|
|
94
|
+
paragraph = doc.add_paragraph("python-hwpx로 생성한 문단입니다.")
|
|
126
95
|
|
|
127
96
|
# 표 추가 (2×3)
|
|
128
97
|
table = doc.add_table(rows=2, cols=3)
|
|
@@ -130,9 +99,8 @@ table.set_cell_text(0, 0, "이름")
|
|
|
130
99
|
table.set_cell_text(0, 1, "부서")
|
|
131
100
|
table.set_cell_text(0, 2, "연락처")
|
|
132
101
|
|
|
133
|
-
# 메모 추가 (
|
|
134
|
-
paragraph =
|
|
135
|
-
doc.add_memo_with_anchor("검토 필요", paragraph=paragraph)
|
|
102
|
+
# 메모 추가 (기본 템플릿의 memo shape 사용)
|
|
103
|
+
doc.add_memo_with_anchor("검토 필요", paragraph=paragraph, memo_shape_id_ref="0")
|
|
136
104
|
|
|
137
105
|
# 저장
|
|
138
106
|
doc.save_to_path("결과물.hwpx")
|
|
@@ -166,7 +134,7 @@ doc.save_to_path("결과물.hwpx")
|
|
|
166
134
|
| 🎨 **스타일 치환** | 서식 기반 필터 | 색상/밑줄/charPrIDRef 기반 Run 검색 및 교체 |
|
|
167
135
|
| 📤 **내보내기** | 텍스트/HTML/Markdown | 문서 변환 출력 |
|
|
168
136
|
| ✅ **유효성 검사** | XSD + 패키지 구조 | CLI(`hwpx-validate`, `hwpx-validate-package`) 및 API |
|
|
169
|
-
| 🧰
|
|
137
|
+
| 🧰 **작업 도구** | unpack/pack/분석/비교 | pack-ready 작업 디렉터리 추출과 재구성 점검 |
|
|
170
138
|
| 🏗️ **저수준 XML** | 데이터클래스 매핑 | OWPML 스키마 ↔ Python 객체 직접 조작 |
|
|
171
139
|
| 🔄 **네임스페이스 호환** | 자동 정규화 | HWPML 2016 → 2011 자동 변환 |
|
|
172
140
|
|
|
@@ -174,7 +142,7 @@ doc.save_to_path("결과물.hwpx")
|
|
|
174
142
|
|
|
175
143
|
### 📄 문서 편집
|
|
176
144
|
|
|
177
|
-
문단, 표, 메모,
|
|
145
|
+
문단, 표, 메모, 머리글/바닥글을 Python 객체로 다룹니다.
|
|
178
146
|
|
|
179
147
|
```python
|
|
180
148
|
# 단락 추가·삭제
|
|
@@ -186,9 +154,9 @@ new_sec = doc.add_section() # 문서 끝에 섹션 추가
|
|
|
186
154
|
new_sec.add_paragraph("두 번째 섹션 내용")
|
|
187
155
|
doc.remove_section(1) # 인덱스로 섹션 삭제
|
|
188
156
|
|
|
189
|
-
#
|
|
157
|
+
# 머리글·바닥글
|
|
190
158
|
doc.set_header_text("기밀 문서", page_type="BOTH")
|
|
191
|
-
doc.set_footer_text("
|
|
159
|
+
doc.set_footer_text("1 / 10", page_type="BOTH")
|
|
192
160
|
|
|
193
161
|
# 표 셀 병합·분할
|
|
194
162
|
table.merge_cells(0, 0, 1, 1) # (0,0)~(1,1) 병합
|
|
@@ -201,13 +169,14 @@ table.set_cell_text(0, 0, "병합된 셀", logical=True, split_merged=True)
|
|
|
201
169
|
from hwpx import TextExtractor, ObjectFinder
|
|
202
170
|
|
|
203
171
|
# 텍스트 추출
|
|
204
|
-
|
|
205
|
-
for
|
|
206
|
-
|
|
172
|
+
with TextExtractor("문서.hwpx") as extractor:
|
|
173
|
+
for section in extractor.iter_sections():
|
|
174
|
+
for para in extractor.iter_paragraphs(section):
|
|
175
|
+
print(para.text())
|
|
207
176
|
|
|
208
177
|
# 특정 객체 탐색
|
|
209
|
-
for obj in ObjectFinder("문서.hwpx").
|
|
210
|
-
print(obj.tag, obj.
|
|
178
|
+
for obj in ObjectFinder("문서.hwpx").find_all(tag="tbl"):
|
|
179
|
+
print(obj.tag, obj.path)
|
|
211
180
|
```
|
|
212
181
|
|
|
213
182
|
### 🎨 스타일 기반 텍스트 치환
|
|
@@ -270,7 +239,7 @@ python-hwpx
|
|
|
270
239
|
│ ├── exporter # 텍스트/HTML/Markdown 내보내기
|
|
271
240
|
│ ├── validator # 스키마 유효성 검사 (hwpx-validate CLI)
|
|
272
241
|
│ ├── package_validator# ZIP/OPC/HWPX 구조 검사
|
|
273
|
-
│ ├── page_guard #
|
|
242
|
+
│ ├── page_guard # 구조 변화 징후 점검
|
|
274
243
|
│ └── template_analyzer# 레퍼런스 문서 분석/추출
|
|
275
244
|
└── hwpx.templates # 내장 빈 문서 템플릿
|
|
276
245
|
```
|
|
@@ -289,7 +258,7 @@ hwpx-unpack 문서.hwpx ./unpacked
|
|
|
289
258
|
hwpx-unpack 문서.hwpx ./pretty-unpacked --pretty-xml
|
|
290
259
|
hwpx-pack ./unpacked ./repacked.hwpx
|
|
291
260
|
|
|
292
|
-
# 레퍼런스
|
|
261
|
+
# 레퍼런스 문서 분석과 작업 디렉터리 추출
|
|
293
262
|
hwpx-analyze-template 문서.hwpx --extract-dir ./template-parts --json
|
|
294
263
|
hwpx-pack ./template-parts ./template-roundtrip.hwpx
|
|
295
264
|
hwpx-validate-package ./template-roundtrip.hwpx
|
|
@@ -297,15 +266,15 @@ hwpx-validate-package ./template-roundtrip.hwpx
|
|
|
297
266
|
# plain / markdown 텍스트 추출
|
|
298
267
|
hwpx-text-extract 문서.hwpx --format markdown --output 문서.md
|
|
299
268
|
|
|
300
|
-
#
|
|
269
|
+
# 문서 구조 변화 징후 비교
|
|
301
270
|
hwpx-page-guard --reference 원본.hwpx --output 결과.hwpx
|
|
302
271
|
```
|
|
303
272
|
|
|
304
|
-
`hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이
|
|
273
|
+
`hwpx-page-guard`는 렌더된 실제 쪽수를 계산하지 않습니다. 대신 단락 수, 표 수, shape/control 수, 명시적 page/column break, 텍스트 길이 같은 구조 지표를 비교해 편집 전후 변화 징후를 빠르게 점검합니다.
|
|
305
274
|
|
|
306
|
-
`hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를
|
|
275
|
+
`hwpx-validate-package`는 `Contents/content.hpf` 같은 고정 경로를 전제로 두지 않고, `META-INF/container.xml`과 실제 rootfile/manifest 선언을 따라가며 패키지 구조를 확인합니다. 엔진이 열 수 있는 비표준 패키지는 가능한 경우 경고로 분리해 보여줍니다.
|
|
307
276
|
|
|
308
|
-
`hwpx-analyze-template --extract-dir`는
|
|
277
|
+
`hwpx-analyze-template --extract-dir`는 다시 묶고 점검하기 쉬운 작업 디렉터리를 만듭니다. 재구성과 구조 검증에 필요한 파일을 함께 꺼내는 용도이며, 편집기에서의 최종 렌더링 결과까지 보장한다는 뜻은 아닙니다.
|
|
309
278
|
|
|
310
279
|
## 문서
|
|
311
280
|
|
|
@@ -352,13 +321,15 @@ pytest
|
|
|
352
321
|
|
|
353
322
|
## License
|
|
354
323
|
|
|
355
|
-
[
|
|
324
|
+
[Custom Non-Commercial License](LICENSE) © python-hwpx Maintainers
|
|
325
|
+
|
|
326
|
+
Commercial use requires separate permission from the copyright holders.
|
|
356
327
|
|
|
357
328
|
<br>
|
|
358
329
|
|
|
359
|
-
##
|
|
330
|
+
## Maintainer
|
|
360
331
|
|
|
361
|
-
**고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
332
|
+
Primary maintainer/contact: **고규현** — 광교고등학교 정보·컴퓨터 교사
|
|
362
333
|
|
|
363
334
|
- ✉️ [kokyuhyun@hotmail.com](mailto:kokyuhyun@hotmail.com)
|
|
364
335
|
- 🐙 [@airmang](https://github.com/airmang)
|
|
@@ -54,6 +54,7 @@ tests/test_memo_and_style_editing.py
|
|
|
54
54
|
tests/test_new_features.py
|
|
55
55
|
tests/test_opc_package.py
|
|
56
56
|
tests/test_oxml_parsing.py
|
|
57
|
+
tests/test_packaging_license_metadata.py
|
|
57
58
|
tests/test_packaging_py_typed.py
|
|
58
59
|
tests/test_paragraph_section_management.py
|
|
59
60
|
tests/test_repr_snapshots.py
|
|
@@ -238,6 +238,24 @@ def test_add_memo_with_anchor_creates_paragraph_when_missing() -> None:
|
|
|
238
238
|
assert field_end.get("beginIDRef") == "field-02"
|
|
239
239
|
|
|
240
240
|
|
|
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
|
+
|
|
241
259
|
def test_document_ensure_run_style_creates_bold_entry() -> None:
|
|
242
260
|
document, _, header = _build_document()
|
|
243
261
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tarfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from zipfile import ZipFile
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LICENSE_EXPRESSION = "LicenseRef-python-hwpx-NonCommercial"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_distribution(tmp_path: Path, distribution: str) -> Path:
|
|
16
|
+
pytest.importorskip("build")
|
|
17
|
+
|
|
18
|
+
project_root = Path(__file__).resolve().parents[1]
|
|
19
|
+
build_args = [
|
|
20
|
+
sys.executable,
|
|
21
|
+
"-m",
|
|
22
|
+
"build",
|
|
23
|
+
f"--{distribution}",
|
|
24
|
+
"--outdir",
|
|
25
|
+
str(tmp_path),
|
|
26
|
+
]
|
|
27
|
+
subprocess.run(build_args, cwd=project_root, check=True)
|
|
28
|
+
|
|
29
|
+
pattern = "*.whl" if distribution == "wheel" else "*.tar.gz"
|
|
30
|
+
return next(tmp_path.glob(pattern))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.parametrize("distribution", ["wheel", "sdist"])
|
|
34
|
+
def test_built_distributions_expose_custom_license_metadata(
|
|
35
|
+
tmp_path: Path, distribution: str
|
|
36
|
+
) -> None:
|
|
37
|
+
artifact = _build_distribution(tmp_path, distribution)
|
|
38
|
+
|
|
39
|
+
if distribution == "wheel":
|
|
40
|
+
with ZipFile(artifact) as wheel_archive:
|
|
41
|
+
members = set(wheel_archive.namelist())
|
|
42
|
+
metadata_name = next(
|
|
43
|
+
name for name in members if name.endswith(".dist-info/METADATA")
|
|
44
|
+
)
|
|
45
|
+
metadata = wheel_archive.read(metadata_name).decode("utf-8")
|
|
46
|
+
|
|
47
|
+
assert f"License-Expression: {LICENSE_EXPRESSION}" in metadata
|
|
48
|
+
assert "License-File: LICENSE" in metadata
|
|
49
|
+
assert "Classifier: License ::" not in metadata
|
|
50
|
+
assert any(name.endswith(".dist-info/licenses/LICENSE") for name in members)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
with tarfile.open(artifact, "r:gz") as sdist_archive:
|
|
54
|
+
members = sdist_archive.getnames()
|
|
55
|
+
pkg_info_name = next(name for name in members if name.endswith("/PKG-INFO"))
|
|
56
|
+
pkg_info_member = sdist_archive.extractfile(pkg_info_name)
|
|
57
|
+
assert pkg_info_member is not None
|
|
58
|
+
metadata = pkg_info_member.read().decode("utf-8")
|
|
59
|
+
|
|
60
|
+
assert f"License-Expression: {LICENSE_EXPRESSION}" in metadata
|
|
61
|
+
assert "License-File: LICENSE" in metadata
|
|
62
|
+
assert "Classifier: License ::" not in metadata
|
|
63
|
+
assert any(name.endswith("/LICENSE") for name in members)
|
|
@@ -141,3 +141,20 @@ def test_document_helpers_manage_header_apply_nodes() -> None:
|
|
|
141
141
|
|
|
142
142
|
document.remove_header(section=section)
|
|
143
143
|
assert sec_pr.find(f"{HP}headerApply") is None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_header_footer_helpers_work_on_real_hwpx_document() -> None:
|
|
147
|
+
document = HwpxDocument.new()
|
|
148
|
+
|
|
149
|
+
document.set_header_text("Header {{HDR1}}")
|
|
150
|
+
document.set_footer_text("Footer {{FTR1}}")
|
|
151
|
+
|
|
152
|
+
reopened = HwpxDocument.open(document.to_bytes())
|
|
153
|
+
|
|
154
|
+
header = reopened.sections[0].properties.get_header()
|
|
155
|
+
footer = reopened.sections[0].properties.get_footer()
|
|
156
|
+
|
|
157
|
+
assert header is not None
|
|
158
|
+
assert footer is not None
|
|
159
|
+
assert header.text == "Header {{HDR1}}"
|
|
160
|
+
assert footer.text == "Footer {{FTR1}}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|