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/api.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Directly exposed API classes, Presentation for now.
|
|
2
|
+
|
|
3
|
+
Provides some syntactic sugar for interacting with the pptx2.presentation.Package graph and also
|
|
4
|
+
provides some insulation so not so many classes in the other modules need to be named as internal
|
|
5
|
+
(leading underscore).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import IO, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from pptx2.opc.constants import CONTENT_TYPE as CT
|
|
14
|
+
from pptx2.package import Package
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pptx2 import presentation
|
|
18
|
+
from pptx2.parts.presentation import PresentationPart
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def Presentation(pptx: str | IO[bytes] | None = None) -> presentation.Presentation:
|
|
22
|
+
"""
|
|
23
|
+
Return a |Presentation| object loaded from *pptx*, where *pptx* can be
|
|
24
|
+
either a path to a ``.pptx`` file (a string) or a file-like object. If
|
|
25
|
+
*pptx* is missing or ``None``, the built-in default presentation
|
|
26
|
+
"template" is loaded.
|
|
27
|
+
"""
|
|
28
|
+
if pptx is None:
|
|
29
|
+
pptx = _default_pptx_path()
|
|
30
|
+
|
|
31
|
+
presentation_part = Package.open(pptx).main_document_part
|
|
32
|
+
|
|
33
|
+
if not _is_pptx_package(presentation_part):
|
|
34
|
+
tmpl = "file '%s' is not a PowerPoint file, content type is '%s'"
|
|
35
|
+
raise ValueError(tmpl % (pptx, presentation_part.content_type))
|
|
36
|
+
|
|
37
|
+
return presentation_part.presentation
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_pptx_path() -> str:
|
|
41
|
+
"""Return the path to the built-in default .pptx package."""
|
|
42
|
+
_thisdir = os.path.split(__file__)[0]
|
|
43
|
+
return os.path.join(_thisdir, "templates", "default.pptx")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_pptx_package(prs_part: PresentationPart):
|
|
47
|
+
"""Return |True| if *prs_part* is a valid main document part, |False| otherwise."""
|
|
48
|
+
valid_content_types = (CT.PML_PRESENTATION_MAIN, CT.PML_PRES_MACRO_MAIN)
|
|
49
|
+
return prs_part.content_type in valid_content_types
|
pptx2/audit.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""One-call deck audit — combines lint, picture sanity, and empty-slide checks.
|
|
2
|
+
|
|
3
|
+
For agents producing a deck end-to-end the question is "did I do a
|
|
4
|
+
good job?". :func:`audit` returns a small structured report so the
|
|
5
|
+
agent can include a "what I shipped" summary in its reply to the user
|
|
6
|
+
without crawling every slide manually::
|
|
7
|
+
|
|
8
|
+
from pptx2 import audit
|
|
9
|
+
|
|
10
|
+
report = audit(prs)
|
|
11
|
+
print(report.markdown())
|
|
12
|
+
|
|
13
|
+
The report contains:
|
|
14
|
+
|
|
15
|
+
* ``lint_issues`` — every :class:`~pptx2.lint.LintIssue` aggregated
|
|
16
|
+
across slides, each annotated with the slide index it came from.
|
|
17
|
+
* ``broken_pictures`` — pictures whose dimensions are zero or whose
|
|
18
|
+
embedded image part appears corrupt.
|
|
19
|
+
* ``empty_slides`` — slide indexes whose only shapes are background
|
|
20
|
+
decoration (full-bleed rects, layout placeholders).
|
|
21
|
+
* ``font_warnings`` — fonts referenced by shapes that aren't typically
|
|
22
|
+
pre-installed on Windows / macOS / Linux (raises false positives;
|
|
23
|
+
treat as advisory).
|
|
24
|
+
* ``size_warnings`` — pictures larger than ``size_warn_bytes`` (default
|
|
25
|
+
2 MB) — flagged so callers can choose to compress.
|
|
26
|
+
|
|
27
|
+
The audit is read-only — it never mutates the deck.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from pptx2.api import Presentation
|
|
37
|
+
from pptx2.lint import LintIssue
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["AuditReport", "audit"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Fonts that ship with stock Windows / macOS / Office installs. Used as
|
|
44
|
+
# a (very conservative) safe-list for the font_warnings probe.
|
|
45
|
+
_COMMON_FONTS = frozenset(
|
|
46
|
+
name.lower()
|
|
47
|
+
for name in (
|
|
48
|
+
"Arial", "Calibri", "Helvetica", "Times New Roman", "Cambria",
|
|
49
|
+
"Verdana", "Tahoma", "Georgia", "Courier New", "Consolas",
|
|
50
|
+
"Segoe UI", "Trebuchet MS", "Garamond", "Impact", "Comic Sans MS",
|
|
51
|
+
"Lucida Console", "Palatino", "Palatino Linotype", "Symbol",
|
|
52
|
+
"Wingdings", "Wingdings 2", "Wingdings 3", "Webdings",
|
|
53
|
+
"Inter", "Roboto", "Open Sans", "Lato", "Noto Sans",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AuditReport:
|
|
60
|
+
"""Structured summary returned by :func:`audit`."""
|
|
61
|
+
|
|
62
|
+
lint_issues: list[tuple[int, "LintIssue"]] = field(default_factory=list)
|
|
63
|
+
broken_pictures: list[tuple[int, Any]] = field(default_factory=list)
|
|
64
|
+
empty_slides: list[int] = field(default_factory=list)
|
|
65
|
+
font_warnings: list[tuple[int, str]] = field(default_factory=list)
|
|
66
|
+
size_warnings: list[tuple[int, str, int]] = field(default_factory=list)
|
|
67
|
+
total_slides: int = 0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def has_errors(self) -> bool:
|
|
71
|
+
from pptx2.lint import LintSeverity
|
|
72
|
+
|
|
73
|
+
return any(
|
|
74
|
+
issue.severity == LintSeverity.ERROR
|
|
75
|
+
for _idx, issue in self.lint_issues
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def markdown(self) -> str:
|
|
79
|
+
"""Render the report as a markdown string suitable for chat replies."""
|
|
80
|
+
lines = [f"# Audit report — {self.total_slides} slide(s)"]
|
|
81
|
+
if not (
|
|
82
|
+
self.lint_issues
|
|
83
|
+
or self.broken_pictures
|
|
84
|
+
or self.empty_slides
|
|
85
|
+
or self.font_warnings
|
|
86
|
+
or self.size_warnings
|
|
87
|
+
):
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append("**No issues found.**")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
if self.lint_issues:
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.append(f"## Lint ({len(self.lint_issues)})")
|
|
95
|
+
for idx, issue in self.lint_issues:
|
|
96
|
+
lines.append(f"- slide {idx}: {issue}")
|
|
97
|
+
if self.broken_pictures:
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append(f"## Broken pictures ({len(self.broken_pictures)})")
|
|
100
|
+
for idx, shape in self.broken_pictures:
|
|
101
|
+
lines.append(f"- slide {idx}: `{shape.name}` is zero-sized or missing image data")
|
|
102
|
+
if self.empty_slides:
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append("## Empty slides")
|
|
105
|
+
for idx in self.empty_slides:
|
|
106
|
+
lines.append(f"- slide {idx}: no content-bearing shapes")
|
|
107
|
+
if self.font_warnings:
|
|
108
|
+
lines.append("")
|
|
109
|
+
lines.append(f"## Font warnings ({len(self.font_warnings)})")
|
|
110
|
+
seen = set()
|
|
111
|
+
for idx, font in self.font_warnings:
|
|
112
|
+
key = (idx, font)
|
|
113
|
+
if key in seen:
|
|
114
|
+
continue
|
|
115
|
+
seen.add(key)
|
|
116
|
+
lines.append(f"- slide {idx}: uses `{font}` (not in the common safe-list)")
|
|
117
|
+
if self.size_warnings:
|
|
118
|
+
lines.append("")
|
|
119
|
+
lines.append(f"## Large pictures ({len(self.size_warnings)})")
|
|
120
|
+
for idx, name, size_bytes in self.size_warnings:
|
|
121
|
+
lines.append(
|
|
122
|
+
f"- slide {idx}: `{name}` is {size_bytes / 1_000_000:.1f} MB"
|
|
123
|
+
)
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
"""Return a JSON-serializable dict of the whole audit.
|
|
128
|
+
|
|
129
|
+
This is the machine-readable counterpart to :meth:`markdown` — built
|
|
130
|
+
for the agent loop that generates a deck, audits it, and feeds the
|
|
131
|
+
result back to a model (or a CI gate) to decide what to fix. Every
|
|
132
|
+
lint issue is expanded via :meth:`~pptx2.lint.LintIssue.to_dict`,
|
|
133
|
+
and slide-relative shape references are reduced to names::
|
|
134
|
+
|
|
135
|
+
report = audit(prs)
|
|
136
|
+
if report.to_dict()["has_errors"]:
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
Top-level keys: ``total_slides``, ``has_errors``, ``lint_issues``,
|
|
140
|
+
``broken_pictures``, ``empty_slides``, ``font_warnings``,
|
|
141
|
+
``size_warnings``.
|
|
142
|
+
"""
|
|
143
|
+
return {
|
|
144
|
+
"total_slides": self.total_slides,
|
|
145
|
+
"has_errors": self.has_errors,
|
|
146
|
+
"lint_issues": [
|
|
147
|
+
{"slide": idx, **issue.to_dict()} for idx, issue in self.lint_issues
|
|
148
|
+
],
|
|
149
|
+
"broken_pictures": [
|
|
150
|
+
{"slide": idx, "shape": shape.name} for idx, shape in self.broken_pictures
|
|
151
|
+
],
|
|
152
|
+
"empty_slides": list(self.empty_slides),
|
|
153
|
+
"font_warnings": [
|
|
154
|
+
{"slide": idx, "font": font} for idx, font in self.font_warnings
|
|
155
|
+
],
|
|
156
|
+
"size_warnings": [
|
|
157
|
+
{"slide": idx, "shape": name, "bytes": size_bytes}
|
|
158
|
+
for idx, name, size_bytes in self.size_warnings
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
163
|
+
"""Return :meth:`to_dict` serialized as a JSON string."""
|
|
164
|
+
import json
|
|
165
|
+
|
|
166
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
167
|
+
|
|
168
|
+
def __str__(self) -> str:
|
|
169
|
+
return self.markdown()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def audit(
|
|
173
|
+
prs: "Presentation",
|
|
174
|
+
*,
|
|
175
|
+
size_warn_bytes: int = 2_000_000,
|
|
176
|
+
check_fonts: bool = True,
|
|
177
|
+
) -> AuditReport:
|
|
178
|
+
"""Walk the deck and return an :class:`AuditReport` summary.
|
|
179
|
+
|
|
180
|
+
Read-only — never mutates the presentation. Each slide is linted
|
|
181
|
+
in passing-defaults mode; per-slide overrides should be supplied
|
|
182
|
+
via the slide-level ``slide.lint(...)`` API directly.
|
|
183
|
+
"""
|
|
184
|
+
from pptx2.shapes.picture import Picture
|
|
185
|
+
|
|
186
|
+
report = AuditReport()
|
|
187
|
+
slides = list(prs.slides)
|
|
188
|
+
report.total_slides = len(slides)
|
|
189
|
+
|
|
190
|
+
# Read slide dimensions once for the full-bleed-background heuristic.
|
|
191
|
+
slide_w = int(prs.slide_width) if prs.slide_width else 0
|
|
192
|
+
slide_h = int(prs.slide_height) if prs.slide_height else 0
|
|
193
|
+
bleed_w = slide_w * 0.95
|
|
194
|
+
bleed_h = slide_h * 0.95
|
|
195
|
+
|
|
196
|
+
for idx, slide in enumerate(slides):
|
|
197
|
+
# Lint each slide and prefix with the index.
|
|
198
|
+
for issue in slide.lint().issues:
|
|
199
|
+
report.lint_issues.append((idx, issue))
|
|
200
|
+
|
|
201
|
+
content_shapes = 0
|
|
202
|
+
for shape in slide.shapes:
|
|
203
|
+
# Skip layout placeholders that are entirely inherited.
|
|
204
|
+
if (
|
|
205
|
+
shape.is_placeholder
|
|
206
|
+
and getattr(shape, "has_text_frame", False)
|
|
207
|
+
and not shape.text_frame.text.strip()
|
|
208
|
+
):
|
|
209
|
+
continue
|
|
210
|
+
# Skip full-bleed background rectangles — they're decoration,
|
|
211
|
+
# not content, so a slide that only contains them should be
|
|
212
|
+
# flagged as empty (matches the documented contract for
|
|
213
|
+
# ``empty_slides``).
|
|
214
|
+
try:
|
|
215
|
+
sw = int(shape.width)
|
|
216
|
+
sh = int(shape.height)
|
|
217
|
+
except Exception:
|
|
218
|
+
sw = sh = 0
|
|
219
|
+
if (
|
|
220
|
+
bleed_w > 0
|
|
221
|
+
and bleed_h > 0
|
|
222
|
+
and sw >= bleed_w
|
|
223
|
+
and sh >= bleed_h
|
|
224
|
+
):
|
|
225
|
+
has_text = (
|
|
226
|
+
getattr(shape, "has_text_frame", False)
|
|
227
|
+
and shape.text_frame.text.strip()
|
|
228
|
+
)
|
|
229
|
+
if not has_text:
|
|
230
|
+
continue
|
|
231
|
+
content_shapes += 1
|
|
232
|
+
|
|
233
|
+
# Picture sanity.
|
|
234
|
+
if isinstance(shape, Picture):
|
|
235
|
+
if int(shape.width) <= 0 or int(shape.height) <= 0:
|
|
236
|
+
report.broken_pictures.append((idx, shape))
|
|
237
|
+
# Picture size warning via the underlying image part.
|
|
238
|
+
try:
|
|
239
|
+
img = shape.image # type: ignore[attr-defined]
|
|
240
|
+
size_bytes = len(img.blob)
|
|
241
|
+
if size_bytes > size_warn_bytes:
|
|
242
|
+
report.size_warnings.append((idx, shape.name, size_bytes))
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Font warning sweep.
|
|
247
|
+
if check_fonts and getattr(shape, "has_text_frame", False):
|
|
248
|
+
tf = shape.text_frame
|
|
249
|
+
for para in tf.paragraphs:
|
|
250
|
+
for run in para.runs:
|
|
251
|
+
name = run.font.name
|
|
252
|
+
if name and name.lower() not in _COMMON_FONTS:
|
|
253
|
+
report.font_warnings.append((idx, name))
|
|
254
|
+
|
|
255
|
+
if content_shapes == 0:
|
|
256
|
+
report.empty_slides.append(idx)
|
|
257
|
+
|
|
258
|
+
return report
|
pptx2/chart/__init__.py
ADDED
|
File without changes
|
pptx2/chart/analytics.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Series-level analytics: trendlines and error bars.
|
|
2
|
+
|
|
3
|
+
These objects wrap the ``<c:trendline>`` and ``<c:errBars>`` children of a
|
|
4
|
+
``<c:ser>`` element and expose an Excel-like authoring API on chart series.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from pptx2.enum.chart import (
|
|
12
|
+
XL_ERROR_BAR_INCLUDE,
|
|
13
|
+
XL_ERROR_BAR_TYPE,
|
|
14
|
+
XL_TRENDLINE_TYPE,
|
|
15
|
+
)
|
|
16
|
+
from pptx2.oxml import parse_xml
|
|
17
|
+
from pptx2.oxml.ns import nsdecls
|
|
18
|
+
|
|
19
|
+
# -- map of the short-name strings accepted at the API boundary to the
|
|
20
|
+
# -- corresponding enum member, so callers can pass e.g. "linear" or
|
|
21
|
+
# -- "movingAvg" without importing the enum.
|
|
22
|
+
_TRENDLINE_ALIASES = {
|
|
23
|
+
"linear": XL_TRENDLINE_TYPE.LINEAR,
|
|
24
|
+
"exp": XL_TRENDLINE_TYPE.EXPONENTIAL,
|
|
25
|
+
"exponential": XL_TRENDLINE_TYPE.EXPONENTIAL,
|
|
26
|
+
"log": XL_TRENDLINE_TYPE.LOGARITHMIC,
|
|
27
|
+
"logarithmic": XL_TRENDLINE_TYPE.LOGARITHMIC,
|
|
28
|
+
"movingavg": XL_TRENDLINE_TYPE.MOVING_AVERAGE,
|
|
29
|
+
"moving_average": XL_TRENDLINE_TYPE.MOVING_AVERAGE,
|
|
30
|
+
"poly": XL_TRENDLINE_TYPE.POLYNOMIAL,
|
|
31
|
+
"polynomial": XL_TRENDLINE_TYPE.POLYNOMIAL,
|
|
32
|
+
"power": XL_TRENDLINE_TYPE.POWER,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_ERR_DIRECTION_ALIASES = {
|
|
36
|
+
"both": XL_ERROR_BAR_TYPE.BOTH,
|
|
37
|
+
"plus": XL_ERROR_BAR_TYPE.PLUS,
|
|
38
|
+
"minus": XL_ERROR_BAR_TYPE.MINUS,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_trendline_type(kind):
|
|
43
|
+
"""Return the `XL_TRENDLINE_TYPE` member for *kind*.
|
|
44
|
+
|
|
45
|
+
*kind* may already be an enum member, or one of the short-name strings
|
|
46
|
+
such as ``"linear"`` / ``"movingAvg"`` / ``"poly"``.
|
|
47
|
+
"""
|
|
48
|
+
if isinstance(kind, XL_TRENDLINE_TYPE):
|
|
49
|
+
return kind
|
|
50
|
+
try:
|
|
51
|
+
return _TRENDLINE_ALIASES[str(kind).lower()]
|
|
52
|
+
except KeyError:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"trendline kind must be one of %r or an XL_TRENDLINE_TYPE member; got %r"
|
|
55
|
+
% (sorted(set(_TRENDLINE_ALIASES)), kind)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_err_direction(direction):
|
|
60
|
+
"""Return the `XL_ERROR_BAR_TYPE` member for *direction*."""
|
|
61
|
+
if isinstance(direction, XL_ERROR_BAR_TYPE):
|
|
62
|
+
return direction
|
|
63
|
+
try:
|
|
64
|
+
return _ERR_DIRECTION_ALIASES[str(direction).lower()]
|
|
65
|
+
except KeyError:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"error-bar direction must be 'both', 'plus', or 'minus' (or an "
|
|
68
|
+
"XL_ERROR_BAR_TYPE member); got %r" % (direction,)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Trendline(object):
|
|
73
|
+
"""A single ``<c:trendline>`` fitted to a chart series."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, trendline):
|
|
76
|
+
self._element = trendline
|
|
77
|
+
self._trendline = trendline
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def trendline_type(self):
|
|
81
|
+
"""Member of :ref:`XL_TRENDLINE_TYPE` for this trendline's curve."""
|
|
82
|
+
return self._trendline.trendlineType.val
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def show_equation(self):
|
|
86
|
+
"""Read/write bool: whether the fitted equation is displayed."""
|
|
87
|
+
dispEq = self._trendline.dispEq
|
|
88
|
+
if dispEq is None:
|
|
89
|
+
return False
|
|
90
|
+
return bool(dispEq.val)
|
|
91
|
+
|
|
92
|
+
@show_equation.setter
|
|
93
|
+
def show_equation(self, value):
|
|
94
|
+
if bool(value):
|
|
95
|
+
self._trendline.get_or_add_dispEq().val = True
|
|
96
|
+
else:
|
|
97
|
+
self._trendline._remove_dispEq()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def show_r_squared(self):
|
|
101
|
+
"""Read/write bool: whether the R-squared value is displayed."""
|
|
102
|
+
dispRSqr = self._trendline.dispRSqr
|
|
103
|
+
if dispRSqr is None:
|
|
104
|
+
return False
|
|
105
|
+
return bool(dispRSqr.val)
|
|
106
|
+
|
|
107
|
+
@show_r_squared.setter
|
|
108
|
+
def show_r_squared(self, value):
|
|
109
|
+
if bool(value):
|
|
110
|
+
self._trendline.get_or_add_dispRSqr().val = True
|
|
111
|
+
else:
|
|
112
|
+
self._trendline._remove_dispRSqr()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def name(self):
|
|
116
|
+
"""Read/write str: the legend label for this trendline, or |None|."""
|
|
117
|
+
name = self._trendline.name
|
|
118
|
+
if name is None:
|
|
119
|
+
return None
|
|
120
|
+
return name.text
|
|
121
|
+
|
|
122
|
+
@name.setter
|
|
123
|
+
def name(self, value):
|
|
124
|
+
if value is None:
|
|
125
|
+
self._trendline._remove_name()
|
|
126
|
+
return
|
|
127
|
+
self._trendline.get_or_add_name().text = str(value)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def order(self):
|
|
131
|
+
"""Read/write int: polynomial order (2-6), or |None| if unset."""
|
|
132
|
+
order = self._trendline.order
|
|
133
|
+
if order is None:
|
|
134
|
+
return None
|
|
135
|
+
return order.val
|
|
136
|
+
|
|
137
|
+
@order.setter
|
|
138
|
+
def order(self, value):
|
|
139
|
+
if value is None:
|
|
140
|
+
self._trendline._remove_order()
|
|
141
|
+
return
|
|
142
|
+
order = int(value)
|
|
143
|
+
if not 2 <= order <= 6:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"polynomial trendline order must be in the range 2-6 "
|
|
146
|
+
"(ST_Order); got %r" % (value,)
|
|
147
|
+
)
|
|
148
|
+
self._trendline.get_or_add_order().val = order
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def period(self):
|
|
152
|
+
"""Read/write int: moving-average period (>=2), or |None| if unset."""
|
|
153
|
+
period = self._trendline.period
|
|
154
|
+
if period is None:
|
|
155
|
+
return None
|
|
156
|
+
return period.val
|
|
157
|
+
|
|
158
|
+
@period.setter
|
|
159
|
+
def period(self, value):
|
|
160
|
+
if value is None:
|
|
161
|
+
self._trendline._remove_period()
|
|
162
|
+
return
|
|
163
|
+
period = int(value)
|
|
164
|
+
if period < 2:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"moving-average trendline period must be 2 or greater "
|
|
167
|
+
"(ST_Period); got %r" % (value,)
|
|
168
|
+
)
|
|
169
|
+
self._trendline.get_or_add_period().val = period
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def forward(self):
|
|
173
|
+
"""Read/write float: forward projection in periods, or |None|."""
|
|
174
|
+
forward = self._trendline.forward
|
|
175
|
+
if forward is None:
|
|
176
|
+
return None
|
|
177
|
+
return forward.val
|
|
178
|
+
|
|
179
|
+
@forward.setter
|
|
180
|
+
def forward(self, value):
|
|
181
|
+
if value is None:
|
|
182
|
+
self._trendline._remove_forward()
|
|
183
|
+
return
|
|
184
|
+
self._trendline.get_or_add_forward().val = float(value)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def backward(self):
|
|
188
|
+
"""Read/write float: backward projection in periods, or |None|."""
|
|
189
|
+
backward = self._trendline.backward
|
|
190
|
+
if backward is None:
|
|
191
|
+
return None
|
|
192
|
+
return backward.val
|
|
193
|
+
|
|
194
|
+
@backward.setter
|
|
195
|
+
def backward(self, value):
|
|
196
|
+
if value is None:
|
|
197
|
+
self._trendline._remove_backward()
|
|
198
|
+
return
|
|
199
|
+
self._trendline.get_or_add_backward().val = float(value)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Trendlines(Sequence):
|
|
203
|
+
"""The collection of ``<c:trendline>`` elements for a series.
|
|
204
|
+
|
|
205
|
+
Supports ``len()``, iteration, indexed access, and ``.add(...)``.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self, ser):
|
|
209
|
+
self._element = ser
|
|
210
|
+
self._ser = ser
|
|
211
|
+
|
|
212
|
+
def __getitem__(self, index):
|
|
213
|
+
trendlines = self._ser.trendline_lst
|
|
214
|
+
if isinstance(index, slice):
|
|
215
|
+
return [Trendline(t) for t in trendlines[index]]
|
|
216
|
+
return Trendline(trendlines[index])
|
|
217
|
+
|
|
218
|
+
def __len__(self):
|
|
219
|
+
return len(self._ser.trendline_lst)
|
|
220
|
+
|
|
221
|
+
def add(
|
|
222
|
+
self,
|
|
223
|
+
kind=XL_TRENDLINE_TYPE.LINEAR,
|
|
224
|
+
*,
|
|
225
|
+
show_equation=False,
|
|
226
|
+
show_r_squared=False,
|
|
227
|
+
name=None,
|
|
228
|
+
order=None,
|
|
229
|
+
period=None,
|
|
230
|
+
forward=None,
|
|
231
|
+
backward=None,
|
|
232
|
+
):
|
|
233
|
+
"""Add and return a new |Trendline| of the given *kind*.
|
|
234
|
+
|
|
235
|
+
*kind* accepts an :class:`XL_TRENDLINE_TYPE` member or a short-name
|
|
236
|
+
string (``"linear"``, ``"exp"``, ``"log"``, ``"movingAvg"``,
|
|
237
|
+
``"poly"``, ``"power"``). *order* applies to polynomial trendlines,
|
|
238
|
+
*period* to moving-average trendlines. *forward* / *backward* project
|
|
239
|
+
the curve that many periods past the data.
|
|
240
|
+
"""
|
|
241
|
+
trendline_type = _resolve_trendline_type(kind)
|
|
242
|
+
trendline = self._ser._add_trendline()
|
|
243
|
+
# -- write the `val` attribute explicitly (even for the schema default
|
|
244
|
+
# -- "linear") so the emitted XML matches Excel/PowerPoint output and
|
|
245
|
+
# -- is unambiguous to readers.
|
|
246
|
+
trendline.trendlineType.set("val", trendline_type.xml_value)
|
|
247
|
+
|
|
248
|
+
proxy = Trendline(trendline)
|
|
249
|
+
if name is not None:
|
|
250
|
+
proxy.name = name
|
|
251
|
+
if order is not None:
|
|
252
|
+
proxy.order = order
|
|
253
|
+
if period is not None:
|
|
254
|
+
proxy.period = period
|
|
255
|
+
if forward is not None:
|
|
256
|
+
proxy.forward = forward
|
|
257
|
+
if backward is not None:
|
|
258
|
+
proxy.backward = backward
|
|
259
|
+
# -- dispRSqr / dispEq come late in the schema, set after order/period
|
|
260
|
+
if show_r_squared:
|
|
261
|
+
proxy.show_r_squared = True
|
|
262
|
+
if show_equation:
|
|
263
|
+
proxy.show_equation = True
|
|
264
|
+
return proxy
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ErrorBars(object):
|
|
268
|
+
"""The ``<c:errBars>`` element for a series, with Excel-style constructors.
|
|
269
|
+
|
|
270
|
+
The collection is materialised lazily — the underlying ``<c:errBars>``
|
|
271
|
+
element is only created when one of the constructor methods (``fixed``,
|
|
272
|
+
``percentage``, ``standard_deviation``, ``standard_error``, ``custom``) is
|
|
273
|
+
called. Calling a constructor a second time replaces the prior settings.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(self, ser):
|
|
277
|
+
self._element = ser
|
|
278
|
+
self._ser = ser
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def exists(self):
|
|
282
|
+
"""True if an ``<c:errBars>`` element is present on this series."""
|
|
283
|
+
return self._ser.errBars is not None
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def error_bar_type(self):
|
|
287
|
+
"""The :ref:`XL_ERROR_BAR_TYPE` direction, or |None| if no error bars."""
|
|
288
|
+
errBars = self._ser.errBars
|
|
289
|
+
if errBars is None or errBars.errBarType is None:
|
|
290
|
+
return None
|
|
291
|
+
return errBars.errBarType.val
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def include_type(self):
|
|
295
|
+
"""The :ref:`XL_ERROR_BAR_INCLUDE` value-type, or |None|."""
|
|
296
|
+
errBars = self._ser.errBars
|
|
297
|
+
if errBars is None or errBars.errValType is None:
|
|
298
|
+
return None
|
|
299
|
+
return errBars.errValType.val
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def value(self):
|
|
303
|
+
"""The numeric error amount (fixed/percentage/stdDev), or |None|."""
|
|
304
|
+
errBars = self._ser.errBars
|
|
305
|
+
if errBars is None or errBars.val is None:
|
|
306
|
+
return None
|
|
307
|
+
return float(errBars.val.get("val"))
|
|
308
|
+
|
|
309
|
+
def remove(self):
|
|
310
|
+
"""Remove any error bars present on this series."""
|
|
311
|
+
self._ser._remove_errBars()
|
|
312
|
+
|
|
313
|
+
# -- Excel-mirroring constructors -----------------------------------
|
|
314
|
+
|
|
315
|
+
def fixed(self, value, *, direction="both"):
|
|
316
|
+
"""Set fixed-value error bars of magnitude *value*."""
|
|
317
|
+
return self._configure(XL_ERROR_BAR_INCLUDE.FIXED_VALUE, direction, value=value)
|
|
318
|
+
|
|
319
|
+
def percentage(self, pct, *, direction="both"):
|
|
320
|
+
"""Set percentage error bars of *pct* percent of each value."""
|
|
321
|
+
return self._configure(XL_ERROR_BAR_INCLUDE.PERCENTAGE, direction, value=pct)
|
|
322
|
+
|
|
323
|
+
def standard_deviation(self, n=1, *, direction="both"):
|
|
324
|
+
"""Set error bars of *n* standard deviations."""
|
|
325
|
+
return self._configure(XL_ERROR_BAR_INCLUDE.STANDARD_DEVIATION, direction, value=n)
|
|
326
|
+
|
|
327
|
+
def standard_error(self, *, direction="both"):
|
|
328
|
+
"""Set standard-error error bars (no numeric amount)."""
|
|
329
|
+
return self._configure(XL_ERROR_BAR_INCLUDE.STANDARD_ERROR, direction, value=None)
|
|
330
|
+
|
|
331
|
+
def custom(self, plus, minus, *, direction="both"):
|
|
332
|
+
"""Set custom error bars from per-point *plus* / *minus* sequences.
|
|
333
|
+
|
|
334
|
+
*plus* and *minus* are sequences of floats, one per data point.
|
|
335
|
+
"""
|
|
336
|
+
errBars = self._reset_errBars(direction)
|
|
337
|
+
errBars.get_or_add_errValType().set("val", XL_ERROR_BAR_INCLUDE.CUSTOM.xml_value)
|
|
338
|
+
errBars.append(self._num_data_source("c:plus", plus))
|
|
339
|
+
errBars.append(self._num_data_source("c:minus", minus))
|
|
340
|
+
return self
|
|
341
|
+
|
|
342
|
+
# -- internals ------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def _configure(self, include_type, direction, *, value):
|
|
345
|
+
errBars = self._reset_errBars(direction)
|
|
346
|
+
errBars.get_or_add_errValType().set("val", include_type.xml_value)
|
|
347
|
+
if value is not None:
|
|
348
|
+
val = parse_xml('<c:val %s val="%s"/>' % (nsdecls("c"), float(value)))
|
|
349
|
+
errBars.append(val)
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
def _reset_errBars(self, direction):
|
|
353
|
+
"""Return a fresh ``<c:errBars>`` element with *direction* applied.
|
|
354
|
+
|
|
355
|
+
Any pre-existing error bars are removed first so repeated constructor
|
|
356
|
+
calls don't accumulate stale ``plus`` / ``minus`` / ``val`` children.
|
|
357
|
+
"""
|
|
358
|
+
self._ser._remove_errBars()
|
|
359
|
+
errBars = self._ser.get_or_add_errBars()
|
|
360
|
+
errBars.get_or_add_errBarType().set("val", _resolve_err_direction(direction).xml_value)
|
|
361
|
+
return errBars
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _num_data_source(tag, values):
|
|
365
|
+
"""Return a ``<c:plus>`` or ``<c:minus>`` numeric-literal data source."""
|
|
366
|
+
seq = list(values)
|
|
367
|
+
pts = "".join(
|
|
368
|
+
'<c:pt idx="%d"><c:v>%s</c:v></c:pt>' % (i, float(v))
|
|
369
|
+
for i, v in enumerate(seq)
|
|
370
|
+
if v is not None
|
|
371
|
+
)
|
|
372
|
+
xml = (
|
|
373
|
+
"<%s %s>"
|
|
374
|
+
"<c:numLit>"
|
|
375
|
+
"<c:formatCode>General</c:formatCode>"
|
|
376
|
+
'<c:ptCount val="%d"/>'
|
|
377
|
+
"%s"
|
|
378
|
+
"</c:numLit>"
|
|
379
|
+
"</%s>"
|
|
380
|
+
) % (tag, nsdecls("c"), len(seq), pts, tag)
|
|
381
|
+
return parse_xml(xml)
|