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