python-hwpx 1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx/__init__.py +23 -0
- hwpx/document.py +518 -0
- hwpx/opc/package.py +274 -0
- hwpx/oxml/__init__.py +138 -0
- hwpx/oxml/body.py +151 -0
- hwpx/oxml/common.py +31 -0
- hwpx/oxml/document.py +1932 -0
- hwpx/oxml/header.py +543 -0
- hwpx/oxml/parser.py +62 -0
- hwpx/oxml/schema.py +41 -0
- hwpx/oxml/utils.py +82 -0
- hwpx/package.py +202 -0
- hwpx/tools/__init__.py +36 -0
- hwpx/tools/_schemas/header.xsd +14 -0
- hwpx/tools/_schemas/section.xsd +12 -0
- hwpx/tools/object_finder.py +347 -0
- hwpx/tools/text_extractor.py +726 -0
- hwpx/tools/validator.py +184 -0
- python_hwpx-1.0.dist-info/LICENSE +32 -0
- python_hwpx-1.0.dist-info/METADATA +199 -0
- python_hwpx-1.0.dist-info/RECORD +24 -0
- python_hwpx-1.0.dist-info/WHEEL +5 -0
- python_hwpx-1.0.dist-info/entry_points.txt +2 -0
- python_hwpx-1.0.dist-info/top_level.txt +1 -0
hwpx/oxml/header.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, List, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
from lxml import etree
|
|
9
|
+
|
|
10
|
+
from .common import GenericElement, parse_generic_element
|
|
11
|
+
from .utils import local_name, parse_bool, parse_int, text_or_none
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class BeginNum:
|
|
16
|
+
page: int
|
|
17
|
+
footnote: int
|
|
18
|
+
endnote: int
|
|
19
|
+
pic: int
|
|
20
|
+
tbl: int
|
|
21
|
+
equation: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class LinkInfo:
|
|
26
|
+
path: str
|
|
27
|
+
page_inherit: bool
|
|
28
|
+
footnote_inherit: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class LicenseMark:
|
|
33
|
+
type: int
|
|
34
|
+
flag: int
|
|
35
|
+
lang: Optional[int]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class DocOption:
|
|
40
|
+
link_info: LinkInfo
|
|
41
|
+
license_mark: Optional[LicenseMark] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class KeyDerivation:
|
|
46
|
+
algorithm: Optional[str]
|
|
47
|
+
size: Optional[int]
|
|
48
|
+
count: Optional[int]
|
|
49
|
+
salt: Optional[bytes]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class KeyEncryption:
|
|
54
|
+
derivation_key: KeyDerivation
|
|
55
|
+
hash_value: bytes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class TrackChangeConfig:
|
|
60
|
+
flags: Optional[int]
|
|
61
|
+
encryption: Optional[KeyEncryption] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class FontSubstitution:
|
|
66
|
+
face: str
|
|
67
|
+
type: str
|
|
68
|
+
is_embedded: bool
|
|
69
|
+
binary_item_id_ref: Optional[str]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class FontTypeInfo:
|
|
74
|
+
attributes: Dict[str, str]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class Font:
|
|
79
|
+
id: Optional[int]
|
|
80
|
+
face: str
|
|
81
|
+
type: Optional[str]
|
|
82
|
+
is_embedded: bool
|
|
83
|
+
binary_item_id_ref: Optional[str]
|
|
84
|
+
substitution: Optional[FontSubstitution] = None
|
|
85
|
+
type_info: Optional[FontTypeInfo] = None
|
|
86
|
+
other_children: Dict[str, List[GenericElement]] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class FontFace:
|
|
91
|
+
lang: Optional[str]
|
|
92
|
+
font_cnt: Optional[int]
|
|
93
|
+
fonts: List[Font]
|
|
94
|
+
attributes: Dict[str, str] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class FontFaceList:
|
|
99
|
+
item_cnt: Optional[int]
|
|
100
|
+
fontfaces: List[FontFace]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(slots=True)
|
|
104
|
+
class BorderFillList:
|
|
105
|
+
item_cnt: Optional[int]
|
|
106
|
+
fills: List[GenericElement]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class TabProperties:
|
|
111
|
+
item_cnt: Optional[int]
|
|
112
|
+
tabs: List[GenericElement]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(slots=True)
|
|
116
|
+
class NumberingList:
|
|
117
|
+
item_cnt: Optional[int]
|
|
118
|
+
numberings: List[GenericElement]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True)
|
|
122
|
+
class CharProperty:
|
|
123
|
+
id: Optional[int]
|
|
124
|
+
attributes: Dict[str, str]
|
|
125
|
+
child_attributes: Dict[str, Dict[str, str]] = field(default_factory=dict)
|
|
126
|
+
child_elements: Dict[str, List[GenericElement]] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True)
|
|
130
|
+
class CharPropertyList:
|
|
131
|
+
item_cnt: Optional[int]
|
|
132
|
+
properties: List[CharProperty]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class ForbiddenWordList:
|
|
137
|
+
item_cnt: Optional[int]
|
|
138
|
+
words: List[str]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(slots=True)
|
|
142
|
+
class MemoShape:
|
|
143
|
+
id: Optional[int]
|
|
144
|
+
width: Optional[int]
|
|
145
|
+
line_width: Optional[str]
|
|
146
|
+
line_type: Optional[str]
|
|
147
|
+
line_color: Optional[str]
|
|
148
|
+
fill_color: Optional[str]
|
|
149
|
+
active_color: Optional[str]
|
|
150
|
+
memo_type: Optional[str]
|
|
151
|
+
attributes: Dict[str, str] = field(default_factory=dict)
|
|
152
|
+
|
|
153
|
+
def matches_id(self, memo_shape_id_ref: int | str | None) -> bool:
|
|
154
|
+
if memo_shape_id_ref is None:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if isinstance(memo_shape_id_ref, str):
|
|
158
|
+
candidate = memo_shape_id_ref.strip()
|
|
159
|
+
else:
|
|
160
|
+
candidate = str(memo_shape_id_ref)
|
|
161
|
+
|
|
162
|
+
if not candidate:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
raw_id = self.attributes.get("id")
|
|
166
|
+
if raw_id is not None and candidate == raw_id:
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
if self.id is None:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
return int(candidate) == self.id
|
|
174
|
+
except (TypeError, ValueError): # pragma: no cover - defensive branch
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(slots=True)
|
|
179
|
+
class MemoProperties:
|
|
180
|
+
item_cnt: Optional[int]
|
|
181
|
+
memo_shapes: List[MemoShape]
|
|
182
|
+
attributes: Dict[str, str] = field(default_factory=dict)
|
|
183
|
+
|
|
184
|
+
def shape_by_id(self, memo_shape_id_ref: int | str | None) -> Optional[MemoShape]:
|
|
185
|
+
for shape in self.memo_shapes:
|
|
186
|
+
if shape.matches_id(memo_shape_id_ref):
|
|
187
|
+
return shape
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def as_dict(self) -> Dict[str, MemoShape]:
|
|
191
|
+
mapping: Dict[str, MemoShape] = {}
|
|
192
|
+
for shape in self.memo_shapes:
|
|
193
|
+
raw_id = shape.attributes.get("id")
|
|
194
|
+
keys: List[str] = []
|
|
195
|
+
if raw_id:
|
|
196
|
+
keys.append(raw_id)
|
|
197
|
+
try:
|
|
198
|
+
normalized = str(int(raw_id))
|
|
199
|
+
except ValueError:
|
|
200
|
+
normalized = None
|
|
201
|
+
if normalized and normalized not in keys:
|
|
202
|
+
keys.append(normalized)
|
|
203
|
+
elif shape.id is not None:
|
|
204
|
+
keys.append(str(shape.id))
|
|
205
|
+
|
|
206
|
+
for key in keys:
|
|
207
|
+
if key not in mapping:
|
|
208
|
+
mapping[key] = shape
|
|
209
|
+
return mapping
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(slots=True)
|
|
213
|
+
class RefList:
|
|
214
|
+
fontfaces: Optional[FontFaceList] = None
|
|
215
|
+
border_fills: Optional[BorderFillList] = None
|
|
216
|
+
char_properties: Optional[CharPropertyList] = None
|
|
217
|
+
tab_properties: Optional[TabProperties] = None
|
|
218
|
+
numberings: Optional[NumberingList] = None
|
|
219
|
+
memo_properties: Optional[MemoProperties] = None
|
|
220
|
+
other_collections: Dict[str, List[GenericElement]] = field(default_factory=dict)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(slots=True)
|
|
224
|
+
class Header:
|
|
225
|
+
version: str
|
|
226
|
+
sec_cnt: int
|
|
227
|
+
begin_num: Optional[BeginNum] = None
|
|
228
|
+
ref_list: Optional[RefList] = None
|
|
229
|
+
forbidden_word_list: Optional[ForbiddenWordList] = None
|
|
230
|
+
compatible_document: Optional[GenericElement] = None
|
|
231
|
+
doc_option: Optional[DocOption] = None
|
|
232
|
+
meta_tag: Optional[str] = None
|
|
233
|
+
track_change_config: Optional[TrackChangeConfig] = None
|
|
234
|
+
other_elements: Dict[str, List[GenericElement]] = field(default_factory=dict)
|
|
235
|
+
|
|
236
|
+
def memo_shape(self, memo_shape_id_ref: int | str | None) -> Optional[MemoShape]:
|
|
237
|
+
if self.ref_list is None or self.ref_list.memo_properties is None:
|
|
238
|
+
return None
|
|
239
|
+
return self.ref_list.memo_properties.shape_by_id(memo_shape_id_ref)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_begin_num(node: etree._Element) -> BeginNum:
|
|
243
|
+
return BeginNum(
|
|
244
|
+
page=parse_int(node.get("page"), allow_none=False),
|
|
245
|
+
footnote=parse_int(node.get("footnote"), allow_none=False),
|
|
246
|
+
endnote=parse_int(node.get("endnote"), allow_none=False),
|
|
247
|
+
pic=parse_int(node.get("pic"), allow_none=False),
|
|
248
|
+
tbl=parse_int(node.get("tbl"), allow_none=False),
|
|
249
|
+
equation=parse_int(node.get("equation"), allow_none=False),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_link_info(node: etree._Element) -> LinkInfo:
|
|
254
|
+
return LinkInfo(
|
|
255
|
+
path=node.get("path", ""),
|
|
256
|
+
page_inherit=parse_bool(node.get("pageInherit"), default=False) or False,
|
|
257
|
+
footnote_inherit=parse_bool(node.get("footnoteInherit"), default=False) or False,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def parse_license_mark(node: etree._Element) -> LicenseMark:
|
|
262
|
+
return LicenseMark(
|
|
263
|
+
type=parse_int(node.get("type"), allow_none=False),
|
|
264
|
+
flag=parse_int(node.get("flag"), allow_none=False),
|
|
265
|
+
lang=parse_int(node.get("lang")),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def parse_doc_option(node: etree._Element) -> DocOption:
|
|
270
|
+
link_info: Optional[LinkInfo] = None
|
|
271
|
+
license_mark: Optional[LicenseMark] = None
|
|
272
|
+
|
|
273
|
+
for child in node:
|
|
274
|
+
name = local_name(child)
|
|
275
|
+
if name == "linkinfo":
|
|
276
|
+
link_info = parse_link_info(child)
|
|
277
|
+
elif name == "licensemark":
|
|
278
|
+
license_mark = parse_license_mark(child)
|
|
279
|
+
|
|
280
|
+
if link_info is None:
|
|
281
|
+
raise ValueError("docOption element is missing required linkinfo child")
|
|
282
|
+
|
|
283
|
+
return DocOption(link_info=link_info, license_mark=license_mark)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _decode_base64(value: Optional[str]) -> Optional[bytes]:
|
|
287
|
+
if not value:
|
|
288
|
+
return None
|
|
289
|
+
try:
|
|
290
|
+
return base64.b64decode(value)
|
|
291
|
+
except (ValueError, binascii.Error) as exc: # pragma: no cover - defensive branch
|
|
292
|
+
raise ValueError("Invalid base64 value") from exc
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def parse_key_encryption(node: etree._Element) -> Optional[KeyEncryption]:
|
|
296
|
+
derivation_node: Optional[etree._Element] = None
|
|
297
|
+
hash_node: Optional[etree._Element] = None
|
|
298
|
+
for child in node:
|
|
299
|
+
name = local_name(child)
|
|
300
|
+
if name == "derivationKey":
|
|
301
|
+
derivation_node = child
|
|
302
|
+
elif name == "hash":
|
|
303
|
+
hash_node = child
|
|
304
|
+
|
|
305
|
+
if derivation_node is None or hash_node is None:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
derivation = KeyDerivation(
|
|
309
|
+
algorithm=derivation_node.get("algorithm"),
|
|
310
|
+
size=parse_int(derivation_node.get("size")),
|
|
311
|
+
count=parse_int(derivation_node.get("count")),
|
|
312
|
+
salt=_decode_base64(derivation_node.get("salt")),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
hash_text = text_or_none(hash_node) or ""
|
|
316
|
+
hash_bytes = _decode_base64(hash_text) or b""
|
|
317
|
+
return KeyEncryption(derivation_key=derivation, hash_value=hash_bytes)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def parse_track_change_config(node: etree._Element) -> TrackChangeConfig:
|
|
321
|
+
encryption: Optional[KeyEncryption] = None
|
|
322
|
+
for child in node:
|
|
323
|
+
if local_name(child) == "trackChangeEncrpytion":
|
|
324
|
+
encryption = parse_key_encryption(child)
|
|
325
|
+
break
|
|
326
|
+
return TrackChangeConfig(flags=parse_int(node.get("flags")), encryption=encryption)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_font_substitution(node: etree._Element) -> FontSubstitution:
|
|
330
|
+
return FontSubstitution(
|
|
331
|
+
face=node.get("face", ""),
|
|
332
|
+
type=node.get("type", ""),
|
|
333
|
+
is_embedded=parse_bool(node.get("isEmbedded"), default=False) or False,
|
|
334
|
+
binary_item_id_ref=node.get("binaryItemIDRef"),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def parse_font_type_info(node: etree._Element) -> FontTypeInfo:
|
|
339
|
+
return FontTypeInfo(attributes={key: value for key, value in node.attrib.items()})
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def parse_font(node: etree._Element) -> Font:
|
|
343
|
+
substitution: Optional[FontSubstitution] = None
|
|
344
|
+
type_info: Optional[FontTypeInfo] = None
|
|
345
|
+
other_children: Dict[str, List[GenericElement]] = {}
|
|
346
|
+
|
|
347
|
+
for child in node:
|
|
348
|
+
name = local_name(child)
|
|
349
|
+
if name == "substFont":
|
|
350
|
+
substitution = parse_font_substitution(child)
|
|
351
|
+
elif name == "typeInfo":
|
|
352
|
+
type_info = parse_font_type_info(child)
|
|
353
|
+
else:
|
|
354
|
+
other_children.setdefault(name, []).append(parse_generic_element(child))
|
|
355
|
+
|
|
356
|
+
return Font(
|
|
357
|
+
id=parse_int(node.get("id")),
|
|
358
|
+
face=node.get("face", ""),
|
|
359
|
+
type=node.get("type"),
|
|
360
|
+
is_embedded=parse_bool(node.get("isEmbedded"), default=False) or False,
|
|
361
|
+
binary_item_id_ref=node.get("binaryItemIDRef"),
|
|
362
|
+
substitution=substitution,
|
|
363
|
+
type_info=type_info,
|
|
364
|
+
other_children=other_children,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def parse_font_face(node: etree._Element) -> FontFace:
|
|
369
|
+
fonts = [parse_font(child) for child in node if local_name(child) == "font"]
|
|
370
|
+
attributes = {key: value for key, value in node.attrib.items()}
|
|
371
|
+
return FontFace(
|
|
372
|
+
lang=node.get("lang"),
|
|
373
|
+
font_cnt=parse_int(node.get("fontCnt")),
|
|
374
|
+
fonts=fonts,
|
|
375
|
+
attributes=attributes,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_font_faces(node: etree._Element) -> FontFaceList:
|
|
380
|
+
fontfaces = [parse_font_face(child) for child in node if local_name(child) == "fontface"]
|
|
381
|
+
return FontFaceList(item_cnt=parse_int(node.get("itemCnt")), fontfaces=fontfaces)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def parse_border_fills(node: etree._Element) -> BorderFillList:
|
|
385
|
+
fills = [parse_generic_element(child) for child in node if local_name(child) == "borderFill"]
|
|
386
|
+
return BorderFillList(item_cnt=parse_int(node.get("itemCnt")), fills=fills)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def parse_char_property(node: etree._Element) -> CharProperty:
|
|
390
|
+
child_attributes: Dict[str, Dict[str, str]] = {}
|
|
391
|
+
child_elements: Dict[str, List[GenericElement]] = {}
|
|
392
|
+
for child in node:
|
|
393
|
+
if len(child) == 0 and (child.text is None or not child.text.strip()):
|
|
394
|
+
child_attributes[local_name(child)] = {
|
|
395
|
+
key: value for key, value in child.attrib.items()
|
|
396
|
+
}
|
|
397
|
+
else:
|
|
398
|
+
child_elements.setdefault(local_name(child), []).append(parse_generic_element(child))
|
|
399
|
+
|
|
400
|
+
return CharProperty(
|
|
401
|
+
id=parse_int(node.get("id")),
|
|
402
|
+
attributes={key: value for key, value in node.attrib.items() if key != "id"},
|
|
403
|
+
child_attributes=child_attributes,
|
|
404
|
+
child_elements=child_elements,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def parse_char_properties(node: etree._Element) -> CharPropertyList:
|
|
409
|
+
properties = [
|
|
410
|
+
parse_char_property(child) for child in node if local_name(child) == "charPr"
|
|
411
|
+
]
|
|
412
|
+
return CharPropertyList(item_cnt=parse_int(node.get("itemCnt")), properties=properties)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def parse_tab_properties(node: etree._Element) -> TabProperties:
|
|
416
|
+
tabs = [parse_generic_element(child) for child in node if local_name(child) == "tabPr"]
|
|
417
|
+
return TabProperties(item_cnt=parse_int(node.get("itemCnt")), tabs=tabs)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def parse_numberings(node: etree._Element) -> NumberingList:
|
|
421
|
+
numberings = [
|
|
422
|
+
parse_generic_element(child) for child in node if local_name(child) == "numbering"
|
|
423
|
+
]
|
|
424
|
+
return NumberingList(item_cnt=parse_int(node.get("itemCnt")), numberings=numberings)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def parse_forbidden_word_list(node: etree._Element) -> ForbiddenWordList:
|
|
428
|
+
words = [text_or_none(child) or "" for child in node if local_name(child) == "forbiddenWord"]
|
|
429
|
+
return ForbiddenWordList(item_cnt=parse_int(node.get("itemCnt")), words=words)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def memo_shape_from_attributes(attrs: Mapping[str, str]) -> MemoShape:
|
|
433
|
+
return MemoShape(
|
|
434
|
+
id=parse_int(attrs.get("id")),
|
|
435
|
+
width=parse_int(attrs.get("width")),
|
|
436
|
+
line_width=attrs.get("lineWidth"),
|
|
437
|
+
line_type=attrs.get("lineType"),
|
|
438
|
+
line_color=attrs.get("lineColor"),
|
|
439
|
+
fill_color=attrs.get("fillColor"),
|
|
440
|
+
active_color=attrs.get("activeColor"),
|
|
441
|
+
memo_type=attrs.get("memoType"),
|
|
442
|
+
attributes=dict(attrs),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def parse_memo_shape(node: etree._Element) -> MemoShape:
|
|
447
|
+
return memo_shape_from_attributes(node.attrib)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def parse_memo_properties(node: etree._Element) -> MemoProperties:
|
|
451
|
+
memo_shapes = [
|
|
452
|
+
parse_memo_shape(child) for child in node if local_name(child) == "memoPr"
|
|
453
|
+
]
|
|
454
|
+
attributes = {key: value for key, value in node.attrib.items() if key != "itemCnt"}
|
|
455
|
+
return MemoProperties(
|
|
456
|
+
item_cnt=parse_int(node.get("itemCnt")),
|
|
457
|
+
memo_shapes=memo_shapes,
|
|
458
|
+
attributes=attributes,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def parse_ref_list(node: etree._Element) -> RefList:
|
|
463
|
+
ref_list = RefList()
|
|
464
|
+
for child in node:
|
|
465
|
+
name = local_name(child)
|
|
466
|
+
if name == "fontfaces":
|
|
467
|
+
ref_list.fontfaces = parse_font_faces(child)
|
|
468
|
+
elif name == "borderFills":
|
|
469
|
+
ref_list.border_fills = parse_border_fills(child)
|
|
470
|
+
elif name == "charProperties":
|
|
471
|
+
ref_list.char_properties = parse_char_properties(child)
|
|
472
|
+
elif name == "tabProperties":
|
|
473
|
+
ref_list.tab_properties = parse_tab_properties(child)
|
|
474
|
+
elif name == "numberings":
|
|
475
|
+
ref_list.numberings = parse_numberings(child)
|
|
476
|
+
elif name == "memoProperties":
|
|
477
|
+
ref_list.memo_properties = parse_memo_properties(child)
|
|
478
|
+
else:
|
|
479
|
+
ref_list.other_collections.setdefault(name, []).append(parse_generic_element(child))
|
|
480
|
+
return ref_list
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def parse_header_element(node: etree._Element) -> Header:
|
|
484
|
+
version = node.get("version")
|
|
485
|
+
if version is None:
|
|
486
|
+
raise ValueError("Header element is missing required version attribute")
|
|
487
|
+
sec_cnt = parse_int(node.get("secCnt"), allow_none=False)
|
|
488
|
+
|
|
489
|
+
header = Header(version=version, sec_cnt=sec_cnt)
|
|
490
|
+
|
|
491
|
+
for child in node:
|
|
492
|
+
name = local_name(child)
|
|
493
|
+
if name == "beginNum":
|
|
494
|
+
header.begin_num = parse_begin_num(child)
|
|
495
|
+
elif name == "refList":
|
|
496
|
+
header.ref_list = parse_ref_list(child)
|
|
497
|
+
elif name == "forbiddenWordList":
|
|
498
|
+
header.forbidden_word_list = parse_forbidden_word_list(child)
|
|
499
|
+
elif name == "compatibleDocument":
|
|
500
|
+
header.compatible_document = parse_generic_element(child)
|
|
501
|
+
elif name == "docOption":
|
|
502
|
+
header.doc_option = parse_doc_option(child)
|
|
503
|
+
elif name == "metaTag":
|
|
504
|
+
header.meta_tag = text_or_none(child)
|
|
505
|
+
elif name == "trackchangeConfig":
|
|
506
|
+
header.track_change_config = parse_track_change_config(child)
|
|
507
|
+
else:
|
|
508
|
+
header.other_elements.setdefault(name, []).append(parse_generic_element(child))
|
|
509
|
+
|
|
510
|
+
return header
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
__all__ = [
|
|
514
|
+
"BeginNum",
|
|
515
|
+
"BorderFillList",
|
|
516
|
+
"CharProperty",
|
|
517
|
+
"CharPropertyList",
|
|
518
|
+
"DocOption",
|
|
519
|
+
"Font",
|
|
520
|
+
"FontFace",
|
|
521
|
+
"FontFaceList",
|
|
522
|
+
"FontSubstitution",
|
|
523
|
+
"FontTypeInfo",
|
|
524
|
+
"ForbiddenWordList",
|
|
525
|
+
"Header",
|
|
526
|
+
"KeyDerivation",
|
|
527
|
+
"KeyEncryption",
|
|
528
|
+
"LinkInfo",
|
|
529
|
+
"LicenseMark",
|
|
530
|
+
"MemoProperties",
|
|
531
|
+
"MemoShape",
|
|
532
|
+
"NumberingList",
|
|
533
|
+
"RefList",
|
|
534
|
+
"TabProperties",
|
|
535
|
+
"TrackChangeConfig",
|
|
536
|
+
"memo_shape_from_attributes",
|
|
537
|
+
"parse_begin_num",
|
|
538
|
+
"parse_doc_option",
|
|
539
|
+
"parse_header_element",
|
|
540
|
+
"parse_memo_properties",
|
|
541
|
+
"parse_memo_shape",
|
|
542
|
+
"parse_ref_list",
|
|
543
|
+
]
|
hwpx/oxml/parser.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from lxml import etree
|
|
6
|
+
|
|
7
|
+
from . import body, header
|
|
8
|
+
from .common import parse_generic_element
|
|
9
|
+
from .schema import SchemaPath, load_schema
|
|
10
|
+
from .utils import XmlSource, coerce_xml_source, local_name
|
|
11
|
+
|
|
12
|
+
ParserFn = Callable[[etree._Element], object]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_ELEMENT_FACTORY: Dict[str, ParserFn] = {
|
|
16
|
+
"head": header.parse_header_element,
|
|
17
|
+
"beginNum": header.parse_begin_num,
|
|
18
|
+
"refList": header.parse_ref_list,
|
|
19
|
+
"docOption": header.parse_doc_option,
|
|
20
|
+
"sec": body.parse_section_element,
|
|
21
|
+
"p": body.parse_paragraph_element,
|
|
22
|
+
"run": body.parse_run_element,
|
|
23
|
+
"t": body.parse_text_span,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def element_to_model(node: etree._Element) -> object:
|
|
28
|
+
"""Convert *node* into the corresponding Python object."""
|
|
29
|
+
|
|
30
|
+
parser = _ELEMENT_FACTORY.get(local_name(node))
|
|
31
|
+
if parser is None:
|
|
32
|
+
return parse_generic_element(node)
|
|
33
|
+
return parser(node)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate(tree: etree._ElementTree, schema_path: Optional[SchemaPath], schema: Optional[etree.XMLSchema]) -> None:
|
|
37
|
+
schema_obj = schema or (load_schema(schema_path) if schema_path else None)
|
|
38
|
+
if schema_obj is not None:
|
|
39
|
+
schema_obj.assertValid(tree)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_header_xml(source: XmlSource, *, schema_path: Optional[SchemaPath] = None, schema: Optional[etree.XMLSchema] = None) -> header.Header:
|
|
43
|
+
root, tree = coerce_xml_source(source)
|
|
44
|
+
if local_name(root) != "head":
|
|
45
|
+
raise ValueError("Expected <head> root element")
|
|
46
|
+
_validate(tree, schema_path, schema)
|
|
47
|
+
return header.parse_header_element(root)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_section_xml(source: XmlSource, *, schema_path: Optional[SchemaPath] = None, schema: Optional[etree.XMLSchema] = None) -> body.Section:
|
|
51
|
+
root, tree = coerce_xml_source(source)
|
|
52
|
+
if local_name(root) != "sec":
|
|
53
|
+
raise ValueError("Expected <sec> root element")
|
|
54
|
+
_validate(tree, schema_path, schema)
|
|
55
|
+
return body.parse_section_element(root)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"element_to_model",
|
|
60
|
+
"parse_header_xml",
|
|
61
|
+
"parse_section_xml",
|
|
62
|
+
]
|
hwpx/oxml/schema.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Union
|
|
5
|
+
from urllib.parse import unquote, urlparse
|
|
6
|
+
|
|
7
|
+
from lxml import etree
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SchemaPath = Union[str, Path]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _LocalSchemaResolver(etree.Resolver):
|
|
14
|
+
"""Resolver that maps relative schema imports to local filesystem paths."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_dir: Path) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
self._base_dir = base_dir
|
|
19
|
+
|
|
20
|
+
def resolve(self, system_url: str, public_id: str, context: object):
|
|
21
|
+
parsed = urlparse(system_url)
|
|
22
|
+
|
|
23
|
+
if parsed.scheme in {"", "file"}:
|
|
24
|
+
if parsed.scheme == "file":
|
|
25
|
+
target = Path(unquote(parsed.path))
|
|
26
|
+
else:
|
|
27
|
+
target = self._base_dir / system_url
|
|
28
|
+
|
|
29
|
+
if target.exists():
|
|
30
|
+
return self.resolve_filename(str(target), context)
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_schema(path: SchemaPath) -> etree.XMLSchema:
|
|
35
|
+
"""Load an XML schema from *path* using :mod:`lxml`."""
|
|
36
|
+
|
|
37
|
+
schema_path = Path(path)
|
|
38
|
+
parser = etree.XMLParser()
|
|
39
|
+
parser.resolvers.add(_LocalSchemaResolver(schema_path.parent))
|
|
40
|
+
document = etree.parse(str(schema_path), parser)
|
|
41
|
+
return etree.XMLSchema(document)
|