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/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)