athena-python-pptx 0.7.1__tar.gz → 0.8.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 (86) hide show
  1. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/CHANGELOG.md +35 -0
  2. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/PKG-INFO +1 -1
  3. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/__init__.py +1 -1
  4. athena_python_pptx-0.8.1/pptx/_citations.py +54 -0
  5. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/client.py +9 -7
  6. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/commands.py +98 -21
  7. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/__init__.py +49 -16
  8. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/slides.py +22 -15
  9. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/__init__.py +138 -38
  10. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/typing.py +1 -0
  11. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pyproject.toml +1 -1
  12. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/.gitignore +0 -0
  13. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/API_PARITY_REPORT.md +0 -0
  14. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/CLAUDE.md +0 -0
  15. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/DEV-GUIDE.md +0 -0
  16. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/PARITY_QUESTIONS.md +0 -0
  17. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/PUBLISHING.md +0 -0
  18. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/README.md +0 -0
  19. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/docs/API_PARITY_EXCEPTIONS.md +0 -0
  20. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/docs/athena-api.json +0 -0
  21. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/docs/athena-api.md +0 -0
  22. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/_athena_extension.py +0 -0
  23. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/_ptc.py +0 -0
  24. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/_references.py +0 -0
  25. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/action.py +0 -0
  26. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/batching.py +0 -0
  27. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/__init__.py +0 -0
  28. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/axis.py +0 -0
  29. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/category.py +0 -0
  30. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/chart.py +0 -0
  31. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/data.py +0 -0
  32. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/datalabel.py +0 -0
  33. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/legend.py +0 -0
  34. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/marker.py +0 -0
  35. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/plot.py +0 -0
  36. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/point.py +0 -0
  37. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/series.py +0 -0
  38. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/xlsx.py +0 -0
  39. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/decorators.py +0 -0
  40. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/__init__.py +0 -0
  41. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/chtfmt.py +0 -0
  42. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/color.py +0 -0
  43. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/effect.py +0 -0
  44. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/fill.py +0 -0
  45. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/line.py +0 -0
  46. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/docgen.py +0 -0
  47. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/__init__.py +0 -0
  48. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/action.py +0 -0
  49. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/chart.py +0 -0
  50. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/dml.py +0 -0
  51. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/lang.py +0 -0
  52. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/shapes.py +0 -0
  53. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/text.py +0 -0
  54. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/errors.py +0 -0
  55. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/exc.py +0 -0
  56. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/media.py +0 -0
  57. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/package.py +0 -0
  58. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/__init__.py +0 -0
  59. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/_base.py +0 -0
  60. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/chart.py +0 -0
  61. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/coreprops.py +0 -0
  62. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/embeddedpackage.py +0 -0
  63. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/image.py +0 -0
  64. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/media.py +0 -0
  65. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/presentation.py +0 -0
  66. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/slide.py +0 -0
  67. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/presentation.py +0 -0
  68. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/autoshape.py +0 -0
  69. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/base.py +0 -0
  70. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/connector.py +0 -0
  71. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/freeform.py +0 -0
  72. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/graphfrm.py +0 -0
  73. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/group.py +0 -0
  74. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/picture.py +0 -0
  75. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/placeholder.py +0 -0
  76. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/shapetree.py +0 -0
  77. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shared.py +0 -0
  78. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/slide.py +0 -0
  79. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/spec.py +0 -0
  80. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/table.py +0 -0
  81. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/fonts.py +0 -0
  82. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/layout.py +0 -0
  83. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/text.py +0 -0
  84. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/types.py +0 -0
  85. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/units.py +0 -0
  86. {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/util.py +0 -0
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to `athena-python-pptx` are documented in this file.
4
4
 
5
+ ## 0.8.0 — Surgical run text + faithful style hydration
6
+
7
+ Closes the "agent edited deck text and the styling vanished" class of bugs
8
+ (font size/weight/color jumping to the renderer's 18pt default after a text
9
+ rewrite).
10
+
11
+ **`run.text = …` is now surgical.** The setter (and the run text helpers
12
+ `upper/lower/capitalize/title/strip/clear/replace`) emits the new
13
+ `SetRunText` command — the server updates just that run's text, leaving its
14
+ style, hyperlink, sibling runs, and paragraph properties untouched — instead
15
+ of re-serializing the entire text frame from local state. Setting text on a
16
+ freshly `add_run()`-appended run appends server-side inheriting the previous
17
+ run's style (continue-typing semantics). Requires a pptx-studio deployment
18
+ that knows `SetRunText`; set `ATHENA_PPTX_LEGACY_RUN_TEXT=1` to restore the
19
+ old whole-frame re-emit against older servers.
20
+
21
+ **Snapshot hydration is now faithful.** `Run` hydration previously mapped
22
+ only 8 style keys (bold/italic/underline/fontSizePt/fontFamily/fontRef/
23
+ colorHex/spacingPt) — any full-frame re-emit (`paragraph.text`,
24
+ `add_paragraph()`, pre-0.8 `run.text`) silently stripped everything else
25
+ from every run in the shape. Now:
26
+
27
+ - `schemeColor`/`brightness` hydrate into the color API —
28
+ `run.font.color.theme_color` / `.brightness` return the ingested values.
29
+ - `strike`/`strikethrough`, `subscript`/`superscript`/`baseline`, and
30
+ `languageId` hydrate into their typed fields.
31
+ - Everything else the server sends (`cap`, `highlightColorHex`,
32
+ `highlightSchemeColor`, future keys) is carried verbatim in
33
+ `Font._extra_style` and reproduced by re-serialization.
34
+
35
+ Pairs with the server-side `applySetText` preservation upgrade (same PR):
36
+ unchanged lines keep their exact runs, rewritten lines keep the replaced
37
+ line's explicit style, and new lines continue the surrounding style instead
38
+ of collapsing to the 18pt default.
39
+
5
40
  ## 0.7.1 — CommandBuffer thread-safety
6
41
 
7
42
  Fixes a data-loss/duplication race in `CommandBuffer`. `self._commands` was
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.7.1
3
+ Version: 0.8.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
@@ -133,7 +133,7 @@ def flush_all() -> None:
133
133
  _active_buffers[:] = alive
134
134
 
135
135
 
136
- __version__ = "0.7.1"
136
+ __version__ = "0.8.1"
137
137
 
138
138
  __all__ = [
139
139
  # Main entry point
@@ -0,0 +1,54 @@
1
+ """Helpers for SDK-native Athena citations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ _ATHENA_SPACES_HOST_SUFFIXES: tuple[str, ...] = (
8
+ "athenaintel.com",
9
+ "athenaintelligence.ai",
10
+ )
11
+
12
+
13
+ def _is_athena_spaces_host(hostname: str | None) -> bool:
14
+ host = (hostname or "").lower()
15
+ return any(
16
+ host == suffix or host.endswith(f".{suffix}")
17
+ for suffix in _ATHENA_SPACES_HOST_SUFFIXES
18
+ )
19
+
20
+
21
+ def normalize_citation_string(citation_string: str) -> str:
22
+ value = citation_string.strip()
23
+ if not value:
24
+ raise ValueError("citation_string must not be empty")
25
+
26
+ parsed = urlparse(value)
27
+ if parsed.scheme in ("http", "https") and parsed.netloc:
28
+ if parsed.path.rstrip("/") == "/dashboard/spaces":
29
+ if not _is_athena_spaces_host(parsed.hostname):
30
+ raise ValueError(
31
+ "absolute Athena Spaces citation URLs must use an Athena host"
32
+ )
33
+ asset_ids = parse_qs(parsed.query).get("asset_ids")
34
+ if not asset_ids or not asset_ids[0]:
35
+ raise ValueError("Athena Spaces citation URLs must include asset_ids")
36
+ return value
37
+
38
+ if not parsed.scheme and value.startswith("/"):
39
+ relative = urlparse(value)
40
+ if relative.netloc:
41
+ raise ValueError(
42
+ "protocol-relative Athena Spaces citation URLs are not allowed; "
43
+ "use an absolute Athena host"
44
+ )
45
+ if relative.path.rstrip("/") == "/dashboard/spaces":
46
+ asset_ids = parse_qs(relative.query).get("asset_ids")
47
+ if asset_ids and asset_ids[0]:
48
+ return value
49
+ raise ValueError("Athena Spaces citation URLs must include asset_ids")
50
+
51
+ raise ValueError(
52
+ "citation_string must be a serialized citation URL, such as "
53
+ "'/dashboard/spaces/?asset_ids=...' or an 'https://...' URL"
54
+ )
@@ -129,9 +129,13 @@ class Client:
129
129
  duplicate slides/shapes/cells). This mirrors docx-studio's
130
130
  ``_build_retry_session`` policy:
131
131
 
132
- - **Status-only retries** (``connect``/``read``/``other`` disabled).
133
- A transport drop *after* the server processed the batch must never
134
- trigger an automatic resend.
132
+ - **Connect retries** (``connect=2``) are enabled because a TCP
133
+ connection failure means no data ever reached the server — zero
134
+ risk of double-applying commands. Critical during Kubernetes
135
+ rolling deployments where old pods stop accepting connections.
136
+ - **Read retries** remain disabled (``read=0``): a transport drop
137
+ *after* the server processed the batch must never trigger an
138
+ automatic resend.
135
139
  - **Retry only on transient statuses raised before any commit**
136
140
  (429/502/503/504). ``500`` is intentionally removed — a 500 from
137
141
  ``/commands`` can mean the batch was applied and then
@@ -140,16 +144,14 @@ class Client:
140
144
  concurrent agents don't thunder-herd a recovering pod.
141
145
 
142
146
  Idempotent reads (GET/HEAD) and DELETE remain in ``allowed_methods``
143
- so they still retry on those transient statuses; disabling
144
- connect/read retries only costs a resend on a mid-flight socket drop,
145
- which is the safe trade.
147
+ so they still retry on those transient statuses.
146
148
  """
147
149
  session = requests.Session()
148
150
 
149
151
  retry_kwargs: dict[str, Any] = {
150
152
  "total": None,
151
153
  "status": 3,
152
- "connect": 0,
154
+ "connect": 2,
153
155
  "read": 0,
154
156
  "other": 0,
155
157
  "backoff_factor": 0.5,
@@ -15,6 +15,7 @@ __athena_extension_since__: str = "0.1.0"
15
15
 
16
16
  from dataclasses import dataclass, field, asdict
17
17
  from typing import Any, Optional, Union
18
+ from ._citations import normalize_citation_string
18
19
  from .errors import ValidationError
19
20
  from .typing import ShapeId, SlideId, TextRunPath, TextStyle
20
21
 
@@ -193,6 +194,38 @@ class SetRunStyle(Command):
193
194
  raise ValidationError("path must contain 'p' and 'r'", "path")
194
195
 
195
196
 
197
+ @dataclass
198
+ class SetRunText(Command):
199
+ """
200
+ Surgically set the text of a single run.
201
+
202
+ Unlike ``SetText`` (which replaces the whole text frame), this leaves the
203
+ run's style, hyperlink, sibling runs, and paragraph properties untouched
204
+ server-side. When ``path.r`` equals the paragraph's current run count the
205
+ server appends a run inheriting the previous run's style, matching the
206
+ ``add_run()`` + ``run.text = ...`` flow.
207
+
208
+ Args:
209
+ shape_id: ID of the shape containing the text
210
+ path: Path to the text run (paragraph index, run index)
211
+ text: New text content for the run
212
+ """
213
+
214
+ shape_id: ShapeId
215
+ path: TextRunPath
216
+ text: str
217
+
218
+ @property
219
+ def command_type(self) -> str:
220
+ return "SetRunText"
221
+
222
+ def validate(self) -> None:
223
+ if not self.shape_id:
224
+ raise ValidationError("shape_id is required", "shape_id")
225
+ if "p" not in self.path or "r" not in self.path:
226
+ raise ValidationError("path must contain 'p' and 'r'", "path")
227
+
228
+
196
229
  @dataclass
197
230
  class AddSlide(Command):
198
231
  """
@@ -772,6 +805,45 @@ class AddLinkedTable(Command):
772
805
  )
773
806
 
774
807
 
808
+ def _validate_citation_string(citation_string: str) -> None:
809
+ try:
810
+ normalize_citation_string(citation_string)
811
+ except ValueError as exc:
812
+ raise ValidationError(str(exc), "citation_string") from exc
813
+
814
+
815
+ def _validate_citation_source(
816
+ *,
817
+ source_ref: Optional[dict],
818
+ source_anchor: Optional[dict],
819
+ citation_string: Optional[str],
820
+ ) -> None:
821
+ if source_ref is None and citation_string is None:
822
+ raise ValidationError(
823
+ "source_ref or citation_string is required",
824
+ "source_ref",
825
+ )
826
+ if source_ref is not None and citation_string is not None:
827
+ raise ValidationError(
828
+ "source_ref and citation_string are mutually exclusive",
829
+ "citation_string",
830
+ )
831
+ if source_ref is None and source_anchor is not None:
832
+ raise ValidationError(
833
+ "source_anchor requires source_ref; encode the anchor in citation_string instead",
834
+ "source_anchor",
835
+ )
836
+ if source_ref is not None and (
837
+ not isinstance(source_ref, dict) or not source_ref.get("id")
838
+ ):
839
+ raise ValidationError(
840
+ "source_ref must be an AssetReference dict with an 'id' field",
841
+ "source_ref",
842
+ )
843
+ if citation_string is not None:
844
+ _validate_citation_string(citation_string)
845
+
846
+
775
847
  @dataclass
776
848
  class AddSlideCitation(Command):
777
849
  """Attach an Athena source citation to a whole slide.
@@ -793,19 +865,20 @@ class AddSlideCitation(Command):
793
865
  resolves the stable ``slideId`` from it and stores the resulting
794
866
  ``destinationAnchor.slideIndex`` as 1-based (display "Slide N") — do
795
867
  not read this wire value as a 1-based position.
796
- source_ref: Source ``AssetReference`` JSON (``referenced='asset'`` +
797
- ``id``); the asset the slide is sourced from.
868
+ source_ref: Optional source ``AssetReference`` JSON
869
+ (``referenced='asset'`` + ``id``); the asset the slide is sourced
870
+ from. Omit when passing a fully serialized ``citation_string``.
798
871
  source_anchor: Optional ``Anchor`` JSON within the source (e.g. a
799
872
  spreadsheet range); omit to cite the whole asset.
800
873
  display_value: Optional human-readable badge label.
801
- citation_string: Optional pre-serialized source Spaces URL; the
802
- server derives it from ``source_ref`` + ``source_anchor`` when
803
- unset.
874
+ citation_string: Optional pre-serialized source Spaces URL. Prefer
875
+ this for sources/anchors the SDK does not model directly, such as
876
+ video/audio time ranges or external URLs.
804
877
  client_id: Client-provided ID (optional).
805
878
  """
806
879
 
807
880
  slide_index: int
808
- source_ref: dict
881
+ source_ref: Optional[dict] = None
809
882
  source_anchor: Optional[dict] = None
810
883
  display_value: Optional[str] = None
811
884
  citation_string: Optional[str] = None
@@ -818,11 +891,11 @@ class AddSlideCitation(Command):
818
891
  def validate(self) -> None:
819
892
  if self.slide_index < 0:
820
893
  raise ValidationError("slide_index must be non-negative", "slide_index")
821
- if not isinstance(self.source_ref, dict) or not self.source_ref.get("id"):
822
- raise ValidationError(
823
- "source_ref must be an AssetReference dict with an 'id' field",
824
- "source_ref",
825
- )
894
+ _validate_citation_source(
895
+ source_ref=self.source_ref,
896
+ source_anchor=self.source_anchor,
897
+ citation_string=self.citation_string,
898
+ )
826
899
 
827
900
 
828
901
  @dataclass
@@ -844,16 +917,19 @@ class AddShapeCitation(Command):
844
917
  the resulting ``destinationAnchor.slideIndex`` as 1-based (display
845
918
  "Slide N") — do not read this wire value as a 1-based position.
846
919
  shape_ids: Non-empty list of shape ids on that slide to cite.
847
- source_ref: Source ``AssetReference`` JSON.
920
+ source_ref: Optional source ``AssetReference`` JSON. Omit when passing
921
+ a fully serialized ``citation_string``.
848
922
  source_anchor: Optional ``Anchor`` JSON within the source.
849
923
  display_value: Optional human-readable badge label.
850
- citation_string: Optional pre-serialized source Spaces URL.
924
+ citation_string: Optional pre-serialized source Spaces URL. Prefer
925
+ this for sources/anchors the SDK does not model directly, such as
926
+ video/audio time ranges or external URLs.
851
927
  client_id: Client-provided ID (optional).
852
928
  """
853
929
 
854
930
  slide_index: int
855
931
  shape_ids: list[str]
856
- source_ref: dict
932
+ source_ref: Optional[dict] = None
857
933
  source_anchor: Optional[dict] = None
858
934
  display_value: Optional[str] = None
859
935
  citation_string: Optional[str] = None
@@ -868,11 +944,11 @@ class AddShapeCitation(Command):
868
944
  raise ValidationError("slide_index must be non-negative", "slide_index")
869
945
  if not self.shape_ids:
870
946
  raise ValidationError("shape_ids must be non-empty", "shape_ids")
871
- if not isinstance(self.source_ref, dict) or not self.source_ref.get("id"):
872
- raise ValidationError(
873
- "source_ref must be an AssetReference dict with an 'id' field",
874
- "source_ref",
875
- )
947
+ _validate_citation_source(
948
+ source_ref=self.source_ref,
949
+ source_anchor=self.source_anchor,
950
+ citation_string=self.citation_string,
951
+ )
876
952
 
877
953
 
878
954
  @dataclass
@@ -940,8 +1016,8 @@ class AddTable(Command):
940
1016
  raise ValidationError("slide_index must be non-negative", "slide_index")
941
1017
  if self.rows < 1 or self.rows > 100:
942
1018
  raise ValidationError("rows must be between 1 and 100", "rows")
943
- if self.cols < 1 or self.cols > 26:
944
- raise ValidationError("cols must be between 1 and 26", "cols")
1019
+ if self.cols < 1 or self.cols > 35:
1020
+ raise ValidationError("cols must be between 1 and 35", "cols")
945
1021
  if self.w_emu < 0:
946
1022
  raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
947
1023
  if self.h_emu < 0:
@@ -3226,6 +3302,7 @@ AnyCommand = Union[
3226
3302
  SetText,
3227
3303
  SetTransform,
3228
3304
  SetRunStyle,
3305
+ SetRunText,
3229
3306
  AddSlide,
3230
3307
  DeleteSlide,
3231
3308
  DeleteShape,
@@ -14,6 +14,7 @@ from urllib.parse import quote, urlparse
14
14
 
15
15
  import requests
16
16
 
17
+ from .._citations import normalize_citation_string
17
18
  from ..commands import (
18
19
  AddTextBox, DeleteShape, SetTransform, AddShape as AddShapeCmd,
19
20
  SetShapeStyle, AddPicture as AddPictureCmd, AddOleObject as AddOleObjectCmd,
@@ -90,25 +91,49 @@ def _is_athena_url(url: str) -> bool:
90
91
 
91
92
  def _build_citation_wire(
92
93
  *,
93
- source: "AssetReference",
94
+ source: "AssetReference | str | None",
94
95
  anchor: Optional["Anchor"],
95
- ) -> tuple[dict, Optional[dict], None]:
96
+ citation_string: Optional[str] = None,
97
+ ) -> tuple[Optional[dict], Optional[dict], Optional[str]]:
96
98
  """Build the ``(source_ref, source_anchor, citation_string)`` wire triple
97
99
  for a citation command (Athena extension).
98
100
 
99
- Type-checks ``source`` and duck-types the optional ``anchor`` exactly as
100
- ``add_linked_ole_object`` does, then serializes both to their canonical
101
- wire dicts. ``citation_string`` is left ``None`` so the studio server
102
- derives the Spaces URL from ``source_ref`` + ``source_anchor`` — keeping a
103
- single canonical serializer (matching agora's output) rather than a second
104
- SDK-side one.
101
+ ``source`` can be a typed ``AssetReference`` for SDK-modeled assets or a
102
+ serialized citation URL for arbitrary sources/anchors. When a string source
103
+ is used, the anchor must already be encoded into that URL.
105
104
  """
106
105
  from .._references import AssetReference
107
106
 
108
- if not isinstance(source, AssetReference):
107
+ if isinstance(source, str):
108
+ if (
109
+ citation_string is not None
110
+ and normalize_citation_string(citation_string)
111
+ != normalize_citation_string(source)
112
+ ):
113
+ raise ValueError("Pass citation_string either positionally or by keyword, not both")
114
+ citation_string = source
115
+ source_ref = None
116
+ elif source is None:
117
+ source_ref = None
118
+ elif isinstance(source, AssetReference):
119
+ if citation_string is not None:
120
+ raise ValueError("Pass either an AssetReference source or citation_string, not both")
121
+ source_ref = source.to_dict()
122
+ else:
109
123
  raise TypeError(
110
- f"source must be an AssetReference; got {type(source).__name__}",
124
+ "source must be an AssetReference, citation string, or None; got "
125
+ f"{type(source).__name__}",
111
126
  )
127
+ normalized_citation_string = (
128
+ normalize_citation_string(citation_string)
129
+ if citation_string is not None
130
+ else None
131
+ )
132
+ if source_ref is None and normalized_citation_string is None:
133
+ raise ValueError("source or citation_string is required")
134
+ if source_ref is None and anchor is not None:
135
+ raise ValueError("anchor requires an AssetReference source; encode the anchor in citation_string instead")
136
+
112
137
  source_anchor: Optional[dict] = None
113
138
  if anchor is not None:
114
139
  if not hasattr(anchor, "to_dict"):
@@ -118,7 +143,7 @@ def _build_citation_wire(
118
143
  f"{type(anchor).__name__}",
119
144
  )
120
145
  source_anchor = anchor.to_dict()
121
- return source.to_dict(), source_anchor, None
146
+ return source_ref, source_anchor, normalized_citation_string
122
147
 
123
148
 
124
149
  def _add_citation_command(buffer: Optional["CommandBuffer"], cmd: Any) -> str:
@@ -6118,10 +6143,11 @@ class Shape:
6118
6143
 
6119
6144
  def add_citation(
6120
6145
  self,
6121
- source: "AssetReference",
6146
+ source: "AssetReference | str | None" = None,
6122
6147
  *,
6123
6148
  anchor: Optional["Anchor"] = None,
6124
6149
  display_value: Optional[str] = None,
6150
+ citation_string: Optional[str] = None,
6125
6151
  ) -> str:
6126
6152
  """Attach an Athena source citation to this shape.
6127
6153
 
@@ -6133,18 +6159,24 @@ class Shape:
6133
6159
  user-authored link.
6134
6160
 
6135
6161
  Args:
6136
- source: Source ``AssetReference`` from ``pptx._references`` — the
6137
- asset this shape is sourced from.
6162
+ source: Source ``AssetReference`` from ``pptx._references`` or a
6163
+ serialized citation URL such as
6164
+ ``"/dashboard/spaces/?asset_ids=..."``.
6138
6165
  anchor: Optional ``Anchor`` within the source (e.g.
6139
6166
  ``SheetRangeAnchor``); omit to cite the whole asset.
6140
6167
  display_value: Optional human-readable badge label.
6168
+ citation_string: Optional serialized citation URL. Prefer this for
6169
+ sources/anchors the SDK does not model directly, such as
6170
+ video/audio time ranges or external URLs.
6141
6171
 
6142
6172
  Returns:
6143
6173
  The created citation id (``citation_<...>``).
6144
6174
 
6145
6175
  Raises:
6146
- TypeError: if ``source`` is not an ``AssetReference`` or ``anchor``
6147
- isn't an Anchor dataclass.
6176
+ TypeError: if ``source`` is not an ``AssetReference``/string/None
6177
+ or ``anchor`` isn't an Anchor dataclass.
6178
+ ValueError: if neither ``source`` nor ``citation_string`` is
6179
+ provided, or a string citation is malformed.
6148
6180
 
6149
6181
  Note (Athena extension):
6150
6182
  This method is NOT part of python-pptx. See
@@ -6155,6 +6187,7 @@ class Shape:
6155
6187
  source_ref, source_anchor, citation_string = _build_citation_wire(
6156
6188
  source=source,
6157
6189
  anchor=anchor,
6190
+ citation_string=citation_string,
6158
6191
  )
6159
6192
  cmd = AddShapeCitationCmd(
6160
6193
  slide_index=self._slide.slide_index,
@@ -1066,6 +1066,14 @@ class Slide:
1066
1066
  def clear_shapes(self) -> int:
1067
1067
  """Delete every slide-local shape on this slide.
1068
1068
 
1069
+ .. warning::
1070
+ This permanently deletes user-drawn slide content — rectangles,
1071
+ annotations, images, and anything else collaborators added that
1072
+ is not part of a template. Do not use it to "reset" a slide
1073
+ before editing: edit shapes in place instead (set placeholder
1074
+ text, add the shapes you need). Only call ``clear_shapes`` when
1075
+ the user explicitly asked for the slide to be cleared.
1076
+
1069
1077
  Athena extension — python-pptx has no equivalent. Targets only
1070
1078
  slide-local shapes (the ones ``slide.shapes`` iterates). Inherited
1071
1079
  layout / master placeholders are left untouched because they
@@ -1073,13 +1081,10 @@ class Slide:
1073
1081
  server's ``DeleteShape`` handler raises for inherited ids, which
1074
1082
  would derail the whole batch.
1075
1083
 
1076
- Useful when iterating on a slide design and you want a blank
1077
- canvas to rebuild from — the previous workaround was a manual
1078
- ``for shape in slide.shapes: shape.delete()`` loop, which could
1079
- leave shapes layered on top of each other if any single delete
1080
- failed silently. ``clear_shapes`` queues every delete in one
1081
- batched ``post_commands`` call so partial failures surface
1082
- immediately as a :class:`RemoteError`.
1084
+ Compared with a manual ``for shape in slide.shapes: shape.delete()``
1085
+ loop, ``clear_shapes`` queues every delete in one batched
1086
+ ``post_commands`` call, so partial failures surface immediately as a
1087
+ :class:`RemoteError` instead of silently leaving some shapes behind.
1083
1088
 
1084
1089
  If shapes were created earlier in the session, ``clear_shapes``
1085
1090
  refreshes from the server snapshot first so every targeted
@@ -1087,16 +1092,12 @@ class Slide:
1087
1092
  client id. Buffered ``add_*`` calls keep their local ``shp_<uuid>``
1088
1093
  id until a refresh reconciles them; deleting by that client id makes
1089
1094
  the server raise ``Shape not found`` and roll back the whole atomic
1090
- batch (including any rebuild queued in the same session). The
1095
+ batch (including any other commands queued in the same session). The
1091
1096
  refresh is skipped inside an open ``with prs.batch()`` so it doesn't
1092
1097
  flush a partial user batch.
1093
1098
 
1094
1099
  Returns:
1095
1100
  The number of shapes queued for deletion.
1096
-
1097
- Example:
1098
- slide.clear_shapes()
1099
- slide.shapes.add_textbox(Inches(1), Inches(1), Inches(8), Inches(2))
1100
1101
  """
1101
1102
  from .commands import DeleteShape
1102
1103
 
@@ -1209,10 +1210,11 @@ class Slide:
1209
1210
  )
1210
1211
  def add_citation(
1211
1212
  self,
1212
- source: "AssetReference",
1213
+ source: "AssetReference | str | None" = None,
1213
1214
  *,
1214
1215
  anchor: Optional["Anchor"] = None,
1215
1216
  display_value: Optional[str] = None,
1217
+ citation_string: Optional[str] = None,
1216
1218
  ) -> str:
1217
1219
  """Attach an Athena source citation to this whole slide.
1218
1220
 
@@ -1224,11 +1226,15 @@ class Slide:
1224
1226
  user-authored link.
1225
1227
 
1226
1228
  Args:
1227
- source: Source ``AssetReference`` from ``pptx._references`` — the
1228
- asset this slide is sourced from.
1229
+ source: Source ``AssetReference`` from ``pptx._references`` or a
1230
+ serialized citation URL such as
1231
+ ``"/dashboard/spaces/?asset_ids=..."``.
1229
1232
  anchor: Optional ``Anchor`` within the source (e.g.
1230
1233
  ``SheetRangeAnchor``); omit to cite the whole asset.
1231
1234
  display_value: Optional human-readable badge label.
1235
+ citation_string: Optional serialized citation URL. Prefer this for
1236
+ sources/anchors the SDK does not model directly, such as
1237
+ video/audio time ranges or external URLs.
1232
1238
 
1233
1239
  Returns:
1234
1240
  The created citation id (``citation_<...>``).
@@ -1243,6 +1249,7 @@ class Slide:
1243
1249
  source_ref, source_anchor, citation_string = _build_citation_wire(
1244
1250
  source=source,
1245
1251
  anchor=anchor,
1252
+ citation_string=citation_string,
1246
1253
  )
1247
1254
  cmd = AddSlideCitation(
1248
1255
  slide_index=self._slide_index,
@@ -5,14 +5,16 @@ Provides python-pptx-compatible TextFrame, Paragraph, and Run abstractions.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
8
+
9
+ import os
8
10
  from typing import TYPE_CHECKING, Any, Iterator, Optional, Union
9
11
 
10
12
  from ..commands import (
11
- SetText, SetRunStyle, SetParagraphStyle, SetTextFrameProperties, FitText,
13
+ SetText, SetRunStyle, SetRunText, SetParagraphStyle, SetTextFrameProperties, FitText,
12
14
  # Tier A — most-requested upstream features (this wave)
13
15
  SetRunHyperlinkTarget, MeasureTextFit, ReplaceText,
14
16
  )
15
- from ..dml.color import RGBColor, ColorFormat, theme_color_to_scheme_name
17
+ from ..dml.color import RGBColor, ColorFormat, scheme_name_to_theme_color, theme_color_to_scheme_name
16
18
  from ..errors import UnsupportedFeatureError
17
19
  from ..typing import ShapeId, TextStyle, TextFramePropertiesSnapshot
18
20
  from .._athena_extension import athena_extension
@@ -33,6 +35,34 @@ class PP_ALIGN:
33
35
  THAI_DISTRIBUTE = 'justify'
34
36
 
35
37
 
38
+ # Wire style keys the SDK hydrates into typed Font state. Anything else the
39
+ # server sends (``cap``, ``highlightColorHex``, ``highlightSchemeColor``,
40
+ # future keys) is carried verbatim in ``Font._extra_style`` so full-frame
41
+ # re-emits (run.text / paragraph.text / add_paragraph) don't silently strip
42
+ # properties the SDK doesn't model.
43
+ _TYPED_WIRE_STYLE_KEYS = frozenset({
44
+ "bold", "italic", "underline", "fontSizePt", "fontFamily", "fontRef",
45
+ "colorHex", "spacingPt", "strike", "strikethrough", "baseline",
46
+ "subscript", "superscript", "languageId", "schemeColor", "brightness",
47
+ })
48
+
49
+
50
+ def _baseline_from_wire(style: dict) -> Optional[int]:
51
+ """Derive the rPr ``baseline`` value from whichever wire spelling is present.
52
+
53
+ The studio normalizes the SDK's ``baseline`` into ``subscript`` /
54
+ ``superscript`` booleans on write, so snapshots can carry either form.
55
+ """
56
+ baseline = style.get("baseline")
57
+ if baseline is not None:
58
+ return int(baseline)
59
+ if style.get("subscript"):
60
+ return -25000 # python-pptx's default subscript baseline
61
+ if style.get("superscript"):
62
+ return 30000 # python-pptx's default superscript baseline
63
+ return None
64
+
65
+
36
66
  class _FontColorFormat(ColorFormat):
37
67
  """
38
68
  ColorFormat subclass that notifies the Font when color changes.
@@ -224,6 +254,11 @@ class Font:
224
254
  self._language_id: Optional[int] = (
225
255
  int(language_id) if language_id is not None else None
226
256
  )
257
+ # Wire style properties hydrated from the server that the SDK doesn't
258
+ # model as typed fields (``cap``, highlights, …). Carried verbatim so
259
+ # ``_build_style_dict`` reproduces them on full-frame re-emits instead
260
+ # of stripping them from the deck.
261
+ self._extra_style: dict[str, Any] = {}
227
262
  # Initialize color format with callback
228
263
  rgb = RGBColor.from_string(color_hex) if color_hex else None
229
264
  self._color_format = _FontColorFormat(self, rgb=rgb)
@@ -441,9 +476,36 @@ class Font:
441
476
  self._theme_brightness = brightness if brightness != 0 else None
442
477
  self._emit_style_change()
443
478
 
479
+ def _hydrate_theme_color(
480
+ self, scheme_name: Optional[str], brightness: Optional[float]
481
+ ) -> None:
482
+ """Seed theme-color state from a snapshot style without emitting.
483
+
484
+ Keeps the read path faithful (``font.color.theme_color`` /
485
+ ``color.brightness`` return the ingested values) and keeps full-frame
486
+ re-emits from stripping a theme-colored run down to the renderer
487
+ default.
488
+ """
489
+ if scheme_name is None:
490
+ return
491
+ self._theme_color_name = scheme_name
492
+ if brightness:
493
+ self._theme_brightness = brightness
494
+ theme_color = scheme_name_to_theme_color(scheme_name)
495
+ if theme_color is not None:
496
+ self._color_format._theme_color = theme_color
497
+ if brightness:
498
+ self._color_format._brightness = brightness
499
+
444
500
  def _build_style_dict(self) -> dict:
445
- """Return a dict of non-None style properties for serialization."""
446
- style: dict = {}
501
+ """Return a dict of non-None style properties for serialization.
502
+
503
+ Starts from ``_extra_style`` — hydrated wire properties the SDK
504
+ doesn't model — then overlays the typed fields, so a full-frame
505
+ re-emit reproduces the run's stored style rather than a lossy
506
+ subset of it.
507
+ """
508
+ style: dict = dict(self._extra_style)
447
509
  if self._color_hex is not None:
448
510
  style["colorHex"] = self._color_hex
449
511
  if self._theme_color_name is not None:
@@ -875,17 +937,38 @@ class Run:
875
937
  self._paragraph = paragraph
876
938
  self._index = index
877
939
  self._text = text
940
+ style_dict: dict[str, Any] = dict(style) if style else {}
941
+ strike = style_dict.get("strike")
942
+ if strike is None:
943
+ # The studio stores the normalized ``strikethrough`` spelling.
944
+ # Re-serialization emits the wire spelling ``strike``; the studio's
945
+ # ``normalizeRunStyleWireFields`` maps it back to ``strikethrough``
946
+ # at write time, so the stored spelling stays stable across
947
+ # hydrate → re-emit round-trips.
948
+ strike = style_dict.get("strikethrough")
878
949
  self._font = Font(
879
950
  self,
880
- bold=style.get("bold") if style else None,
881
- italic=style.get("italic") if style else None,
882
- underline=style.get("underline") if style else None,
883
- size=int(style.get("fontSizePt", 0) * 12700) if style and style.get("fontSizePt") else None,
884
- name=style.get("fontFamily") if style else None,
885
- font_ref=style.get("fontRef") if style else None,
886
- color_hex=style.get("colorHex") if style else None,
887
- spacing_pt=style.get("spacingPt") if style else None,
951
+ bold=style_dict.get("bold"),
952
+ italic=style_dict.get("italic"),
953
+ underline=style_dict.get("underline"),
954
+ size=int(style_dict.get("fontSizePt", 0) * 12700) if style_dict.get("fontSizePt") else None,
955
+ name=style_dict.get("fontFamily"),
956
+ font_ref=style_dict.get("fontRef"),
957
+ color_hex=style_dict.get("colorHex"),
958
+ spacing_pt=style_dict.get("spacingPt"),
959
+ strike=strike,
960
+ baseline=_baseline_from_wire(style_dict),
961
+ language_id=style_dict.get("languageId"),
962
+ )
963
+ self._font._hydrate_theme_color(
964
+ style_dict.get("schemeColor"), style_dict.get("brightness")
888
965
  )
966
+ # Everything the SDK doesn't model as a typed field survives verbatim
967
+ # so re-serialization is faithful to the stored run style.
968
+ self._font._extra_style = {
969
+ key: value for key, value in style_dict.items()
970
+ if key not in _TYPED_WIRE_STYLE_KEYS
971
+ }
889
972
  self._hyperlink = Hyperlink(self, address=hyperlink_address, tooltip=hyperlink_tooltip)
890
973
 
891
974
  @property
@@ -895,12 +978,43 @@ class Run:
895
978
 
896
979
  @text.setter
897
980
  def text(self, value: str) -> None:
898
- """Set text content. Note: This currently sets all text in the shape."""
899
- # For Phase 1, we only support setting full text content
900
- # Individual run edits would require more complex tracking
981
+ """Set text content surgically — only this run's text changes.
982
+
983
+ Emits a ``SetRunText`` command so the run's style, hyperlink, sibling
984
+ runs, and paragraph properties stay untouched in the deck, instead of
985
+ re-serializing the whole text frame from (potentially partially
986
+ hydrated) local state.
987
+ """
901
988
  self._text = value
902
- self._paragraph._text_frame._set_full_text(
903
- self._paragraph._text_frame._get_full_text()
989
+ self._emit_run_text()
990
+
991
+ def _emit_run_text(self) -> None:
992
+ """Emit a surgical ``SetRunText`` for this run.
993
+
994
+ Falls back to the legacy whole-frame re-emit when the run can't be
995
+ located in its text frame's current structure (detached proxy) or
996
+ when ``ATHENA_PPTX_LEGACY_RUN_TEXT`` is set (escape hatch for talking
997
+ to a studio that predates the SetRunText command).
998
+ """
999
+ text_frame = self._paragraph._text_frame
1000
+ if text_frame._buffer is None:
1001
+ return
1002
+ if os.environ.get("ATHENA_PPTX_LEGACY_RUN_TEXT"):
1003
+ text_frame._set_full_text(text_frame._get_full_text())
1004
+ return
1005
+ try:
1006
+ paragraph_index = text_frame._paragraphs.index(self._paragraph)
1007
+ run_index = self._paragraph._runs.index(self)
1008
+ except ValueError:
1009
+ text_frame._set_full_text(text_frame._get_full_text())
1010
+ return
1011
+ text_frame._preview_text = text_frame._get_full_text()
1012
+ text_frame._buffer.add(
1013
+ SetRunText(
1014
+ shape_id=text_frame._shape_id,
1015
+ path={"p": paragraph_index, "r": run_index},
1016
+ text=self._text,
1017
+ )
904
1018
  )
905
1019
 
906
1020
  @property
@@ -978,44 +1092,32 @@ class Run:
978
1092
  def upper(self) -> None:
979
1093
  """Convert run text to uppercase."""
980
1094
  self._text = self._text.upper()
981
- self._paragraph._text_frame._set_full_text(
982
- self._paragraph._text_frame._get_full_text()
983
- )
1095
+ self._emit_run_text()
984
1096
 
985
1097
  def lower(self) -> None:
986
1098
  """Convert run text to lowercase."""
987
1099
  self._text = self._text.lower()
988
- self._paragraph._text_frame._set_full_text(
989
- self._paragraph._text_frame._get_full_text()
990
- )
1100
+ self._emit_run_text()
991
1101
 
992
1102
  def capitalize(self) -> None:
993
1103
  """Capitalize first letter of run text."""
994
1104
  self._text = self._text.capitalize()
995
- self._paragraph._text_frame._set_full_text(
996
- self._paragraph._text_frame._get_full_text()
997
- )
1105
+ self._emit_run_text()
998
1106
 
999
1107
  def title(self) -> None:
1000
1108
  """Convert run text to title case."""
1001
1109
  self._text = self._text.title()
1002
- self._paragraph._text_frame._set_full_text(
1003
- self._paragraph._text_frame._get_full_text()
1004
- )
1110
+ self._emit_run_text()
1005
1111
 
1006
1112
  def strip(self) -> None:
1007
1113
  """Remove leading and trailing whitespace from run text."""
1008
1114
  self._text = self._text.strip()
1009
- self._paragraph._text_frame._set_full_text(
1010
- self._paragraph._text_frame._get_full_text()
1011
- )
1115
+ self._emit_run_text()
1012
1116
 
1013
1117
  def clear(self) -> None:
1014
1118
  """Clear the run's text content."""
1015
1119
  self._text = ""
1016
- self._paragraph._text_frame._set_full_text(
1017
- self._paragraph._text_frame._get_full_text()
1018
- )
1120
+ self._emit_run_text()
1019
1121
 
1020
1122
  def __len__(self) -> int:
1021
1123
  """Return length of text (allows len(run))."""
@@ -1059,9 +1161,7 @@ class Run:
1059
1161
  count = self._text.count(old)
1060
1162
  if count > 0:
1061
1163
  self._text = self._text.replace(old, new)
1062
- self._paragraph._text_frame._set_full_text(
1063
- self._paragraph._text_frame._get_full_text()
1064
- )
1164
+ self._emit_run_text()
1065
1165
  return count
1066
1166
 
1067
1167
  def startswith(self, prefix: str) -> bool:
@@ -107,6 +107,7 @@ CommandType = Literal[
107
107
  "SetText",
108
108
  "SetTransform",
109
109
  "SetRunStyle",
110
+ "SetRunText",
110
111
  "AddSlide",
111
112
  "DeleteSlide",
112
113
  "DeleteShape",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "athena-python-pptx"
7
- version = "0.7.1"
7
+ version = "0.8.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"