返回 ppt-master
pptx_opc_validation.py
根目录 / skills / ppt-master / scripts / pptx_opc_validation.py
1 #!/usr/bin/env python3
2 """Shared, dependency-light OPC package relationship validation."""
3
4 from __future__ import annotations
5
6 import posixpath
7 import re
8 from pathlib import Path
9 from urllib.parse import urlsplit
10 from xml.etree import ElementTree as ET
11
12 from hyperlink_contract import (
13 HYPERLINK_REL_TYPE,
14 SLIDE_JUMP_ACTION,
15 SLIDE_REL_TYPE,
16 )
17
18
19 PACKAGE_REL_NS = (
20 "http://schemas.openxmlformats.org/package/2006/relationships"
21 )
22 _RELATIONSHIPS_TAG = f"{{{PACKAGE_REL_NS}}}Relationships"
23 _RELATIONSHIP_TAG = f"{{{PACKAGE_REL_NS}}}Relationship"
24 _DRAWINGML_NS = (
25 "http://schemas.openxmlformats.org/drawingml/2006/main"
26 )
27 _PRESENTATIONML_NS = (
28 "http://schemas.openxmlformats.org/presentationml/2006/main"
29 )
30 _OFFICE_REL_NS = (
31 "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
32 )
33 _OPC_UNRESERVED = frozenset(
34 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
35 )
36 _ASCII_LOWER_TRANSLATION = str.maketrans(
37 "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
38 "abcdefghijklmnopqrstuvwxyz",
39 )
40
41
42 def canonical_opc_part_path(path: str) -> str | None:
43 """Return an OPC-equivalent package path key, or None when invalid."""
44 if (
45 not path
46 or "\\" in path
47 or "?" in path
48 or "#" in path
49 or path.endswith("/")
50 or "//" in path
51 or any(ord(char) <= 0x20 for char in path)
52 ):
53 return None
54 output: list[str] = []
55 index = 0
56 while index < len(path):
57 char = path[index]
58 if char != "%":
59 output.append(char)
60 index += 1
61 continue
62 if (
63 index + 2 >= len(path)
64 or re.fullmatch(
65 r"[0-9A-Fa-f]{2}",
66 path[index + 1:index + 3],
67 )
68 is None
69 ):
70 return None
71 value = int(path[index + 1:index + 3], 16)
72 decoded = chr(value)
73 if value in {0, ord("/"), ord("\\")}:
74 return None
75 output.append(
76 decoded
77 if decoded in _OPC_UNRESERVED
78 else f"%{value:02X}"
79 )
80 index += 3
81
82 decoded_path = "".join(output)
83 if decoded_path.rsplit("/", 1)[-1] in {".", ".."}:
84 return None
85 normalized = posixpath.normpath(decoded_path)
86 if (
87 not normalized
88 or normalized in {".", ".."}
89 or normalized.startswith("/")
90 or normalized.startswith("../")
91 ):
92 return None
93 return normalized.translate(_ASCII_LOWER_TRANSLATION)
94
95
96 def _source_part_for_rels(rels_path: str) -> str | None:
97 filename = posixpath.basename(rels_path)
98 if filename == ".rels" or not filename.endswith(".rels"):
99 return None
100 source_dir = posixpath.dirname(posixpath.dirname(rels_path))
101 source_name = filename.removesuffix(".rels")
102 return (
103 posixpath.join(source_dir, source_name)
104 if source_dir
105 else source_name
106 )
107
108
109 def resolve_internal_opc_target(
110 rels_path: str,
111 target: str,
112 ) -> str | None:
113 """Resolve one valid internal OPC Target to its canonical package key."""
114 target_path_query = target.split("#", 1)[0]
115 if (
116 "\\" in target
117 or "?" in target_path_query
118 or any(ord(char) <= 0x20 for char in target)
119 ):
120 return None
121 try:
122 parsed = urlsplit(target)
123 except ValueError:
124 return None
125 if parsed.scheme or parsed.netloc or parsed.query:
126 return None
127
128 source_part = _source_part_for_rels(rels_path)
129 if parsed.path.startswith("/"):
130 resolved = parsed.path[1:]
131 elif parsed.path:
132 base_dir = posixpath.dirname(source_part) if source_part else ""
133 resolved = (
134 posixpath.join(base_dir, parsed.path)
135 if base_dir
136 else parsed.path
137 )
138 elif source_part and "#" in target:
139 resolved = source_part
140 else:
141 return None
142 return canonical_opc_part_path(resolved)
143
144
145 def _relationships_path_for_part(part_path: Path) -> Path:
146 return part_path.parent / "_rels" / f"{part_path.name}.rels"
147
148
149 def _relationship_attrs_by_id(rels_path: Path) -> dict[str, dict[str, str]]:
150 if not rels_path.is_file():
151 return {}
152 root = ET.parse(rels_path).getroot()
153 return {
154 elem.attrib["Id"]: dict(elem.attrib)
155 for elem in root.findall(_RELATIONSHIP_TAG)
156 if elem.attrib.get("Id")
157 }
158
159
160 def _presentation_slide_roster(extract_dir: Path) -> set[str]:
161 """Return canonical slide parts reachable from ``p:sldIdLst``."""
162 presentation = extract_dir / "ppt" / "presentation.xml"
163 presentation_rels = _relationships_path_for_part(presentation)
164 if not presentation.is_file() or not presentation_rels.is_file():
165 return set()
166 rels = _relationship_attrs_by_id(presentation_rels)
167 root = ET.parse(presentation).getroot()
168 roster: set[str] = set()
169 rels_rel = presentation_rels.relative_to(extract_dir).as_posix()
170 for slide_id in root.findall(
171 f"{{{_PRESENTATIONML_NS}}}sldIdLst/"
172 f"{{{_PRESENTATIONML_NS}}}sldId"
173 ):
174 relationship_id = slide_id.attrib.get(f"{{{_OFFICE_REL_NS}}}id")
175 relationship = rels.get(relationship_id or "")
176 if relationship is None or relationship.get("Type") != SLIDE_REL_TYPE:
177 continue
178 target = relationship.get("Target", "")
179 resolved = resolve_internal_opc_target(rels_rel, target)
180 if resolved is not None:
181 roster.add(resolved)
182 return roster
183
184
185 def verify_hyperlink_relationships(extract_dir: Path) -> list[str]:
186 """Return hyperlink/action mismatches and slide jumps outside the roster."""
187 roster = _presentation_slide_roster(extract_dir)
188 problems: list[str] = []
189 source_patterns = (
190 "ppt/slides/slide*.xml",
191 "ppt/slideLayouts/slideLayout*.xml",
192 "ppt/slideMasters/slideMaster*.xml",
193 )
194 for pattern in source_patterns:
195 for part_path in sorted(extract_dir.glob(pattern)):
196 rels_path = _relationships_path_for_part(part_path)
197 rels = _relationship_attrs_by_id(rels_path)
198 part_rel = part_path.relative_to(extract_dir).as_posix()
199 rels_rel = rels_path.relative_to(extract_dir).as_posix()
200 try:
201 root = ET.parse(part_path).getroot()
202 except ET.ParseError:
203 continue
204 for link in root.iter(f"{{{_DRAWINGML_NS}}}hlinkClick"):
205 action = (link.attrib.get("action") or "").strip()
206 if action == "ppaction://media":
207 continue
208 relationship_id = (
209 link.attrib.get(f"{{{_OFFICE_REL_NS}}}id") or ""
210 ).strip()
211 if not relationship_id:
212 if action == SLIDE_JUMP_ACTION:
213 problems.append(
214 f"{part_rel} -> <slide jump without relationship id>"
215 )
216 continue
217 relationship = rels.get(relationship_id)
218 if relationship is None:
219 problems.append(
220 f"{part_rel} -> <missing hyperlink relationship "
221 f"{relationship_id!r}>"
222 )
223 continue
224 rel_type = relationship.get("Type", "")
225 target_mode = relationship.get("TargetMode", "")
226 target = relationship.get("Target", "")
227 if rel_type == HYPERLINK_REL_TYPE:
228 if target_mode.lower() != "external":
229 problems.append(
230 f"{part_rel} -> <hyperlink relationship "
231 f"{relationship_id!r} is not External>"
232 )
233 if action == SLIDE_JUMP_ACTION:
234 problems.append(
235 f"{part_rel} -> <slide jump {relationship_id!r} "
236 "uses an external hyperlink relationship>"
237 )
238 continue
239 if rel_type == SLIDE_REL_TYPE:
240 if target_mode:
241 problems.append(
242 f"{part_rel} -> <slide relationship "
243 f"{relationship_id!r} cannot be External>"
244 )
245 if action != SLIDE_JUMP_ACTION:
246 problems.append(
247 f"{part_rel} -> <slide relationship "
248 f"{relationship_id!r} lacks hlinksldjump action>"
249 )
250 resolved = resolve_internal_opc_target(rels_rel, target)
251 if resolved is not None and resolved not in roster:
252 problems.append(
253 f"{part_rel} -> <slide jump {relationship_id!r} "
254 f"targets non-roster part {resolved}>"
255 )
256 continue
257 if action == SLIDE_JUMP_ACTION:
258 problems.append(
259 f"{part_rel} -> <slide jump {relationship_id!r} uses "
260 f"relationship type {rel_type!r}>"
261 )
262 return problems
263
264
265 def verify_internal_relationships(extract_dir: Path) -> list[str]:
266 """Return invalid or dangling internal relationships in an OPC package."""
267 package_parts: set[str] = set()
268 for path in extract_dir.rglob("*"):
269 if not path.is_file():
270 continue
271 key = canonical_opc_part_path(
272 path.relative_to(extract_dir).as_posix()
273 )
274 if key is not None:
275 package_parts.add(key)
276
277 problems: list[str] = []
278 for rels_path in sorted(extract_dir.rglob("*.rels")):
279 rels_rel = rels_path.relative_to(extract_dir).as_posix()
280 try:
281 root = ET.parse(rels_path).getroot()
282 except ET.ParseError as exc:
283 problems.append(
284 f"{rels_rel} -> <invalid relationships XML: {exc}>"
285 )
286 continue
287 if root.tag != _RELATIONSHIPS_TAG:
288 problems.append(
289 f"{rels_rel} -> <invalid Relationships namespace>"
290 )
291 continue
292
293 seen_ids: set[str] = set()
294 for element in root:
295 if element.tag != _RELATIONSHIP_TAG:
296 problems.append(
297 f"{rels_rel} -> <invalid relationships child "
298 f"{element.tag!r}>"
299 )
300 continue
301 relationship_id = (element.attrib.get("Id") or "").strip()
302 relationship_type = (
303 element.attrib.get("Type") or ""
304 ).strip()
305 target = (element.attrib.get("Target") or "").strip()
306 target_mode = (
307 element.attrib.get("TargetMode") or ""
308 ).strip()
309
310 if not relationship_id:
311 problems.append(f"{rels_rel} -> <missing relationship Id>")
312 elif relationship_id in seen_ids:
313 problems.append(
314 f"{rels_rel} -> <duplicate relationship Id "
315 f"{relationship_id!r}>"
316 )
317 else:
318 seen_ids.add(relationship_id)
319 if not relationship_type:
320 problems.append(
321 f"{rels_rel} -> <missing relationship Type>"
322 )
323 if not target:
324 problems.append(f"{rels_rel} -> <missing Target>")
325 continue
326 if target_mode and target_mode.lower() not in {
327 "internal",
328 "external",
329 }:
330 problems.append(
331 f"{rels_rel} -> <invalid TargetMode "
332 f"{target_mode!r}>"
333 )
334 continue
335 if target_mode.lower() == "external":
336 continue
337
338 resolved = resolve_internal_opc_target(rels_rel, target)
339 if resolved is None:
340 problems.append(
341 f"{rels_rel} -> <invalid Target {target!r}>"
342 )
343 elif resolved not in package_parts:
344 problems.append(f"{rels_rel} -> {resolved}")
345 problems.extend(verify_hyperlink_relationships(extract_dir))
346 return problems
347
347 lines PYTHON