返回 ppt-master
clone.py
1 """apply: deep-clone a cloned slide's structured private dependency parts.
2
3 When the same source slide is reused for several output slides (the workflow
4 lets a fill plan list one ``source_slide`` many times), copying its
5 relationships verbatim leaves every clone pointing at one shared set of private
6 parts — custom-data tags, per-slide theme overrides, SmartArt diagrams. The
7 pages are not really independent: editing one output slide's structure would
8 bleed into its siblings.
9
10 This helper gives each cloned slide its own copy of every *structured* private
11 dependency (parts that carry an explicit content-type ``Override``) and rewrites
12 the relationship targets. Cloning is recursive, so a private part's own private
13 sub-parts (e.g. a diagram data part's drawing) are cloned too.
14
15 Two classes of target are deliberately left shared:
16
17 * **Shared structure** — slide layout / master / theme / notes master.
18 * **Binary blobs typed by a ``Default`` extension rule** — media (png / jpeg /
19 emf ...) and OLE embeddings. Picture/object edits happen in PowerPoint, which
20 mints a new part and repoints only the edited shape's relationship, so sharing
21 never bleeds — and it avoids duplicating large media when one source page
22 drives many output slides.
23
24 Charts stay owned by ``chart_fill`` (which clones the chart part together with
25 its embedded workbook when an edit is applied) and notes slides by ``notes``;
26 both relationship types are skipped here.
27 """
28
29 from __future__ import annotations
30
31 import posixpath
32 from typing import Callable
33 from xml.etree import ElementTree as ET
34
35 from .ooxml import (
36 CHART_REL_TYPE,
37 CT_NS,
38 NOTES_SLIDE_REL_TYPE,
39 REL_NS,
40 SLIDE_REL_TYPE,
41 _normalize_part,
42 _qn,
43 _rels_name_for_part,
44 _xml_bytes,
45 )
46 from .package import _add_content_type_override, _relative_target
47
48 _REL_TYPE_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
49
50 # Structure shared across every slide: never cloned, target kept as-is. Note that
51 # ``themeOverride`` is a distinct, per-slide private type and is NOT listed here.
52 SHARED_REL_TYPES = frozenset(
53 _REL_TYPE_BASE + name
54 for name in ("slideLayout", "slideMaster", "notesMaster", "theme", "presProps", "viewProps", "tableStyles")
55 )
56
57 # Owned by other apply stages (chart_fill / notes) or a back-reference: skipped here.
58 SKIPPED_REL_TYPES = frozenset({CHART_REL_TYPE, NOTES_SLIDE_REL_TYPE, SLIDE_REL_TYPE})
59
60
61 def _make_part_allocator(entries: dict[str, bytes]) -> Callable[[str], str]:
62 """Return a function that mints a fresh part name beside a source part.
63
64 Names keep the source extension (so a content-type ``Default`` still covers
65 media) and are unique against both existing entries and earlier allocations.
66 """
67 used = set(entries)
68
69 def allocate(source_part: str) -> str:
70 directory = posixpath.dirname(source_part)
71 stem, ext = posixpath.splitext(posixpath.basename(source_part))
72 index = 1
73 while True:
74 candidate = posixpath.join(directory, f"{stem}_tf{index}{ext}")
75 if candidate not in used:
76 used.add(candidate)
77 return candidate
78 index += 1
79
80 return allocate
81
82
83 def _override_content_type(content_root: ET.Element, part: str) -> str | None:
84 """Return the part's explicit content-type ``Override``, or ``None``.
85
86 ``None`` means the part is typed by a ``Default`` extension rule — i.e. a
87 binary blob (media / OLE) we deliberately keep shared rather than clone.
88 """
89 part_pn = "/" + part.lstrip("/")
90 for override in content_root.findall(_qn(CT_NS, "Override")):
91 if override.attrib.get("PartName") == part_pn:
92 return override.attrib.get("ContentType")
93 return None
94
95
96 def _is_shared(rel_type: str | None) -> bool:
97 return bool(rel_type) and rel_type in SHARED_REL_TYPES
98
99
100 def _clone_part_private_deps(
101 rels_root: ET.Element,
102 *,
103 owner_part: str,
104 entries: dict[str, bytes],
105 content_root: ET.Element,
106 allocate: Callable[[str], str],
107 cloned: dict[str, str],
108 ) -> None:
109 """Rewrite ``rels_root`` in place, cloning each private target it references.
110
111 ``cloned`` maps an already-handled source part to its clone so a single slide
112 that references the same asset twice reuses one copy.
113 """
114 for rel in rels_root.findall(_qn(REL_NS, "Relationship")):
115 if rel.attrib.get("TargetMode") == "External":
116 continue
117 rel_type = rel.attrib.get("Type")
118 if _is_shared(rel_type) or rel_type in SKIPPED_REL_TYPES:
119 continue
120 target = rel.attrib.get("Target")
121 if not target:
122 continue
123 source_part = _normalize_part(target, owner_part)
124 if source_part not in entries:
125 continue
126 content_type = _override_content_type(content_root, source_part)
127 if content_type is None:
128 # Binary blob typed by a Default extension rule (media / OLE): keep
129 # it shared. See the module docstring for why this never bleeds.
130 continue
131
132 new_part = cloned.get(source_part)
133 if new_part is None:
134 new_part = allocate(source_part)
135 entries[new_part] = entries[source_part]
136 cloned[source_part] = new_part
137 _add_content_type_override(content_root, new_part, content_type)
138
139 sub_rels_data = entries.get(_rels_name_for_part(source_part))
140 if sub_rels_data:
141 sub_rels_root = ET.fromstring(sub_rels_data)
142 _clone_part_private_deps(
143 sub_rels_root,
144 owner_part=new_part,
145 entries=entries,
146 content_root=content_root,
147 allocate=allocate,
148 cloned=cloned,
149 )
150 entries[_rels_name_for_part(new_part)] = _xml_bytes(sub_rels_root)
151
152 rel.set("Target", _relative_target(owner_part, new_part))
153
154
155 def deep_clone_slide_private_parts(
156 slide_rels_root: ET.Element,
157 *,
158 new_slide_part: str,
159 entries: dict[str, bytes],
160 content_root: ET.Element,
161 allocate: Callable[[str], str],
162 ) -> None:
163 """Give one cloned slide private copies of its private dependency parts.
164
165 Mutates ``slide_rels_root`` (rewriting targets) and ``entries`` (adding the
166 cloned parts and their content-type overrides). ``allocate`` is shared across
167 every slide in the run so minted names never collide.
168 """
169 _clone_part_private_deps(
170 slide_rels_root,
171 owner_part=new_slide_part,
172 entries=entries,
173 content_root=content_root,
174 allocate=allocate,
175 cloned={},
176 )
177
177 lines PYTHON