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.
- pptx2/__init__.py +152 -0
- pptx2/_color.py +75 -0
- pptx2/_slide_importer.py +597 -0
- pptx2/_svg.py +155 -0
- pptx2/_template_applier.py +292 -0
- pptx2/_textstyle.py +187 -0
- pptx2/accessibility.py +365 -0
- pptx2/action.py +270 -0
- pptx2/animation.py +2237 -0
- pptx2/api.py +49 -0
- pptx2/audit.py +258 -0
- pptx2/chart/__init__.py +0 -0
- pptx2/chart/analytics.py +381 -0
- pptx2/chart/axis.py +543 -0
- pptx2/chart/category.py +200 -0
- pptx2/chart/chart.py +670 -0
- pptx2/chart/data.py +864 -0
- pptx2/chart/datalabel.py +406 -0
- pptx2/chart/legend.py +86 -0
- pptx2/chart/marker.py +70 -0
- pptx2/chart/palettes.py +129 -0
- pptx2/chart/plot.py +462 -0
- pptx2/chart/point.py +101 -0
- pptx2/chart/quick_layouts.py +325 -0
- pptx2/chart/series.py +334 -0
- pptx2/chart/xlsx.py +272 -0
- pptx2/chart/xmlwriter.py +1845 -0
- pptx2/compose/__init__.py +28 -0
- pptx2/compose/from_spec.py +1094 -0
- pptx2/design/__init__.py +8 -0
- pptx2/design/components.py +607 -0
- pptx2/design/figures.py +389 -0
- pptx2/design/layout.py +370 -0
- pptx2/design/recipes.py +1967 -0
- pptx2/design/style.py +209 -0
- pptx2/design/tokens.py +915 -0
- pptx2/diagrams.py +754 -0
- pptx2/dml/__init__.py +0 -0
- pptx2/dml/chtfmt.py +40 -0
- pptx2/dml/color.py +496 -0
- pptx2/dml/effect.py +909 -0
- pptx2/dml/fill.py +691 -0
- pptx2/dml/line.py +287 -0
- pptx2/dml/picture.py +212 -0
- pptx2/dml/three_d.py +381 -0
- pptx2/enum/__init__.py +0 -0
- pptx2/enum/action.py +71 -0
- pptx2/enum/animation.py +31 -0
- pptx2/enum/base.py +218 -0
- pptx2/enum/chart.py +574 -0
- pptx2/enum/dml.py +740 -0
- pptx2/enum/lang.py +685 -0
- pptx2/enum/presentation.py +133 -0
- pptx2/enum/shapes.py +1029 -0
- pptx2/enum/text.py +230 -0
- pptx2/exc.py +42 -0
- pptx2/formats.py +139 -0
- pptx2/geometry.py +420 -0
- pptx2/inherit.py +109 -0
- pptx2/lint.py +2256 -0
- pptx2/math.py +177 -0
- pptx2/media.py +197 -0
- pptx2/opc/__init__.py +0 -0
- pptx2/opc/constants.py +332 -0
- pptx2/opc/oxml.py +188 -0
- pptx2/opc/package.py +762 -0
- pptx2/opc/packuri.py +109 -0
- pptx2/opc/serialized.py +296 -0
- pptx2/opc/shared.py +20 -0
- pptx2/opc/spec.py +45 -0
- pptx2/oxml/__init__.py +555 -0
- pptx2/oxml/action.py +53 -0
- pptx2/oxml/chart/__init__.py +0 -0
- pptx2/oxml/chart/axis.py +337 -0
- pptx2/oxml/chart/chart.py +481 -0
- pptx2/oxml/chart/datalabel.py +253 -0
- pptx2/oxml/chart/legend.py +72 -0
- pptx2/oxml/chart/marker.py +61 -0
- pptx2/oxml/chart/plot.py +365 -0
- pptx2/oxml/chart/series.py +425 -0
- pptx2/oxml/chart/shared.py +220 -0
- pptx2/oxml/coreprops.py +288 -0
- pptx2/oxml/dml/__init__.py +0 -0
- pptx2/oxml/dml/color.py +135 -0
- pptx2/oxml/dml/effect.py +213 -0
- pptx2/oxml/dml/fill.py +316 -0
- pptx2/oxml/dml/line.py +12 -0
- pptx2/oxml/dml/three_d.py +110 -0
- pptx2/oxml/ns.py +135 -0
- pptx2/oxml/presentation.py +313 -0
- pptx2/oxml/shapes/__init__.py +19 -0
- pptx2/oxml/shapes/autoshape.py +467 -0
- pptx2/oxml/shapes/connector.py +107 -0
- pptx2/oxml/shapes/graphfrm.py +347 -0
- pptx2/oxml/shapes/groupshape.py +329 -0
- pptx2/oxml/shapes/picture.py +270 -0
- pptx2/oxml/shapes/shared.py +577 -0
- pptx2/oxml/simpletypes.py +1027 -0
- pptx2/oxml/slide.py +563 -0
- pptx2/oxml/table.py +650 -0
- pptx2/oxml/text.py +815 -0
- pptx2/oxml/theme.py +36 -0
- pptx2/oxml/xmlchemy.py +717 -0
- pptx2/package.py +222 -0
- pptx2/parts/__init__.py +0 -0
- pptx2/parts/chart.py +95 -0
- pptx2/parts/coreprops.py +167 -0
- pptx2/parts/diagram.py +37 -0
- pptx2/parts/embeddedpackage.py +93 -0
- pptx2/parts/image.py +275 -0
- pptx2/parts/media.py +37 -0
- pptx2/parts/presentation.py +136 -0
- pptx2/parts/slide.py +371 -0
- pptx2/presentation.py +408 -0
- pptx2/py.typed +0 -0
- pptx2/render.py +586 -0
- pptx2/section.py +272 -0
- pptx2/shapes/__init__.py +26 -0
- pptx2/shapes/autoshape.py +442 -0
- pptx2/shapes/base.py +1078 -0
- pptx2/shapes/connector.py +297 -0
- pptx2/shapes/freeform.py +337 -0
- pptx2/shapes/graphfrm.py +316 -0
- pptx2/shapes/group.py +264 -0
- pptx2/shapes/picture.py +422 -0
- pptx2/shapes/placeholder.py +468 -0
- pptx2/shapes/shapetree.py +2027 -0
- pptx2/shared.py +82 -0
- pptx2/skill/SKILL.md +450 -0
- pptx2/skill/__init__.py +78 -0
- pptx2/skill/__main__.py +64 -0
- pptx2/skill/references/animations.md +189 -0
- pptx2/skill/references/basics.md +421 -0
- pptx2/skill/references/charts.md +254 -0
- pptx2/skill/references/compose.md +234 -0
- pptx2/skill/references/design.md +366 -0
- pptx2/skill/references/effects.md +249 -0
- pptx2/skill/references/end-to-end-deck.md +231 -0
- pptx2/skill/references/geometry-and-arrows.md +334 -0
- pptx2/skill/references/lint.md +275 -0
- pptx2/skill/references/math.md +86 -0
- pptx2/skill/references/picture-effects.md +129 -0
- pptx2/skill/references/render.md +151 -0
- pptx2/skill/references/smart-art.md +75 -0
- pptx2/skill/references/space-aware-authoring.md +249 -0
- pptx2/skill/references/tables.md +244 -0
- pptx2/skill/references/theme.md +127 -0
- pptx2/skill/references/three-d.md +109 -0
- pptx2/skill/references/transitions.md +100 -0
- pptx2/slide.py +1244 -0
- pptx2/smart_art.py +220 -0
- pptx2/spec.py +633 -0
- pptx2/table.py +1181 -0
- pptx2/table_styles.py +184 -0
- pptx2/templates/default.pptx +0 -0
- pptx2/templates/docx-icon.emf +0 -0
- pptx2/templates/generic-icon.emf +0 -0
- pptx2/templates/notes.xml +23 -0
- pptx2/templates/notesMaster.xml +352 -0
- pptx2/templates/pptx-icon.emf +0 -0
- pptx2/templates/theme.xml +321 -0
- pptx2/templates/xlsx-icon.emf +0 -0
- pptx2/text/__init__.py +0 -0
- pptx2/text/fonts.py +482 -0
- pptx2/text/layout.py +374 -0
- pptx2/text/text.py +1272 -0
- pptx2/theme.py +721 -0
- pptx2/types.py +36 -0
- pptx2/util.py +263 -0
- python_pptx2-2.13.0.dist-info/METADATA +351 -0
- python_pptx2-2.13.0.dist-info/RECORD +175 -0
- python_pptx2-2.13.0.dist-info/WHEEL +5 -0
- python_pptx2-2.13.0.dist-info/entry_points.txt +3 -0
- python_pptx2-2.13.0.dist-info/licenses/LICENSE +22 -0
- 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
|
+
]
|