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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Layout linter (Phase 2)
|
|
2
|
+
|
|
3
|
+
Programmatic decks tend to ship the same handful of bugs over and
|
|
4
|
+
over: text spilling out of its container, shapes off-slide, layered
|
|
5
|
+
elements that aren't intended overlaps. The linter is built for
|
|
6
|
+
exactly that use case — it's especially useful when feeding decks
|
|
7
|
+
generated from LLM output or arbitrary user input.
|
|
8
|
+
|
|
9
|
+
## Run on a slide
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
report = slide.lint()
|
|
13
|
+
report.issues # list[LintIssue]
|
|
14
|
+
report.has_errors # bool
|
|
15
|
+
print(report.summary())
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For a whole deck, iterate the slides yourself:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
all_issues = []
|
|
22
|
+
for slide in prs.slides:
|
|
23
|
+
all_issues.extend(slide.lint().issues)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`from_spec` (see `compose.md`) accepts a deck-level
|
|
27
|
+
``"lint": "warn" | "raise"`` field that walks every slide for you.
|
|
28
|
+
|
|
29
|
+
## Issue types
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from pptx2.lint import TextOverflow, OffSlide, ShapeCollision
|
|
33
|
+
|
|
34
|
+
for issue in report.issues:
|
|
35
|
+
if isinstance(issue, TextOverflow):
|
|
36
|
+
print("overflow", issue.shapes[0].name, "ratio", issue.ratio)
|
|
37
|
+
elif isinstance(issue, OffSlide):
|
|
38
|
+
print("off-slide", issue.shapes[0].name, "side", issue.side)
|
|
39
|
+
elif isinstance(issue, ShapeCollision):
|
|
40
|
+
a, b = issue.shapes
|
|
41
|
+
print("collision", a.name, b.name,
|
|
42
|
+
"intersection_pct", issue.intersection_pct)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`LayerOrderViolation` carries the declared `layer` name, and its
|
|
46
|
+
`shapes` tuple is `(declaring_shape, layer_shape)`.
|
|
47
|
+
|
|
48
|
+
Every issue carries a `severity` (`LintSeverity.ERROR` / `WARNING` /
|
|
49
|
+
`INFO`), a `code` string, a `message`, and a `shapes` tuple of the
|
|
50
|
+
shapes it implicates.
|
|
51
|
+
|
|
52
|
+
`TextOverflow` uses Pillow font metrics and respects margins, vertical
|
|
53
|
+
anchor, line spacing, and `auto_size`.
|
|
54
|
+
|
|
55
|
+
## Auto-fix
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
fixes = report.auto_fix() # mutates; returns list[str]
|
|
59
|
+
preview = report.auto_fix(dry_run=True) # no mutation; returns list[str]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
What's currently fixable:
|
|
63
|
+
|
|
64
|
+
- **`OffSlide`** → translates the shape so it sits inside the slide
|
|
65
|
+
bounds (shrinking it first if it is larger than the slide). Returns a
|
|
66
|
+
one-line description of each nudge.
|
|
67
|
+
- **`TextOverflow`** → flips the frame to
|
|
68
|
+
`MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE` so PowerPoint shrinks the runs at
|
|
69
|
+
render time. Non-destructive: the text is preserved verbatim.
|
|
70
|
+
- **`OffGridDrift`** → snaps the drifted edge onto the dominant grid
|
|
71
|
+
line.
|
|
72
|
+
- **`LayerOrderViolation`** → restacks the shape that declared
|
|
73
|
+
`layer_above` so the drawing order matches what you declared.
|
|
74
|
+
Geometry is untouched.
|
|
75
|
+
|
|
76
|
+
Reported only, never auto-fixed:
|
|
77
|
+
|
|
78
|
+
- **`ShapeCollision`** → auto-nudging would almost always break the
|
|
79
|
+
design. Declare the overlap instead — see below.
|
|
80
|
+
- **`LowContrast`**, **`MinFontSize`**, **`ZOrderAnomaly`**,
|
|
81
|
+
**`MasterPlaceholderCollision`** → need designer judgment.
|
|
82
|
+
|
|
83
|
+
`slide.tidy()` is the one-call wrapper: it lints, then applies the safe
|
|
84
|
+
subset (`fix_offslide`, `fix_overflow`, `fix_layer_order` on by default;
|
|
85
|
+
`fix_grid_drift` off).
|
|
86
|
+
|
|
87
|
+
## Declaring an overlap is intentional
|
|
88
|
+
|
|
89
|
+
`ShapeCollision` is the noisiest rule, because deliberate layering —
|
|
90
|
+
a badge on a card, an accent bar on a panel — looks identical to a
|
|
91
|
+
copy-paste bug from a bounding box alone. Tell the linter what you
|
|
92
|
+
meant and it stops guessing. Three ways, narrowest last:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# 1. Group tag — n-ary and symmetric. Everything sharing a non-empty
|
|
96
|
+
# tag may overlap everything else in the tag.
|
|
97
|
+
card.lint_group = "kpi-1"
|
|
98
|
+
accent.lint_group = "kpi-1"
|
|
99
|
+
slide.lint_group("kpi-1", card, accent, label) # batch form
|
|
100
|
+
slide.lint_group_overlaps(card, accent, label) # auto-names the group
|
|
101
|
+
|
|
102
|
+
# 2. Pairwise allowance — licenses exactly one pair, nothing else.
|
|
103
|
+
badge.allow_overlap_with(card)
|
|
104
|
+
badge.disallow_overlap_with(card) # revoke
|
|
105
|
+
badge.overlap_allowances # frozenset[int] of shape ids
|
|
106
|
+
|
|
107
|
+
# 3. Layer hints — the only form that also asserts z-order.
|
|
108
|
+
card.layer = "card"
|
|
109
|
+
badge.layer_above = "card"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use a **group** when several shapes form one visual cluster; a
|
|
113
|
+
**pairwise allowance** when only one specific overlap is meant to be
|
|
114
|
+
legal and you want the rest still policed; **layer hints** when the
|
|
115
|
+
stacking order itself matters.
|
|
116
|
+
|
|
117
|
+
An allowance may only name a shape on the **same slide**, and passing
|
|
118
|
+
one from another slide raises `ValueError`. Use `shape.delete()` rather
|
|
119
|
+
than removing the element by hand — it purges allowances naming the
|
|
120
|
+
deleted shape, which matters because ids get recycled and a stale one
|
|
121
|
+
would later match an unrelated shape. Allowances are keyed on
|
|
122
|
+
`cNvPr/@id`, which is unique per slide but repeats across a deck — so a
|
|
123
|
+
borrowed id would either read as a bogus self-reference or silently
|
|
124
|
+
match an unrelated shape here and suppress a collision that was real.
|
|
125
|
+
|
|
126
|
+
Layer hints are the only one that can *fail*. Declaring
|
|
127
|
+
`layer_above = "card"` asserts this shape is painted on top of every
|
|
128
|
+
overlapping shape whose `layer` is `"card"`. If the shape tree says
|
|
129
|
+
otherwise — the shape claiming to be on top comes earlier in `spTree`
|
|
130
|
+
and is drawn underneath — you get a `LayerOrderViolation` (severity
|
|
131
|
+
ERROR), because the declaration records what you meant and the drawing
|
|
132
|
+
order is what failed to deliver it:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
report = slide.lint()
|
|
136
|
+
report.auto_fix() # restacks it for you; geometry untouched
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A shape name with a dotted prefix is grouped implicitly, so naming
|
|
140
|
+
shapes `"card.bg"` / `"card.title"` groups them under `"card"` with no
|
|
141
|
+
extra calls. Set `shape.lint_group = ""` to opt a dotted name out.
|
|
142
|
+
|
|
143
|
+
All of it lives in the shape's `cNvPr/extLst`, the OOXML-sanctioned
|
|
144
|
+
extension point, so it survives save/open and PowerPoint leaves it
|
|
145
|
+
alone. Related but different: `shape.lint_skip = {"MinFontSize"}`
|
|
146
|
+
silences a rule on a shape rather than declaring intent.
|
|
147
|
+
|
|
148
|
+
## Machine-readable output (for agents / CI)
|
|
149
|
+
|
|
150
|
+
`summary()` is for humans; `to_dict()` / `to_json()` are for code. Use
|
|
151
|
+
them to feed lint results back into an LLM auto-fix loop or a CI gate
|
|
152
|
+
instead of regex-parsing the summary string.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
report = slide.lint()
|
|
156
|
+
report.to_dict() # {"has_errors", "issue_count", "issues": [...]}
|
|
157
|
+
report.to_json() # same, as a JSON string (indent=2 default)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Each issue is self-describing — it carries `code`, `severity`,
|
|
161
|
+
`message`, the names of the `shapes` involved, and every
|
|
162
|
+
detector-specific field (`OffSlide.side`, `TextOverflow.ratio`, the
|
|
163
|
+
`ShapeCollision` scoring, …):
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{"code": "OffSlide", "severity": "error",
|
|
167
|
+
"message": "Shape 'Rectangle 1' extends beyond the right edge of the slide.",
|
|
168
|
+
"shapes": ["Rectangle 1"], "side": "right"}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The whole-deck `audit()` report has the same pair:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from pptx2 import audit
|
|
175
|
+
|
|
176
|
+
data = audit(prs).to_dict() # adds a "slide" index to every lint issue
|
|
177
|
+
if data["has_errors"]:
|
|
178
|
+
... # hand `data` straight to the model to fix
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Save-time hooks (via `from_spec`)
|
|
182
|
+
|
|
183
|
+
If you build the deck through `pptx2.compose.from_spec`, the spec dict
|
|
184
|
+
accepts a top-level ``"lint"`` field:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from pptx2.compose import from_spec
|
|
188
|
+
|
|
189
|
+
prs = from_spec({
|
|
190
|
+
"slides": [...],
|
|
191
|
+
"lint": "raise", # also "warn" or "off" (default)
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`"warn"` logs every issue through stdlib `logging`; `"raise"` raises
|
|
196
|
+
`pptx2.exc.LintError` if any error-severity issue is found.
|
|
197
|
+
|
|
198
|
+
## Save-time hooks (any presentation)
|
|
199
|
+
|
|
200
|
+
Every `Presentation` has a `lint_on_save` switch, whatever built it:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
prs = pptx2.Presentation("deck.pptx")
|
|
204
|
+
prs.lint_on_save = "off" # default — no checks, no cost
|
|
205
|
+
prs.lint_on_save = "warn" # log error-severity issues, still write the file
|
|
206
|
+
prs.lint_on_save = "raise" # raise LintError instead of writing the file
|
|
207
|
+
|
|
208
|
+
prs.save("out.pptx")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Only **error**-severity issues count; warnings and info never trigger it.
|
|
212
|
+
The lint pass runs *before* anything is written, so `"raise"` never leaves a
|
|
213
|
+
bad file on disk. `"warn"` logs on the `pptx2.presentation` logger. The
|
|
214
|
+
setting lives on the in-memory object only — re-open the saved file and it is
|
|
215
|
+
back to `"off"`.
|
|
216
|
+
|
|
217
|
+
## Recommended pattern for generators
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from pptx2.exc import LintError
|
|
221
|
+
|
|
222
|
+
prs = build_deck_from_user_input(...)
|
|
223
|
+
|
|
224
|
+
# 1. Auto-fix what we can, slide by slide
|
|
225
|
+
for slide in prs.slides:
|
|
226
|
+
report = slide.lint()
|
|
227
|
+
report.auto_fix()
|
|
228
|
+
|
|
229
|
+
# 2. Re-run and bail on any remaining errors
|
|
230
|
+
remaining = []
|
|
231
|
+
for slide in prs.slides:
|
|
232
|
+
remaining.extend(i for i in slide.lint().issues
|
|
233
|
+
if getattr(i, "severity", None) == "error")
|
|
234
|
+
if remaining:
|
|
235
|
+
raise LintError("; ".join(str(i) for i in remaining))
|
|
236
|
+
|
|
237
|
+
prs.save("out.pptx")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## CI loop: SARIF export + baseline diff
|
|
241
|
+
|
|
242
|
+
`summary()` is for humans; SARIF and diff are for CI.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
report = slide.lint()
|
|
246
|
+
report.to_sarif(slide_index=0) # SARIF v2.1.0 dict (GitHub code-scanning)
|
|
247
|
+
report.to_sarif_json() # JSON string
|
|
248
|
+
|
|
249
|
+
from pptx2.lint import lint_report_to_sarif
|
|
250
|
+
sarif = lint_report_to_sarif([s.lint() for s in prs.slides]) # whole deck
|
|
251
|
+
|
|
252
|
+
report.fingerprints() # list[str] -- the stable ids diff() uses
|
|
253
|
+
new_issues = current.diff(baseline) # only issues NOT in baseline
|
|
254
|
+
both = current.diff_detail(baseline) # {"added": [...], "fixed": [...]}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Severities map ERROR→error, WARNING→warning, INFO→note. A
|
|
258
|
+
moved-but-still-broken shape keeps its fingerprint, so `diff()` won't
|
|
259
|
+
flag it as new.
|
|
260
|
+
|
|
261
|
+
## Accessibility
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
picture.alt_text = "Bar chart of Q3 revenue by region." # -> <p:cNvPr descr=...>
|
|
265
|
+
picture.title_text = "Q3 revenue" # -> <p:cNvPr title=...>
|
|
266
|
+
|
|
267
|
+
from pptx2 import accessibility
|
|
268
|
+
report = accessibility.audit_accessibility(prs) # read-only
|
|
269
|
+
if report.has_errors: # picture with no alt text = error
|
|
270
|
+
print(report.markdown())
|
|
271
|
+
report.to_dict() # JSON-serializable for a CI gate / LLM loop
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Flags: `MissingAltText` (pictures = error), `LowContrast` (below WCAG AA
|
|
275
|
+
4.5:1), `NoSlideTitle` (no title landmark for navigation).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Native equations from LaTeX
|
|
2
|
+
|
|
3
|
+
PowerPoint stores editable equations as Office Math (OMML) inside an
|
|
4
|
+
`a14:m` marker. python-pptx2 does **not** compile LaTeX itself. It calls
|
|
5
|
+
two libraries and wraps the result:
|
|
6
|
+
|
|
7
|
+
1. [`latex2mathml`](https://pypi.org/project/latex2mathml/) — LaTeX → MathML
|
|
8
|
+
2. [`mathml2omml`](https://pypi.org/project/mathml2omml/) — MathML → OMML
|
|
9
|
+
|
|
10
|
+
Install them with:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install "python-pptx2[math]"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Missing converters raise `MathBackendUnavailable` with that install line.
|
|
17
|
+
|
|
18
|
+
## Display equation — `slide.shapes.add_equation`
|
|
19
|
+
|
|
20
|
+
Same calling convention as `add_text`: a `BBox` or `(left, top, width, height)`.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pptx2 import Presentation, BBox
|
|
24
|
+
from pptx2.util import Inches
|
|
25
|
+
|
|
26
|
+
prs = Presentation()
|
|
27
|
+
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
28
|
+
|
|
29
|
+
slide.shapes.add_equation(
|
|
30
|
+
BBox.from_inches(1, 2, 8, 1.5),
|
|
31
|
+
latex=r"\frac{-b \pm \sqrt{b^2-4ac}}{2a}",
|
|
32
|
+
size_pt=28,
|
|
33
|
+
color="#111827",
|
|
34
|
+
align="center",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
slide.shapes.add_equation(
|
|
38
|
+
Inches(1), Inches(4), Inches(8), Inches(1),
|
|
39
|
+
latex=r"E = mc^2",
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The shape is a text box. The equation is editable in PowerPoint's
|
|
44
|
+
equation editor. `display=True` (the default) wraps OMML in
|
|
45
|
+
`m:oMathPara`.
|
|
46
|
+
|
|
47
|
+
Keyword args: `latex` (required), `display`, `font`, `size_pt`,
|
|
48
|
+
`color`, `align`, `anchor`, `margin_pt`.
|
|
49
|
+
|
|
50
|
+
## Inline equation — `paragraph.add_math`
|
|
51
|
+
|
|
52
|
+
Sits between ordinary runs in the same paragraph:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(1))
|
|
56
|
+
p = box.text_frame.paragraphs[0]
|
|
57
|
+
p.add_run().text = "Euler's identity "
|
|
58
|
+
p.add_math(r"e^{i\pi} + 1 = 0")
|
|
59
|
+
p.add_run().text = " holds for all real π."
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`display=False` (the default here) emits inline `m:oMath`. Pass
|
|
63
|
+
`size_pt` / `color` / `font` to style the math runs.
|
|
64
|
+
|
|
65
|
+
`paragraph.clear()` and assigning `paragraph.text` remove the equation
|
|
66
|
+
along with the runs.
|
|
67
|
+
|
|
68
|
+
## Bare converter
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pptx2.math import latex_to_omml
|
|
72
|
+
|
|
73
|
+
omml = latex_to_omml(r"\sum_{i=1}^{n} i")
|
|
74
|
+
# '<m:oMath>…</m:oMath>'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Wrappers (`$…$`, `$$…$$`, `\(…\)`, `\[…\]`, `equation` / `align`
|
|
78
|
+
environments) are stripped before conversion.
|
|
79
|
+
|
|
80
|
+
## What this is not
|
|
81
|
+
|
|
82
|
+
- Not a TeX engine. Environments like `tikzpicture` will not render.
|
|
83
|
+
- Not Microsoft's `MML2OMML.XSL` (that stylesheet cannot be
|
|
84
|
+
redistributed). The MathML → OMML step is the `mathml2omml` package.
|
|
85
|
+
- Not an image. If you need a PNG of a formula, use
|
|
86
|
+
`add_matplotlib_figure` with mathtext instead.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Picture effects + native SVG (Phase 6)
|
|
2
|
+
|
|
3
|
+
Pictures gain a dedicated `effects` accessor that wraps the OOXML
|
|
4
|
+
`<a:blip>` filters, plus native SVG support with PNG fallback.
|
|
5
|
+
|
|
6
|
+
## Picture filters
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from pptx2 import Presentation
|
|
10
|
+
from pptx2.util import Inches
|
|
11
|
+
from pptx2.dml.color import RGBColor
|
|
12
|
+
|
|
13
|
+
prs = Presentation()
|
|
14
|
+
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
|
15
|
+
|
|
16
|
+
pic = slide.shapes.add_picture(
|
|
17
|
+
"hero.jpg", Inches(0), Inches(0),
|
|
18
|
+
width=prs.slide_width, height=prs.slide_height,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Continuous adjustments — all in [-1.0, 1.0] (or [0.0, 1.0] for transparency)
|
|
22
|
+
pic.effects.transparency = 0.3 # 30% see-through
|
|
23
|
+
pic.effects.brightness = 0.10
|
|
24
|
+
pic.effects.contrast = 0.05
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Recolor presets
|
|
28
|
+
|
|
29
|
+
`recolor` is a **property, not a method** — assign to it. There are
|
|
30
|
+
exactly four modes; anything else raises `ValueError`, and the existing
|
|
31
|
+
effect is left intact because the value is validated before it mutates.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
pic.effects.recolor = "grayscale"
|
|
35
|
+
pic.effects.recolor = "sepia" # a preset duotone under the hood
|
|
36
|
+
pic.effects.recolor = "washout" # PowerPoint's "Washout"
|
|
37
|
+
pic.effects.recolor = "duotone" # neutral grey duotone; see below
|
|
38
|
+
# to pick your own two colours
|
|
39
|
+
|
|
40
|
+
pic.effects.recolor # read it back: the mode, or None
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
There is no `"black_and_white"` mode. `"washout"` is the closest
|
|
44
|
+
equivalent.
|
|
45
|
+
|
|
46
|
+
## Duotone
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
pic.effects.set_duotone(
|
|
50
|
+
RGBColor(0x12, 0x1E, 0x4D), # shadow color
|
|
51
|
+
"#A8C0FF", # highlight color (hex with or without '#')
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Plain RGB tuples are also accepted
|
|
55
|
+
pic.effects.set_duotone((18, 30, 77), (168, 192, 255))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To clear, assign `None` — there is no `clear_recolor()` method:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
pic.effects.recolor = None # drops any duotone / grayscale / etc.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Native SVG with PNG fallback
|
|
65
|
+
|
|
66
|
+
`add_svg_picture` embeds both an SVG and a PNG fallback inside the
|
|
67
|
+
same `<a:blip>` so PowerPoint and earlier viewers each render the
|
|
68
|
+
right one.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# Auto-rasterise via the optional `cairosvg` dependency
|
|
72
|
+
slide.shapes.add_svg_picture("logo.svg", Inches(0.5), Inches(0.5))
|
|
73
|
+
|
|
74
|
+
# Bring your own fallback PNG
|
|
75
|
+
slide.shapes.add_svg_picture(
|
|
76
|
+
"logo.svg",
|
|
77
|
+
Inches(0.5), Inches(0.5),
|
|
78
|
+
width=Inches(1.5), height=Inches(1.5),
|
|
79
|
+
png_fallback="logo.png",
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If `cairosvg` isn't installed and you don't pass `png_fallback`, the
|
|
84
|
+
call raises `pptx2._svg.CairoSvgUnavailable` with a clear install hint.
|
|
85
|
+
|
|
86
|
+
The `image/svg+xml` content type is registered with the package so
|
|
87
|
+
SVG parts authored elsewhere round-trip through PowerPoint untouched.
|
|
88
|
+
|
|
89
|
+
## End-to-end: tinted photo with overlay text
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pptx2 import Presentation
|
|
93
|
+
from pptx2.util import Inches, Pt
|
|
94
|
+
from pptx2.dml.color import RGBColor
|
|
95
|
+
from pptx2.enum.shapes import MSO_SHAPE
|
|
96
|
+
|
|
97
|
+
prs = Presentation()
|
|
98
|
+
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
99
|
+
|
|
100
|
+
# Full-bleed hero image, duotoned to brand colors
|
|
101
|
+
pic = slide.shapes.add_picture(
|
|
102
|
+
"hero.jpg", 0, 0,
|
|
103
|
+
width=prs.slide_width, height=prs.slide_height,
|
|
104
|
+
)
|
|
105
|
+
pic.effects.set_duotone(RGBColor(0x12, 0x1E, 0x4D), "#A8C0FF")
|
|
106
|
+
|
|
107
|
+
# Bottom band with overlay text
|
|
108
|
+
band = slide.shapes.add_shape(
|
|
109
|
+
MSO_SHAPE.RECTANGLE,
|
|
110
|
+
0, prs.slide_height - Inches(1.5),
|
|
111
|
+
prs.slide_width, Inches(1.5),
|
|
112
|
+
)
|
|
113
|
+
band.fill.solid()
|
|
114
|
+
band.fill.fore_color.rgb = RGBColor(0x12, 0x1E, 0x4D)
|
|
115
|
+
band.fill.fore_color.alpha = 0.55
|
|
116
|
+
band.line.fill.background()
|
|
117
|
+
|
|
118
|
+
box = slide.shapes.add_textbox(
|
|
119
|
+
Inches(0.6), prs.slide_height - Inches(1.2),
|
|
120
|
+
prs.slide_width - Inches(1.2), Inches(0.9),
|
|
121
|
+
)
|
|
122
|
+
p = box.text_frame.paragraphs[0]
|
|
123
|
+
p.text = "Q4 2026"
|
|
124
|
+
p.font.size = Pt(40)
|
|
125
|
+
p.font.bold = True
|
|
126
|
+
p.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
|
|
127
|
+
|
|
128
|
+
prs.save("hero.pptx")
|
|
129
|
+
```
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Slide thumbnails (Phase 10)
|
|
2
|
+
|
|
3
|
+
`pptx2.render` shells out to LibreOffice to rasterise slides as PNGs.
|
|
4
|
+
This is for review tooling, dashboards, and CI artifacts — it does not
|
|
5
|
+
require Microsoft PowerPoint or an Office license, but `soffice` must
|
|
6
|
+
be on `$PATH` (or you can point at a custom binary).
|
|
7
|
+
|
|
8
|
+
## Convenience methods
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
# All slides → ./thumbs/<n>.png
|
|
12
|
+
paths = prs.render_thumbnails(out_dir="thumbs")
|
|
13
|
+
|
|
14
|
+
# Single slide as bytes
|
|
15
|
+
png = slide.render_thumbnail(return_bytes=True)
|
|
16
|
+
|
|
17
|
+
# Single slide written to a specific path
|
|
18
|
+
slide.render_thumbnail(out_path="cover.png")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Module-level entry points
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pptx2.render import (
|
|
25
|
+
render_slide_thumbnails,
|
|
26
|
+
render_slide_thumbnail,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
paths = render_slide_thumbnails(
|
|
30
|
+
prs,
|
|
31
|
+
out_dir="thumbs",
|
|
32
|
+
slide_indexes=[0, 3, 7], # only these slides
|
|
33
|
+
soffice_bin="/opt/libreoffice/program/soffice",
|
|
34
|
+
timeout=60, # seconds
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
png = render_slide_thumbnail(slide, return_bytes=True)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The output resolution is whatever LibreOffice's headless PNG
|
|
41
|
+
converter chooses — there's no `width=` knob. If you need a specific
|
|
42
|
+
size, post-process with Pillow (``Image.open(...).resize(...)``).
|
|
43
|
+
|
|
44
|
+
## Pointing at a custom binary
|
|
45
|
+
|
|
46
|
+
Three ways to choose `soffice`, in priority order:
|
|
47
|
+
|
|
48
|
+
1. The `soffice_bin=` keyword argument
|
|
49
|
+
2. The `POWER_PPTX_SOFFICE` environment variable
|
|
50
|
+
3. The first `soffice` (or `libreoffice`) on `$PATH`
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import os
|
|
54
|
+
os.environ["POWER_PPTX_SOFFICE"] = "/opt/libreoffice/program/soffice"
|
|
55
|
+
prs.render_thumbnails(out_dir="thumbs")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Known limitations of the LibreOffice thumbnail path
|
|
59
|
+
|
|
60
|
+
The `soffice` + `pdftoppm` pipeline that backs `render_thumbnails`
|
|
61
|
+
renders most decks faithfully, but a few cross-renderer quirks are
|
|
62
|
+
worth knowing about — these show up in the thumbnail even when the
|
|
63
|
+
generated `.pptx` opens correctly in PowerPoint:
|
|
64
|
+
|
|
65
|
+
* **Emoji glyphs render as tofu** on systems without an emoji font
|
|
66
|
+
installed (most Linux runtimes lack one by default). PowerPoint
|
|
67
|
+
uses Segoe UI Emoji as a fallback; LibreOffice headless typically
|
|
68
|
+
does not. For decks meant to be thumbnail-reviewed on the same
|
|
69
|
+
machine, prefer Unicode glyphs from the shipped DejaVu families
|
|
70
|
+
(`•`, `→`, `★`, `‹›`, `■`, etc.) over emoji codepoints.
|
|
71
|
+
* **Un-aligned text in fresh textboxes** renders centered instead of
|
|
72
|
+
left-aligned. A textbox created via `slide.shapes.add_textbox(...)`
|
|
73
|
+
has no `a:pPr/@algn` attribute; PowerPoint treats that as the
|
|
74
|
+
OOXML default (left), LibreOffice as centered. Set
|
|
75
|
+
`paragraph.alignment = PP_ALIGN.LEFT` explicitly to make
|
|
76
|
+
thumbnails match how PowerPoint will render the slide.
|
|
77
|
+
* **`wrap="none" + spAutoFit` (the textbox default) re-centers under
|
|
78
|
+
LibreOffice.** A fresh textbox has `<a:bodyPr wrap="none">
|
|
79
|
+
<a:spAutoFit/></a:bodyPr>`. PowerPoint shrinks the box around the
|
|
80
|
+
declared anchor; LibreOffice re-centers the shrunken box inside
|
|
81
|
+
its original width, so a kicker line declared at
|
|
82
|
+
`left=Inches(1.1), width=Inches(11)` renders near the middle of
|
|
83
|
+
the slide rather than the left edge. Setting `tf.word_wrap = True`
|
|
84
|
+
suppresses `spAutoFit` and keeps the declared geometry intact —
|
|
85
|
+
fold it into every textbox in a recipe-style helper.
|
|
86
|
+
|
|
87
|
+
## Errors
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from pptx2.render import (
|
|
91
|
+
ThumbnailRendererUnavailable,
|
|
92
|
+
ThumbnailRendererError,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
paths = prs.render_thumbnails(out_dir="thumbs")
|
|
97
|
+
except ThumbnailRendererUnavailable as e:
|
|
98
|
+
# soffice not on PATH — message includes an install hint
|
|
99
|
+
print(e)
|
|
100
|
+
except ThumbnailRendererError as e:
|
|
101
|
+
# soffice ran but produced no PNG / exited non-zero / timed out
|
|
102
|
+
print(e)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Patterns
|
|
106
|
+
|
|
107
|
+
### Generate review images for an HTML preview
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import base64
|
|
111
|
+
|
|
112
|
+
prs.save("deck.pptx")
|
|
113
|
+
images = []
|
|
114
|
+
for i in range(len(prs.slides)):
|
|
115
|
+
png = prs.slides[i].render_thumbnail(return_bytes=True)
|
|
116
|
+
images.append(base64.b64encode(png).decode("ascii"))
|
|
117
|
+
|
|
118
|
+
html = "\n".join(
|
|
119
|
+
f'<img src="data:image/png;base64,{b64}" width="640">'
|
|
120
|
+
for b64 in images
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### CI artefacts
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# In tests/conftest.py or similar
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
|
|
130
|
+
def attach_deck_thumbs(prs, out: Path):
|
|
131
|
+
out.mkdir(exist_ok=True)
|
|
132
|
+
return prs.render_thumbnails(out_dir=out)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Skip on dev machines without LibreOffice
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import shutil
|
|
139
|
+
import pytest
|
|
140
|
+
|
|
141
|
+
requires_soffice = pytest.mark.skipif(
|
|
142
|
+
shutil.which("soffice") is None and shutil.which("libreoffice") is None,
|
|
143
|
+
reason="LibreOffice not installed",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@requires_soffice
|
|
147
|
+
def test_renders_thumbnails(tmp_path):
|
|
148
|
+
prs = build_demo_deck()
|
|
149
|
+
paths = prs.render_thumbnails(out_dir=tmp_path)
|
|
150
|
+
assert len(paths) == len(prs.slides)
|
|
151
|
+
```
|