返回 ppt-master
package.py
1 """Write-side OOXML package plumbing for the apply stage.
2
3 Content-type override insertion, relationship-element construction / lookup, and
4 part-number allocation used when cloning slides into a new package.
5 """
6
7 from __future__ import annotations
8
9 import posixpath
10 import re
11 from xml.etree import ElementTree as ET
12
13 from .ooxml import (
14 CT_NS,
15 NOTES_SLIDE_CONTENT_TYPE,
16 NS,
17 REL_NS,
18 SLIDE_CONTENT_TYPE,
19 _normalize_part,
20 _qn,
21 _rels_name_for_part,
22 )
23
24
25 def _content_type_root(root: ET.Element) -> ET.Element:
26 if root.tag != _qn(CT_NS, "Types"):
27 raise RuntimeError("[Content_Types].xml has an unexpected root element")
28 return root
29
30
31 def _add_content_type_override(content_root: ET.Element, part_name: str, content_type: str) -> None:
32 part_name = "/" + part_name.lstrip("/")
33 for override in content_root.findall(_qn(CT_NS, "Override")):
34 if override.attrib.get("PartName") == part_name:
35 return
36 ET.SubElement(
37 content_root,
38 _qn(CT_NS, "Override"),
39 {"PartName": part_name, "ContentType": content_type},
40 )
41
42
43 def _add_slide_override(content_root: ET.Element, part_name: str) -> None:
44 _add_content_type_override(content_root, part_name, SLIDE_CONTENT_TYPE)
45
46
47 def _add_notes_override(content_root: ET.Element, part_name: str) -> None:
48 _add_content_type_override(content_root, part_name, NOTES_SLIDE_CONTENT_TYPE)
49
50
51 def _empty_relationships_root() -> ET.Element:
52 return ET.Element(_qn(REL_NS, "Relationships"))
53
54
55 def _find_relationship(root: ET.Element, rel_id: str) -> ET.Element | None:
56 for rel in root.findall(_qn(REL_NS, "Relationship")):
57 if rel.attrib.get("Id") == rel_id:
58 return rel
59 return None
60
61
62 def _relative_target(from_part: str, to_part: str) -> str:
63 return posixpath.relpath(to_part, posixpath.dirname(from_part))
64
65
66 def _max_slide_part_number(entries: dict[str, bytes]) -> int:
67 max_number = 0
68 pattern = re.compile(r"^ppt/slides/slide(\d+)\.xml$")
69 for name in entries:
70 match = pattern.match(name)
71 if match:
72 max_number = max(max_number, int(match.group(1)))
73 return max_number
74
75
76 def _max_numeric_rid(root: ET.Element) -> int:
77 max_id = 0
78 for rel in root.findall(_qn(REL_NS, "Relationship")):
79 rel_id = rel.attrib.get("Id", "")
80 match = re.fullmatch(r"rId(\d+)", rel_id)
81 if match:
82 max_id = max(max_id, int(match.group(1)))
83 return max_id
84
85
86 def _max_slide_id(sld_id_lst: ET.Element) -> int:
87 max_id = 255
88 for sld_id in sld_id_lst.findall("p:sldId", NS):
89 try:
90 max_id = max(max_id, int(sld_id.attrib.get("id", "0")))
91 except ValueError:
92 continue
93 return max_id
94
95
96 def _enqueue_rel_targets(
97 entries: dict[str, bytes],
98 rels_part: str,
99 base_part: str,
100 queue: list[str],
101 ) -> None:
102 data = entries.get(rels_part)
103 if not data:
104 return
105 try:
106 root = ET.fromstring(data)
107 except ET.ParseError:
108 return
109 for rel in root.findall(_qn(REL_NS, "Relationship")):
110 if rel.attrib.get("TargetMode") == "External":
111 continue
112 target = rel.attrib.get("Target")
113 if target:
114 queue.append(_normalize_part(target, base_part or "x"))
115
116
117 def _reachable_parts(entries: dict[str, bytes]) -> set[str]:
118 """Parts reachable from the package root by following relationships."""
119 keep: set[str] = set()
120 queue: list[str] = []
121 _enqueue_rel_targets(entries, "_rels/.rels", "", queue)
122 while queue:
123 part = queue.pop()
124 if part in keep:
125 continue
126 keep.add(part)
127 _enqueue_rel_targets(entries, _rels_name_for_part(part), part, queue)
128 return keep
129
130
131 def _prune_unreferenced_parts(entries: dict[str, bytes], content_root: ET.Element) -> None:
132 """Drop parts not reachable from the package root through relationships.
133
134 After cloning only the planned slides, the original slide / notesSlide /
135 chart / embedding parts left in ``entries`` are orphaned — nothing in the
136 rebuilt presentation references them. Reachability GC removes that dead
137 weight so the output deck carries only the selected pages and their assets,
138 and prunes the matching ``[Content_Types].xml`` overrides.
139 """
140 reachable = _reachable_parts(entries)
141 keep = set(reachable)
142 keep.update({"[Content_Types].xml", "_rels/.rels"})
143 for part in reachable:
144 rels = _rels_name_for_part(part)
145 if rels in entries:
146 keep.add(rels)
147
148 for name in list(entries):
149 if name not in keep:
150 del entries[name]
151
152 for override in list(content_root.findall(_qn(CT_NS, "Override"))):
153 part_name = (override.attrib.get("PartName") or "").lstrip("/")
154 if part_name and part_name not in reachable:
155 content_root.remove(override)
156
156 lines PYTHON