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.
Files changed (85) hide show
  1. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/CHANGELOG.md +41 -0
  2. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/PKG-INFO +1 -1
  3. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/docs/API_PARITY_EXCEPTIONS.md +61 -0
  4. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/__init__.py +1 -1
  5. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/__init__.py +581 -10
  6. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/slides.py +96 -0
  7. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pyproject.toml +1 -1
  8. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/.gitignore +0 -0
  9. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/API_PARITY_REPORT.md +0 -0
  10. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/CLAUDE.md +0 -0
  11. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/DEV-GUIDE.md +0 -0
  12. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/PARITY_QUESTIONS.md +0 -0
  13. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/PUBLISHING.md +0 -0
  14. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/README.md +0 -0
  15. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/docs/athena-api.json +0 -0
  16. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/docs/athena-api.md +0 -0
  17. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/_athena_extension.py +0 -0
  18. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/_ptc.py +0 -0
  19. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/_references.py +0 -0
  20. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/action.py +0 -0
  21. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/batching.py +0 -0
  22. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/__init__.py +0 -0
  23. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/axis.py +0 -0
  24. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/category.py +0 -0
  25. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/chart.py +0 -0
  26. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/data.py +0 -0
  27. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/datalabel.py +0 -0
  28. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/legend.py +0 -0
  29. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/marker.py +0 -0
  30. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/plot.py +0 -0
  31. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/point.py +0 -0
  32. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/series.py +0 -0
  33. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/chart/xlsx.py +0 -0
  34. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/client.py +0 -0
  35. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/commands.py +0 -0
  36. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/decorators.py +0 -0
  37. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/__init__.py +0 -0
  38. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/chtfmt.py +0 -0
  39. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/color.py +0 -0
  40. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/effect.py +0 -0
  41. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/fill.py +0 -0
  42. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/dml/line.py +0 -0
  43. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/docgen.py +0 -0
  44. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/__init__.py +0 -0
  45. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/action.py +0 -0
  46. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/chart.py +0 -0
  47. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/dml.py +0 -0
  48. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/lang.py +0 -0
  49. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/shapes.py +0 -0
  50. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/enum/text.py +0 -0
  51. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/errors.py +0 -0
  52. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/exc.py +0 -0
  53. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/media.py +0 -0
  54. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/package.py +0 -0
  55. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/__init__.py +0 -0
  56. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/_base.py +0 -0
  57. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/chart.py +0 -0
  58. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/coreprops.py +0 -0
  59. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/embeddedpackage.py +0 -0
  60. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/image.py +0 -0
  61. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/media.py +0 -0
  62. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/presentation.py +0 -0
  63. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/parts/slide.py +0 -0
  64. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/presentation.py +0 -0
  65. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/autoshape.py +0 -0
  66. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/base.py +0 -0
  67. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/connector.py +0 -0
  68. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/freeform.py +0 -0
  69. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/graphfrm.py +0 -0
  70. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/group.py +0 -0
  71. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/picture.py +0 -0
  72. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/placeholder.py +0 -0
  73. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shapes/shapetree.py +0 -0
  74. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/shared.py +0 -0
  75. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/slide.py +0 -0
  76. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/spec.py +0 -0
  77. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/table.py +0 -0
  78. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/__init__.py +0 -0
  79. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/fonts.py +0 -0
  80. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/layout.py +0 -0
  81. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/text/text.py +0 -0
  82. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/types.py +0 -0
  83. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/typing.py +0 -0
  84. {athena_python_pptx-0.3.0 → athena_python_pptx-0.3.1}/pptx/units.py +0 -0
  85. {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.0
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)
@@ -132,7 +132,7 @@ def flush_all() -> None:
132
132
  _active_buffers[:] = alive
133
133
 
134
134
 
135
- __version__ = "0.3.0"
135
+ __version__ = "0.3.1"
136
136
 
137
137
  __all__ = [
138
138
  # Main entry point
@@ -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
- Real python-pptx returns a full ``TextFrame`` with paragraphs/runs.
1502
- Athena's chart-title pipeline only persists a single string title
1503
- (``SetChartTitle`` patch), so this shim collapses the ``text_frame.
1504
- text = ...`` form down to that same patch. Multi-paragraph chart
1505
- titles aren't supported on the export side yet.
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
- self._chart_title._emit(value)
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
- # which reports SVG raising "cannot identify image file".
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.0"
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"