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.
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/CHANGELOG.md +35 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/PKG-INFO +1 -1
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/__init__.py +1 -1
- athena_python_pptx-0.8.1/pptx/_citations.py +54 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/client.py +9 -7
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/commands.py +98 -21
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/__init__.py +49 -16
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/slides.py +22 -15
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/__init__.py +138 -38
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/typing.py +1 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pyproject.toml +1 -1
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/.gitignore +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/API_PARITY_REPORT.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/CLAUDE.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/DEV-GUIDE.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/PARITY_QUESTIONS.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/PUBLISHING.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/README.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/docs/API_PARITY_EXCEPTIONS.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/docs/athena-api.json +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/docs/athena-api.md +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/_athena_extension.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/_ptc.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/_references.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/action.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/batching.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/axis.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/category.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/chart.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/data.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/datalabel.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/legend.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/marker.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/plot.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/point.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/series.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/chart/xlsx.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/decorators.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/chtfmt.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/color.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/effect.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/fill.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/dml/line.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/docgen.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/action.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/chart.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/dml.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/lang.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/shapes.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/enum/text.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/errors.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/exc.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/media.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/package.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/__init__.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/_base.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/chart.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/coreprops.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/embeddedpackage.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/image.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/media.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/presentation.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/parts/slide.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/presentation.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/autoshape.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/base.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/connector.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/freeform.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/graphfrm.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/group.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/picture.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/placeholder.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shapes/shapetree.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/shared.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/slide.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/spec.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/table.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/fonts.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/layout.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/text/text.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/types.py +0 -0
- {athena_python_pptx-0.7.1 → athena_python_pptx-0.8.1}/pptx/units.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
- **
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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":
|
|
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:
|
|
797
|
-
``id``); the asset the slide is sourced
|
|
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
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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:
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 >
|
|
944
|
-
raise ValidationError("cols must be between 1 and
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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``
|
|
6137
|
-
|
|
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
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
``
|
|
1079
|
-
|
|
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
|
|
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``
|
|
1228
|
-
|
|
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
|
-
|
|
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=
|
|
881
|
-
italic=
|
|
882
|
-
underline=
|
|
883
|
-
size=int(
|
|
884
|
-
name=
|
|
885
|
-
font_ref=
|
|
886
|
-
color_hex=
|
|
887
|
-
spacing_pt=
|
|
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
|
|
899
|
-
|
|
900
|
-
|
|
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.
|
|
903
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "athena-python-pptx"
|
|
7
|
-
version = "0.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|