python-docx-oss 0.3.2__py3-none-any.whl → 0.4.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.
- docx/__init__.py +4 -2
- docx/inspection/__init__.py +24 -0
- docx/inspection/features.py +157 -0
- docx/inspection/fingerprint.py +150 -0
- docx/inspection/reader.py +245 -0
- docx/inspection/result.py +59 -0
- docx/inspection/source.py +40 -0
- docx/transforms/__init__.py +19 -0
- docx/transforms/hyperlinks.py +401 -0
- docx/transforms/normalize.py +93 -0
- docx/transforms/package.py +102 -0
- docx/transforms/repair.py +46 -0
- docx/transforms/result.py +48 -0
- {python_docx_oss-0.3.2.dist-info → python_docx_oss-0.4.0.dist-info}/METADATA +43 -4
- {python_docx_oss-0.3.2.dist-info → python_docx_oss-0.4.0.dist-info}/RECORD +18 -6
- {python_docx_oss-0.3.2.dist-info → python_docx_oss-0.4.0.dist-info}/WHEEL +1 -1
- {python_docx_oss-0.3.2.dist-info → python_docx_oss-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_docx_oss-0.3.2.dist-info → python_docx_oss-0.4.0.dist-info}/top_level.txt +0 -0
docx/__init__.py
CHANGED
|
@@ -9,14 +9,16 @@ from __future__ import annotations
|
|
|
9
9
|
from typing import TYPE_CHECKING, Type
|
|
10
10
|
|
|
11
11
|
from docx.api import Document
|
|
12
|
+
from docx.inspection import FingerprintPolicy, fingerprint, inspect
|
|
13
|
+
from docx.transforms import normalize, repair
|
|
12
14
|
|
|
13
15
|
if TYPE_CHECKING:
|
|
14
16
|
from docx.opc.part import Part
|
|
15
17
|
|
|
16
|
-
__version__ = "0.
|
|
18
|
+
__version__ = "0.4.0"
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
__all__ = ["Document"]
|
|
21
|
+
__all__ = ["Document", "FingerprintPolicy", "fingerprint", "inspect", "normalize", "repair"]
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
# -- register custom Part classes with opc package reader --
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Read-only package inspection and configurable content fingerprints."""
|
|
2
|
+
|
|
3
|
+
from docx.inspection.fingerprint import FingerprintPolicy, fingerprint
|
|
4
|
+
from docx.inspection.reader import inspect
|
|
5
|
+
from docx.inspection.result import (
|
|
6
|
+
Features,
|
|
7
|
+
Finding,
|
|
8
|
+
PackageInspection,
|
|
9
|
+
PackageInventory,
|
|
10
|
+
PartInventory,
|
|
11
|
+
RelationshipInventory,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Features",
|
|
16
|
+
"Finding",
|
|
17
|
+
"FingerprintPolicy",
|
|
18
|
+
"PackageInspection",
|
|
19
|
+
"PackageInventory",
|
|
20
|
+
"PartInventory",
|
|
21
|
+
"RelationshipInventory",
|
|
22
|
+
"fingerprint",
|
|
23
|
+
"inspect",
|
|
24
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""XML event targets keep no element tree, including for very large story parts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from docx.inspection.result import Features
|
|
8
|
+
|
|
9
|
+
W_NAMESPACES = (
|
|
10
|
+
"http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
11
|
+
"http://purl.oclc.org/ooxml/wordprocessingml/main",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def word_tag(tag: str) -> str:
|
|
16
|
+
for namespace in W_NAMESPACES:
|
|
17
|
+
prefix = "{" + namespace + "}"
|
|
18
|
+
if tag.startswith(prefix):
|
|
19
|
+
return tag[len(prefix) :]
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FieldCommand:
|
|
24
|
+
"""Match only the first token, keeping constant state across parser callbacks."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self._length = 0
|
|
28
|
+
self._done = False
|
|
29
|
+
self.matched = False
|
|
30
|
+
|
|
31
|
+
def feed(self, text: str) -> None:
|
|
32
|
+
for char in text:
|
|
33
|
+
if self._done:
|
|
34
|
+
break
|
|
35
|
+
if char.isspace():
|
|
36
|
+
if self._length:
|
|
37
|
+
self.finish()
|
|
38
|
+
elif self._length < len("HYPERLINK") and char.upper() == "HYPERLINK"[self._length]:
|
|
39
|
+
self._length += 1
|
|
40
|
+
else:
|
|
41
|
+
self._done = True
|
|
42
|
+
|
|
43
|
+
def finish(self) -> None:
|
|
44
|
+
if not self._done:
|
|
45
|
+
self.matched = self._length == len("HYPERLINK")
|
|
46
|
+
self._done = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class XmlFacts:
|
|
50
|
+
"""Observe field instructions across runs, without retaining instruction text."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, scan_features: bool = False):
|
|
53
|
+
self.scan_features = scan_features
|
|
54
|
+
self.field_hyperlink = False
|
|
55
|
+
self.split_hyperlink_runs = False
|
|
56
|
+
self._stack: list[str] = []
|
|
57
|
+
self._hyperlinks: list[tuple[int, int]] = []
|
|
58
|
+
self._instruction = False
|
|
59
|
+
self._fields: list[_FieldCommand] = []
|
|
60
|
+
self._loose_command = _FieldCommand()
|
|
61
|
+
|
|
62
|
+
def _finish_command(self, command: _FieldCommand) -> None:
|
|
63
|
+
command.finish()
|
|
64
|
+
self.field_hyperlink |= command.matched
|
|
65
|
+
|
|
66
|
+
def start(self, tag: str, attrs: Mapping[str, str]) -> None:
|
|
67
|
+
name = word_tag(tag) if self.scan_features else ""
|
|
68
|
+
self._stack.append(name)
|
|
69
|
+
if name == "hyperlink":
|
|
70
|
+
self._hyperlinks.append((len(self._stack), 0))
|
|
71
|
+
elif name == "r" and self._hyperlinks:
|
|
72
|
+
depth, count = self._hyperlinks[-1]
|
|
73
|
+
if len(self._stack) == depth + 1:
|
|
74
|
+
self._hyperlinks[-1] = (depth, count + 1)
|
|
75
|
+
self.split_hyperlink_runs |= count >= 1
|
|
76
|
+
elif name == "fldChar":
|
|
77
|
+
namespace = tag[: tag.index("}") + 1]
|
|
78
|
+
kind = attrs.get(namespace + "fldCharType")
|
|
79
|
+
if kind == "begin":
|
|
80
|
+
self._fields.append(_FieldCommand())
|
|
81
|
+
elif kind in ("separate", "end"):
|
|
82
|
+
command = self._fields[-1] if self._fields else self._loose_command
|
|
83
|
+
self._finish_command(command)
|
|
84
|
+
if kind == "end":
|
|
85
|
+
if self._fields:
|
|
86
|
+
self._fields.pop()
|
|
87
|
+
self._loose_command = _FieldCommand()
|
|
88
|
+
elif name == "instrText":
|
|
89
|
+
self._instruction = True
|
|
90
|
+
elif name == "fldSimple":
|
|
91
|
+
namespace = tag[: tag.index("}") + 1]
|
|
92
|
+
command = _FieldCommand()
|
|
93
|
+
command.feed(attrs.get(namespace + "instr", ""))
|
|
94
|
+
self._finish_command(command)
|
|
95
|
+
elif name == "p":
|
|
96
|
+
self._loose_command = _FieldCommand()
|
|
97
|
+
|
|
98
|
+
def end(self, tag: str) -> None:
|
|
99
|
+
name = self._stack.pop()
|
|
100
|
+
if name == "hyperlink":
|
|
101
|
+
self._hyperlinks.pop()
|
|
102
|
+
elif name == "instrText":
|
|
103
|
+
self._instruction = False
|
|
104
|
+
elif name == "p":
|
|
105
|
+
self._finish_command(self._loose_command)
|
|
106
|
+
self._loose_command = _FieldCommand()
|
|
107
|
+
|
|
108
|
+
def data(self, text: str) -> None:
|
|
109
|
+
if self._instruction:
|
|
110
|
+
command = self._fields[-1] if self._fields else self._loose_command
|
|
111
|
+
command.feed(text)
|
|
112
|
+
self.field_hyperlink |= command.matched
|
|
113
|
+
|
|
114
|
+
def start_ns(self, prefix: str | None, uri: str) -> None:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def end_ns(self, prefix: str | None) -> None:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def comment(self, text: str) -> None:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def pi(self, target: str, data: str) -> None:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def doctype(self, name: str | None, public_id: str | None, system_id: str | None) -> None:
|
|
127
|
+
raise ValueError("DTDs are not supported in package XML")
|
|
128
|
+
|
|
129
|
+
def close(self) -> Features:
|
|
130
|
+
self._finish_command(self._loose_command)
|
|
131
|
+
for command in self._fields:
|
|
132
|
+
self._finish_command(command)
|
|
133
|
+
return Features(self.field_hyperlink, self.split_hyperlink_runs)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class MetadataFacts(XmlFacts):
|
|
137
|
+
"""Keep OPC metadata entries, never arbitrary XML text or unrelated elements."""
|
|
138
|
+
|
|
139
|
+
def __init__(self):
|
|
140
|
+
super().__init__()
|
|
141
|
+
self.elements: list[tuple[str, dict[str, str]]] = []
|
|
142
|
+
self.root_tag: str | None = None
|
|
143
|
+
self._depth = 0
|
|
144
|
+
|
|
145
|
+
def start(self, tag: str, attrs: Mapping[str, str]) -> None:
|
|
146
|
+
if self._depth == 0:
|
|
147
|
+
self.root_tag = tag
|
|
148
|
+
self._depth += 1
|
|
149
|
+
if self._depth == 2 and tag in (
|
|
150
|
+
"{http://schemas.openxmlformats.org/package/2006/content-types}Default",
|
|
151
|
+
"{http://schemas.openxmlformats.org/package/2006/content-types}Override",
|
|
152
|
+
"{http://schemas.openxmlformats.org/package/2006/relationships}Relationship",
|
|
153
|
+
):
|
|
154
|
+
self.elements.append((tag, dict(attrs)))
|
|
155
|
+
|
|
156
|
+
def end(self, tag: str) -> None:
|
|
157
|
+
self._depth -= 1
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Stream sorted package members into a versioned, unambiguously framed digest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from fnmatch import fnmatchcase
|
|
9
|
+
from urllib.parse import unquote
|
|
10
|
+
from zipfile import ZipFile
|
|
11
|
+
|
|
12
|
+
from lxml import etree
|
|
13
|
+
|
|
14
|
+
from docx.inspection.features import MetadataFacts
|
|
15
|
+
from docx.inspection.reader import CT_NS, REL_NS, is_xml, scan_xml
|
|
16
|
+
from docx.inspection.source import PackageSource, open_source
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class FingerprintPolicy:
|
|
21
|
+
"""Explicit exclusions; the default includes every member and all XML content.
|
|
22
|
+
|
|
23
|
+
Part patterns match package-relative names using shell-style fnmatch (``*``
|
|
24
|
+
also matches ``/``). XML exclusions use expanded names, e.g. ``{namespace}tag``.
|
|
25
|
+
Relationship exclusions match complete Type URIs. Excluding a part does not
|
|
26
|
+
implicitly remove its relationships or Content-Type declaration.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
exclude_parts: tuple[str, ...] = ()
|
|
30
|
+
exclude_relationship_types: tuple[str, ...] = ()
|
|
31
|
+
exclude_xml_tags: tuple[str, ...] = ()
|
|
32
|
+
exclude_xml_attributes: tuple[str, ...] = ()
|
|
33
|
+
canonicalize_xml: bool = True
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not self.canonicalize_xml and (
|
|
37
|
+
self.exclude_relationship_types or self.exclude_xml_tags or self.exclude_xml_attributes
|
|
38
|
+
):
|
|
39
|
+
raise ValueError("XML exclusions require canonicalize_xml=True")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _CanonicalXml:
|
|
43
|
+
def __init__(self, write: Callable[[str], None], policy: FingerprintPolicy):
|
|
44
|
+
self._writer = etree.C14NWriterTarget(
|
|
45
|
+
write,
|
|
46
|
+
with_comments=True,
|
|
47
|
+
exclude_tags=set(policy.exclude_xml_tags),
|
|
48
|
+
exclude_attrs=set(policy.exclude_xml_attributes),
|
|
49
|
+
)
|
|
50
|
+
self._excluded_relationships = policy.exclude_relationship_types
|
|
51
|
+
self._skip_depth = 0
|
|
52
|
+
|
|
53
|
+
def start_ns(self, prefix: str | None, uri: str) -> None:
|
|
54
|
+
self._writer.start_ns(prefix or "", uri)
|
|
55
|
+
|
|
56
|
+
def end_ns(self, prefix: str | None) -> None:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def start(self, tag: str, attrs: Mapping[str, str]) -> None:
|
|
60
|
+
if self._skip_depth or (
|
|
61
|
+
tag == f"{{{REL_NS}}}Relationship" and attrs.get("Type") in self._excluded_relationships
|
|
62
|
+
):
|
|
63
|
+
self._skip_depth += 1
|
|
64
|
+
else:
|
|
65
|
+
self._writer.start(tag, dict(attrs))
|
|
66
|
+
|
|
67
|
+
def end(self, tag: str) -> None:
|
|
68
|
+
if self._skip_depth:
|
|
69
|
+
self._skip_depth -= 1
|
|
70
|
+
else:
|
|
71
|
+
self._writer.end(tag)
|
|
72
|
+
|
|
73
|
+
def data(self, text: str) -> None:
|
|
74
|
+
if not self._skip_depth:
|
|
75
|
+
self._writer.data(text)
|
|
76
|
+
|
|
77
|
+
def comment(self, text: str) -> None:
|
|
78
|
+
if not self._skip_depth:
|
|
79
|
+
self._writer.comment(text)
|
|
80
|
+
|
|
81
|
+
def pi(self, target: str, data: str) -> None:
|
|
82
|
+
if not self._skip_depth:
|
|
83
|
+
self._writer.pi(target, data)
|
|
84
|
+
|
|
85
|
+
def doctype(self, name: str | None, public_id: str | None, system_id: str | None) -> None:
|
|
86
|
+
raise ValueError("DTDs are not supported in package XML")
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
self._writer.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _xml_types(package: ZipFile) -> tuple[dict[str, str], dict[str, str]]:
|
|
93
|
+
if "[Content_Types].xml" not in package.namelist():
|
|
94
|
+
return {}, {}
|
|
95
|
+
target = MetadataFacts()
|
|
96
|
+
try:
|
|
97
|
+
scan_xml(package, package.getinfo("[Content_Types].xml"), target)
|
|
98
|
+
except (etree.XMLSyntaxError, ValueError) as exc:
|
|
99
|
+
raise ValueError(f"Cannot fingerprint XML part '[Content_Types].xml': {exc}") from exc
|
|
100
|
+
defaults: dict[str, str] = {}
|
|
101
|
+
overrides: dict[str, str] = {}
|
|
102
|
+
for tag, attrs in target.elements:
|
|
103
|
+
if tag == f"{{{CT_NS}}}Default":
|
|
104
|
+
defaults[attrs.get("Extension", "").lower()] = attrs.get("ContentType", "")
|
|
105
|
+
elif tag == f"{{{CT_NS}}}Override":
|
|
106
|
+
overrides[unquote(attrs.get("PartName", "")).lstrip("/")] = attrs.get("ContentType", "")
|
|
107
|
+
return defaults, overrides
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fingerprint(source: PackageSource, *, policy: FingerprintPolicy | None = None) -> str:
|
|
111
|
+
"""Return a SHA-256 content fingerprint, ignoring ZIP order and ZIP metadata.
|
|
112
|
+
|
|
113
|
+
XML uses C14N 2.0 with comments, preserving text whitespace and namespace
|
|
114
|
+
prefixes. This is a package-content comparison, not a semantic Word diff.
|
|
115
|
+
Malformed XML and ambiguous duplicate names raise; no partial hash is returned.
|
|
116
|
+
Stream ownership and position guarantees match inspect().
|
|
117
|
+
"""
|
|
118
|
+
policy = FingerprintPolicy() if policy is None else policy
|
|
119
|
+
with open_source(source) as stream, ZipFile(stream) as package:
|
|
120
|
+
members = [member for member in package.infolist() if not member.is_dir()]
|
|
121
|
+
if len({member.filename for member in members}) != len(members):
|
|
122
|
+
raise ValueError("Cannot fingerprint a package with duplicate member names")
|
|
123
|
+
defaults, overrides = _xml_types(package) if policy.canonicalize_xml else ({}, {})
|
|
124
|
+
digest = hashlib.sha256(b"python-docx-oss package fingerprint v1\0")
|
|
125
|
+
for member in sorted(members, key=lambda member: member.filename):
|
|
126
|
+
name = member.filename
|
|
127
|
+
if any(fnmatchcase(name, pattern) for pattern in policy.exclude_parts):
|
|
128
|
+
continue
|
|
129
|
+
part_digest = hashlib.sha256()
|
|
130
|
+
content_type = overrides.get(name) or defaults.get(name.rsplit(".", 1)[-1].lower())
|
|
131
|
+
with package.open(member) as content:
|
|
132
|
+
if policy.canonicalize_xml and is_xml(name, content_type):
|
|
133
|
+
target = _CanonicalXml(
|
|
134
|
+
lambda text: part_digest.update(text.encode("utf-8")), policy
|
|
135
|
+
)
|
|
136
|
+
parser = etree.XMLParser(target=target, resolve_entities=False, no_network=True)
|
|
137
|
+
try:
|
|
138
|
+
while chunk := content.read(64 * 1024):
|
|
139
|
+
parser.feed(chunk)
|
|
140
|
+
parser.close()
|
|
141
|
+
except (etree.XMLSyntaxError, ValueError) as exc:
|
|
142
|
+
raise ValueError(f"Cannot fingerprint XML part {name!r}: {exc}") from exc
|
|
143
|
+
else:
|
|
144
|
+
while chunk := content.read(64 * 1024):
|
|
145
|
+
part_digest.update(chunk)
|
|
146
|
+
encoded_name = name.encode("utf-8")
|
|
147
|
+
digest.update(len(encoded_name).to_bytes(8, "big"))
|
|
148
|
+
digest.update(encoded_name)
|
|
149
|
+
digest.update(part_digest.digest())
|
|
150
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Tolerant OPC inventory, independent of Document and the eager package reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
import zlib
|
|
7
|
+
from collections import deque
|
|
8
|
+
from urllib.parse import unquote, urlsplit
|
|
9
|
+
from zipfile import BadZipFile, ZipFile, ZipInfo
|
|
10
|
+
|
|
11
|
+
from lxml import etree
|
|
12
|
+
|
|
13
|
+
from docx.inspection.features import MetadataFacts, XmlFacts
|
|
14
|
+
from docx.inspection.result import (
|
|
15
|
+
Features,
|
|
16
|
+
Finding,
|
|
17
|
+
PackageInspection,
|
|
18
|
+
PackageInventory,
|
|
19
|
+
PartInventory,
|
|
20
|
+
RelationshipInventory,
|
|
21
|
+
)
|
|
22
|
+
from docx.inspection.source import PackageSource, open_source
|
|
23
|
+
|
|
24
|
+
CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
|
25
|
+
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
26
|
+
OFFICE_REL_BASES = (
|
|
27
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/",
|
|
28
|
+
"http://purl.oclc.org/ooxml/officeDocument/relationships/",
|
|
29
|
+
)
|
|
30
|
+
PACKAGE_ERRORS = (
|
|
31
|
+
BadZipFile,
|
|
32
|
+
EOFError,
|
|
33
|
+
OSError,
|
|
34
|
+
RuntimeError,
|
|
35
|
+
NotImplementedError,
|
|
36
|
+
UnicodeError,
|
|
37
|
+
zlib.error,
|
|
38
|
+
)
|
|
39
|
+
STORIES = {"officeDocument", "header", "footer", "footnotes", "endnotes", "comments"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def relationship_kind(relationship_type: str) -> str:
|
|
43
|
+
for base in OFFICE_REL_BASES:
|
|
44
|
+
if relationship_type.startswith(base):
|
|
45
|
+
return relationship_type[len(base) :]
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def relationship_source(name: str) -> str | None:
|
|
50
|
+
if name == "_rels/.rels":
|
|
51
|
+
return "/"
|
|
52
|
+
directory, filename = posixpath.split(name)
|
|
53
|
+
if posixpath.basename(directory) == "_rels" and filename.endswith(".rels"):
|
|
54
|
+
return posixpath.join(posixpath.dirname(directory), filename[:-5])
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_target(source: str, target: str) -> str:
|
|
59
|
+
path = unquote(urlsplit(target).path)
|
|
60
|
+
if path.startswith("/"):
|
|
61
|
+
return posixpath.normpath(path)[1:]
|
|
62
|
+
directory = "" if source == "/" else posixpath.dirname(source)
|
|
63
|
+
return posixpath.normpath(posixpath.join(directory, path))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def scan_xml(package: ZipFile, member: ZipInfo, target: XmlFacts) -> Features:
|
|
67
|
+
parser = etree.XMLParser(target=target, resolve_entities=False, no_network=True)
|
|
68
|
+
nonblank = False
|
|
69
|
+
with package.open(member) as stream:
|
|
70
|
+
while chunk := stream.read(64 * 1024):
|
|
71
|
+
nonblank |= bool(chunk.strip(b" \t\r\n"))
|
|
72
|
+
parser.feed(chunk)
|
|
73
|
+
if not nonblank:
|
|
74
|
+
raise EmptyXmlError("XML part is empty or contains only whitespace")
|
|
75
|
+
return parser.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EmptyXmlError(ValueError):
|
|
79
|
+
"""An XML member contains no document content."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_xml(name: str, content_type: str | None) -> bool:
|
|
83
|
+
return name.endswith((".xml", ".rels")) or bool(
|
|
84
|
+
content_type
|
|
85
|
+
and (content_type.endswith("+xml") or content_type in ("text/xml", "application/xml"))
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Inspection:
|
|
90
|
+
def __init__(self, package: ZipFile):
|
|
91
|
+
self.package = package
|
|
92
|
+
self.findings: list[Finding] = []
|
|
93
|
+
self.members: dict[str, ZipInfo] = {}
|
|
94
|
+
self.defaults: dict[str, str] = {}
|
|
95
|
+
self.overrides: dict[str, str] = {}
|
|
96
|
+
self.relationships: list[RelationshipInventory] = []
|
|
97
|
+
for member in package.infolist():
|
|
98
|
+
name = member.filename
|
|
99
|
+
if member.is_dir():
|
|
100
|
+
continue
|
|
101
|
+
if name in self.members:
|
|
102
|
+
self.findings.append(Finding("duplicate_member", name))
|
|
103
|
+
else:
|
|
104
|
+
self.members[name] = member
|
|
105
|
+
if name.startswith("/") or "\\" in name or ".." in name.split("/"):
|
|
106
|
+
self.findings.append(Finding("unsafe_member_name", name))
|
|
107
|
+
|
|
108
|
+
def _scan(self, name: str, target: XmlFacts) -> Features:
|
|
109
|
+
member = self.members[name]
|
|
110
|
+
if member.file_size == 0:
|
|
111
|
+
self.findings.append(Finding("empty_xml_part", name))
|
|
112
|
+
return Features()
|
|
113
|
+
try:
|
|
114
|
+
return scan_xml(self.package, member, target)
|
|
115
|
+
except EmptyXmlError:
|
|
116
|
+
self.findings.append(Finding("empty_xml_part", name))
|
|
117
|
+
except (etree.XMLSyntaxError, ValueError) as exc:
|
|
118
|
+
self.findings.append(Finding("unparseable_xml_part", name, str(exc)))
|
|
119
|
+
except PACKAGE_ERRORS as exc:
|
|
120
|
+
self.findings.append(Finding("unreadable_part", name, str(exc)))
|
|
121
|
+
return Features()
|
|
122
|
+
|
|
123
|
+
def _read_metadata(self) -> None:
|
|
124
|
+
if "[Content_Types].xml" not in self.members:
|
|
125
|
+
self.findings.append(Finding("missing_content_types"))
|
|
126
|
+
else:
|
|
127
|
+
target = MetadataFacts()
|
|
128
|
+
self._scan("[Content_Types].xml", target)
|
|
129
|
+
if target.root_tag != f"{{{CT_NS}}}Types":
|
|
130
|
+
self.findings.append(Finding("invalid_content_types", "[Content_Types].xml"))
|
|
131
|
+
for tag, attrs in target.elements:
|
|
132
|
+
if tag == f"{{{CT_NS}}}Default":
|
|
133
|
+
self.defaults[attrs.get("Extension", "").lower()] = attrs.get("ContentType", "")
|
|
134
|
+
elif tag == f"{{{CT_NS}}}Override":
|
|
135
|
+
self.overrides[unquote(attrs.get("PartName", "")).lstrip("/")] = attrs.get(
|
|
136
|
+
"ContentType", ""
|
|
137
|
+
)
|
|
138
|
+
for name in sorted(self.members):
|
|
139
|
+
source = relationship_source(name)
|
|
140
|
+
if source is None:
|
|
141
|
+
continue
|
|
142
|
+
target = MetadataFacts()
|
|
143
|
+
self._scan(name, target)
|
|
144
|
+
if target.root_tag != f"{{{REL_NS}}}Relationships":
|
|
145
|
+
self.findings.append(Finding("invalid_relationships", name))
|
|
146
|
+
continue
|
|
147
|
+
ids: set[str] = set()
|
|
148
|
+
for tag, attrs in target.elements:
|
|
149
|
+
if tag != f"{{{REL_NS}}}Relationship":
|
|
150
|
+
continue
|
|
151
|
+
raw_target = attrs.get("Target", "")
|
|
152
|
+
rel_id = attrs.get("Id", "")
|
|
153
|
+
if not raw_target or not rel_id or not attrs.get("Type") or rel_id in ids:
|
|
154
|
+
self.findings.append(Finding("invalid_relationships", name))
|
|
155
|
+
continue
|
|
156
|
+
ids.add(rel_id)
|
|
157
|
+
external = attrs.get("TargetMode") == "External"
|
|
158
|
+
try:
|
|
159
|
+
resolved = raw_target if external else resolve_target(source, raw_target)
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
self.findings.append(Finding("invalid_relationships", name, str(exc)))
|
|
162
|
+
continue
|
|
163
|
+
relationship = RelationshipInventory(
|
|
164
|
+
source, rel_id, attrs["Type"], resolved, external
|
|
165
|
+
)
|
|
166
|
+
self.relationships.append(relationship)
|
|
167
|
+
if not external and resolved not in self.members:
|
|
168
|
+
self.findings.append(
|
|
169
|
+
Finding("missing_relationship_target", name, target=resolved)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _reachable(self) -> set[str]:
|
|
173
|
+
edges: dict[str, list[str]] = {}
|
|
174
|
+
for rel in self.relationships:
|
|
175
|
+
if not rel.external:
|
|
176
|
+
edges.setdefault(rel.source, []).append(rel.target)
|
|
177
|
+
reached = {"/"}
|
|
178
|
+
pending = deque(["/"])
|
|
179
|
+
while pending:
|
|
180
|
+
for target in edges.get(pending.popleft(), []):
|
|
181
|
+
if target not in reached and target in self.members:
|
|
182
|
+
reached.add(target)
|
|
183
|
+
pending.append(target)
|
|
184
|
+
reached.add("[Content_Types].xml")
|
|
185
|
+
reached.update(name for name in self.members if relationship_source(name) in reached)
|
|
186
|
+
return reached
|
|
187
|
+
|
|
188
|
+
def content_type(self, name: str) -> str | None:
|
|
189
|
+
return self.overrides.get(name) or self.defaults.get(name.rsplit(".", 1)[-1].lower())
|
|
190
|
+
|
|
191
|
+
def report(self) -> PackageInspection:
|
|
192
|
+
self._read_metadata()
|
|
193
|
+
reachable = self._reachable()
|
|
194
|
+
main_parts = {
|
|
195
|
+
rel.target
|
|
196
|
+
for rel in self.relationships
|
|
197
|
+
if rel.source == "/"
|
|
198
|
+
and relationship_kind(rel.relationship_type) == "officeDocument"
|
|
199
|
+
and not rel.external
|
|
200
|
+
and rel.target in self.members
|
|
201
|
+
}
|
|
202
|
+
if not main_parts:
|
|
203
|
+
self.findings.append(Finding("missing_main_document"))
|
|
204
|
+
story_parts = main_parts | {
|
|
205
|
+
rel.target
|
|
206
|
+
for rel in self.relationships
|
|
207
|
+
if rel.source in reachable
|
|
208
|
+
and relationship_kind(rel.relationship_type) in STORIES
|
|
209
|
+
and not rel.external
|
|
210
|
+
}
|
|
211
|
+
features = Features()
|
|
212
|
+
for name in sorted(self.members):
|
|
213
|
+
if name == "[Content_Types].xml" or relationship_source(name) is not None:
|
|
214
|
+
continue
|
|
215
|
+
if not is_xml(name, self.content_type(name)):
|
|
216
|
+
continue
|
|
217
|
+
observed = self._scan(name, XmlFacts(name in story_parts))
|
|
218
|
+
features = Features(
|
|
219
|
+
features.field_hyperlink or observed.field_hyperlink,
|
|
220
|
+
features.split_hyperlink_runs or observed.split_hyperlink_runs,
|
|
221
|
+
)
|
|
222
|
+
inventory = PackageInventory(
|
|
223
|
+
tuple(
|
|
224
|
+
PartInventory(name, member.file_size, self.content_type(name), name in reachable)
|
|
225
|
+
for name, member in sorted(self.members.items())
|
|
226
|
+
),
|
|
227
|
+
tuple(self.relationships),
|
|
228
|
+
)
|
|
229
|
+
return PackageInspection(inventory, tuple(self.findings), features)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def inspect(source: PackageSource) -> PackageInspection:
|
|
233
|
+
"""Observe package structure and Word story features without constructing a Document.
|
|
234
|
+
|
|
235
|
+
Format failures become findings. Source opening failures propagate, and invalid
|
|
236
|
+
argument types raise TypeError.
|
|
237
|
+
Seekable streams are read from the beginning and restored; borrowed streams stay open.
|
|
238
|
+
Binary members are inventoried without reading their payloads.
|
|
239
|
+
"""
|
|
240
|
+
with open_source(source) as stream:
|
|
241
|
+
try:
|
|
242
|
+
with ZipFile(stream) as package:
|
|
243
|
+
return _Inspection(package).report()
|
|
244
|
+
except PACKAGE_ERRORS as exc:
|
|
245
|
+
return PackageInspection(findings=(Finding("not_a_package", message=str(exc)),))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Immutable observations of a package; findings do not prescribe repair policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Finding:
|
|
10
|
+
code: str
|
|
11
|
+
partname: str | None = None
|
|
12
|
+
message: str = ""
|
|
13
|
+
target: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PartInventory:
|
|
18
|
+
"""ZIP member metadata. Names are package-relative, without a leading slash."""
|
|
19
|
+
|
|
20
|
+
partname: str
|
|
21
|
+
size: int
|
|
22
|
+
content_type: str | None
|
|
23
|
+
reachable: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RelationshipInventory:
|
|
28
|
+
"""A relationship with its resolved internal target or original external URI."""
|
|
29
|
+
|
|
30
|
+
source: str
|
|
31
|
+
relationship_id: str
|
|
32
|
+
relationship_type: str
|
|
33
|
+
target: str
|
|
34
|
+
external: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PackageInventory:
|
|
39
|
+
parts: tuple[PartInventory, ...] = ()
|
|
40
|
+
relationships: tuple[RelationshipInventory, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Features:
|
|
45
|
+
field_hyperlink: bool = False
|
|
46
|
+
split_hyperlink_runs: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PackageInspection:
|
|
51
|
+
inventory: PackageInventory = PackageInventory()
|
|
52
|
+
findings: tuple[Finding, ...] = ()
|
|
53
|
+
features: Features = Features()
|
|
54
|
+
|
|
55
|
+
def has_finding(self, code: str) -> bool:
|
|
56
|
+
return any(finding.code == code for finding in self.findings)
|
|
57
|
+
|
|
58
|
+
def findings_by_code(self, code: str) -> tuple[Finding, ...]:
|
|
59
|
+
return tuple(finding for finding in self.findings if finding.code == code)
|