athena-python-pptx 0.8.1__tar.gz → 0.9.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.8.1 → athena_python_pptx-0.9.1}/CHANGELOG.md +27 -0
  2. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/PKG-INFO +1 -1
  3. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/docs/API_PARITY_EXCEPTIONS.md +13 -0
  4. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/__init__.py +1 -1
  5. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/batching.py +11 -2
  6. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/client.py +5 -3
  7. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/commands.py +38 -1
  8. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/slides.py +157 -7
  9. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/text/__init__.py +28 -3
  10. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/typing.py +2 -0
  11. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pyproject.toml +1 -1
  12. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/.gitignore +0 -0
  13. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/API_PARITY_REPORT.md +0 -0
  14. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/CLAUDE.md +0 -0
  15. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/DEV-GUIDE.md +0 -0
  16. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/PARITY_QUESTIONS.md +0 -0
  17. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/PUBLISHING.md +0 -0
  18. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/README.md +0 -0
  19. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/docs/athena-api.json +0 -0
  20. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/docs/athena-api.md +0 -0
  21. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/_athena_extension.py +0 -0
  22. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/_citations.py +0 -0
  23. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/_ptc.py +0 -0
  24. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/_references.py +0 -0
  25. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/action.py +0 -0
  26. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/__init__.py +0 -0
  27. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/axis.py +0 -0
  28. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/category.py +0 -0
  29. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/chart.py +0 -0
  30. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/data.py +0 -0
  31. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/datalabel.py +0 -0
  32. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/legend.py +0 -0
  33. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/marker.py +0 -0
  34. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/plot.py +0 -0
  35. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/point.py +0 -0
  36. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/series.py +0 -0
  37. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/chart/xlsx.py +0 -0
  38. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/decorators.py +0 -0
  39. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/dml/__init__.py +0 -0
  40. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/dml/chtfmt.py +0 -0
  41. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/dml/color.py +0 -0
  42. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/dml/effect.py +0 -0
  43. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/dml/fill.py +0 -0
  44. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/dml/line.py +0 -0
  45. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/docgen.py +0 -0
  46. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/__init__.py +0 -0
  47. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/action.py +0 -0
  48. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/chart.py +0 -0
  49. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/dml.py +0 -0
  50. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/lang.py +0 -0
  51. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/shapes.py +0 -0
  52. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/enum/text.py +0 -0
  53. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/errors.py +0 -0
  54. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/exc.py +0 -0
  55. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/media.py +0 -0
  56. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/package.py +0 -0
  57. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/__init__.py +0 -0
  58. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/_base.py +0 -0
  59. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/chart.py +0 -0
  60. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/coreprops.py +0 -0
  61. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/embeddedpackage.py +0 -0
  62. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/image.py +0 -0
  63. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/media.py +0 -0
  64. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/presentation.py +0 -0
  65. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/parts/slide.py +0 -0
  66. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/presentation.py +0 -0
  67. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/__init__.py +0 -0
  68. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/autoshape.py +0 -0
  69. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/base.py +0 -0
  70. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/connector.py +0 -0
  71. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/freeform.py +0 -0
  72. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/graphfrm.py +0 -0
  73. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/group.py +0 -0
  74. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/picture.py +0 -0
  75. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/placeholder.py +0 -0
  76. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shapes/shapetree.py +0 -0
  77. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/shared.py +0 -0
  78. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/slide.py +0 -0
  79. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/spec.py +0 -0
  80. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/table.py +0 -0
  81. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/text/fonts.py +0 -0
  82. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/text/layout.py +0 -0
  83. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/text/text.py +0 -0
  84. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/types.py +0 -0
  85. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/units.py +0 -0
  86. {athena_python_pptx-0.8.1 → athena_python_pptx-0.9.1}/pptx/util.py +0 -0
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to `athena-python-pptx` are documented in this file.
4
4
 
5
+ ## 0.9.0 — eager validation, table overflow diagnostics, and text caps
6
+
7
+ Publishes the SDK changes already deployed with PPTX Studio after 0.8.1.
8
+
9
+ - Invalid buffered commands now raise at the originating SDK call instead of
10
+ waiting until `flush()`. Validation errors include command context such as
11
+ the slide index, shape ID, or name, making the offending edit actionable
12
+ without sacrificing the final send-time validation backstop.
13
+ - `Slide.validate()` now warns when table-cell text is likely to wrap beyond
14
+ the available column width. The offline heuristic accounts for cell margins,
15
+ font size, merged-column spans, digits, and common punctuation.
16
+ - `Font.cap` and the `TextCap` type expose PowerPoint's native `none`, `all`,
17
+ and `small` capitalization modes. The value hydrates from existing decks and
18
+ survives full-frame text re-emission.
19
+
20
+ The public text-capitalization surface makes this a minor release.
21
+
22
+ ## 0.8.1 — version-string fix (re-cut of 0.8.0)
23
+
24
+ Functionally identical to 0.8.0. The 0.8.0 wheel was auto-published from a
25
+ tree where `pptx/__init__.py::__version__` was never bumped off `0.7.1`, so
26
+ `import pptx; pptx.__version__` reported `0.7.1` even though the wheel
27
+ contained the surgical-`SetRunText` + faithful-hydration code. PyPI versions
28
+ are immutable, so 0.8.1 re-cuts the release with the corrected `__version__`
29
+ string. No behavior change vs 0.8.0; use 0.8.1 (the `presentation-exec:v55`
30
+ snapshot bundles it).
31
+
5
32
  ## 0.8.0 — Surgical run text + faithful style hydration
6
33
 
7
34
  Closes the "agent edited deck text and the styling vanished" class of bugs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.8.1
3
+ Version: 0.9.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
@@ -164,6 +164,19 @@ takes the rest of the queued edits down with it.
164
164
 
165
165
  These properties/methods were added because LLM agents (Claude, GPT, etc.) frequently generate code using these patterns. Without them, agent-generated table code fails at runtime.
166
166
 
167
+ ### `Font.cap` — PowerPoint-native capitalization mode
168
+
169
+ The SDK exposes PowerPoint's OOXML run capitalization property as
170
+ `run.font.cap`, accepting `"none"`, `"all"`, or `"small"`. Stock
171
+ python-pptx 1.0.2 does not expose `a:rPr/@cap`, but the Athena SDK needs a
172
+ typed surface so template-authored all-caps and small-caps text survives
173
+ agent edits and full-frame text re-emission.
174
+
175
+ ```python
176
+ run.font.cap = "all"
177
+ assert run.font.cap == "all"
178
+ ```
179
+
167
180
  ### `TableCell` — RGB tuple aliases
168
181
 
169
182
  | Property | Type | Maps to | Why |
@@ -133,7 +133,7 @@ def flush_all() -> None:
133
133
  _active_buffers[:] = alive
134
134
 
135
135
 
136
- __version__ = "0.8.1"
136
+ __version__ = "0.9.1"
137
137
 
138
138
  __all__ = [
139
139
  # Main entry point
@@ -19,7 +19,7 @@ from threading import local
19
19
  from typing import TYPE_CHECKING, Any, Generator, Optional
20
20
 
21
21
  from . import _ptc
22
- from .commands import AnyCommand
22
+ from .commands import AnyCommand, validate_command
23
23
  from .errors import BatchPartialError
24
24
  from .typing import CommandsResponse
25
25
 
@@ -234,12 +234,21 @@ class CommandBuffer:
234
234
 
235
235
  Returns:
236
236
  Response if sent immediately, None if buffered
237
+
238
+ Raises:
239
+ ValidationError: If the command's parameters are invalid. Raised
240
+ here — at the API call that created the command — rather than
241
+ at the eventual ``flush()``, so the traceback points at the
242
+ offending user line and the invalid command is never buffered
243
+ (a flush-time failure would discard the whole pending batch).
237
244
  """
238
245
  # Surface any error captured by the background auto-flush timer
239
246
  # before accepting more work — otherwise the user could continue
240
247
  # queueing commands on top of a partially-failed batch.
241
248
  self._raise_deferred_error()
242
249
 
250
+ validate_command(command)
251
+
243
252
  # PTC begin: emit only for commands in the allow-list. Other
244
253
  # commands still flow through the buffer (and through the batched
245
254
  # HTTP POST), they just don't produce a sub-tool-card. ``emit_end``
@@ -365,7 +374,7 @@ class CommandBuffer:
365
374
  if len(chunks) > 1:
366
375
  try:
367
376
  for command in commands:
368
- command.validate()
377
+ validate_command(command)
369
378
  except Exception:
370
379
  with self._commands_lock:
371
380
  self._commands = commands + self._commands
@@ -37,7 +37,7 @@ from .typing import (
37
37
  ExportStatus,
38
38
  RenderStatus,
39
39
  )
40
- from .commands import AnyCommand
40
+ from .commands import AnyCommand, validate_command
41
41
 
42
42
 
43
43
  SDK_VERSION = "0.1.0"
@@ -647,9 +647,11 @@ class Client:
647
647
  Returns:
648
648
  Response with applied status, created IDs, and optional snapshot
649
649
  """
650
- # Validate all commands
650
+ # Validate all commands (backstop — CommandBuffer.add validates
651
+ # eagerly at the creating call site; this covers direct client use
652
+ # and commands mutated after they were buffered).
651
653
  for cmd in commands:
652
- cmd.validate()
654
+ validate_command(cmd)
653
655
 
654
656
  payload = {
655
657
  "client": {"type": SDK_CLIENT_TYPE, "version": SDK_VERSION},
@@ -14,7 +14,7 @@ __athena_extension_description__: str = (
14
14
  __athena_extension_since__: str = "0.1.0"
15
15
 
16
16
  from dataclasses import dataclass, field, asdict
17
- from typing import Any, Optional, Union
17
+ from typing import Any, ClassVar, Optional, Union
18
18
  from ._citations import normalize_citation_string
19
19
  from .errors import ValidationError
20
20
  from .typing import ShapeId, SlideId, TextRunPath, TextStyle
@@ -39,6 +39,26 @@ class Command:
39
39
  components = snake_str.split("_")
40
40
  return components[0] + "".join(x.title() for x in components[1:])
41
41
 
42
+ # Identifying fields surfaced in validation errors so a failure names the
43
+ # exact command instance ("AddShape(slide_index=4, name='Card 2')") rather
44
+ # than only the bad value.
45
+ _CONTEXT_FIELDS: ClassVar[tuple[str, ...]] = (
46
+ "slide_index",
47
+ "index",
48
+ "shape_id",
49
+ "name",
50
+ )
51
+
52
+ def context_label(self) -> str:
53
+ """Short human-readable identity for this command instance."""
54
+ parts: list[str] = []
55
+ for field_name in self._CONTEXT_FIELDS:
56
+ value = getattr(self, field_name, None)
57
+ if value is not None:
58
+ parts.append(f"{field_name}={value!r}")
59
+ label = type(self).__name__
60
+ return f"{label}({', '.join(parts)})" if parts else label
61
+
42
62
  def validate(self) -> None:
43
63
  """Validate command parameters. Raises ValidationError if invalid."""
44
64
  pass
@@ -49,6 +69,23 @@ class Command:
49
69
  raise NotImplementedError
50
70
 
51
71
 
72
+ def validate_command(command: "Command") -> None:
73
+ """Validate ``command``, prefixing any ValidationError with its identity.
74
+
75
+ The bare per-command ``validate()`` messages name only the bad field/value
76
+ ("height must be >= 0 EMU"); when validation runs away from the creating
77
+ call site (batched flush, multi-command POST) that alone can't be traced
78
+ back to a shape or slide. Every SDK-internal validation path goes through
79
+ this wrapper so the error always carries the command context.
80
+ """
81
+ try:
82
+ command.validate()
83
+ except ValidationError as exc:
84
+ raise ValidationError(
85
+ f"{command.context_label()}: {exc.message}", exc.field
86
+ ) from None
87
+
88
+
52
89
  @dataclass
53
90
  class AddTextBox(Command):
54
91
  """
@@ -41,6 +41,106 @@ def _normalize_layout_name(name: str) -> str:
41
41
  return " ".join(name.strip().lower().replace("_", " ").split())
42
42
 
43
43
 
44
+ # --- table text-overflow heuristic (used by Slide.validate) ----------------
45
+ # Approximate per-glyph advance widths in em units (Arial-ish). Deliberately
46
+ # conservative and digit/punctuation-aware because the common overflow case is
47
+ # wide numeric values (currency / counts) in narrow columns.
48
+ _TABLE_GLYPH_EM = {
49
+ **{d: 0.556 for d in "0123456789"},
50
+ ",": 0.278, ".": 0.278, "(": 0.333, ")": 0.333,
51
+ "%": 0.889, "$": 0.556, "-": 0.333, " ": 0.278, "/": 0.278,
52
+ }
53
+ _EMU_PER_PT = 12700
54
+ # PowerPoint's default table body font when a cell exposes no explicit size.
55
+ _DEFAULT_TABLE_FONT_PT = 18.0
56
+
57
+
58
+ def _estimate_text_width_emu(text: str, font_pt: float) -> float:
59
+ """Conservative single-line width estimate for ``text`` at ``font_pt``."""
60
+ return sum(_TABLE_GLYPH_EM.get(ch, 0.5) for ch in text) * font_pt * _EMU_PER_PT
61
+
62
+
63
+ def _cell_font_pt(cell: Any) -> Optional[float]:
64
+ """Resolve a cell's font size (pt): cell-level, else first run, else None."""
65
+ fp = getattr(cell, "font_size_pt", None)
66
+ if fp:
67
+ return float(fp)
68
+ try:
69
+ for para in cell.text_frame.paragraphs:
70
+ for run in para.runs:
71
+ if run.font is not None and run.font.size is not None:
72
+ # SDK run font size is a raw EMU int (no .pt); Length
73
+ # objects do expose .pt — handle both.
74
+ size = run.font.size
75
+ return float(getattr(size, "pt", size / _EMU_PER_PT))
76
+ except Exception:
77
+ pass
78
+ return None
79
+
80
+
81
+ def _table_overflow_warnings(table: Any, label: str) -> list[str]:
82
+ """Heuristic warnings for table cells whose single-line text likely
83
+ exceeds the column width (minus cell margins) and would wrap — growing the
84
+ row and breaking alignment — in PowerPoint. No rendering; uses a
85
+ conservative font-metric estimate.
86
+
87
+ Skips columns whose width is unknown (e.g. tables cloned server-side whose
88
+ widths are not in the local model) and resolves the font size per cell
89
+ (cell-level -> first run -> the PowerPoint default). Being a heuristic it
90
+ can occasionally false-positive on intentionally tight cells.
91
+ """
92
+ out: list[str] = []
93
+ try:
94
+ ncols = int(table.cols)
95
+ nrows = len(table.rows)
96
+ except Exception:
97
+ return out
98
+
99
+ def _colw(i: int) -> int:
100
+ try:
101
+ w = table.columns[i].width
102
+ return int(w) if w and int(w) > 0 else 0
103
+ except Exception:
104
+ return 0
105
+
106
+ for r in range(nrows):
107
+ for c in range(ncols):
108
+ try:
109
+ cell = table.cell(r, c)
110
+ except Exception:
111
+ continue
112
+ # Continuation cells of a horizontal merge hold no text of their
113
+ # own — their width belongs to the merge origin.
114
+ if getattr(cell, "_h_merge", False):
115
+ continue
116
+ text = (cell.text or "").strip()
117
+ if not text:
118
+ continue
119
+ # A horizontally-merged origin cell spans grid_span columns; its
120
+ # usable width is the sum of those columns (PowerPoint semantics).
121
+ span = getattr(cell, "grid_span", None) or getattr(cell, "_grid_span", 1) or 1
122
+ colw = sum(_colw(c + k) for k in range(int(span)))
123
+ if colw <= 0:
124
+ continue # unknown/zero width — cannot judge overflow
125
+ font_pt = _cell_font_pt(cell) or _DEFAULT_TABLE_FONT_PT
126
+ try:
127
+ avail = colw - int(cell.margin_left) - int(cell.margin_right)
128
+ except Exception:
129
+ avail = colw
130
+ if avail <= 0:
131
+ continue
132
+ est = _estimate_text_width_emu(text, font_pt)
133
+ if est > avail:
134
+ span_note = f" across {int(span)} cols" if int(span) > 1 else ""
135
+ out.append(
136
+ f"table '{label}' cell r{r}c{c} text {text!r} "
137
+ f"(~{int(est)} EMU at {font_pt:g}pt) likely overflows column "
138
+ f"width {colw} EMU{span_note} (~{avail} EMU after cell "
139
+ f"margins) — will wrap and grow the row in PowerPoint"
140
+ )
141
+ return out
142
+
143
+
44
144
  def _visible_element_ids(
45
145
  *,
46
146
  slide_snapshot: SlideSnapshot,
@@ -645,6 +745,7 @@ class Slide:
645
745
  notes: Optional[str] = None,
646
746
  layout_index: Optional[int] = None,
647
747
  layout_name: Optional[str] = None,
748
+ layout_path: Optional[str] = None,
648
749
  ):
649
750
  self._presentation = presentation
650
751
  self._slide_id = slide_id
@@ -656,6 +757,7 @@ class Slide:
656
757
  # Layout tracking
657
758
  self._layout_index = layout_index
658
759
  self._layout_name = layout_name
760
+ self._layout_path = layout_path
659
761
  self._layout: Optional[SlideLayout] = None
660
762
 
661
763
  # Initialize shapes collection
@@ -780,6 +882,8 @@ class Slide:
780
882
  name=self._layout_name or "Unknown",
781
883
  index=self._layout_index or 0,
782
884
  slide=self,
885
+ presentation=self._presentation,
886
+ path=getattr(self, "_layout_path", None),
783
887
  )
784
888
  return self._layout
785
889
 
@@ -810,6 +914,7 @@ class Slide:
810
914
 
811
915
  self._layout_index = layout_index
812
916
  self._layout_name = layout_name
917
+ self._layout_path = getattr(value, "_path", None) if isinstance(value, SlideLayout) else None
813
918
  self._layout = None # Reset cached layout
814
919
 
815
920
  if self._buffer:
@@ -1158,10 +1263,16 @@ class Slide:
1158
1263
  can ignore those warnings; the goal is to catch the *accidental*
1159
1264
  on-top-of-each-other case (the "235 shapes layered" failure
1160
1265
  from the agent retrospective).
1266
+ * **Table cell text overflow** — for shapes that carry a table, cells
1267
+ whose single-line text is likely wider than the column (minus cell
1268
+ margins) and would wrap — growing the row and breaking alignment —
1269
+ when opened in PowerPoint. Uses a conservative font-metric estimate
1270
+ (no rendering), so it catches the "looks fine in the Studio preview
1271
+ but wraps in PowerPoint" class for tables. Heuristic: may
1272
+ occasionally false-positive; skips columns of unknown width.
1161
1273
 
1162
- Limitations: does *not* render text and so cannot detect text
1163
- overflowing its container; for that, render the slide and
1164
- inspect the PNG.
1274
+ Limitations: for *non-table* text it does not render and so cannot
1275
+ detect overflow — for that, render the slide and inspect the PNG.
1165
1276
  """
1166
1277
  prs = self._presentation
1167
1278
  slide_w = int(prs.slide_width) if prs.slide_width else 12192000
@@ -1200,6 +1311,16 @@ class Slide:
1200
1311
  f"({overlap_area} EMU² ≈ "
1201
1312
  f"{overlap_area / (slide_w * slide_h) * 100:.2f}% of slide)"
1202
1313
  )
1314
+
1315
+ # Table cell text-overflow (heuristic; no render). Catches the
1316
+ # "looks fine in Studio, wraps in PowerPoint" class for tables.
1317
+ for shape in list(self._shapes):
1318
+ if getattr(shape, "has_table", False):
1319
+ try:
1320
+ label = shape.name or shape.shape_id
1321
+ warnings.extend(_table_overflow_warnings(shape.table, label))
1322
+ except Exception:
1323
+ pass
1203
1324
  return warnings
1204
1325
 
1205
1326
  @athena_only(
@@ -1722,6 +1843,7 @@ class Slide:
1722
1843
  self._notes = slide_snapshot.notes
1723
1844
  self._layout_index = _parse_layout_index(slide_snapshot.layout_path)
1724
1845
  self._layout_name = slide_snapshot.layout_name
1846
+ self._layout_path = slide_snapshot.layout_path
1725
1847
  self._layout = None
1726
1848
 
1727
1849
  # Rebuild shapes collection
@@ -1774,6 +1896,7 @@ class Slides:
1774
1896
  notes=slide_snapshot.notes,
1775
1897
  layout_index=_parse_layout_index(slide_snapshot.layout_path),
1776
1898
  layout_name=slide_snapshot.layout_name,
1899
+ layout_path=slide_snapshot.layout_path,
1777
1900
  )
1778
1901
  self._slides.append(slide)
1779
1902
  self._slides_by_id[slide_snapshot.id] = slide
@@ -2528,6 +2651,7 @@ class Slides:
2528
2651
  notes=slide_snapshot.notes,
2529
2652
  layout_index=_parse_layout_index(slide_snapshot.layout_path),
2530
2653
  layout_name=slide_snapshot.layout_name,
2654
+ layout_path=slide_snapshot.layout_path,
2531
2655
  )
2532
2656
  else:
2533
2657
  slide._update_from_snapshot(slide_snapshot, snapshot.elements)
@@ -2874,11 +2998,37 @@ class SlideLayout:
2874
2998
  def slide_master(self) -> Optional["SlideMaster"]:
2875
2999
  """The :class:`SlideMaster` this layout belongs to.
2876
3000
 
2877
- Returns ``None`` if the layout was constructed without a master
2878
- reference (e.g., from the legacy hardcoded fallback set used when no
2879
- snapshot is available).
3001
+ python-pptx resolves this through the layout part's relationship to
3002
+ its master and never returns ``None``. Layout proxies here are often
3003
+ constructed without a master backreference (e.g. ``slide.slide_layout``),
3004
+ so the master is resolved lazily from the presentation's theme
3005
+ hierarchy: first by layout path, then by layout name, falling back to
3006
+ the first master. Returns ``None`` only when the deck snapshot carries
3007
+ no master data at all.
2880
3008
  """
2881
- return self._slide_master_ref
3009
+ if self._slide_master_ref is not None:
3010
+ return self._slide_master_ref
3011
+ prs = self._presentation
3012
+ if prs is None and self._slide is not None:
3013
+ prs = self._slide._presentation
3014
+ if prs is None:
3015
+ return None
3016
+ masters = prs.slide_masters
3017
+ by_name: Optional["SlideMaster"] = None
3018
+ for master in masters:
3019
+ for layout in master.slide_layouts:
3020
+ if self._path is not None and layout._path == self._path:
3021
+ self._slide_master_ref = master
3022
+ return master
3023
+ if by_name is None and self._name is not None and layout._name == self._name:
3024
+ by_name = master
3025
+ if by_name is not None:
3026
+ self._slide_master_ref = by_name
3027
+ return by_name
3028
+ if len(masters) > 0:
3029
+ self._slide_master_ref = masters[0]
3030
+ return masters[0]
3031
+ return None
2882
3032
 
2883
3033
  @property
2884
3034
  def background(self) -> "_LayoutBackground":
@@ -16,7 +16,7 @@ from ..commands import (
16
16
  )
17
17
  from ..dml.color import RGBColor, ColorFormat, scheme_name_to_theme_color, theme_color_to_scheme_name
18
18
  from ..errors import UnsupportedFeatureError
19
- from ..typing import ShapeId, TextStyle, TextFramePropertiesSnapshot
19
+ from ..typing import ShapeId, TextCap, TextStyle, TextFramePropertiesSnapshot
20
20
  from .._athena_extension import athena_extension
21
21
 
22
22
  if TYPE_CHECKING:
@@ -36,7 +36,7 @@ class PP_ALIGN:
36
36
 
37
37
 
38
38
  # Wire style keys the SDK hydrates into typed Font state. Anything else the
39
- # server sends (``cap``, ``highlightColorHex``, ``highlightSchemeColor``,
39
+ # server sends (``highlightColorHex``, ``highlightSchemeColor``,
40
40
  # future keys) is carried verbatim in ``Font._extra_style`` so full-frame
41
41
  # re-emits (run.text / paragraph.text / add_paragraph) don't silently strip
42
42
  # properties the SDK doesn't model.
@@ -44,6 +44,7 @@ _TYPED_WIRE_STYLE_KEYS = frozenset({
44
44
  "bold", "italic", "underline", "fontSizePt", "fontFamily", "fontRef",
45
45
  "colorHex", "spacingPt", "strike", "strikethrough", "baseline",
46
46
  "subscript", "superscript", "languageId", "schemeColor", "brightness",
47
+ "cap",
47
48
  })
48
49
 
49
50
 
@@ -212,6 +213,7 @@ class Font:
212
213
  strike: Optional[bool] = None,
213
214
  baseline: Optional[int] = None,
214
215
  language_id: Optional[int] = None,
216
+ cap: Optional[TextCap] = None,
215
217
  change_listener: Optional[Any] = None,
216
218
  ):
217
219
  self._run = run
@@ -254,8 +256,9 @@ class Font:
254
256
  self._language_id: Optional[int] = (
255
257
  int(language_id) if language_id is not None else None
256
258
  )
259
+ self._cap = cap
257
260
  # Wire style properties hydrated from the server that the SDK doesn't
258
- # model as typed fields (``cap``, highlights, …). Carried verbatim so
261
+ # model as typed fields (highlights, …). Carried verbatim so
259
262
  # ``_build_style_dict`` reproduces them on full-frame re-emits instead
260
263
  # of stripping them from the deck.
261
264
  self._extra_style: dict[str, Any] = {}
@@ -293,6 +296,23 @@ class Font:
293
296
  self._underline = value
294
297
  self._emit_style_change()
295
298
 
299
+ @property
300
+ def cap(self) -> Optional[TextCap]:
301
+ """Text capitalization mode (``none``, ``all``, or ``small``).
302
+
303
+ This Athena extension maps directly to OOXML ``a:rPr/@cap``. Use
304
+ ``"all"`` for template titles whose uppercase appearance is authored
305
+ as a run property rather than baked into the text value.
306
+ """
307
+ return self._cap
308
+
309
+ @cap.setter
310
+ def cap(self, value: TextCap) -> None:
311
+ if value not in {"none", "all", "small"}:
312
+ raise ValueError("cap must be one of: 'none', 'all', 'small'")
313
+ self._cap = value
314
+ self._emit_style_change()
315
+
296
316
  @property
297
317
  def size(self) -> Optional[int]:
298
318
  """Font size in EMU."""
@@ -533,6 +553,8 @@ class Font:
533
553
  style["baseline"] = self._baseline
534
554
  if self._language_id is not None:
535
555
  style["languageId"] = self._language_id
556
+ if self._cap is not None:
557
+ style["cap"] = self._cap
536
558
  return style
537
559
 
538
560
  def _style_payload(self) -> "TextStyle":
@@ -605,6 +627,8 @@ class Font:
605
627
  style["baseline"] = self._baseline
606
628
  if self._language_id is not None:
607
629
  style["languageId"] = self._language_id
630
+ if self._cap is not None:
631
+ style["cap"] = self._cap
608
632
 
609
633
  @athena_extension(
610
634
  issue=1063,
@@ -959,6 +983,7 @@ class Run:
959
983
  strike=strike,
960
984
  baseline=_baseline_from_wire(style_dict),
961
985
  language_id=style_dict.get("languageId"),
986
+ cap=style_dict.get("cap"),
962
987
  )
963
988
  self._font._hydrate_theme_color(
964
989
  style_dict.get("schemeColor"), style_dict.get("brightness")
@@ -16,6 +16,7 @@ from enum import IntEnum
16
16
 
17
17
 
18
18
  # Type aliases for IDs
19
+ TextCap = Literal["none", "all", "small"]
19
20
  DeckId = str
20
21
  SlideId = str
21
22
  ShapeId = str
@@ -153,6 +154,7 @@ class TextStyle(TypedDict, total=False):
153
154
  strike: bool
154
155
  baseline: int
155
156
  languageId: int
157
+ cap: TextCap
156
158
 
157
159
 
158
160
  @dataclass
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "athena-python-pptx"
7
- version = "0.8.1"
7
+ version = "0.9.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"