python-pptx2 2.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. pptx2/__init__.py +152 -0
  2. pptx2/_color.py +75 -0
  3. pptx2/_slide_importer.py +597 -0
  4. pptx2/_svg.py +155 -0
  5. pptx2/_template_applier.py +292 -0
  6. pptx2/_textstyle.py +187 -0
  7. pptx2/accessibility.py +365 -0
  8. pptx2/action.py +270 -0
  9. pptx2/animation.py +2237 -0
  10. pptx2/api.py +49 -0
  11. pptx2/audit.py +258 -0
  12. pptx2/chart/__init__.py +0 -0
  13. pptx2/chart/analytics.py +381 -0
  14. pptx2/chart/axis.py +543 -0
  15. pptx2/chart/category.py +200 -0
  16. pptx2/chart/chart.py +670 -0
  17. pptx2/chart/data.py +864 -0
  18. pptx2/chart/datalabel.py +406 -0
  19. pptx2/chart/legend.py +86 -0
  20. pptx2/chart/marker.py +70 -0
  21. pptx2/chart/palettes.py +129 -0
  22. pptx2/chart/plot.py +462 -0
  23. pptx2/chart/point.py +101 -0
  24. pptx2/chart/quick_layouts.py +325 -0
  25. pptx2/chart/series.py +334 -0
  26. pptx2/chart/xlsx.py +272 -0
  27. pptx2/chart/xmlwriter.py +1845 -0
  28. pptx2/compose/__init__.py +28 -0
  29. pptx2/compose/from_spec.py +1094 -0
  30. pptx2/design/__init__.py +8 -0
  31. pptx2/design/components.py +607 -0
  32. pptx2/design/figures.py +389 -0
  33. pptx2/design/layout.py +370 -0
  34. pptx2/design/recipes.py +1967 -0
  35. pptx2/design/style.py +209 -0
  36. pptx2/design/tokens.py +915 -0
  37. pptx2/diagrams.py +754 -0
  38. pptx2/dml/__init__.py +0 -0
  39. pptx2/dml/chtfmt.py +40 -0
  40. pptx2/dml/color.py +496 -0
  41. pptx2/dml/effect.py +909 -0
  42. pptx2/dml/fill.py +691 -0
  43. pptx2/dml/line.py +287 -0
  44. pptx2/dml/picture.py +212 -0
  45. pptx2/dml/three_d.py +381 -0
  46. pptx2/enum/__init__.py +0 -0
  47. pptx2/enum/action.py +71 -0
  48. pptx2/enum/animation.py +31 -0
  49. pptx2/enum/base.py +218 -0
  50. pptx2/enum/chart.py +574 -0
  51. pptx2/enum/dml.py +740 -0
  52. pptx2/enum/lang.py +685 -0
  53. pptx2/enum/presentation.py +133 -0
  54. pptx2/enum/shapes.py +1029 -0
  55. pptx2/enum/text.py +230 -0
  56. pptx2/exc.py +42 -0
  57. pptx2/formats.py +139 -0
  58. pptx2/geometry.py +420 -0
  59. pptx2/inherit.py +109 -0
  60. pptx2/lint.py +2256 -0
  61. pptx2/math.py +177 -0
  62. pptx2/media.py +197 -0
  63. pptx2/opc/__init__.py +0 -0
  64. pptx2/opc/constants.py +332 -0
  65. pptx2/opc/oxml.py +188 -0
  66. pptx2/opc/package.py +762 -0
  67. pptx2/opc/packuri.py +109 -0
  68. pptx2/opc/serialized.py +296 -0
  69. pptx2/opc/shared.py +20 -0
  70. pptx2/opc/spec.py +45 -0
  71. pptx2/oxml/__init__.py +555 -0
  72. pptx2/oxml/action.py +53 -0
  73. pptx2/oxml/chart/__init__.py +0 -0
  74. pptx2/oxml/chart/axis.py +337 -0
  75. pptx2/oxml/chart/chart.py +481 -0
  76. pptx2/oxml/chart/datalabel.py +253 -0
  77. pptx2/oxml/chart/legend.py +72 -0
  78. pptx2/oxml/chart/marker.py +61 -0
  79. pptx2/oxml/chart/plot.py +365 -0
  80. pptx2/oxml/chart/series.py +425 -0
  81. pptx2/oxml/chart/shared.py +220 -0
  82. pptx2/oxml/coreprops.py +288 -0
  83. pptx2/oxml/dml/__init__.py +0 -0
  84. pptx2/oxml/dml/color.py +135 -0
  85. pptx2/oxml/dml/effect.py +213 -0
  86. pptx2/oxml/dml/fill.py +316 -0
  87. pptx2/oxml/dml/line.py +12 -0
  88. pptx2/oxml/dml/three_d.py +110 -0
  89. pptx2/oxml/ns.py +135 -0
  90. pptx2/oxml/presentation.py +313 -0
  91. pptx2/oxml/shapes/__init__.py +19 -0
  92. pptx2/oxml/shapes/autoshape.py +467 -0
  93. pptx2/oxml/shapes/connector.py +107 -0
  94. pptx2/oxml/shapes/graphfrm.py +347 -0
  95. pptx2/oxml/shapes/groupshape.py +329 -0
  96. pptx2/oxml/shapes/picture.py +270 -0
  97. pptx2/oxml/shapes/shared.py +577 -0
  98. pptx2/oxml/simpletypes.py +1027 -0
  99. pptx2/oxml/slide.py +563 -0
  100. pptx2/oxml/table.py +650 -0
  101. pptx2/oxml/text.py +815 -0
  102. pptx2/oxml/theme.py +36 -0
  103. pptx2/oxml/xmlchemy.py +717 -0
  104. pptx2/package.py +222 -0
  105. pptx2/parts/__init__.py +0 -0
  106. pptx2/parts/chart.py +95 -0
  107. pptx2/parts/coreprops.py +167 -0
  108. pptx2/parts/diagram.py +37 -0
  109. pptx2/parts/embeddedpackage.py +93 -0
  110. pptx2/parts/image.py +275 -0
  111. pptx2/parts/media.py +37 -0
  112. pptx2/parts/presentation.py +136 -0
  113. pptx2/parts/slide.py +371 -0
  114. pptx2/presentation.py +408 -0
  115. pptx2/py.typed +0 -0
  116. pptx2/render.py +586 -0
  117. pptx2/section.py +272 -0
  118. pptx2/shapes/__init__.py +26 -0
  119. pptx2/shapes/autoshape.py +442 -0
  120. pptx2/shapes/base.py +1078 -0
  121. pptx2/shapes/connector.py +297 -0
  122. pptx2/shapes/freeform.py +337 -0
  123. pptx2/shapes/graphfrm.py +316 -0
  124. pptx2/shapes/group.py +264 -0
  125. pptx2/shapes/picture.py +422 -0
  126. pptx2/shapes/placeholder.py +468 -0
  127. pptx2/shapes/shapetree.py +2027 -0
  128. pptx2/shared.py +82 -0
  129. pptx2/skill/SKILL.md +450 -0
  130. pptx2/skill/__init__.py +78 -0
  131. pptx2/skill/__main__.py +64 -0
  132. pptx2/skill/references/animations.md +189 -0
  133. pptx2/skill/references/basics.md +421 -0
  134. pptx2/skill/references/charts.md +254 -0
  135. pptx2/skill/references/compose.md +234 -0
  136. pptx2/skill/references/design.md +366 -0
  137. pptx2/skill/references/effects.md +249 -0
  138. pptx2/skill/references/end-to-end-deck.md +231 -0
  139. pptx2/skill/references/geometry-and-arrows.md +334 -0
  140. pptx2/skill/references/lint.md +275 -0
  141. pptx2/skill/references/math.md +86 -0
  142. pptx2/skill/references/picture-effects.md +129 -0
  143. pptx2/skill/references/render.md +151 -0
  144. pptx2/skill/references/smart-art.md +75 -0
  145. pptx2/skill/references/space-aware-authoring.md +249 -0
  146. pptx2/skill/references/tables.md +244 -0
  147. pptx2/skill/references/theme.md +127 -0
  148. pptx2/skill/references/three-d.md +109 -0
  149. pptx2/skill/references/transitions.md +100 -0
  150. pptx2/slide.py +1244 -0
  151. pptx2/smart_art.py +220 -0
  152. pptx2/spec.py +633 -0
  153. pptx2/table.py +1181 -0
  154. pptx2/table_styles.py +184 -0
  155. pptx2/templates/default.pptx +0 -0
  156. pptx2/templates/docx-icon.emf +0 -0
  157. pptx2/templates/generic-icon.emf +0 -0
  158. pptx2/templates/notes.xml +23 -0
  159. pptx2/templates/notesMaster.xml +352 -0
  160. pptx2/templates/pptx-icon.emf +0 -0
  161. pptx2/templates/theme.xml +321 -0
  162. pptx2/templates/xlsx-icon.emf +0 -0
  163. pptx2/text/__init__.py +0 -0
  164. pptx2/text/fonts.py +482 -0
  165. pptx2/text/layout.py +374 -0
  166. pptx2/text/text.py +1272 -0
  167. pptx2/theme.py +721 -0
  168. pptx2/types.py +36 -0
  169. pptx2/util.py +263 -0
  170. python_pptx2-2.13.0.dist-info/METADATA +351 -0
  171. python_pptx2-2.13.0.dist-info/RECORD +175 -0
  172. python_pptx2-2.13.0.dist-info/WHEEL +5 -0
  173. python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
  174. python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
  175. python_pptx2-2.13.0.dist-info/top_level.txt +1 -0
pptx2/_svg.py ADDED
@@ -0,0 +1,155 @@
1
+ """SVG support for ``ShapeTree.add_svg_picture``.
2
+
3
+ Modern PowerPoint requires every embedded SVG to ship alongside a PNG
4
+ fallback: the ``<a:blip>`` references the PNG and an
5
+ ``<asvg:svgBlip>`` extension references the SVG. This module provides
6
+ the helpers that drive that wiring — SVG detection, blob loading,
7
+ optional rasterisation via ``cairosvg``, and OOXML element rewriting.
8
+
9
+ ``cairosvg`` is an *optional* dependency: callers can supply their own
10
+ PNG fallback (``png_fallback=`` argument on ``add_svg_picture``) and the
11
+ import is never attempted, so installs without ``cairosvg`` keep
12
+ working. The import is deferred to first use and routed through a
13
+ clear error message when missing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import IO, Tuple, Union
20
+
21
+ from lxml import etree
22
+
23
+ from pptx2.opc.constants import CONTENT_TYPE as CT
24
+ from pptx2.oxml.ns import nsuri, qn
25
+
26
+ PathOrFile = Union[str, "os.PathLike[str]", IO[bytes], bytes]
27
+ """Either a filesystem path, a binary file-like, or a raw blob."""
28
+
29
+
30
+ # {96DAC541-7B7A-43D3-8B79-37D633B846F1} is the well-known URI for the
31
+ # Microsoft "SVG Image Extension" element introduced with Office 2016.
32
+ _SVG_EXT_URI = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"
33
+
34
+
35
+ class CairoSvgUnavailable(RuntimeError):
36
+ """Raised when SVG rasterisation is needed but ``cairosvg`` is missing."""
37
+
38
+
39
+ def load_image_blob(source: PathOrFile) -> Tuple[bytes, str | None]:
40
+ """Return ``(blob, filename)`` for `source`.
41
+
42
+ `source` may be a path, a binary file-like, or a raw ``bytes``
43
+ blob. The filename component is best-effort and is only used as a
44
+ nice-to-have for the partname; ``None`` is returned for in-memory
45
+ sources.
46
+
47
+ .. note::
48
+ File-like sources are rewound with ``source.seek(0)`` before
49
+ reading when seeking is supported, so the *entire* contents of
50
+ the stream are loaded regardless of where the cursor was when
51
+ the function was called. This matches the behavior of
52
+ :meth:`Image.from_file` (used by ``add_picture``) and means
53
+ callers who pass a partially-read stream will get the full blob,
54
+ not just the unread tail. Pass a :class:`bytes` blob directly
55
+ if you want to feed the function a pre-sliced subset of a
56
+ stream.
57
+ """
58
+ if isinstance(source, (bytes, bytearray)):
59
+ return bytes(source), None
60
+ if isinstance(source, (str, os.PathLike)):
61
+ with open(source, "rb") as f:
62
+ return f.read(), os.path.basename(os.fspath(source))
63
+ # Assume file-like. Match `Image.from_file`'s rewind-then-read
64
+ # convention so behavior is consistent across the picture APIs.
65
+ if callable(getattr(source, "seek", None)):
66
+ source.seek(0)
67
+ return source.read(), None
68
+
69
+
70
+ def looks_like_svg(blob: bytes) -> bool:
71
+ """Heuristic: does `blob` smell like an SVG document?"""
72
+ head = blob[:512].lstrip()
73
+ if not head:
74
+ return False
75
+ # Allow leading XML declaration / DOCTYPE; just look for "<svg" up
76
+ # near the start. Real SVG sniffing would require an XML parse,
77
+ # which is overkill for a one-shot helper.
78
+ lowered = head.lower()
79
+ return b"<svg" in lowered
80
+
81
+
82
+ def rasterize_svg(svg_blob: bytes, *, output_size: tuple[int, int] | None = None) -> bytes:
83
+ """Rasterise `svg_blob` to PNG bytes using ``cairosvg``.
84
+
85
+ `output_size` is an optional ``(width_px, height_px)`` pair. When
86
+ omitted, ``cairosvg`` uses the SVG's intrinsic size, which is
87
+ usually the right thing for embedding. Raises
88
+ :class:`CairoSvgUnavailable` when ``cairosvg`` isn't installed.
89
+ """
90
+ try:
91
+ import cairosvg # type: ignore[import-not-found]
92
+ except ImportError as exc:
93
+ raise CairoSvgUnavailable(
94
+ "rasterising SVG requires the optional `cairosvg` dependency; "
95
+ "install it with `pip install cairosvg`, or pass an explicit "
96
+ "`png_fallback=` argument to add_svg_picture()."
97
+ ) from exc
98
+
99
+ kwargs = {}
100
+ if output_size is not None:
101
+ kwargs["output_width"] = int(output_size[0])
102
+ kwargs["output_height"] = int(output_size[1])
103
+ return cairosvg.svg2png(bytestring=svg_blob, **kwargs)
104
+
105
+
106
+ def add_svg_blip_extension(pic_elm, svg_rId: str) -> None:
107
+ """Inject an ``<asvg:svgBlip>`` extension into the picture's blip.
108
+
109
+ `pic_elm` is the ``<p:pic>`` lxml element returned by ``new_pic``.
110
+ `svg_rId` is the relationship id of the SVG image part.
111
+ """
112
+ blip = pic_elm.find(".//" + qn("a:blip"))
113
+ if blip is None:
114
+ raise ValueError("picture has no <a:blip> to attach the SVG extension to")
115
+ extLst = blip.find(qn("a:extLst"))
116
+ if extLst is None:
117
+ extLst = etree.SubElement(blip, qn("a:extLst"))
118
+ ext = etree.SubElement(extLst, qn("a:ext"), uri=_SVG_EXT_URI)
119
+ asvg_uri = nsuri("asvg")
120
+ svgBlip = etree.SubElement(
121
+ ext,
122
+ "{%s}svgBlip" % asvg_uri,
123
+ nsmap={"asvg": asvg_uri},
124
+ )
125
+ svgBlip.set(qn("r:embed"), svg_rId)
126
+
127
+
128
+ def add_svg_image_part(slide_part, svg_blob: bytes, filename: str | None = None):
129
+ """Register `svg_blob` as a new SVG image part on `slide_part`'s package.
130
+
131
+ Returns ``(image_part, rId)``: the freshly minted
132
+ :class:`pptx2.parts.image.ImagePart` plus the relationship id that
133
+ points the slide at it. Bypasses the Pillow-driven ``Image``
134
+ constructor (which can't read SVG) and constructs the part directly
135
+ with ``content_type='image/svg+xml'``.
136
+ """
137
+ from pptx2.opc.constants import RELATIONSHIP_TYPE as RT
138
+ from pptx2.parts.image import ImagePart
139
+
140
+ package = slide_part.package
141
+ partname = package.next_image_partname("svg")
142
+ image_part = ImagePart(
143
+ partname,
144
+ SVG_CONTENT_TYPE,
145
+ package,
146
+ svg_blob,
147
+ filename=filename,
148
+ )
149
+ rId = slide_part.relate_to(image_part, RT.IMAGE)
150
+ return image_part, rId
151
+
152
+
153
+ # Re-exported here so callers in ``shapetree.py`` don't need to know
154
+ # about the constants module organisation.
155
+ SVG_CONTENT_TYPE = CT.SVG
@@ -0,0 +1,292 @@
1
+ """Template application machinery for ``Presentation.apply_template()``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from copy import deepcopy
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pptx2.opc.constants import RELATIONSHIP_TYPE as RT
10
+ from pptx2.opc.packuri import PackURI
11
+
12
+ if TYPE_CHECKING:
13
+ from pptx2.opc.package import Part
14
+ from pptx2.package import Package
15
+ from pptx2.parts.presentation import PresentationPart
16
+ from pptx2.parts.slide import SlideLayoutPart, SlideMasterPart, SlidePart
17
+ from pptx2.slide import Slide
18
+
19
+
20
+ def apply_template(
21
+ dst_prs_part: PresentationPart,
22
+ template_prs_part: PresentationPart,
23
+ ) -> None:
24
+ """Re-point every slide in *dst_prs_part* at the masters/layouts from *template_prs_part*.
25
+
26
+ Existing slide content (shapes, text, animations) is preserved.
27
+ Each slide's layout is matched to the closest layout in the template (by name, then by
28
+ type, then fallback to the template's first layout).
29
+
30
+ After all slides are remapped the old master/layout/theme parts that are no longer
31
+ referenced are dropped automatically when the package is saved (they are no longer
32
+ reachable from any relationship).
33
+
34
+ Parameters
35
+ ----------
36
+ dst_prs_part:
37
+ The presentation to modify in place.
38
+ template_prs_part:
39
+ The template presentation whose masters/layouts/themes are to be applied.
40
+ """
41
+ applier = _TemplateApplier(dst_prs_part, template_prs_part)
42
+ applier.run()
43
+
44
+
45
+ class _TemplateApplier:
46
+ """Stateful helper for template application."""
47
+
48
+ def __init__(
49
+ self,
50
+ dst_prs_part: PresentationPart,
51
+ tpl_prs_part: PresentationPart,
52
+ ) -> None:
53
+ self._dst_prs_part = dst_prs_part
54
+ self._tpl_prs_part = tpl_prs_part
55
+ self._dst_package: Package = dst_prs_part.package # type: ignore[assignment]
56
+ # Tracks partnames already reserved during this run to avoid collisions
57
+ self._reserved: set[str] = set()
58
+
59
+ def run(self) -> None:
60
+ """Execute the template application."""
61
+ # 1. Clone the template masters (with themes + layouts) into the destination
62
+ tpl_master_to_dst_master = self._clone_template_masters()
63
+
64
+ # 2. Build a flat list of all template layout parts (for matching)
65
+ tpl_layouts: list[SlideLayoutPart] = []
66
+ for tpl_master_part, dst_master_part in tpl_master_to_dst_master.items():
67
+ for rel in tpl_master_part.rels.values():
68
+ if rel.is_external or rel.reltype != RT.SLIDE_LAYOUT:
69
+ continue
70
+ tpl_layouts.append(rel.target_part) # type: ignore[arg-type]
71
+
72
+ # Map tpl layout partname → corresponding dst layout part
73
+ tpl_partname_to_dst_layout: dict[str, SlideLayoutPart] = {}
74
+ for tpl_master_part, dst_master_part in tpl_master_to_dst_master.items():
75
+ for tpl_rel, dst_rel in zip(
76
+ [r for r in tpl_master_part.rels.values() if not r.is_external and r.reltype == RT.SLIDE_LAYOUT],
77
+ [r for r in dst_master_part.rels.values() if not r.is_external and r.reltype == RT.SLIDE_LAYOUT],
78
+ ):
79
+ tpl_partname_to_dst_layout[tpl_rel.target_part.partname] = dst_rel.target_part # type: ignore[assignment]
80
+
81
+ # Build dst layouts list in the same order as tpl_layouts (parallel)
82
+ dst_layouts: list[SlideLayoutPart] = [
83
+ tpl_partname_to_dst_layout[lp.partname] # type: ignore[index]
84
+ for lp in tpl_layouts
85
+ if lp.partname in tpl_partname_to_dst_layout
86
+ ]
87
+
88
+ # 3. Remap each existing slide to the best-matching template layout
89
+ dst_prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
90
+ sldIdLst = dst_prs_element.sldIdLst
91
+ if sldIdLst is not None:
92
+ for sldId in list(sldIdLst.sldId_lst):
93
+ slide_part: SlidePart = self._dst_prs_part.related_part(sldId.rId) # type: ignore[assignment]
94
+ self._remap_slide(slide_part, tpl_layouts, dst_layouts)
95
+
96
+ # 4. Remove old masters from the presentation element
97
+ # (unreachable parts are not included when the package is saved)
98
+ self._drop_old_masters(set(tpl_master_to_dst_master.values()))
99
+
100
+ # 5. Register new masters in presentation element
101
+ for dst_master_part in tpl_master_to_dst_master.values():
102
+ rId = self._dst_prs_part.relate_to(dst_master_part, RT.SLIDE_MASTER)
103
+ sldMasterIdLst = dst_prs_element.get_or_add_sldMasterIdLst()
104
+ sldMasterIdLst._add_sldMasterId(rId=rId) # pyright: ignore[reportAttributeAccessIssue]
105
+
106
+ # ------------------------------------------------------------------
107
+ # Master cloning
108
+ # ------------------------------------------------------------------
109
+
110
+ def _clone_template_masters(self) -> dict[SlideMasterPart, SlideMasterPart]:
111
+ """Clone each master in the template into the destination.
112
+
113
+ Returns a mapping: template SlideMasterPart → cloned destination SlideMasterPart.
114
+ """
115
+ result: dict[SlideMasterPart, SlideMasterPart] = {}
116
+ dst_package = self._dst_package
117
+ tpl_prs_element = self._tpl_prs_part._element # pyright: ignore[reportPrivateUsage]
118
+
119
+ if tpl_prs_element.sldMasterIdLst is None:
120
+ return result
121
+
122
+ for entry in tpl_prs_element.sldMasterIdLst.sldMasterId_lst:
123
+ tpl_master_part: SlideMasterPart = self._tpl_prs_part.related_part(entry.rId) # type: ignore[assignment]
124
+
125
+ # Clone theme
126
+ dst_theme_part: Part | None = None
127
+ try:
128
+ src_theme = tpl_master_part.part_related_by(RT.THEME)
129
+ theme_pn = self._next_partname("/ppt/theme/theme%d.xml")
130
+ dst_theme_part = _clone_part(src_theme, theme_pn, dst_package)
131
+ except KeyError:
132
+ pass
133
+
134
+ # Clone master
135
+ master_pn = self._next_partname("/ppt/slideMasters/slideMaster%d.xml")
136
+ dst_master_part = _clone_xml_part(tpl_master_part, master_pn, dst_package)
137
+ if dst_theme_part is not None:
138
+ dst_master_part.relate_to(dst_theme_part, RT.THEME)
139
+
140
+ # Clone layouts
141
+ for rel in tpl_master_part.rels.values():
142
+ if rel.is_external or rel.reltype != RT.SLIDE_LAYOUT:
143
+ continue
144
+ src_lo: SlideLayoutPart = rel.target_part # type: ignore[assignment]
145
+ lo_pn = self._next_partname("/ppt/slideLayouts/slideLayout%d.xml")
146
+ dst_lo = _clone_xml_part(src_lo, lo_pn, dst_package)
147
+ dst_lo.relate_to(dst_master_part, RT.SLIDE_MASTER)
148
+ dst_master_part.relate_to(dst_lo, RT.SLIDE_LAYOUT)
149
+
150
+ result[tpl_master_part] = dst_master_part # type: ignore[assignment]
151
+
152
+ return result
153
+
154
+ # ------------------------------------------------------------------
155
+ # Slide remapping
156
+ # ------------------------------------------------------------------
157
+
158
+ def _remap_slide(
159
+ self,
160
+ slide_part: SlidePart,
161
+ tpl_layouts: list[SlideLayoutPart],
162
+ dst_layouts: list[SlideLayoutPart],
163
+ ) -> None:
164
+ """Replace the slide's layout relationship to point to a template layout."""
165
+ # Find the current layout rel and remove it
166
+ old_layout_rId: str | None = None
167
+ for rId, rel in list(slide_part.rels.items()):
168
+ if not rel.is_external and rel.reltype == RT.SLIDE_LAYOUT:
169
+ old_layout_rId = rId
170
+ break
171
+
172
+ if not dst_layouts:
173
+ warnings.warn(
174
+ "apply_template: no layouts available in the template; "
175
+ "slide layout relationship could not be updated.",
176
+ stacklevel=3,
177
+ )
178
+ return
179
+
180
+ # Determine current slide's layout info for matching
181
+ current_lo_name: str = ""
182
+ current_lo_type: str = ""
183
+ if old_layout_rId is not None:
184
+ old_lo_part: SlideLayoutPart = slide_part.related_part(old_layout_rId) # type: ignore[assignment]
185
+ try:
186
+ current_lo_name = old_lo_part._element.cSld.name # pyright: ignore[reportPrivateUsage]
187
+ except AttributeError:
188
+ pass
189
+ current_lo_type = (old_lo_part._element.get("type") or "") # pyright: ignore[reportPrivateUsage]
190
+
191
+ # Match: name, then type, then first
192
+ matched_dst_lo: SlideLayoutPart | None = None
193
+ for tpl_lo, dst_lo in zip(tpl_layouts, dst_layouts):
194
+ tpl_name = tpl_lo._element.cSld.name # pyright: ignore[reportPrivateUsage]
195
+ if tpl_name == current_lo_name:
196
+ matched_dst_lo = dst_lo
197
+ break
198
+ if matched_dst_lo is None and current_lo_type:
199
+ for tpl_lo, dst_lo in zip(tpl_layouts, dst_layouts):
200
+ tpl_type = tpl_lo._element.get("type") or "" # pyright: ignore[reportPrivateUsage]
201
+ if tpl_type == current_lo_type:
202
+ matched_dst_lo = dst_lo
203
+ break
204
+ if matched_dst_lo is None:
205
+ matched_dst_lo = dst_layouts[0]
206
+
207
+ # Remove old layout relationship
208
+ if old_layout_rId is not None:
209
+ slide_part.rels.pop(old_layout_rId)
210
+
211
+ # Add new layout relationship
212
+ slide_part.relate_to(matched_dst_lo, RT.SLIDE_LAYOUT)
213
+
214
+ # ------------------------------------------------------------------
215
+ # Old master removal
216
+ # ------------------------------------------------------------------
217
+
218
+ def _drop_old_masters(self, new_masters: set[SlideMasterPart]) -> None:
219
+ """Remove old master entries from the presentation element.
220
+
221
+ Also removes any direct presentation→theme relationship, since the
222
+ theme is now owned by the new master.
223
+
224
+ The old Part objects remain in memory but will not be written to the package
225
+ because they are no longer reachable from any relationship.
226
+ """
227
+ prs_element = self._dst_prs_part._element # pyright: ignore[reportPrivateUsage]
228
+ sldMasterIdLst = prs_element.sldMasterIdLst
229
+
230
+ if sldMasterIdLst is None:
231
+ return
232
+
233
+ # Collect rIds that point to OLD masters (not in new_masters)
234
+ old_rIds: list[str] = []
235
+ for entry in list(sldMasterIdLst.sldMasterId_lst):
236
+ part = self._dst_prs_part.related_part(entry.rId)
237
+ if part not in new_masters:
238
+ old_rIds.append(entry.rId)
239
+
240
+ # Remove relationships to old masters
241
+ for rId in old_rIds:
242
+ self._dst_prs_part.rels.pop(rId)
243
+
244
+ # Remove any direct presentation→theme relationship so the old theme
245
+ # is no longer reachable from the presentation.
246
+ theme_rIds_to_remove: list[str] = []
247
+ for rId, rel in list(self._dst_prs_part.rels.items()):
248
+ if not rel.is_external and rel.reltype == RT.THEME:
249
+ theme_rIds_to_remove.append(rId)
250
+ for rId in theme_rIds_to_remove:
251
+ self._dst_prs_part.rels.pop(rId)
252
+
253
+ # Remove the entire sldMasterIdLst — it will be rebuilt in run()
254
+ prs_element.remove(sldMasterIdLst)
255
+
256
+ # ------------------------------------------------------------------
257
+ # Partname helpers
258
+ # ------------------------------------------------------------------
259
+
260
+ def _next_partname(self, tmpl: str) -> PackURI:
261
+ """Return the next non-colliding partname, accounting for locally reserved names."""
262
+ prefix = tmpl[: (tmpl % 42).find("42")]
263
+ existing = {
264
+ p.partname for p in self._dst_package.iter_parts()
265
+ if p.partname.startswith(prefix)
266
+ }
267
+ taken = existing | {pn for pn in self._reserved if pn.startswith(prefix)}
268
+ n = 1
269
+ while True:
270
+ candidate = tmpl % n
271
+ if candidate not in taken:
272
+ self._reserved.add(candidate)
273
+ return PackURI(candidate)
274
+ n += 1
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Part-copy helpers (shared logic)
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ def _clone_part(src_part: Part, new_partname: PackURI, dst_package: Package) -> Part:
283
+ from pptx2.opc.package import PartFactory
284
+
285
+ return PartFactory(new_partname, src_part.content_type, dst_package, blob=src_part.blob)
286
+
287
+
288
+ def _clone_xml_part(src_part: Part, new_partname: PackURI, dst_package: Package) -> Part:
289
+ from pptx2.opc.package import XmlPart
290
+
291
+ new_element = deepcopy(src_part._element) # pyright: ignore[reportPrivateUsage]
292
+ return src_part.__class__(new_partname, src_part.content_type, dst_package, new_element)
pptx2/_textstyle.py ADDED
@@ -0,0 +1,187 @@
1
+ """Internal shared text-styling vocabulary.
2
+
3
+ One place to translate the short, string-flavoured keywords the public
4
+ surface accepts (``align="center"``, ``anchor="middle"``, ``size_pt=11``,
5
+ ``color="#1F2937"``) into the enum / :class:`~pptx2.util.Length`
6
+ values the XML layer wants.
7
+
8
+ Used by :meth:`ShapeTree.add_text` and :meth:`pptx2.table._Cell.format`
9
+ so the same words mean the same thing wherever text is styled.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Sequence
15
+
16
+ from pptx2._color import coerce_color
17
+ from pptx2.enum.text import MSO_VERTICAL_ANCHOR, PP_PARAGRAPH_ALIGNMENT
18
+ from pptx2.util import Length, Pt
19
+
20
+ ALIGN_MAP = {
21
+ "left": PP_PARAGRAPH_ALIGNMENT.LEFT,
22
+ "right": PP_PARAGRAPH_ALIGNMENT.RIGHT,
23
+ "center": PP_PARAGRAPH_ALIGNMENT.CENTER,
24
+ "centre": PP_PARAGRAPH_ALIGNMENT.CENTER,
25
+ "justify": PP_PARAGRAPH_ALIGNMENT.JUSTIFY,
26
+ }
27
+
28
+ ANCHOR_MAP = {
29
+ "top": MSO_VERTICAL_ANCHOR.TOP,
30
+ "middle": MSO_VERTICAL_ANCHOR.MIDDLE,
31
+ "center": MSO_VERTICAL_ANCHOR.MIDDLE,
32
+ "centre": MSO_VERTICAL_ANCHOR.MIDDLE,
33
+ "bottom": MSO_VERTICAL_ANCHOR.BOTTOM,
34
+ }
35
+
36
+
37
+ def coerce_align(value: str) -> PP_PARAGRAPH_ALIGNMENT:
38
+ """Return the `PP_ALIGN` member named by `value` (case-insensitive)."""
39
+ try:
40
+ return ALIGN_MAP[str(value).lower()]
41
+ except KeyError:
42
+ raise ValueError(
43
+ f"align must be one of {sorted(set(ALIGN_MAP))}; got {value!r}"
44
+ ) from None
45
+
46
+
47
+ def coerce_anchor(value: str) -> MSO_VERTICAL_ANCHOR:
48
+ """Return the `MSO_VERTICAL_ANCHOR` member named by `value` (case-insensitive)."""
49
+ try:
50
+ return ANCHOR_MAP[str(value).lower()]
51
+ except KeyError:
52
+ raise ValueError(
53
+ f"anchor must be one of {sorted(set(ANCHOR_MAP))}; got {value!r}"
54
+ ) from None
55
+
56
+
57
+ def coerce_length(value: Any) -> Length:
58
+ """Coerce a point number or a `Length` to a `Length`."""
59
+ return value if isinstance(value, Length) else Pt(float(value))
60
+
61
+
62
+
63
+ def apply_margins(
64
+ tf: Any, margin: float | Length | Sequence[float | Length] | None
65
+ ) -> None:
66
+ """Set text-frame insets from a scalar or a ``(top, right, bottom, left)`` sequence.
67
+
68
+ Scalars in points (or any :class:`~pptx2.util.Length`) — ``0`` means
69
+ "flush to the edge", which is what a dense table cell usually wants.
70
+ """
71
+ if margin is None:
72
+ return
73
+ if isinstance(margin, (tuple, list)):
74
+ if len(margin) != 4:
75
+ raise ValueError(
76
+ "margin tuple must have 4 elements (top, right, bottom, left); "
77
+ f"got {len(margin)}"
78
+ )
79
+ top, right, bottom, left = (coerce_length(v) for v in margin)
80
+ else:
81
+ top = right = bottom = left = coerce_length(margin)
82
+ tf.margin_top, tf.margin_right = top, right
83
+ tf.margin_bottom, tf.margin_left = bottom, left
84
+
85
+
86
+ def apply_text_style(
87
+ tf: Any,
88
+ *,
89
+ font: str | None = None,
90
+ size_pt: float | None = None,
91
+ bold: bool | None = None,
92
+ italic: bool | None = None,
93
+ color: Any = None,
94
+ align: str | None = None,
95
+ anchor: str | None = None,
96
+ margin: float | Length | Sequence[float | Length] | None = None,
97
+ word_wrap: bool | None = None,
98
+ paragraph_defaults: bool = False,
99
+ ) -> None:
100
+ """Apply the shared text-styling keywords to text frame `tf`.
101
+
102
+ Every keyword is optional and ``None`` means "leave as-is", so this can be
103
+ layered over text that already carries formatting. `paragraph_defaults`
104
+ additionally writes the run properties onto each paragraph's default run
105
+ properties, so text added to the frame *later* inherits the styling —
106
+ what a table cell wants, and what a one-shot ``add_text`` does not need.
107
+ """
108
+ if word_wrap is not None:
109
+ tf.word_wrap = bool(word_wrap)
110
+ apply_margins(tf, margin)
111
+ if anchor is not None:
112
+ tf.vertical_anchor = coerce_anchor(anchor)
113
+
114
+ align_value = None if align is None else coerce_align(align)
115
+ rgb = None if color is None else coerce_color(color)
116
+ size = None if size_pt is None else coerce_length(size_pt)
117
+
118
+ for paragraph in tf.paragraphs:
119
+ if align_value is not None:
120
+ paragraph.alignment = align_value
121
+ fonts = [run.font for run in paragraph.runs]
122
+ if paragraph_defaults:
123
+ fonts.append(paragraph.font)
124
+ for f in fonts:
125
+ if font is not None:
126
+ f.name = font
127
+ if size is not None:
128
+ f.size = size
129
+ if bold is not None:
130
+ f.bold = bool(bold)
131
+ if italic is not None:
132
+ f.italic = bool(italic)
133
+ if rgb is not None:
134
+ f.color.rgb = rgb
135
+
136
+
137
+ def apply_body_defaults(
138
+ tf: Any,
139
+ *,
140
+ font: str | None = None,
141
+ size_pt: float | None = None,
142
+ bold: bool | None = None,
143
+ italic: bool | None = None,
144
+ color: Any = None,
145
+ align: str | None = None,
146
+ ) -> None:
147
+ """Write text styling to `tf`'s text-body defaults (`<a:lstStyle>`).
148
+
149
+ Styling only the existing paragraphs and runs is lost the moment the frame
150
+ is repopulated: ``TextFrame.text = ...`` drops every ``<a:p>`` and builds
151
+ fresh, unstyled ones. `<a:lstStyle>` survives that (``clear_content()``
152
+ removes only the paragraphs), so defaults written here still apply to text
153
+ assigned afterwards — which is what makes "style the header row, then fill
154
+ in the cells" behave the way it reads.
155
+
156
+ Only level-1 defaults are written; explicit run properties still win.
157
+ """
158
+ from pptx2.text.text import Font
159
+
160
+ lvl1pPr = tf._txBody.get_or_add_lstStyle().get_or_add_lvl1pPr()
161
+ if align is not None:
162
+ lvl1pPr.algn = coerce_align(align)
163
+ if all(v is None for v in (font, size_pt, bold, italic, color)):
164
+ return
165
+ default_font = Font(lvl1pPr.get_or_add_defRPr())
166
+ if font is not None:
167
+ default_font.name = font
168
+ if size_pt is not None:
169
+ default_font.size = coerce_length(size_pt)
170
+ if bold is not None:
171
+ default_font.bold = bool(bold)
172
+ if italic is not None:
173
+ default_font.italic = bool(italic)
174
+ if color is not None:
175
+ default_font.color.rgb = coerce_color(color)
176
+
177
+
178
+ __all__ = [
179
+ "ALIGN_MAP",
180
+ "ANCHOR_MAP",
181
+ "apply_body_defaults",
182
+ "apply_margins",
183
+ "apply_text_style",
184
+ "coerce_align",
185
+ "coerce_anchor",
186
+ "coerce_length",
187
+ ]