| 1 | """apply: replace text inside cloned slide shapes while keeping frames editable. |
| 2 | |
| 3 | ``_set_container_text`` is the shared text-writing primitive and is also reused |
| 4 | by ``table_fill`` for table-cell edits. |
| 5 | """ |
| 6 | |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | from typing import Any |
| 10 | from xml.etree import ElementTree as ET |
| 11 | |
| 12 | from .ooxml import NS, _qn, _shape_identity, _text_containers |
| 13 | from .selectors import _replacement_text |
| 14 | |
| 15 | |
| 16 | def _shape_key_maps(slide_root: ET.Element, source_slide: int) -> dict[str, ET.Element]: |
| 17 | maps: dict[str, ET.Element] = {} |
| 18 | for order, container in enumerate(_text_containers(slide_root), start=1): |
| 19 | shape_id, shape_name = _shape_identity(container, order) |
| 20 | maps[f"slot_id:s{source_slide:02d}_sh{shape_id}"] = container |
| 21 | maps[f"shape_id:{shape_id}"] = container |
| 22 | if shape_name: |
| 23 | maps[f"shape_name:{shape_name}"] = container |
| 24 | return maps |
| 25 | |
| 26 | |
| 27 | def _ensure_text_nodes(container: ET.Element) -> list[ET.Element]: |
| 28 | text_nodes = container.findall(".//a:t", NS) |
| 29 | if text_nodes: |
| 30 | return text_nodes |
| 31 | tx_body = container.find(".//p:txBody", NS) |
| 32 | if tx_body is None: |
| 33 | tx_body = container.find(".//a:txBody", NS) |
| 34 | if tx_body is None: |
| 35 | return [] |
| 36 | paragraph = tx_body.find("a:p", NS) |
| 37 | if paragraph is None: |
| 38 | paragraph = ET.SubElement(tx_body, _qn(NS["a"], "p")) |
| 39 | run = paragraph.find("a:r", NS) |
| 40 | if run is None: |
| 41 | run = ET.SubElement(paragraph, _qn(NS["a"], "r")) |
| 42 | text_node = run.find("a:t", NS) |
| 43 | if text_node is None: |
| 44 | text_node = ET.SubElement(run, _qn(NS["a"], "t")) |
| 45 | return [text_node] |
| 46 | |
| 47 | |
| 48 | def _ensure_paragraph_text_node(paragraph: ET.Element) -> list[ET.Element]: |
| 49 | text_nodes = paragraph.findall(".//a:t", NS) |
| 50 | if text_nodes: |
| 51 | return text_nodes |
| 52 | run = paragraph.find("a:r", NS) |
| 53 | if run is None: |
| 54 | run = ET.SubElement(paragraph, _qn(NS["a"], "r")) |
| 55 | text_node = run.find("a:t", NS) |
| 56 | if text_node is None: |
| 57 | text_node = ET.SubElement(run, _qn(NS["a"], "t")) |
| 58 | return [text_node] |
| 59 | |
| 60 | |
| 61 | def _set_paragraph_text(paragraph: ET.Element, text: str) -> None: |
| 62 | text_nodes = _ensure_paragraph_text_node(paragraph) |
| 63 | text_nodes[0].text = text |
| 64 | for node in text_nodes[1:]: |
| 65 | node.text = "" |
| 66 | |
| 67 | |
| 68 | def _set_container_text(container: ET.Element, text: str) -> None: |
| 69 | lines = text.splitlines() or [""] |
| 70 | paragraphs = container.findall(".//a:p", NS) |
| 71 | if len(lines) > 1 and paragraphs: |
| 72 | for index, paragraph in enumerate(paragraphs): |
| 73 | if index < len(lines): |
| 74 | _set_paragraph_text(paragraph, lines[index]) |
| 75 | else: |
| 76 | _set_paragraph_text(paragraph, "") |
| 77 | if len(lines) > len(paragraphs): |
| 78 | _set_paragraph_text(paragraphs[-1], "\n".join(lines[len(paragraphs) - 1 :])) |
| 79 | return |
| 80 | |
| 81 | text_nodes = _ensure_text_nodes(container) |
| 82 | if not text_nodes: |
| 83 | raise RuntimeError("Matched shape does not contain a text body") |
| 84 | if len(lines) <= len(text_nodes): |
| 85 | for index, node in enumerate(text_nodes): |
| 86 | node.text = lines[index] if index < len(lines) else "" |
| 87 | return |
| 88 | text_nodes[0].text = text |
| 89 | for node in text_nodes[1:]: |
| 90 | node.text = "" |
| 91 | |
| 92 | |
| 93 | def _apply_replacements_to_slide( |
| 94 | slide_root: ET.Element, |
| 95 | *, |
| 96 | source_slide: int, |
| 97 | replacements: list[dict[str, Any]], |
| 98 | ) -> None: |
| 99 | maps = _shape_key_maps(slide_root, source_slide) |
| 100 | errors: list[str] = [] |
| 101 | for replacement in replacements: |
| 102 | selectors = [] |
| 103 | if replacement.get("slot_id"): |
| 104 | selectors.append(f"slot_id:{replacement['slot_id']}") |
| 105 | if replacement.get("shape_id"): |
| 106 | selectors.append(f"shape_id:{replacement['shape_id']}") |
| 107 | if replacement.get("shape_name"): |
| 108 | selectors.append(f"shape_name:{replacement['shape_name']}") |
| 109 | container = next((maps[key] for key in selectors if key in maps), None) |
| 110 | if container is None: |
| 111 | if replacement.get("optional"): |
| 112 | continue |
| 113 | errors.append(", ".join(selectors) or "<missing selector>") |
| 114 | continue |
| 115 | _set_container_text(container, _replacement_text(replacement)) |
| 116 | if errors: |
| 117 | raise RuntimeError(f"Missing replacement target(s) on slide {source_slide}: {'; '.join(errors)}") |
| 118 |