athena-python-pptx 0.6.0__tar.gz → 0.7.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.6.0 → athena_python_pptx-0.7.1}/CHANGELOG.md +86 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/CLAUDE.md +4 -2
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/PKG-INFO +1 -1
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/docs/API_PARITY_EXCEPTIONS.md +91 -50
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/__init__.py +1 -1
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/batching.py +178 -19
- athena_python_pptx-0.7.1/pptx/chart/data.py +446 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/client.py +85 -10
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/commands.py +6 -1
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/dml/color.py +35 -45
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/dml.py +10 -11
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/shapes.py +23 -1
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/text.py +22 -8
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/media.py +37 -5
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/presentation.py +17 -6
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/__init__.py +315 -61
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/slides.py +74 -31
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/text/__init__.py +79 -40
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/units.py +39 -20
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pyproject.toml +1 -1
- athena_python_pptx-0.6.0/pptx/chart/data.py +0 -266
- athena_python_pptx-0.6.0/uv.lock +0 -1215
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/.gitignore +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/API_PARITY_REPORT.md +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/DEV-GUIDE.md +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/PARITY_QUESTIONS.md +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/PUBLISHING.md +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/README.md +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/docs/athena-api.json +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/docs/athena-api.md +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/_athena_extension.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/_ptc.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/_references.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/action.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/__init__.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/axis.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/category.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/chart.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/datalabel.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/legend.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/marker.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/plot.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/point.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/series.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/chart/xlsx.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/decorators.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/dml/__init__.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/dml/chtfmt.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/dml/effect.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/dml/fill.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/dml/line.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/docgen.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/__init__.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/action.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/chart.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/enum/lang.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/errors.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/exc.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/package.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/__init__.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/_base.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/chart.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/coreprops.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/embeddedpackage.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/image.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/media.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/presentation.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/parts/slide.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/autoshape.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/base.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/connector.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/freeform.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/graphfrm.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/group.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/picture.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/placeholder.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shapes/shapetree.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/shared.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/slide.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/spec.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/table.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/text/fonts.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/text/layout.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/text/text.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/types.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/typing.py +0 -0
- {athena_python_pptx-0.6.0 → athena_python_pptx-0.7.1}/pptx/util.py +0 -0
|
@@ -2,6 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `athena-python-pptx` are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.7.1 — CommandBuffer thread-safety
|
|
6
|
+
|
|
7
|
+
Fixes a data-loss/duplication race in `CommandBuffer`. `self._commands` was
|
|
8
|
+
shared between the foreground `add`/`flush`/`clear` path and the 2.0s
|
|
9
|
+
auto-flush daemon thread, with only the timer guarded — so a flush that
|
|
10
|
+
snapshotted-then-reset the buffer could drop a command a concurrent `add`
|
|
11
|
+
appended in between (silent slide/shape loss), and a timer flush racing a
|
|
12
|
+
foreground flush could POST the same commands twice (duplication). In
|
|
13
|
+
long-lived Daytona executor processes this silently dropped or duplicated
|
|
14
|
+
slides/shapes with no error. A dedicated `_commands_lock` now guards every
|
|
15
|
+
read/append/reassign of `_commands` (drain atomically under the lock; POST
|
|
16
|
+
outside it). No public API change. (#24278)
|
|
17
|
+
|
|
18
|
+
## 0.7.0
|
|
19
|
+
|
|
20
|
+
**python-pptx behavioral-parity sweep — read-path return types, enum
|
|
21
|
+
values, and chart-data builder objects now match python-pptx 1.0.2.**
|
|
22
|
+
|
|
23
|
+
Surface parity (every public class/method/signature) was already 100%, but a
|
|
24
|
+
deep-dive against python-pptx 1.0.2 found *behavioral* divergences where
|
|
25
|
+
getters returned raw strings/`None` instead of enum members or typed
|
|
26
|
+
defaults, two enums had wrong integer values, and the chart-data builders
|
|
27
|
+
weren't python-pptx-shaped. All fixes are pure-local (no wire/server change);
|
|
28
|
+
`to_series_payload()` output is byte-identical to before.
|
|
29
|
+
|
|
30
|
+
**Enum value corrections (silent drop-in breakers):**
|
|
31
|
+
|
|
32
|
+
- **`MSO_THEME_COLOR.TEXT_2` / `BACKGROUND_2` were swapped** — now `15` /
|
|
33
|
+
`16` to match upstream (the `<a:schemeClr>` read/write maps were updated in
|
|
34
|
+
lockstep so OOXML output is unchanged). `MSO_THEME_COLOR_INDEX` inherits.
|
|
35
|
+
- **`MSO_TEXT_UNDERLINE_TYPE` had 6 wrong values, 9 missing members, and a
|
|
36
|
+
bogus `DASHED_LINE`** — rewritten to upstream's exact 19 members/values;
|
|
37
|
+
added the `MSO_UNDERLINE` alias (`from pptx.enum.text import MSO_UNDERLINE`).
|
|
38
|
+
- **`PP_MEDIA_TYPE`**: `MOVIE` corrected `2`→`3`, added `OTHER = 1`, and the
|
|
39
|
+
enum is now importable from its canonical `pptx.enum.shapes` location.
|
|
40
|
+
- Added `MSO_SHAPE_TYPE.MIXED = -2` and the `MSO` alias; added the
|
|
41
|
+
upstream-typo `MSO_PATTERN.ERCENT_40` member.
|
|
42
|
+
|
|
43
|
+
**Read-path return-type parity (getters now return enums / typed defaults):**
|
|
44
|
+
|
|
45
|
+
- `FillFormat.type` → `MSO_FILL_TYPE` (was a raw string); `background()` now
|
|
46
|
+
reports `MSO_FILL_TYPE.BACKGROUND`. `gradient_angle`/`gradient_stops`/
|
|
47
|
+
`pattern` raise `TypeError` on a wrong-type fill (parity).
|
|
48
|
+
- `LineFormat.dash_style` → `MSO_LINE_DASH_STYLE` (or `None` when unstyled,
|
|
49
|
+
instead of defaulting to `"solid"`); `LineFormat.width` defaults to
|
|
50
|
+
`Emu(0)` for an unstyled line (a color-/dash-only edit no longer injects a
|
|
51
|
+
synthetic 1pt width).
|
|
52
|
+
- `Chart.chart_type` → `XL_CHART_TYPE` member (was a raw server string);
|
|
53
|
+
`add_chart` now persists `grouping` so stacked/clustered variants read back
|
|
54
|
+
exactly.
|
|
55
|
+
- `TableCell.vertical_anchor` → `MSO_VERTICAL_ANCHOR`; `TableCell.margin_*`
|
|
56
|
+
default to the OOXML defaults (`91440`/`45720` EMU) instead of `None`.
|
|
57
|
+
- `Shape.rotation` is normalized to `[0, 360)` and float-coerced.
|
|
58
|
+
- `_Column.width` / `_Row.height` are populated for `add_table(...)` tables
|
|
59
|
+
(even division, last column/row absorbs the remainder).
|
|
60
|
+
- `RGBColor` is now a real `tuple` subclass (`RGBColor(255,0,0) == (255,0,0)`,
|
|
61
|
+
`isinstance(c, tuple)`).
|
|
62
|
+
- `ColorFormat.theme_color` returns `MSO_THEME_COLOR.NOT_THEME_COLOR` for an
|
|
63
|
+
sRGB color (raises `TypeError` when no color is present) instead of `None`.
|
|
64
|
+
- `TextFrame.auto_size` / `vertical_anchor` return their enums; `margin_*`
|
|
65
|
+
default to the python-pptx `Length` defaults; `Paragraph.line_spacing =
|
|
66
|
+
None` clears the override without crashing; `Font.language_id` returns
|
|
67
|
+
`MSO_LANGUAGE_ID.NONE` on an unset run.
|
|
68
|
+
- `SlideLayouts.get_by_name` is exact/case-sensitive (the fuzzy match moved to
|
|
69
|
+
the Athena-only `by_name`); `Slide.has_notes_slide` flips `True` once
|
|
70
|
+
`notes_slide` is accessed.
|
|
71
|
+
- `Video.ext` returns the bare extension (`'mp4'`, `'vid'` fallback) and
|
|
72
|
+
`Video.filename` synthesizes `'movie.{ext}'`.
|
|
73
|
+
|
|
74
|
+
**`pptx.util` truncation parity:** unit constructors (`Emu`/`Pt`/`Inches`/…)
|
|
75
|
+
truncate toward zero like upstream (`int(Pt(1.9999)) == 25398`) instead of
|
|
76
|
+
rounding; `Length.centipoints` is integer floor-division.
|
|
77
|
+
|
|
78
|
+
**Chart-data builder objects (`pptx.chart.data`) are now python-pptx-shaped:**
|
|
79
|
+
|
|
80
|
+
- `CategoryChartData.add_series(name, values)` returns a real
|
|
81
|
+
`CategorySeriesData` with `.values`, `.add_data_point()`, and `.index`;
|
|
82
|
+
`XyChartData`/`BubbleChartData.add_series` return `XySeriesData`/
|
|
83
|
+
`BubbleSeriesData` whose `add_data_point(...)` returns the data point and
|
|
84
|
+
expose `.x_values` / `.y_values` / `.bubble_sizes`.
|
|
85
|
+
- `CategoryChartData.add_category(label)` and a `Categories`/`Category`
|
|
86
|
+
sequence with `are_dates` / `are_numeric` / `number_format`; category label
|
|
87
|
+
types (dates, numbers) are preserved through `cd.categories[i].label`.
|
|
88
|
+
- The three chart-data containers implement the `Sequence` protocol
|
|
89
|
+
(`for s in chart_data` / `chart_data[0]`).
|
|
90
|
+
|
|
5
91
|
## 0.4.2
|
|
6
92
|
|
|
7
93
|
**Paragraph-level font fidelity — `paragraph.font` now matches stock
|
|
@@ -52,8 +52,10 @@ A small number of REST-SDK-specific departures are documented in
|
|
|
52
52
|
use `slide.notes_slide.notes_text_frame` for notes text.
|
|
53
53
|
- **`XyChartData.add_series(name, values)`** signature drift vs upstream's
|
|
54
54
|
`add_series(name, number_format=None)` — caller passes inline tuples.
|
|
55
|
-
- **`TextFitter.best_fit_font_size()`**
|
|
56
|
-
|
|
55
|
+
- **`TextFitter.best_fit_font_size()`** is server-measured: with a base
|
|
56
|
+
URL configured it queries `POST /measure-text-fit` and returns the
|
|
57
|
+
largest fitting size; only falls back to `max_size` (+ `RuntimeWarning`)
|
|
58
|
+
when no studio client is reachable.
|
|
57
59
|
- **`_BaseSeriesData.index`** raises `NotImplementedError` (raising
|
|
58
60
|
rather than silently returning 0).
|
|
59
61
|
- **`Package.open()`** raises `UnsupportedFeatureError`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: athena-python-pptx
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -45,48 +45,51 @@ text = slide.notes_slide.notes_text_frame.text
|
|
|
45
45
|
A future server-side notes-master patch op will plumb the full shape
|
|
46
46
|
tree through. Tracked in `PARITY_QUESTIONS.md`.
|
|
47
47
|
|
|
48
|
-
### `XyChartData.add_series()` —
|
|
48
|
+
### `XyChartData.add_series()` — both the upstream and inline patterns work (v0.7.0)
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
**Our signature:** `add_series(name, values)` — caller passes the
|
|
54
|
-
(x, y) tuples up front.
|
|
55
|
-
|
|
56
|
-
**Migration:**
|
|
50
|
+
As of v0.7.0 `add_series` returns a real `XySeriesData` (resp.
|
|
51
|
+
`BubbleSeriesData`), so the **upstream pattern works**:
|
|
57
52
|
|
|
58
53
|
```python
|
|
59
|
-
# python-pptx pattern (
|
|
54
|
+
# python-pptx pattern (now supported):
|
|
60
55
|
series = data.add_series("Series 1")
|
|
61
|
-
series.add_data_point(1.0, 2.0)
|
|
56
|
+
series.add_data_point(1.0, 2.0) # returns the XyDataPoint
|
|
62
57
|
series.add_data_point(3.0, 4.0)
|
|
58
|
+
series.x_values # [1.0, 3.0]; series.y_values -> [2.0, 4.0]
|
|
63
59
|
|
|
64
|
-
# athena-python-pptx
|
|
60
|
+
# athena-python-pptx inline shorthand (still supported):
|
|
65
61
|
data.add_series("Series 1", [(1.0, 2.0), (3.0, 4.0)])
|
|
66
62
|
```
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
`add_series` additionally accepts an optional `number_format=` for
|
|
65
|
+
signature parity. The same applies to `BubbleChartData.add_series`
|
|
66
|
+
(inline triples are `(x, y, size)`).
|
|
71
67
|
|
|
72
|
-
### `TextFitter.best_fit_font_size()` —
|
|
68
|
+
### `TextFitter.best_fit_font_size()` — server-measured (needs a base URL)
|
|
73
69
|
|
|
74
|
-
Upstream binary-searches font sizes locally
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
Upstream binary-searches font sizes against locally-installed font
|
|
71
|
+
metrics, which a REST SDK can't read. When a base URL is configured
|
|
72
|
+
(`ATHENA_PPTX_BASE_URL` or an explicit `client`) the classmethod issues
|
|
73
|
+
a `MeasureTextFit` query to the studio's `POST /measure-text-fit`
|
|
74
|
+
endpoint and returns the largest integer size that fits — measured
|
|
75
|
+
against the studio's bundled fonts via the same layouter the renderer
|
|
76
|
+
uses (see the extension entry below). It falls back to `max_size` with a
|
|
77
|
+
`RuntimeWarning` **only** when no studio client is reachable (offline /
|
|
78
|
+
import-time tests). For full-deck layout, prefer letting the text frame
|
|
79
|
+
auto-fit at render time:
|
|
78
80
|
|
|
79
81
|
```python
|
|
80
82
|
from pptx.enum.text import MSO_AUTO_SIZE
|
|
81
83
|
text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
### `_BaseSeriesData.index` —
|
|
86
|
+
### `_BaseSeriesData.index` — resolves against the parent chart-data (v0.7.0)
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
As of v0.7.0, a series created via `chart_data.add_series(...)` carries a
|
|
89
|
+
back-reference to its parent, so `series.index` returns the series's
|
|
90
|
+
offset within the chart-data exactly like upstream. A *detached* series
|
|
91
|
+
constructed standalone (no parent) still raises `NotImplementedError`
|
|
92
|
+
rather than lying about the offset.
|
|
90
93
|
|
|
91
94
|
### `Package.open(pkg_file)` — raises `UnsupportedFeatureError`
|
|
92
95
|
|
|
@@ -137,6 +140,24 @@ prs.save() # flush only (Athena-only)
|
|
|
137
140
|
prs.save("out.pptx") # flush + export + write to local file (parity)
|
|
138
141
|
```
|
|
139
142
|
|
|
143
|
+
### Oversized flushes are split into multiple atomic sub-transactions
|
|
144
|
+
|
|
145
|
+
The SDK serializes every buffered command into one JSON body and POSTs it
|
|
146
|
+
to `/decks/:id/commands`. pptx-studio's apps/api (Fastify) caps that body
|
|
147
|
+
at 32 MiB, and `AddPicture` / `SubstitutePlaceholder(picture)` carry inline
|
|
148
|
+
base64 image bytes (~4/3 expansion), so a few large images can blow past
|
|
149
|
+
the ceiling. For batches that exceed the limit, `CommandBuffer.flush`
|
|
150
|
+
splits them into multiple atomic sub-transactions (still validating the
|
|
151
|
+
whole batch up front, so an invalid command aborts before anything is
|
|
152
|
+
applied). The all-or-nothing guarantee then holds *per sub-transaction*
|
|
153
|
+
rather than end-to-end — a server-side failure mid-sequence can leave
|
|
154
|
+
earlier chunks committed. The split threshold is tunable via
|
|
155
|
+
`ATHENA_PPTX_MAX_REQUEST_BYTES`; batches within the limit ship as one
|
|
156
|
+
atomic request, unchanged. A single command larger than the budget (one
|
|
157
|
+
very large inline image) cannot be split — it is sent alone and may still
|
|
158
|
+
be rejected with a teaching `IMAGE_TOO_LARGE` error, but it no longer
|
|
159
|
+
takes the rest of the queued edits down with it.
|
|
160
|
+
|
|
140
161
|
---
|
|
141
162
|
|
|
142
163
|
## Agent-Friendly Additions (not in python-pptx)
|
|
@@ -215,34 +236,23 @@ python-pptx's `_Row.height` and `_Column.width` are read/write EMU properties. T
|
|
|
215
236
|
|
|
216
237
|
In python-pptx, `Table.rows` returns a `_RowCollection` of `_Row` objects (each with `.height`). This SDK matches that. In a prior version, `Table.rows` returned a flat collection of cell lists.
|
|
217
238
|
|
|
218
|
-
### `TextFrame.clear()` —
|
|
219
|
-
|
|
220
|
-
In upstream python-pptx, `text_frame.clear()` removes every paragraph except the first, and on that first paragraph it removes every child element **except** `<a:pPr>`. Any bullet definition the author wrote into `<a:pPr>` survives the clear, and an inherited bullet from the master / layout's `<a:lstStyle>` is left to be re-resolved by PowerPoint at render time.
|
|
221
|
-
|
|
222
|
-
This SDK additionally emits a `SetParagraphStyle(paragraph_index=0, bullet='none')` after the empty `SetText`, explicitly overriding the bullet on the cleared paragraph.
|
|
223
|
-
|
|
224
|
-
The deviation exists because pptx-studio **pre-bakes** master/layout list-style inheritance into `richContent.paragraphs[i].bullet` at ingest time (and at slide materialization), then preserves that baked field through every subsequent `applySetText` via `preserveParagraphProps`. Without the explicit override, the python-pptx idiom
|
|
225
|
-
|
|
226
|
-
```python
|
|
227
|
-
tf.clear()
|
|
228
|
-
p = tf.paragraphs[0]
|
|
229
|
-
run = p.add_run()
|
|
230
|
-
run.text = "Title"
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
renders the title with a stray "•" whenever the placeholder lives on a layout whose master defines a level-1 `<a:buChar/>` (the common case for `<p:bodyStyle>` and any content placeholder). The explicit `bullet='none'` clears the baked inheritance so the literal "no pPr was authored" intent reaches the renderer.
|
|
239
|
+
### `TextFrame.clear()` — bullet inheritance (now matches upstream)
|
|
234
240
|
|
|
235
|
-
|
|
241
|
+
`text_frame.clear()` matches upstream python-pptx: it removes the runs but
|
|
242
|
+
preserves the cleared paragraph's inherited list style. A content/body
|
|
243
|
+
placeholder therefore keeps its master `<p:bodyStyle>` bullet, and a title
|
|
244
|
+
keeps its `<p:titleStyle>` `<a:buNone/>` (no bullet), exactly as PowerPoint
|
|
245
|
+
re-resolves them at render time.
|
|
236
246
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
247
|
+
> **Historical note:** earlier versions emitted a
|
|
248
|
+
> `SetParagraphStyle(paragraph_index=0, bullet='none')` after the empty
|
|
249
|
+
> `SetText` to mask a *stray title bullet*. The real cause was incorrect
|
|
250
|
+
> per-type bullet baking — empty layout/master prompt paragraphs were baked
|
|
251
|
+
> with `bullet:'none'` instead of their master `<p:txStyles>` value, and a
|
|
252
|
+
> blanket override then stripped bullets from **content** placeholders too
|
|
253
|
+
> (where bullets are expected). That baking is now correct per placeholder
|
|
254
|
+
> type (`titleStyle` → no bullet, `bodyStyle` → its `<a:buChar/>`), so the
|
|
255
|
+
> override was removed and `clear()` is faithful to upstream.
|
|
246
256
|
|
|
247
257
|
---
|
|
248
258
|
|
|
@@ -328,6 +338,37 @@ img = ws.export_range_as_image_asset("A1:F20", name="Q4 Revenue")
|
|
|
328
338
|
slide.shapes.add_picture(img.url, Inches(1), Inches(1), width=Inches(6))
|
|
329
339
|
```
|
|
330
340
|
|
|
341
|
+
### `Picture.image_url` — owner-scoped media URL for cross-deck image copy
|
|
342
|
+
|
|
343
|
+
python-pptx upstream exposes `Picture.image` returning an `Image` with
|
|
344
|
+
`.blob` bytes. The REST SDK can't materialize bytes locally, so `.image`
|
|
345
|
+
stays `None` (see the omissions list above). **Additionally**, when a
|
|
346
|
+
picture is loaded from a server snapshot it now carries `image_url`: a
|
|
347
|
+
stable `{base_url}/media?key=<assetId>` URL that streams the picture's
|
|
348
|
+
bytes. It returns `None` for pictures with no backing media asset (an
|
|
349
|
+
SDK-added picture not yet flushed/refreshed, a placeholder with no image,
|
|
350
|
+
or exotic sources like OLE/movie posters that lack a top-level asset id).
|
|
351
|
+
|
|
352
|
+
The URL is owner/EDIT-scoped (the `/media` route runs the same deck-access
|
|
353
|
+
check as every other deck op), and `add_picture` auto-attaches the
|
|
354
|
+
sandbox bearer for Athena hosts — so it composes directly into the
|
|
355
|
+
canonical "copy every image from deck A into deck B" recipe:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
src = Presentation(asset_id="asset_source")
|
|
359
|
+
dst = Presentation(asset_id="asset_dest")
|
|
360
|
+
for i, slide in enumerate(src.slides):
|
|
361
|
+
for pic in slide.shapes.get_images():
|
|
362
|
+
if pic.image_url:
|
|
363
|
+
dst.slides[i].shapes.add_picture(
|
|
364
|
+
pic.image_url, pic.left, pic.top, pic.width, pic.height
|
|
365
|
+
)
|
|
366
|
+
dst.save()
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Portable code that needs to run against stock python-pptx cannot rely
|
|
370
|
+
on `image_url`** — it is an Athena-only accessor.
|
|
371
|
+
|
|
331
372
|
### `name=` kwarg on `add_textbox()` / `add_shape()` / `add_picture()`
|
|
332
373
|
|
|
333
374
|
Optional keyword for setting a stable shape name at creation. python-pptx
|
|
@@ -8,11 +8,11 @@ context manager interface for batch operations.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
__athena_extension_module__: bool = True
|
|
11
|
-
__athena_extension_description__: str = (
|
|
12
|
-
"Command buffer + batching helpers (REST-SDK only)."
|
|
13
|
-
)
|
|
11
|
+
__athena_extension_description__: str = "Command buffer + batching helpers (REST-SDK only)."
|
|
14
12
|
__athena_extension_since__: str = "0.1.0"
|
|
15
13
|
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
16
|
import threading
|
|
17
17
|
from contextlib import contextmanager
|
|
18
18
|
from threading import local
|
|
@@ -20,12 +20,71 @@ from typing import TYPE_CHECKING, Any, Generator, Optional
|
|
|
20
20
|
|
|
21
21
|
from . import _ptc
|
|
22
22
|
from .commands import AnyCommand
|
|
23
|
+
from .errors import BatchPartialError
|
|
23
24
|
from .typing import CommandsResponse
|
|
24
25
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from .client import Client
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
# The SDK serializes every buffered command into one JSON body and POSTs it to
|
|
31
|
+
# ``/decks/:id/commands``. pptx-studio's apps/api (Fastify) caps that body at
|
|
32
|
+
# 32 MiB, so a single oversized flush 413s and silently loses the entire write
|
|
33
|
+
# (text edits + adds alike) because Fastify rejects the body before the route
|
|
34
|
+
# handler runs. AddPicture / SubstitutePlaceholder(picture) carry inline base64
|
|
35
|
+
# image bytes (~4/3 expansion), so a few large images blow past the ceiling.
|
|
36
|
+
# ``CommandBuffer.flush`` therefore splits the buffer into chunks that stay
|
|
37
|
+
# comfortably under that ceiling; the headroom absorbs the request envelope
|
|
38
|
+
# (client/txn/return), JSON structural characters, and multi-byte expansion.
|
|
39
|
+
_DEFAULT_MAX_REQUEST_BYTES = 24 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _max_request_bytes() -> int:
|
|
43
|
+
"""Per-request command-body budget in bytes.
|
|
44
|
+
|
|
45
|
+
``ATHENA_PPTX_MAX_REQUEST_BYTES`` overrides the default when set to a
|
|
46
|
+
positive integer (e.g. for a server configured with a different limit).
|
|
47
|
+
"""
|
|
48
|
+
raw = os.environ.get("ATHENA_PPTX_MAX_REQUEST_BYTES")
|
|
49
|
+
if raw:
|
|
50
|
+
try:
|
|
51
|
+
override = int(raw)
|
|
52
|
+
except ValueError:
|
|
53
|
+
override = 0
|
|
54
|
+
if override > 0:
|
|
55
|
+
return override
|
|
56
|
+
return _DEFAULT_MAX_REQUEST_BYTES
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _command_size_bytes(command: AnyCommand) -> int:
|
|
60
|
+
"""Serialized JSON byte size of one command's contribution to the body."""
|
|
61
|
+
return len(json.dumps(command.to_dict()).encode("utf-8"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _chunk_commands_by_size(commands: list[AnyCommand], max_bytes: int) -> list[list[AnyCommand]]:
|
|
65
|
+
"""Greedily pack ``commands`` into ordered chunks under ``max_bytes``.
|
|
66
|
+
|
|
67
|
+
A single command larger than ``max_bytes`` (e.g. a large inline base64
|
|
68
|
+
image) cannot be split, so it lands alone in its own chunk; that one POST
|
|
69
|
+
may still be rejected by the server, but only that command is at risk
|
|
70
|
+
rather than the entire flush.
|
|
71
|
+
"""
|
|
72
|
+
chunks: list[list[AnyCommand]] = []
|
|
73
|
+
current: list[AnyCommand] = []
|
|
74
|
+
current_bytes = 0
|
|
75
|
+
for command in commands:
|
|
76
|
+
size = _command_size_bytes(command)
|
|
77
|
+
if current and current_bytes + size > max_bytes:
|
|
78
|
+
chunks.append(current)
|
|
79
|
+
current = []
|
|
80
|
+
current_bytes = 0
|
|
81
|
+
current.append(command)
|
|
82
|
+
current_bytes += size
|
|
83
|
+
if current:
|
|
84
|
+
chunks.append(current)
|
|
85
|
+
return chunks
|
|
86
|
+
|
|
87
|
+
|
|
29
88
|
# Only commands whose class name is in this set produce PTC sub-tool-cards.
|
|
30
89
|
# Every Command subclass is a candidate, but each emit is now a synchronous
|
|
31
90
|
# HTTP POST to agora (see ``_ptc._send``), so emitting one card per low-level
|
|
@@ -112,20 +171,49 @@ class CommandBuffer:
|
|
|
112
171
|
self._auto_flush_seconds = auto_flush_seconds
|
|
113
172
|
self._flush_timer: Optional[threading.Timer] = None
|
|
114
173
|
self._timer_lock = threading.Lock()
|
|
174
|
+
# Guards every read/append/reassignment of ``_commands`` so the
|
|
175
|
+
# background auto-flush timer thread can't interleave with foreground
|
|
176
|
+
# add/flush/clear calls and drop, duplicate, or reorder buffered
|
|
177
|
+
# commands. Held only for the in-memory buffer ops — NEVER across the
|
|
178
|
+
# network POST in ``flush`` (that would serialize every write behind a
|
|
179
|
+
# multi-second request and let the timer block the foreground).
|
|
180
|
+
self._commands_lock = threading.Lock()
|
|
115
181
|
# Exception captured by the background auto-flush timer. Re-raised on
|
|
116
182
|
# the next foreground operation (add/flush/refresh/save) so server-
|
|
117
183
|
# rejected commands aren't silently lost.
|
|
118
184
|
self._deferred_error: Optional[BaseException] = None
|
|
185
|
+
# True once a shape-creating command has been BUFFERED without a
|
|
186
|
+
# subsequent refresh. Buffered creates keep their local client id
|
|
187
|
+
# (the proxy is only reconciled to the server id by a full refresh),
|
|
188
|
+
# so this flag tells callers like ``Slide.clear_shapes`` whether a
|
|
189
|
+
# reconciling refresh is actually needed before deleting by id.
|
|
190
|
+
self._unreconciled_creates = False
|
|
119
191
|
|
|
120
192
|
@property
|
|
121
193
|
def is_batching(self) -> bool:
|
|
122
194
|
"""Return True if currently in batch mode."""
|
|
123
195
|
return self._batch_depth > 0
|
|
124
196
|
|
|
197
|
+
@property
|
|
198
|
+
def has_unreconciled_creates(self) -> bool:
|
|
199
|
+
"""True if a shape-creating command was buffered since the last refresh.
|
|
200
|
+
|
|
201
|
+
Buffered creates carry a client id (`shp_<uuid>`) that only reconciles
|
|
202
|
+
to the server id on a full `refresh()`. Synchronously-flushed creates
|
|
203
|
+
reconcile from the response immediately and do not set this flag.
|
|
204
|
+
"""
|
|
205
|
+
return self._unreconciled_creates
|
|
206
|
+
|
|
207
|
+
def mark_creates_reconciled(self) -> None:
|
|
208
|
+
"""Clear the unreconciled-creates flag. Called after a full refresh
|
|
209
|
+
rebuilds every shape proxy from the server snapshot (server ids)."""
|
|
210
|
+
self._unreconciled_creates = False
|
|
211
|
+
|
|
125
212
|
@property
|
|
126
213
|
def pending_count(self) -> int:
|
|
127
214
|
"""Return the number of pending commands."""
|
|
128
|
-
|
|
215
|
+
with self._commands_lock:
|
|
216
|
+
return len(self._commands)
|
|
129
217
|
|
|
130
218
|
@property
|
|
131
219
|
def last_response(self) -> Optional[CommandsResponse]:
|
|
@@ -166,10 +254,17 @@ class CommandBuffer:
|
|
|
166
254
|
except Exception: # noqa: BLE001
|
|
167
255
|
pass
|
|
168
256
|
|
|
169
|
-
self.
|
|
257
|
+
with self._commands_lock:
|
|
258
|
+
self._commands.append(command)
|
|
170
259
|
|
|
171
260
|
if not self._auto_batch and not self.is_batching:
|
|
261
|
+
# Synchronous flush — the caller reconciles the proxy's id from the
|
|
262
|
+
# response, so this create does not leave a stale client id.
|
|
172
263
|
return self.flush()
|
|
264
|
+
# Buffered create: its client id won't reconcile to the server id until
|
|
265
|
+
# the next refresh(), so flag it for clear_shapes et al.
|
|
266
|
+
if getattr(command, "client_id", None) is not None:
|
|
267
|
+
self._unreconciled_creates = True
|
|
173
268
|
# Schedule auto-flush if enabled and not inside explicit batch()
|
|
174
269
|
if self._auto_flush_seconds and self._auto_flush_seconds > 0 and not self.is_batching:
|
|
175
270
|
self._schedule_auto_flush()
|
|
@@ -228,35 +323,99 @@ class CommandBuffer:
|
|
|
228
323
|
Send all buffered commands to the server.
|
|
229
324
|
|
|
230
325
|
Returns:
|
|
231
|
-
Server response, or None if no commands to send
|
|
326
|
+
Server response from the final POST, or None if no commands to send.
|
|
327
|
+
|
|
328
|
+
Large buffers are split into multiple ``/decks/:id/commands`` POSTs so
|
|
329
|
+
no single request exceeds the server's body-size limit (see
|
|
330
|
+
:func:`_max_request_bytes`). Each chunk is applied as its own atomic
|
|
331
|
+
server transaction and chunks are sent in order. No flush requests a
|
|
332
|
+
snapshot — auto-flushes never read it, so building one is wasted
|
|
333
|
+
O(whole-deck) work. A buffer that fits within the budget — the common
|
|
334
|
+
case — is sent as a single atomic POST, unchanged.
|
|
335
|
+
|
|
336
|
+
On POST failure (network, 4xx/5xx, auth, or a ``BatchPartialError``
|
|
337
|
+
for a chunk that committed a prefix), the commands that have not yet
|
|
338
|
+
been applied are restored to ``self._commands`` and the exception
|
|
339
|
+
propagates. Earlier chunks already committed server-side, so the
|
|
340
|
+
restore deliberately excludes them — callers that catch and retry
|
|
341
|
+
resend only the un-applied tail instead of double-applying.
|
|
232
342
|
"""
|
|
233
343
|
self._cancel_timer()
|
|
234
344
|
# Surface a background-flush error before draining the buffer, so
|
|
235
345
|
# the caller sees it instead of a no-op return.
|
|
236
346
|
self._raise_deferred_error()
|
|
237
347
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
self.
|
|
243
|
-
|
|
348
|
+
# Atomically drain the buffer under the lock so a concurrent foreground
|
|
349
|
+
# ``add`` (or a second flush) can't have its append wiped by the
|
|
350
|
+
# ``= []`` below, and two flushes can't both POST the same commands.
|
|
351
|
+
# The POST itself runs OUTSIDE the lock.
|
|
352
|
+
with self._commands_lock:
|
|
353
|
+
if not self._commands:
|
|
354
|
+
return None
|
|
355
|
+
commands = self._commands
|
|
356
|
+
self._commands = []
|
|
357
|
+
|
|
358
|
+
chunks = _chunk_commands_by_size(commands, _max_request_bytes())
|
|
359
|
+
|
|
360
|
+
# A multi-chunk flush cannot be a single atomic server transaction, so
|
|
361
|
+
# validate the whole batch up front: an invalid command must abort the
|
|
362
|
+
# flush before any chunk is applied, preserving all-or-nothing
|
|
363
|
+
# semantics for bad input. Single-chunk flushes skip this —
|
|
364
|
+
# ``post_commands`` validates that one batch before it reaches the wire.
|
|
365
|
+
if len(chunks) > 1:
|
|
366
|
+
try:
|
|
367
|
+
for command in commands:
|
|
368
|
+
command.validate()
|
|
369
|
+
except Exception:
|
|
370
|
+
with self._commands_lock:
|
|
371
|
+
self._commands = commands + self._commands
|
|
372
|
+
_ptc_emit_end_batch(commands, is_error=True)
|
|
373
|
+
raise
|
|
374
|
+
|
|
375
|
+
applied = 0
|
|
244
376
|
try:
|
|
245
|
-
|
|
246
|
-
self.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
377
|
+
for chunk in chunks:
|
|
378
|
+
self._last_response = self._client.post_commands(
|
|
379
|
+
self._deck_id,
|
|
380
|
+
chunk,
|
|
381
|
+
return_snapshot=False,
|
|
382
|
+
)
|
|
383
|
+
applied += len(chunk)
|
|
384
|
+
except BatchPartialError as exc:
|
|
385
|
+
# The failing chunk committed its commands before
|
|
386
|
+
# ``failure_index`` server-side; everything in earlier chunks
|
|
387
|
+
# already committed too. Restore only the unsent suffix — the
|
|
388
|
+
# failing command (at ``applied + exc.failure_index``) and the
|
|
389
|
+
# rest of the buffer — so a retry doesn't re-apply the committed
|
|
390
|
+
# prefix.
|
|
391
|
+
committed = applied + exc.failure_index
|
|
392
|
+
self._restore_unsent(commands[committed:])
|
|
393
|
+
_ptc_emit_end_batch(commands[:committed], is_error=False)
|
|
394
|
+
_ptc_emit_end_batch(commands[committed:], is_error=True)
|
|
395
|
+
raise
|
|
250
396
|
except Exception:
|
|
251
|
-
|
|
397
|
+
# The current chunk's POST failed atomically (network, 4xx/5xx,
|
|
398
|
+
# auth), so nothing in it committed; earlier chunks already did.
|
|
399
|
+
# Restore only the un-applied tail so callers that retry resend
|
|
400
|
+
# the tail instead of double-applying the committed prefix.
|
|
401
|
+
self._restore_unsent(commands[applied:])
|
|
402
|
+
_ptc_emit_end_batch(commands[:applied], is_error=False)
|
|
403
|
+
_ptc_emit_end_batch(commands[applied:], is_error=True)
|
|
252
404
|
raise
|
|
253
405
|
_ptc_emit_end_batch(commands, is_error=False)
|
|
254
406
|
return self._last_response
|
|
255
407
|
|
|
408
|
+
def _restore_unsent(self, commands: list[AnyCommand]) -> None:
|
|
409
|
+
"""Prepend unsent commands back onto the buffer after a failed flush."""
|
|
410
|
+
if commands:
|
|
411
|
+
with self._commands_lock:
|
|
412
|
+
self._commands = commands + self._commands
|
|
413
|
+
|
|
256
414
|
def clear(self) -> None:
|
|
257
415
|
"""Clear all pending commands without sending."""
|
|
258
416
|
self._cancel_timer()
|
|
259
|
-
self.
|
|
417
|
+
with self._commands_lock:
|
|
418
|
+
self._commands = []
|
|
260
419
|
|
|
261
420
|
@contextmanager
|
|
262
421
|
def batch(self) -> Generator[CommandBuffer, None, None]:
|