athena-python-pptx 0.3.0__tar.gz → 0.3.1__tar.gz
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.
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/CHANGELOG.md +41 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/PKG-INFO +1 -1
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/docs/API_PARITY_EXCEPTIONS.md +61 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/__init__.py +1 -1
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/__init__.py +581 -10
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/slides.py +96 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pyproject.toml +1 -1
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/.gitignore +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/API_PARITY_REPORT.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/CLAUDE.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/DEV-GUIDE.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/PARITY_QUESTIONS.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/PUBLISHING.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/README.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/docs/athena-api.json +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/docs/athena-api.md +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/_athena_extension.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/_ptc.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/_references.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/action.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/batching.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/__init__.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/axis.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/category.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/chart.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/data.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/datalabel.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/legend.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/marker.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/plot.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/point.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/series.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/xlsx.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/client.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/commands.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/decorators.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/__init__.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/chtfmt.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/color.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/effect.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/fill.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/line.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/docgen.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/__init__.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/action.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/chart.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/dml.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/lang.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/shapes.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/text.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/errors.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/exc.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/media.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/package.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/__init__.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/_base.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/chart.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/coreprops.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/embeddedpackage.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/image.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/media.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/presentation.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/slide.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/presentation.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/autoshape.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/base.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/connector.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/freeform.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/graphfrm.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/group.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/picture.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/placeholder.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/shapetree.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shared.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/slide.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/spec.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/table.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/__init__.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/fonts.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/layout.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/text.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/types.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/typing.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/units.py +0 -0
- {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/util.py +0 -0
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `athena-python-pptx` are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.3.1
|
|
6
|
+
|
|
7
|
+
**Chart title runs, slide insertion, and shape-delete local state — Insight
|
|
8
|
+
Partners session feedback batch.**
|
|
9
|
+
|
|
10
|
+
Investigated [thread_7b324723-…](https://app.athenaintel.com/dashboard/spaces/?session_id=thread_7b324723-a478-4e99-ac8f-6e7a69766292)
|
|
11
|
+
where an agent built a 15-slide LP pitch deck and tripped over three
|
|
12
|
+
SDK rough edges. Each is addressed below.
|
|
13
|
+
|
|
14
|
+
- **`chart.chart_title.text_frame.paragraphs[0].runs[0].font` now works.**
|
|
15
|
+
The `_ChartTitleTextFrame` previously exposed only `text`; iterating
|
|
16
|
+
`paragraphs` raised `AttributeError`, and the agent fell back to
|
|
17
|
+
unstyled titles. The text frame now carries paragraph and run
|
|
18
|
+
proxies; per-run font writes coalesce into a single
|
|
19
|
+
`SetChartTitleRich` patch on the next flush. The simple
|
|
20
|
+
`text_frame.text = "..."` path still uses the cheaper `SetChartTitle`
|
|
21
|
+
patch — no wire-size regression on the no-formatting case.
|
|
22
|
+
|
|
23
|
+
- **`prs.slides.insert(idx, layout)` added.** Previously, placing a new
|
|
24
|
+
slide between two existing slides required `add_slide()` followed by
|
|
25
|
+
`clone(target_index=...)` to shuffle the list — a workaround that
|
|
26
|
+
cloned 11 shapes onto the new slide and then couldn't fully clear
|
|
27
|
+
them. `insert()` emits a single `AddSlide(index=...)` and refreshes
|
|
28
|
+
the local collection.
|
|
29
|
+
|
|
30
|
+
- **`shape.delete()` updates the local `slide.shapes` view immediately.**
|
|
31
|
+
The buffered delete left `len(slide.shapes)` stale, so the
|
|
32
|
+
"Before: 33, After: 33" symptom appeared even when the delete was
|
|
33
|
+
going through correctly server-side. `delete()` now removes the
|
|
34
|
+
shape from the parent `Shapes` collection in addition to queueing
|
|
35
|
+
the `DeleteShape` command. Layout-inherited shapes
|
|
36
|
+
(`source == "layout"`) no-op rather than queue a guaranteed-to-fail
|
|
37
|
+
delete.
|
|
38
|
+
|
|
39
|
+
- **Toolkit prompt updated** to surface the working chart APIs —
|
|
40
|
+
`series.format.fill.solid()` + `series.format.fill.fore_color.rgb`,
|
|
41
|
+
`chart.legend.position`, doughnut/pie chart enums — and the new
|
|
42
|
+
`slide.clear_shapes()` / `prs.slides.insert()` helpers. The
|
|
43
|
+
Insight Partners agent assumed series styling was unsupported and
|
|
44
|
+
skipped per-series brand colors entirely.
|
|
45
|
+
|
|
5
46
|
## 0.1.76
|
|
6
47
|
|
|
7
48
|
**Table cell paragraph alignment now round-trips to OOXML (baseline-parity Gap A2).**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: athena-python-pptx
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Drop-in replacement for python-pptx that connects to PPTX Studio for real-time collaboration
|
|
5
5
|
Project-URL: Homepage, https://github.com/pptx-studio/python-sdk
|
|
6
6
|
Project-URL: Documentation, https://docs.pptx-studio.com/sdk/python
|
|
@@ -830,6 +830,67 @@ The default `fit=None` preserves stock python-pptx behaviour. The only
|
|
|
830
830
|
other accepted value is `"contain"`; passing anything else raises
|
|
831
831
|
`ValueError` to fail loudly rather than silently no-op.
|
|
832
832
|
|
|
833
|
+
## `Shapes.add_athena_anchor()` — Athena-only cross-asset screenshot insert
|
|
834
|
+
|
|
835
|
+
Athena extension with no python-pptx analogue. Renders an Athena
|
|
836
|
+
`Anchor` (a structured reference to a region of another asset — sheet
|
|
837
|
+
cells, sheet ranges, etc.) to a PNG and inserts it as a regular
|
|
838
|
+
`Picture` shape on the current slide. The result is a one-shot raster
|
|
839
|
+
snapshot, **not** a live-linked binding — use `add_linked_table()` /
|
|
840
|
+
`add_linked_ole_object()` for refreshable bindings.
|
|
841
|
+
|
|
842
|
+
```python
|
|
843
|
+
from pptx._references import AssetReference, SheetRangeAnchor
|
|
844
|
+
|
|
845
|
+
slide.shapes.add_athena_anchor(
|
|
846
|
+
AssetReference(id="asset_20880ea3-95fb-4577-9a2e-ad11b7773939"),
|
|
847
|
+
SheetRangeAnchor(sheet_id=1, range="A1:F20"),
|
|
848
|
+
Inches(1), Inches(1),
|
|
849
|
+
width=Inches(8),
|
|
850
|
+
)
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Signature:
|
|
854
|
+
|
|
855
|
+
```python
|
|
856
|
+
add_athena_anchor(
|
|
857
|
+
source: AssetReference,
|
|
858
|
+
anchor: Anchor,
|
|
859
|
+
left: Length,
|
|
860
|
+
top: Length,
|
|
861
|
+
width: Length | None = None,
|
|
862
|
+
height: Length | None = None,
|
|
863
|
+
*,
|
|
864
|
+
name: str | None = None,
|
|
865
|
+
fit: str | None = None, # "contain" — same semantics as add_picture
|
|
866
|
+
) -> Shape
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
Supported anchor types today:
|
|
870
|
+
|
|
871
|
+
| Anchor type | Behaviour |
|
|
872
|
+
|--------------------|----------------------------------------------------------|
|
|
873
|
+
| `SheetCellAnchor` | Renders the single cell as a 1×1 range PNG. |
|
|
874
|
+
| `SheetRangeAnchor` | Renders the A1 range via xlsx-studio's screenshot.png. |
|
|
875
|
+
| `SheetTableAnchor` | Raises `NotImplementedError` (resolve to A1 + retry). |
|
|
876
|
+
| `SlideAnchor` | Raises `NotImplementedError` (no slide-snapshot route). |
|
|
877
|
+
| anything else | Raises `NotImplementedError`. |
|
|
878
|
+
|
|
879
|
+
Sheet anchors route through `GET {ATHENA_XLSX_BASE_URL}/workbooks/:id/screenshot.png`
|
|
880
|
+
with the sandbox's `ATHENA_XLSX_API_KEY` forwarded as a Bearer token —
|
|
881
|
+
xlsx-studio's ownership middleware checks that the calling workspace
|
|
882
|
+
can read the source workbook. Render scale is fixed at 3 (matches
|
|
883
|
+
xlsx-studio's `range-as-asset` default — slide inserts sit at 6–9″
|
|
884
|
+
widths where scale=2 leaves text soft). When `ATHENA_XLSX_BASE_URL` /
|
|
885
|
+
`ATHENA_XLSX_API_KEY` aren't set (e.g. running outside a Daytona
|
|
886
|
+
presentation_exec sandbox), the call raises `ValueError` rather than
|
|
887
|
+
producing a broken picture.
|
|
888
|
+
|
|
889
|
+
For finer control (custom scale, `include_headers`, off-snapshot
|
|
890
|
+
post-processing), fetch the PNG bytes directly via athena-openpyxl's
|
|
891
|
+
`Worksheet.export_range_sync()` and call `add_picture(bytes, …)`
|
|
892
|
+
yourself.
|
|
893
|
+
|
|
833
894
|
---
|
|
834
895
|
|
|
835
896
|
## `Shapes.add_linked_table()` — Athena studio-linking extension (added in Phase 3)
|
|
@@ -88,6 +88,137 @@ def _is_athena_url(url: str) -> bool:
|
|
|
88
88
|
return any(host == suffix or host.endswith("." + suffix) for suffix in _ATHENA_URL_SUFFIXES)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
# Default render scale for xlsx-range screenshots embedded in slides.
|
|
92
|
+
# scale=3 matches xlsx-studio's ``range-as-asset`` default — slide inserts
|
|
93
|
+
# sit at 6–9″ widths, where scale=2 leaves text visibly soft.
|
|
94
|
+
_XLSX_RANGE_DEFAULT_SCALE = 3
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch_xlsx_range_as_png(
|
|
98
|
+
asset_id: str,
|
|
99
|
+
*,
|
|
100
|
+
sheet_id: Optional[int] = None,
|
|
101
|
+
sheet_name: Optional[str] = None,
|
|
102
|
+
cell_range: Optional[str] = None,
|
|
103
|
+
scale: int = _XLSX_RANGE_DEFAULT_SCALE,
|
|
104
|
+
) -> bytes:
|
|
105
|
+
"""Fetch a PNG screenshot of an xlsx workbook range.
|
|
106
|
+
|
|
107
|
+
Hits xlsx-studio's ``GET /workbooks/:id/screenshot.png`` endpoint with
|
|
108
|
+
the sandbox's ``ATHENA_XLSX_API_KEY`` as a Bearer token. The endpoint
|
|
109
|
+
resolves ownership against the workspace tied to the API key, so the
|
|
110
|
+
caller must own (or have ABAC read on) the source workbook asset.
|
|
111
|
+
|
|
112
|
+
Exactly one of ``sheet_id`` / ``sheet_name`` may be supplied (or
|
|
113
|
+
neither — the server falls back to the first sheet). ``cell_range``
|
|
114
|
+
is optional; when omitted the server auto-detects the data bounds
|
|
115
|
+
for that sheet (max 100 rows × 20 cols).
|
|
116
|
+
"""
|
|
117
|
+
base_url = os.environ.get("ATHENA_XLSX_BASE_URL")
|
|
118
|
+
api_key = os.environ.get("ATHENA_XLSX_API_KEY")
|
|
119
|
+
if not base_url or not api_key:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"Fetching an xlsx range as a picture requires the "
|
|
122
|
+
"ATHENA_XLSX_BASE_URL and ATHENA_XLSX_API_KEY env vars to be "
|
|
123
|
+
"set in the sandbox. Re-run from a presentation_exec sandbox "
|
|
124
|
+
"created after the env-var rollout, or fetch the PNG via the "
|
|
125
|
+
"athena-openpyxl SDK's Worksheet.export_range_sync() and pass "
|
|
126
|
+
"the bytes to add_picture() directly."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if sheet_id is not None and sheet_name is not None:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"Pass only one of sheet_id or sheet_name to _fetch_xlsx_range_as_png; "
|
|
132
|
+
"the xlsx-studio screenshot endpoint rejects both at once."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
params: dict[str, Any] = {"scale": scale}
|
|
136
|
+
if sheet_id is not None:
|
|
137
|
+
params["sheet_id"] = int(sheet_id)
|
|
138
|
+
if sheet_name is not None:
|
|
139
|
+
params["sheet_name"] = sheet_name
|
|
140
|
+
if cell_range is not None:
|
|
141
|
+
params["range"] = cell_range
|
|
142
|
+
|
|
143
|
+
url = f"{base_url.rstrip('/')}/workbooks/{asset_id}/screenshot.png"
|
|
144
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
145
|
+
try:
|
|
146
|
+
with requests.get(
|
|
147
|
+
url, headers=headers, params=params, timeout=60, stream=True
|
|
148
|
+
) as response:
|
|
149
|
+
response.raise_for_status()
|
|
150
|
+
chunks: list[bytes] = []
|
|
151
|
+
received = 0
|
|
152
|
+
for chunk in response.iter_content(chunk_size=65536):
|
|
153
|
+
if not chunk:
|
|
154
|
+
continue
|
|
155
|
+
received += len(chunk)
|
|
156
|
+
if received > _MAX_URL_IMAGE_BYTES:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"xlsx-studio response for asset "
|
|
159
|
+
f"{asset_id!r} exceeds the {_MAX_URL_IMAGE_BYTES}-byte cap"
|
|
160
|
+
)
|
|
161
|
+
chunks.append(chunk)
|
|
162
|
+
except requests.RequestException as e:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"failed to fetch xlsx range as PNG for asset {asset_id!r} "
|
|
165
|
+
f"(sheet_id={sheet_id!r}, sheet_name={sheet_name!r}, "
|
|
166
|
+
f"range={cell_range!r}): {e}"
|
|
167
|
+
) from e
|
|
168
|
+
return b"".join(chunks)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _try_convert_to_png(data: bytes, image_format: str) -> Optional[bytes]:
|
|
172
|
+
"""Convert non-PNG image bytes (WebP, SVG) to PNG.
|
|
173
|
+
|
|
174
|
+
Returns the PNG bytes on success, ``None`` if the required converter
|
|
175
|
+
isn't installed in the runtime. Pptx-studio's renderer doesn't
|
|
176
|
+
decode WebP or SVG today, so add_picture has to either convert
|
|
177
|
+
upstream or fail loudly — silently sending the bytes through
|
|
178
|
+
produces a broken-image placeholder on the slide.
|
|
179
|
+
|
|
180
|
+
WebP → PNG: requires ``Pillow`` (transitively included via
|
|
181
|
+
``matplotlib`` in the presentation_exec Daytona snapshot, so this
|
|
182
|
+
just works in the standard agent runtime).
|
|
183
|
+
|
|
184
|
+
SVG → PNG: requires ``svglib`` + ``reportlab``. Neither is in the
|
|
185
|
+
snapshot today, so SVG falls through to the caller's error path.
|
|
186
|
+
Direct ``cairosvg`` would be lighter but pulls a C dep.
|
|
187
|
+
"""
|
|
188
|
+
from io import BytesIO
|
|
189
|
+
|
|
190
|
+
if image_format == "webp":
|
|
191
|
+
try:
|
|
192
|
+
from PIL import Image # type: ignore[import-not-found]
|
|
193
|
+
except ImportError:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
with Image.open(BytesIO(data)) as im:
|
|
197
|
+
out = BytesIO()
|
|
198
|
+
im.convert("RGBA").save(out, format="PNG")
|
|
199
|
+
return out.getvalue()
|
|
200
|
+
except Exception:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
if image_format == "svg":
|
|
204
|
+
try:
|
|
205
|
+
from svglib.svglib import svg2rlg # type: ignore[import-not-found]
|
|
206
|
+
from reportlab.graphics import renderPM # type: ignore[import-not-found]
|
|
207
|
+
except ImportError:
|
|
208
|
+
return None
|
|
209
|
+
try:
|
|
210
|
+
drawing = svg2rlg(BytesIO(data))
|
|
211
|
+
if drawing is None:
|
|
212
|
+
return None
|
|
213
|
+
out = BytesIO()
|
|
214
|
+
renderPM.drawToFile(drawing, out, fmt="PNG")
|
|
215
|
+
return out.getvalue()
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
91
222
|
def _fetch_image_from_url(url: str) -> bytes:
|
|
92
223
|
"""Fetch image bytes from a URL.
|
|
93
224
|
|
|
@@ -1495,26 +1626,291 @@ class ClickAction:
|
|
|
1495
1626
|
return f"<ClickAction action={self._action_type}>"
|
|
1496
1627
|
|
|
1497
1628
|
|
|
1629
|
+
class _ChartTitleRun:
|
|
1630
|
+
"""A run inside the chart title's text frame.
|
|
1631
|
+
|
|
1632
|
+
Exposes ``text`` and ``font`` so the python-pptx pattern
|
|
1633
|
+
``run.text = "..."; run.font.size = Pt(11); run.font.bold = True;
|
|
1634
|
+
run.font.color.rgb = RGBColor(...)`` works end-to-end. Mutations
|
|
1635
|
+
are coalesced by the owning text frame into a single
|
|
1636
|
+
:class:`SetChartTitleRich` patch.
|
|
1637
|
+
"""
|
|
1638
|
+
|
|
1639
|
+
def __init__(
|
|
1640
|
+
self,
|
|
1641
|
+
paragraph: "_ChartTitleParagraph",
|
|
1642
|
+
text: str = "",
|
|
1643
|
+
font: Optional[Any] = None,
|
|
1644
|
+
):
|
|
1645
|
+
from ..text import Font
|
|
1646
|
+
from ..units import EMU_PER_PT
|
|
1647
|
+
|
|
1648
|
+
self._paragraph = paragraph
|
|
1649
|
+
self._text = text
|
|
1650
|
+
self._emu_per_pt = EMU_PER_PT
|
|
1651
|
+
if font is None:
|
|
1652
|
+
font = Font(
|
|
1653
|
+
run=None, # type: ignore[arg-type]
|
|
1654
|
+
change_listener=self._on_font_changed,
|
|
1655
|
+
)
|
|
1656
|
+
else:
|
|
1657
|
+
font._change_listener = self._on_font_changed
|
|
1658
|
+
self._font: Any = font
|
|
1659
|
+
|
|
1660
|
+
@property
|
|
1661
|
+
def text(self) -> str:
|
|
1662
|
+
return self._text
|
|
1663
|
+
|
|
1664
|
+
@text.setter
|
|
1665
|
+
def text(self, value: str) -> None:
|
|
1666
|
+
self._text = str(value)
|
|
1667
|
+
self._paragraph._notify_change()
|
|
1668
|
+
|
|
1669
|
+
@property
|
|
1670
|
+
def font(self) -> Any:
|
|
1671
|
+
return self._font
|
|
1672
|
+
|
|
1673
|
+
def _on_font_changed(self, _font: Any) -> None:
|
|
1674
|
+
self._paragraph._notify_change()
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
class _ChartTitleParagraph:
|
|
1678
|
+
"""A paragraph inside the chart title's text frame.
|
|
1679
|
+
|
|
1680
|
+
Holds a list of :class:`_ChartTitleRun` objects. Iterating ``runs``
|
|
1681
|
+
after setting ``text_frame.text`` yields a single run carrying the
|
|
1682
|
+
title text, enabling per-run font styling. Setting paragraph-level
|
|
1683
|
+
``font`` on a paragraph with no runs creates an empty run and
|
|
1684
|
+
attaches the font to it (python-pptx parity for the default-paragraph
|
|
1685
|
+
case).
|
|
1686
|
+
"""
|
|
1687
|
+
|
|
1688
|
+
def __init__(self, text_frame: "_ChartTitleTextFrame"):
|
|
1689
|
+
self._text_frame = text_frame
|
|
1690
|
+
self._runs: list[_ChartTitleRun] = []
|
|
1691
|
+
self._alignment: Optional[Any] = None
|
|
1692
|
+
# Lazily-created paragraph-level font; in python-pptx the
|
|
1693
|
+
# paragraph font supplies defaults for runs without explicit
|
|
1694
|
+
# rPr. For SetChartTitleRich we don't have a separate paragraph
|
|
1695
|
+
# bucket on the wire, so we mirror the value onto the first run
|
|
1696
|
+
# when needed.
|
|
1697
|
+
self._para_font: Optional[Any] = None
|
|
1698
|
+
|
|
1699
|
+
@property
|
|
1700
|
+
def runs(self) -> list[_ChartTitleRun]:
|
|
1701
|
+
return list(self._runs)
|
|
1702
|
+
|
|
1703
|
+
@property
|
|
1704
|
+
def text(self) -> str:
|
|
1705
|
+
return "".join(r._text for r in self._runs)
|
|
1706
|
+
|
|
1707
|
+
@text.setter
|
|
1708
|
+
def text(self, value: str) -> None:
|
|
1709
|
+
self._runs = [_ChartTitleRun(self, text=str(value))]
|
|
1710
|
+
self._notify_change()
|
|
1711
|
+
|
|
1712
|
+
@property
|
|
1713
|
+
def alignment(self) -> Optional[Any]:
|
|
1714
|
+
return self._alignment
|
|
1715
|
+
|
|
1716
|
+
@alignment.setter
|
|
1717
|
+
def alignment(self, value: Optional[Any]) -> None:
|
|
1718
|
+
# Stored locally; the SetChartTitleRich patch model doesn't carry
|
|
1719
|
+
# per-paragraph alignment yet, but accepting the assignment keeps
|
|
1720
|
+
# python-pptx-style code paths from raising.
|
|
1721
|
+
self._alignment = value
|
|
1722
|
+
|
|
1723
|
+
@property
|
|
1724
|
+
def font(self) -> Any:
|
|
1725
|
+
"""Paragraph-level font. Mirrors python-pptx — defaults for any
|
|
1726
|
+
run that doesn't set its own properties. We surface this as a
|
|
1727
|
+
Font hooked to the same change listener so callers can write
|
|
1728
|
+
``paragraphs[0].font.size = Pt(20)`` and have the value flow
|
|
1729
|
+
through to the underlying SetChartTitleRich patch.
|
|
1730
|
+
|
|
1731
|
+
When there's at least one run, returns the first run's font so
|
|
1732
|
+
subsequent reads see writes (python-pptx behavior for the
|
|
1733
|
+
single-run case). When empty, creates a tracked Font that's
|
|
1734
|
+
promoted onto an empty run when a write happens.
|
|
1735
|
+
"""
|
|
1736
|
+
if self._runs:
|
|
1737
|
+
return self._runs[0].font
|
|
1738
|
+
if self._para_font is None:
|
|
1739
|
+
from ..text import Font
|
|
1740
|
+
|
|
1741
|
+
self._para_font = Font(
|
|
1742
|
+
run=None, # type: ignore[arg-type]
|
|
1743
|
+
change_listener=self._on_para_font_changed,
|
|
1744
|
+
)
|
|
1745
|
+
return self._para_font
|
|
1746
|
+
|
|
1747
|
+
def add_run(self) -> _ChartTitleRun:
|
|
1748
|
+
run = _ChartTitleRun(self, text="")
|
|
1749
|
+
self._runs.append(run)
|
|
1750
|
+
return run
|
|
1751
|
+
|
|
1752
|
+
def clear(self) -> None:
|
|
1753
|
+
self._runs = []
|
|
1754
|
+
self._notify_change()
|
|
1755
|
+
|
|
1756
|
+
def _on_para_font_changed(self, font: Any) -> None:
|
|
1757
|
+
# Promote the paragraph-level font onto an empty run so its
|
|
1758
|
+
# properties show up in the next SetChartTitleRich emission.
|
|
1759
|
+
if not self._runs:
|
|
1760
|
+
run = _ChartTitleRun(self, text="", font=font)
|
|
1761
|
+
self._runs.append(run)
|
|
1762
|
+
else:
|
|
1763
|
+
# Copy any newly-set fields onto run[0]'s font.
|
|
1764
|
+
target = self._runs[0]._font
|
|
1765
|
+
for attr in ("_size", "_color_hex", "_theme_color_name", "_bold",
|
|
1766
|
+
"_italic", "_name", "_underline", "_strike",
|
|
1767
|
+
"_baseline", "_language_id", "_spacing_pt"):
|
|
1768
|
+
v = getattr(font, attr, None)
|
|
1769
|
+
if v is not None:
|
|
1770
|
+
setattr(target, attr, v)
|
|
1771
|
+
self._notify_change()
|
|
1772
|
+
|
|
1773
|
+
def _notify_change(self) -> None:
|
|
1774
|
+
self._text_frame._emit_rich()
|
|
1775
|
+
|
|
1776
|
+
|
|
1498
1777
|
class _ChartTitleTextFrame:
|
|
1499
1778
|
"""python-pptx-compatible ``TextFrame`` adapter for ``chart.chart_title``.
|
|
1500
1779
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1780
|
+
Exposes ``text``, ``paragraphs``, ``add_paragraph``, ``clear`` and
|
|
1781
|
+
``word_wrap`` for python-pptx parity. ``text``-only writes route
|
|
1782
|
+
through the simpler :class:`SetChartTitle` patch (1 byte on the
|
|
1783
|
+
wire); paragraph / run / font mutations route through
|
|
1784
|
+
:class:`SetChartTitleRich`, which carries structured runs and lets
|
|
1785
|
+
the export-worker preserve per-run formatting.
|
|
1786
|
+
|
|
1787
|
+
Local paragraph state is initialised lazily on first read from the
|
|
1788
|
+
server-side title string so the read-modify-write pattern
|
|
1789
|
+
(``tf.text = "X"; tf.paragraphs[0].runs[0].font.bold = True``) works
|
|
1790
|
+
without an explicit ``has_title`` toggle first.
|
|
1506
1791
|
"""
|
|
1507
1792
|
|
|
1508
1793
|
def __init__(self, chart_title: "_ChartTitle"):
|
|
1509
1794
|
self._chart_title = chart_title
|
|
1795
|
+
self._paragraphs: list[_ChartTitleParagraph] = []
|
|
1796
|
+
self._paragraphs_initialised: bool = False
|
|
1797
|
+
# Suppress rich emits while we're applying the initial
|
|
1798
|
+
# ``text = ...`` setter so the simpler SetChartTitle patch is
|
|
1799
|
+
# used for the common no-formatting case.
|
|
1800
|
+
self._suppress_emit: bool = False
|
|
1801
|
+
# Optional word-wrap state — accepted but not currently
|
|
1802
|
+
# propagated through the patch (SetChartTitleRich doesn't carry
|
|
1803
|
+
# a wrap flag).
|
|
1804
|
+
self._word_wrap: Optional[bool] = None
|
|
1805
|
+
|
|
1806
|
+
def _ensure_paragraphs(self) -> list[_ChartTitleParagraph]:
|
|
1807
|
+
"""Lazy-init the paragraph list from the current server-side title."""
|
|
1808
|
+
if not self._paragraphs_initialised:
|
|
1809
|
+
existing = self._chart_title._text
|
|
1810
|
+
para = _ChartTitleParagraph(self)
|
|
1811
|
+
if existing:
|
|
1812
|
+
# ``_ChartTitleRun.__init__`` calls Font(..., change_listener=…)
|
|
1813
|
+
# which we don't want to fire at construction time, so build
|
|
1814
|
+
# the run directly under ``_suppress_emit``.
|
|
1815
|
+
self._suppress_emit = True
|
|
1816
|
+
try:
|
|
1817
|
+
para._runs = [_ChartTitleRun(para, text=existing)]
|
|
1818
|
+
finally:
|
|
1819
|
+
self._suppress_emit = False
|
|
1820
|
+
self._paragraphs = [para]
|
|
1821
|
+
self._paragraphs_initialised = True
|
|
1822
|
+
return self._paragraphs
|
|
1510
1823
|
|
|
1511
1824
|
@property
|
|
1512
1825
|
def text(self) -> str:
|
|
1826
|
+
if self._paragraphs_initialised:
|
|
1827
|
+
return "\n".join(p.text for p in self._paragraphs)
|
|
1513
1828
|
return self._chart_title._text or ""
|
|
1514
1829
|
|
|
1515
1830
|
@text.setter
|
|
1516
1831
|
def text(self, value: str) -> None:
|
|
1517
|
-
|
|
1832
|
+
# Reset local state to a single paragraph / single run with the
|
|
1833
|
+
# given text and emit the simpler SetChartTitle patch. The
|
|
1834
|
+
# paragraph collection is now initialised so subsequent reads
|
|
1835
|
+
# off ``paragraphs`` reflect the new state.
|
|
1836
|
+
value_str = str(value)
|
|
1837
|
+
para = _ChartTitleParagraph(self)
|
|
1838
|
+
self._suppress_emit = True
|
|
1839
|
+
try:
|
|
1840
|
+
para._runs = [_ChartTitleRun(para, text=value_str)]
|
|
1841
|
+
finally:
|
|
1842
|
+
self._suppress_emit = False
|
|
1843
|
+
self._paragraphs = [para]
|
|
1844
|
+
self._paragraphs_initialised = True
|
|
1845
|
+
self._chart_title._emit(value_str)
|
|
1846
|
+
|
|
1847
|
+
@property
|
|
1848
|
+
def paragraphs(self) -> list[_ChartTitleParagraph]:
|
|
1849
|
+
return self._ensure_paragraphs()
|
|
1850
|
+
|
|
1851
|
+
def add_paragraph(self) -> _ChartTitleParagraph:
|
|
1852
|
+
paragraphs = self._ensure_paragraphs()
|
|
1853
|
+
para = _ChartTitleParagraph(self)
|
|
1854
|
+
paragraphs.append(para)
|
|
1855
|
+
return para
|
|
1856
|
+
|
|
1857
|
+
def clear(self) -> None:
|
|
1858
|
+
# python-pptx ``TextFrame.clear()`` resets to one empty paragraph
|
|
1859
|
+
# with no runs. Mirror that for parity, even though chart titles
|
|
1860
|
+
# in OOXML always require at least one run on export.
|
|
1861
|
+
self._paragraphs = [_ChartTitleParagraph(self)]
|
|
1862
|
+
self._paragraphs_initialised = True
|
|
1863
|
+
self._emit_rich()
|
|
1864
|
+
|
|
1865
|
+
@property
|
|
1866
|
+
def word_wrap(self) -> Optional[bool]:
|
|
1867
|
+
return self._word_wrap
|
|
1868
|
+
|
|
1869
|
+
@word_wrap.setter
|
|
1870
|
+
def word_wrap(self, value: Optional[bool]) -> None:
|
|
1871
|
+
self._word_wrap = None if value is None else bool(value)
|
|
1872
|
+
|
|
1873
|
+
def _emit_rich(self) -> None:
|
|
1874
|
+
"""Build and emit a :class:`SetChartTitleRich` patch for the
|
|
1875
|
+
current paragraph/run state.
|
|
1876
|
+
|
|
1877
|
+
``_suppress_emit`` guards against re-entrant emits during the
|
|
1878
|
+
initial paragraph hydration so we don't fire a spurious rich
|
|
1879
|
+
patch for the no-formatting case.
|
|
1880
|
+
"""
|
|
1881
|
+
if self._suppress_emit:
|
|
1882
|
+
return
|
|
1883
|
+
shape = self._chart_title._chart._shape
|
|
1884
|
+
if shape._buffer is None:
|
|
1885
|
+
return
|
|
1886
|
+
runs_data: list[dict[str, Any]] = []
|
|
1887
|
+
for para in self._paragraphs:
|
|
1888
|
+
for r in para._runs:
|
|
1889
|
+
if not r._text:
|
|
1890
|
+
continue
|
|
1891
|
+
style: dict[str, Any] = {"text": r._text}
|
|
1892
|
+
font = r._font
|
|
1893
|
+
if font._size is not None:
|
|
1894
|
+
style["fontSizePt"] = font._size / r._emu_per_pt
|
|
1895
|
+
if font._color_hex is not None:
|
|
1896
|
+
style["fontColorHex"] = font._color_hex
|
|
1897
|
+
if font._bold is not None:
|
|
1898
|
+
style["fontBold"] = bool(font._bold)
|
|
1899
|
+
if font._italic is not None:
|
|
1900
|
+
style["fontItalic"] = bool(font._italic)
|
|
1901
|
+
if font._name is not None:
|
|
1902
|
+
style["fontName"] = font._name
|
|
1903
|
+
runs_data.append(style)
|
|
1904
|
+
if not runs_data:
|
|
1905
|
+
return
|
|
1906
|
+
shape._buffer.add(UpdateChartDataCmd(
|
|
1907
|
+
shape_id=shape.shape_id,
|
|
1908
|
+
patches=[{
|
|
1909
|
+
"op": "SetChartTitleRich",
|
|
1910
|
+
"chartId": shape.shape_id,
|
|
1911
|
+
"runs": runs_data,
|
|
1912
|
+
}],
|
|
1913
|
+
))
|
|
1518
1914
|
|
|
1519
1915
|
|
|
1520
1916
|
class _ChartTitle:
|
|
@@ -5396,10 +5792,38 @@ class Shape:
|
|
|
5396
5792
|
self._buffer.add(cmd)
|
|
5397
5793
|
|
|
5398
5794
|
def delete(self) -> None:
|
|
5399
|
-
"""Delete this shape from the slide.
|
|
5795
|
+
"""Delete this shape from the slide.
|
|
5796
|
+
|
|
5797
|
+
Emits a ``DeleteShape`` command and also removes the shape from
|
|
5798
|
+
the parent :class:`Shapes` collection's local view. Without the
|
|
5799
|
+
local mutation, ``len(slide.shapes)`` stays stale even after
|
|
5800
|
+
commands flush — agents repeatedly hit "Before: 33, After: 33"
|
|
5801
|
+
and assumed the delete had been silently dropped.
|
|
5802
|
+
|
|
5803
|
+
Inherited layout-level shapes (``shape._source == 'layout'``)
|
|
5804
|
+
are skipped — the server rejects deletes on them with a 400
|
|
5805
|
+
("Cannot delete inherited layout element"), and python-pptx
|
|
5806
|
+
doesn't surface inherited content in ``slide.shapes`` at all,
|
|
5807
|
+
so the SDK matches that contract by no-oping rather than
|
|
5808
|
+
queuing an op that's guaranteed to fail at flush.
|
|
5809
|
+
"""
|
|
5810
|
+
if self._source == "layout":
|
|
5811
|
+
return
|
|
5400
5812
|
if self._buffer:
|
|
5401
5813
|
cmd = DeleteShape(shape_id=self._shape_id)
|
|
5402
5814
|
self._buffer.add(cmd)
|
|
5815
|
+
slide = getattr(self, "_slide", None)
|
|
5816
|
+
shapes_coll = getattr(slide, "_shapes", None) if slide is not None else None
|
|
5817
|
+
if shapes_coll is not None:
|
|
5818
|
+
shapes_list = getattr(shapes_coll, "_shapes", None)
|
|
5819
|
+
if isinstance(shapes_list, list):
|
|
5820
|
+
try:
|
|
5821
|
+
shapes_list.remove(self)
|
|
5822
|
+
except ValueError:
|
|
5823
|
+
pass
|
|
5824
|
+
by_id = getattr(shapes_coll, "_shapes_by_id", None)
|
|
5825
|
+
if isinstance(by_id, dict):
|
|
5826
|
+
by_id.pop(self._shape_id, None)
|
|
5403
5827
|
|
|
5404
5828
|
# -------------------------------------------------------------------------
|
|
5405
5829
|
# Fill and line styling
|
|
@@ -7012,7 +7436,6 @@ class Shapes:
|
|
|
7012
7436
|
w_emu = int(ensure_emu(width)) if width else None
|
|
7013
7437
|
h_emu = int(ensure_emu(height)) if height else None
|
|
7014
7438
|
|
|
7015
|
-
# Read image data and determine format
|
|
7016
7439
|
if isinstance(image_file, bytes):
|
|
7017
7440
|
image_data = image_file
|
|
7018
7441
|
elif isinstance(image_file, BytesIO):
|
|
@@ -7029,6 +7452,30 @@ class Shapes:
|
|
|
7029
7452
|
|
|
7030
7453
|
image_format = self._detect_image_format(image_data)
|
|
7031
7454
|
|
|
7455
|
+
# pptx-studio's renderer (CanvasKit + @napi-rs/canvas) only decodes
|
|
7456
|
+
# ``png/jpeg/gif/bmp/tiff``. WebP, SVG, and other modern formats
|
|
7457
|
+
# need to be converted to PNG before they hit the wire — otherwise
|
|
7458
|
+
# the renderer can't decode and the slide shows the broken-image
|
|
7459
|
+
# placeholder, OR the AddPicture command's image_format validator
|
|
7460
|
+
# rejects with a cryptic "must be 'png', 'jpeg', ..." message that
|
|
7461
|
+
# gives no hint about the actual cause.
|
|
7462
|
+
if image_format in ("webp", "svg"):
|
|
7463
|
+
converted = _try_convert_to_png(image_data, image_format)
|
|
7464
|
+
if converted is not None:
|
|
7465
|
+
image_data = converted
|
|
7466
|
+
image_format = "png"
|
|
7467
|
+
else:
|
|
7468
|
+
_convert_hint = (
|
|
7469
|
+
"Install Pillow (and svglib for SVG) in the sandbox, or "
|
|
7470
|
+
"fetch a PNG URL directly. For placeholder URLs use a "
|
|
7471
|
+
"`.png` suffix (e.g. `placehold.co/600x400.png`, NOT "
|
|
7472
|
+
"`placehold.co/600x400` which serves SVG by default)."
|
|
7473
|
+
)
|
|
7474
|
+
raise ValueError(
|
|
7475
|
+
f"add_picture: detected image_format={image_format!r}, "
|
|
7476
|
+
f"which pptx-studio cannot decode. {_convert_hint}"
|
|
7477
|
+
)
|
|
7478
|
+
|
|
7032
7479
|
if (w_emu is None) != (h_emu is None):
|
|
7033
7480
|
native = self._read_image_pixel_size(image_data, image_format)
|
|
7034
7481
|
if native is not None:
|
|
@@ -7121,6 +7568,116 @@ class Shapes:
|
|
|
7121
7568
|
self._shapes_by_id[client_id] = shape
|
|
7122
7569
|
return shape
|
|
7123
7570
|
|
|
7571
|
+
@athena_extension(
|
|
7572
|
+
since="0.4.0",
|
|
7573
|
+
description=(
|
|
7574
|
+
"Shapes.add_athena_anchor — insert a rendered screenshot of an "
|
|
7575
|
+
"Athena anchor (currently SheetCellAnchor / SheetRangeAnchor) "
|
|
7576
|
+
"as a picture on the slide."
|
|
7577
|
+
),
|
|
7578
|
+
)
|
|
7579
|
+
def add_athena_anchor(
|
|
7580
|
+
self,
|
|
7581
|
+
source: "AssetReference",
|
|
7582
|
+
anchor: "Anchor",
|
|
7583
|
+
left: Length,
|
|
7584
|
+
top: Length,
|
|
7585
|
+
width: Optional[Length] = None,
|
|
7586
|
+
height: Optional[Length] = None,
|
|
7587
|
+
*,
|
|
7588
|
+
name: Optional[str] = None,
|
|
7589
|
+
fit: Optional[str] = None,
|
|
7590
|
+
) -> Shape:
|
|
7591
|
+
"""Insert a rendered screenshot of an Athena anchor as a picture.
|
|
7592
|
+
|
|
7593
|
+
Routes through the appropriate studio's screenshot endpoint based
|
|
7594
|
+
on the anchor type and inserts the PNG via the standard
|
|
7595
|
+
``add_picture`` command path so the result is a regular ``Picture``
|
|
7596
|
+
shape (selectable, movable, resizable). The picture is NOT a
|
|
7597
|
+
live-linked binding — it is a one-shot raster snapshot.
|
|
7598
|
+
|
|
7599
|
+
Currently supports:
|
|
7600
|
+
|
|
7601
|
+
- ``SheetCellAnchor`` — renders a single cell from xlsx-studio.
|
|
7602
|
+
- ``SheetRangeAnchor`` — renders an A1-style range from xlsx-studio.
|
|
7603
|
+
|
|
7604
|
+
Other anchor types (``SheetTableAnchor``, ``SlideAnchor``, …)
|
|
7605
|
+
raise ``NotImplementedError``.
|
|
7606
|
+
|
|
7607
|
+
Args:
|
|
7608
|
+
source: Athena ``AssetReference`` pointing at the source asset
|
|
7609
|
+
(e.g. an xlsx workbook).
|
|
7610
|
+
anchor: ``Anchor`` describing the region to render. Sheet
|
|
7611
|
+
anchors carry a numeric ``sheet_id``.
|
|
7612
|
+
left, top: Position of the resulting picture.
|
|
7613
|
+
width, height: Optional dimensions. Same aspect-ratio
|
|
7614
|
+
derivation rules as :meth:`add_picture`.
|
|
7615
|
+
name: Optional stable shape name.
|
|
7616
|
+
fit: Optional ``"contain"`` to clamp the picture to slide
|
|
7617
|
+
bounds. Same semantics as :meth:`add_picture`.
|
|
7618
|
+
|
|
7619
|
+
Returns:
|
|
7620
|
+
The newly created picture ``Shape``.
|
|
7621
|
+
|
|
7622
|
+
Note (Athena extension):
|
|
7623
|
+
Not in python-pptx. See ``docs/API_PARITY_EXCEPTIONS.md``.
|
|
7624
|
+
"""
|
|
7625
|
+
from .._references import (
|
|
7626
|
+
AssetReference,
|
|
7627
|
+
SheetCellAnchor,
|
|
7628
|
+
SheetRangeAnchor,
|
|
7629
|
+
SheetTableAnchor,
|
|
7630
|
+
SlideAnchor,
|
|
7631
|
+
)
|
|
7632
|
+
|
|
7633
|
+
if not isinstance(source, AssetReference):
|
|
7634
|
+
raise TypeError(
|
|
7635
|
+
f"source must be an AssetReference; got {type(source).__name__}"
|
|
7636
|
+
)
|
|
7637
|
+
|
|
7638
|
+
if isinstance(anchor, SheetCellAnchor):
|
|
7639
|
+
cell_range = f"{anchor.cell}:{anchor.cell}"
|
|
7640
|
+
png_bytes = _fetch_xlsx_range_as_png(
|
|
7641
|
+
source.id,
|
|
7642
|
+
sheet_id=anchor.sheet_id,
|
|
7643
|
+
cell_range=cell_range,
|
|
7644
|
+
)
|
|
7645
|
+
elif isinstance(anchor, SheetRangeAnchor):
|
|
7646
|
+
png_bytes = _fetch_xlsx_range_as_png(
|
|
7647
|
+
source.id,
|
|
7648
|
+
sheet_id=anchor.sheet_id,
|
|
7649
|
+
cell_range=anchor.range,
|
|
7650
|
+
)
|
|
7651
|
+
elif isinstance(anchor, SheetTableAnchor):
|
|
7652
|
+
raise NotImplementedError(
|
|
7653
|
+
"add_athena_anchor does not yet support SheetTableAnchor. "
|
|
7654
|
+
"Resolve the table to an A1 range and pass SheetRangeAnchor "
|
|
7655
|
+
"instead, or use add_linked_table() for a live-linked table."
|
|
7656
|
+
)
|
|
7657
|
+
elif isinstance(anchor, SlideAnchor):
|
|
7658
|
+
raise NotImplementedError(
|
|
7659
|
+
"add_athena_anchor does not yet support SlideAnchor — "
|
|
7660
|
+
"rendering a slide screenshot from a source deck is not "
|
|
7661
|
+
"wired up. Use the source deck's render_slide() and pass "
|
|
7662
|
+
"the bytes to add_picture() directly."
|
|
7663
|
+
)
|
|
7664
|
+
else:
|
|
7665
|
+
raise NotImplementedError(
|
|
7666
|
+
f"add_athena_anchor: unsupported anchor type "
|
|
7667
|
+
f"{type(anchor).__name__!r}. Supported today: "
|
|
7668
|
+
"SheetCellAnchor, SheetRangeAnchor."
|
|
7669
|
+
)
|
|
7670
|
+
|
|
7671
|
+
return self.add_picture(
|
|
7672
|
+
png_bytes,
|
|
7673
|
+
left,
|
|
7674
|
+
top,
|
|
7675
|
+
width=width,
|
|
7676
|
+
height=height,
|
|
7677
|
+
name=name,
|
|
7678
|
+
fit=fit,
|
|
7679
|
+
)
|
|
7680
|
+
|
|
7124
7681
|
@staticmethod
|
|
7125
7682
|
def _read_image_pixel_size(
|
|
7126
7683
|
data: bytes, image_format: str
|
|
@@ -7682,10 +8239,24 @@ class Shapes:
|
|
|
7682
8239
|
return 'bmp'
|
|
7683
8240
|
elif data[:4] in (b'II*\x00', b'MM\x00*'):
|
|
7684
8241
|
return 'tiff'
|
|
8242
|
+
# WebP: ``RIFF`` + 4 bytes filesize + ``WEBP``. Previously fell
|
|
8243
|
+
# through to the PNG default below, which sent invalid bytes to
|
|
8244
|
+
# pptx-studio and rendered as the broken-image placeholder; now
|
|
8245
|
+
# detected explicitly so the upper-layer auto-convert path can
|
|
8246
|
+
# fire (or a clear error surface).
|
|
8247
|
+
elif (
|
|
8248
|
+
len(data) >= 12
|
|
8249
|
+
and data[:4] == b'RIFF'
|
|
8250
|
+
and data[8:12] == b'WEBP'
|
|
8251
|
+
):
|
|
8252
|
+
return 'webp'
|
|
7685
8253
|
elif self._looks_like_svg(data):
|
|
7686
8254
|
# SVG (text-based). PowerPoint 2016+ supports native SVG via
|
|
7687
|
-
# the ``<p:pic>`` extension list — closes python-pptx#1112
|
|
7688
|
-
#
|
|
8255
|
+
# the ``<p:pic>`` extension list — closes python-pptx#1112.
|
|
8256
|
+
# pptx-studio's renderer doesn't decode SVG today, so the
|
|
8257
|
+
# upper-layer auto-convert path turns SVG → PNG via Pillow
|
|
8258
|
+
# when available; we still return ``'svg'`` here so that
|
|
8259
|
+
# path can recognize it.
|
|
7689
8260
|
return 'svg'
|
|
7690
8261
|
else:
|
|
7691
8262
|
# Default to PNG
|
|
@@ -2105,6 +2105,102 @@ class Slides:
|
|
|
2105
2105
|
"""
|
|
2106
2106
|
return self.add_slide(slide_layout=6)
|
|
2107
2107
|
|
|
2108
|
+
@athena_only(
|
|
2109
|
+
description="Insert a slide at an arbitrary index without cloning",
|
|
2110
|
+
since="0.3.1",
|
|
2111
|
+
)
|
|
2112
|
+
def insert(self, index: int, layout: Any = None) -> Slide:
|
|
2113
|
+
"""Insert a slide at ``index``.
|
|
2114
|
+
|
|
2115
|
+
Athena extension — python-pptx's ``Slides`` collection only
|
|
2116
|
+
supports appending via :meth:`add_slide`. When a caller needs to
|
|
2117
|
+
place a new slide at a specific position (for example, between
|
|
2118
|
+
two existing slides), the previous workaround was to
|
|
2119
|
+
``add_slide()`` then ``clone(target_index=...)`` an unrelated
|
|
2120
|
+
slide to shift the list — fragile, leaks the cloned shapes, and
|
|
2121
|
+
surfaced in the Insight Partners session as a 33-shape ghost
|
|
2122
|
+
cover that ``slide.shapes`` couldn't clear.
|
|
2123
|
+
|
|
2124
|
+
``insert(index, layout)`` issues a single ``AddSlide(index=...)``
|
|
2125
|
+
command. The server returns the new slide id and the local
|
|
2126
|
+
collection is refreshed via :meth:`Presentation.refresh`.
|
|
2127
|
+
|
|
2128
|
+
Args:
|
|
2129
|
+
index: Zero-based position for the new slide. Clamped to
|
|
2130
|
+
``[0, len(self)]``. ``index == len(self)`` is equivalent
|
|
2131
|
+
to :meth:`add_slide`.
|
|
2132
|
+
layout: Slide layout — :class:`SlideLayout`, integer, or
|
|
2133
|
+
``None`` for the presentation default.
|
|
2134
|
+
|
|
2135
|
+
Returns:
|
|
2136
|
+
The newly created :class:`Slide`.
|
|
2137
|
+
|
|
2138
|
+
Example:
|
|
2139
|
+
>>> prs.slides.insert(10, prs.slide_layouts[6])
|
|
2140
|
+
"""
|
|
2141
|
+
bounded = max(0, min(int(index), len(self._slides)))
|
|
2142
|
+
layout_index: Optional[int] = None
|
|
2143
|
+
layout_name: Optional[str] = None
|
|
2144
|
+
if layout is not None:
|
|
2145
|
+
if isinstance(layout, int):
|
|
2146
|
+
layout_index = layout
|
|
2147
|
+
elif hasattr(layout, '_index'):
|
|
2148
|
+
layout_index = layout._index
|
|
2149
|
+
layout_name = getattr(layout, "name", None)
|
|
2150
|
+
|
|
2151
|
+
cmd = AddSlide(index=bounded, layout_index=layout_index)
|
|
2152
|
+
|
|
2153
|
+
response = None
|
|
2154
|
+
slide_id: Optional[str] = None
|
|
2155
|
+
|
|
2156
|
+
def extract_created_slide_id(raw_response: object) -> Optional[str]:
|
|
2157
|
+
if not isinstance(raw_response, dict):
|
|
2158
|
+
return None
|
|
2159
|
+
created = raw_response.get("created")
|
|
2160
|
+
if not isinstance(created, dict):
|
|
2161
|
+
return None
|
|
2162
|
+
slide_ids = created.get("slideIds", [])
|
|
2163
|
+
if not isinstance(slide_ids, list) or not slide_ids:
|
|
2164
|
+
return None
|
|
2165
|
+
first = slide_ids[0]
|
|
2166
|
+
return first if isinstance(first, str) else None
|
|
2167
|
+
|
|
2168
|
+
if self._buffer:
|
|
2169
|
+
response = self._buffer.add(cmd)
|
|
2170
|
+
slide_id = extract_created_slide_id(response)
|
|
2171
|
+
|
|
2172
|
+
if self._buffer and not self._buffer.is_batching:
|
|
2173
|
+
if response is None:
|
|
2174
|
+
flushed_response = self._buffer.flush()
|
|
2175
|
+
slide_id = slide_id or extract_created_slide_id(flushed_response)
|
|
2176
|
+
self._presentation.refresh()
|
|
2177
|
+
if slide_id:
|
|
2178
|
+
refreshed = self._slides_by_id.get(slide_id)
|
|
2179
|
+
if refreshed is not None:
|
|
2180
|
+
return refreshed
|
|
2181
|
+
if 0 <= bounded < len(self._slides):
|
|
2182
|
+
return self._slides[bounded]
|
|
2183
|
+
|
|
2184
|
+
if not slide_id:
|
|
2185
|
+
import uuid
|
|
2186
|
+
slide_id = f"sld_{uuid.uuid4().hex[:8]}"
|
|
2187
|
+
|
|
2188
|
+
slide = Slide(
|
|
2189
|
+
presentation=self._presentation,
|
|
2190
|
+
slide_id=slide_id,
|
|
2191
|
+
slide_index=bounded,
|
|
2192
|
+
buffer=self._buffer,
|
|
2193
|
+
element_ids=[],
|
|
2194
|
+
elements={},
|
|
2195
|
+
layout_index=layout_index,
|
|
2196
|
+
layout_name=layout_name,
|
|
2197
|
+
)
|
|
2198
|
+
self._slides.insert(bounded, slide)
|
|
2199
|
+
self._slides_by_id[slide_id] = slide
|
|
2200
|
+
for i, s in enumerate(self._slides):
|
|
2201
|
+
s._slide_index = i
|
|
2202
|
+
return slide
|
|
2203
|
+
|
|
2108
2204
|
def index(self, slide: Slide) -> int:
|
|
2109
2205
|
"""Get the index of a slide."""
|
|
2110
2206
|
return self._slides.index(slide)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "athena-python-pptx"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Drop-in replacement for python-pptx that connects to PPTX Studio for real-time collaboration"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|