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.
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/PKG-INFO +32 -24
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/README.md +31 -23
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/pyproject.toml +30 -4
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/opc/package.py +62 -97
- python_hwpx-2.8.2/src/hwpx/opc/relationships.py +227 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/document.py +34 -18
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/archive_cli.py +35 -11
- python_hwpx-2.8.2/src/hwpx/tools/package_validator.py +352 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/page_guard.py +12 -40
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/template_analyzer.py +35 -19
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/text_extractor.py +44 -27
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/PKG-INFO +32 -24
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/SOURCES.txt +1 -0
- python_hwpx-2.8.2/tests/test_gap_closure_tools.py +548 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_memo_and_style_editing.py +18 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_section_headers.py +17 -0
- python_hwpx-2.7.1/src/hwpx/tools/package_validator.py +0 -219
- python_hwpx-2.7.1/tests/test_gap_closure_tools.py +0 -221
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/LICENSE +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/setup.cfg +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/__init__.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/data/Skeleton.hwpx +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/document.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/opc/xml_utils.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/__init__.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/body.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/common.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/header.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/header_part.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/memo.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/namespaces.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/paragraph.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/parser.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/schema.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/section.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/table.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/oxml/utils.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/package.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/py.typed +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/templates.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/__init__.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/_schemas/header.xsd +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/_schemas/section.xsd +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/exporter.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/object_finder.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/text_extract_cli.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/hwpx/tools/validator.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/dependency_links.txt +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/entry_points.txt +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/requires.txt +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/src/python_hwpx.egg-info/top_level.txt +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_coverage_targets.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_document_context_manager.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_document_formatting.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_document_save_api.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_inline_models.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_integration_hwpx_compatibility.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_integration_roundtrip.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_new_features.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_opc_package.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_oxml_parsing.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_packaging_py_typed.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_paragraph_section_management.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_repr_snapshots.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_split_merged_cell.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_tables_default_border.py +0 -0
- {python_hwpx-2.7.1 → python_hwpx-2.8.2}/tests/test_text_extractor_annotations.py +0 -0
- {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.
|
|
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 =
|
|
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
|
-
| 🧰
|
|
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
|
-
문단, 표, 메모,
|
|
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("
|
|
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
|
-
|
|
205
|
-
for
|
|
206
|
-
|
|
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").
|
|
210
|
-
print(obj.tag, obj.
|
|
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 #
|
|
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 =
|
|
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
|
```
|
|
@@ -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
|
+
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 = [
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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 ==
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"{{{
|
|
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
|
-
|
|
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.
|
|
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
|
|
478
|
+
if changed_path in {self.CONTAINER_PATH, self.main_content.full_path}:
|
|
512
479
|
self._manifest_tree = None
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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,
|