athena-python-pptx 0.3.1__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/PKG-INFO +1 -1
  2. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/docs/API_PARITY_EXCEPTIONS.md +24 -13
  3. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/__init__.py +1 -1
  4. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/client.py +26 -12
  5. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/commands.py +73 -54
  6. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/__init__.py +54 -10
  7. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/text/__init__.py +89 -6
  8. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/typing.py +4 -0
  9. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pyproject.toml +1 -1
  10. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/.gitignore +0 -0
  11. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/API_PARITY_REPORT.md +0 -0
  12. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/CHANGELOG.md +0 -0
  13. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/CLAUDE.md +0 -0
  14. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/DEV-GUIDE.md +0 -0
  15. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/PARITY_QUESTIONS.md +0 -0
  16. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/PUBLISHING.md +0 -0
  17. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/README.md +0 -0
  18. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/docs/athena-api.json +0 -0
  19. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/docs/athena-api.md +0 -0
  20. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/_athena_extension.py +0 -0
  21. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/_ptc.py +0 -0
  22. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/_references.py +0 -0
  23. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/action.py +0 -0
  24. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/batching.py +0 -0
  25. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/__init__.py +0 -0
  26. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/axis.py +0 -0
  27. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/category.py +0 -0
  28. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/chart.py +0 -0
  29. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/data.py +0 -0
  30. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/datalabel.py +0 -0
  31. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/legend.py +0 -0
  32. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/marker.py +0 -0
  33. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/plot.py +0 -0
  34. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/point.py +0 -0
  35. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/series.py +0 -0
  36. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/chart/xlsx.py +0 -0
  37. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/decorators.py +0 -0
  38. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/dml/__init__.py +0 -0
  39. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/dml/chtfmt.py +0 -0
  40. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/dml/color.py +0 -0
  41. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/dml/effect.py +0 -0
  42. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/dml/fill.py +0 -0
  43. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/dml/line.py +0 -0
  44. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/docgen.py +0 -0
  45. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/__init__.py +0 -0
  46. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/action.py +0 -0
  47. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/chart.py +0 -0
  48. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/dml.py +0 -0
  49. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/lang.py +0 -0
  50. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/shapes.py +0 -0
  51. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/enum/text.py +0 -0
  52. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/errors.py +0 -0
  53. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/exc.py +0 -0
  54. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/media.py +0 -0
  55. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/package.py +0 -0
  56. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/__init__.py +0 -0
  57. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/_base.py +0 -0
  58. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/chart.py +0 -0
  59. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/coreprops.py +0 -0
  60. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/embeddedpackage.py +0 -0
  61. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/image.py +0 -0
  62. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/media.py +0 -0
  63. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/presentation.py +0 -0
  64. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/parts/slide.py +0 -0
  65. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/presentation.py +0 -0
  66. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/autoshape.py +0 -0
  67. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/base.py +0 -0
  68. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/connector.py +0 -0
  69. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/freeform.py +0 -0
  70. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/graphfrm.py +0 -0
  71. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/group.py +0 -0
  72. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/picture.py +0 -0
  73. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/placeholder.py +0 -0
  74. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shapes/shapetree.py +0 -0
  75. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/shared.py +0 -0
  76. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/slide.py +0 -0
  77. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/slides.py +0 -0
  78. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/spec.py +0 -0
  79. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/table.py +0 -0
  80. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/text/fonts.py +0 -0
  81. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/text/layout.py +0 -0
  82. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/text/text.py +0 -0
  83. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/types.py +0 -0
  84. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/units.py +0 -0
  85. {athena_python_pptx-0.3.1 → athena_python_pptx-0.4.1}/pptx/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.3.1
3
+ Version: 0.4.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
@@ -352,19 +352,30 @@ public reorder API (callers manipulate `slides.sldIdLst` XML directly).
352
352
  prs.reorder_slides([2, 0, 1]) # move slide 2 to the front
353
353
  ```
354
354
 
355
- ### Strict positive-dimension validation on every `Add*` command
356
-
357
- `AddTextBox`, `AddShape`, `AddPicture`, `AddTable`, and `SetTransform`
358
- now reject **zero** width/height client-side, not just negative values.
359
- This matches the server's Zod schema (`z.number().int().positive()`) so
360
- a stray `Inches(0)` raises a clear, field-named `ValidationError` locally
361
- instead of becoming a generic `commands.N.hEmu: Too small` 400 after the
362
- batch is on the wire. (For `AddPicture`, omit `width=`/`height=` entirely
363
- to fall back to the image's native dimensions — that path is still
364
- supported; the validation only fires when an explicit zero is passed.)
365
-
366
- `SetColWidth` and `SetRowHeight` still allow zero (`min(0)` server-side)
367
- because zero means "auto-fit" for table dimensions.
355
+ ### Non-negative dimension validation on every `Add*` command
356
+
357
+ `AddTextBox`, `AddShape`, `AddPicture`, `AddTable`, `SetTransform`,
358
+ `AddOleObject`, `AddLinkedOleObject`, `AddLinkedTable`, `AddChart`,
359
+ `AddChart2016`, and `AddMovie` reject **negative** width/height
360
+ client-side (the server's Zod schema is `z.number().int().nonnegative()`).
361
+
362
+ **Zero is allowed**, matching python-pptx and OOXML `a:ext` cx/cy are
363
+ `ST_PositiveCoordinate` whose XSD facet is `minInclusive="0"`, so a
364
+ zero-area shape (e.g. a degenerate divider line/connector) is valid and
365
+ python-pptx accepts it without complaint. Earlier SDK versions rejected
366
+ zero too; that was a parity departure that broke the common
367
+ "thin divider" idiom and aborted whole command batches when an agent
368
+ passed `height=0`, so it was reverted. A negative value still raises a
369
+ clear, field-named `ValidationError` locally (and `400` server-side)
370
+ rather than producing an invalid `<a:ext>`.
371
+
372
+ (For `AddPicture`, omit `width=`/`height=` entirely to fall back to the
373
+ image's native dimensions — that path is unchanged.)
374
+
375
+ `SetColWidth` and `SetRowHeight` also allow zero (`min(0)` server-side)
376
+ because zero means "auto-fit" for table dimensions. `SetPresentationSize`
377
+ still requires strictly positive dimensions (a zero-size slide is
378
+ degenerate, not a valid OOXML construct).
368
379
 
369
380
  ### Server fail-fast on partial batch failure
370
381
 
@@ -132,7 +132,7 @@ def flush_all() -> None:
132
132
  _active_buffers[:] = alive
133
133
 
134
134
 
135
- __version__ = "0.3.1"
135
+ __version__ = "0.4.1"
136
136
 
137
137
  __all__ = [
138
138
  # Main entry point
@@ -72,7 +72,11 @@ class Client:
72
72
  base_url: Base URL of the API (e.g., "https://api.pptx-studio.com").
73
73
  If not provided, uses ATHENA_PPTX_BASE_URL environment variable.
74
74
  api_key: Optional API key for authentication.
75
- If not provided, uses ATHENA_PPTX_API_KEY environment variable.
75
+ If not provided, falls back to the ATHENA_PPTX_API_KEY
76
+ environment variable, then to ATHENA_API_KEY (the canonical
77
+ Athena user API key — same value, accepted as a fallback so
78
+ code running in sandboxes that only inject ATHENA_API_KEY
79
+ still authenticates).
76
80
  timeout: Request timeout in seconds
77
81
 
78
82
  Raises:
@@ -83,11 +87,13 @@ class Client:
83
87
  base_url = os.environ.get("ATHENA_PPTX_BASE_URL")
84
88
  if base_url is None:
85
89
  raise ValueError(
86
- "base_url must be provided or ATHENA_PPTX_BASE_URL environment variable must be set"
90
+ "base_url must be provided or the ATHENA_PPTX_BASE_URL "
91
+ "environment variable must be set. Example: "
92
+ 'ATHENA_PPTX_BASE_URL="https://pptx-studio.prd.athenaintel.com".'
87
93
  )
88
94
 
89
95
  if api_key is None:
90
- api_key = os.environ.get("ATHENA_PPTX_API_KEY")
96
+ api_key = os.environ.get("ATHENA_PPTX_API_KEY") or os.environ.get("ATHENA_API_KEY")
91
97
 
92
98
  self.base_url = base_url.rstrip("/")
93
99
  self.api_key = api_key
@@ -129,7 +135,18 @@ class Client:
129
135
  def _handle_response(self, response: requests.Response) -> Any:
130
136
  """Handle API response, raising appropriate errors."""
131
137
  if response.status_code == 401:
132
- raise AuthenticationError("Invalid or expired API key")
138
+ if not self.api_key:
139
+ raise AuthenticationError(
140
+ "No API key was sent. Pass api_key= to Presentation(...) "
141
+ "or set ATHENA_PPTX_API_KEY (or ATHENA_API_KEY as a "
142
+ "fallback) in the environment."
143
+ )
144
+ raise AuthenticationError(
145
+ "Invalid or expired API key. The pptx-studio API rejected the "
146
+ "credentials. Verify ATHENA_PPTX_API_KEY (or the ATHENA_API_KEY "
147
+ "fallback) is set to a valid Athena user API key for the "
148
+ "current workspace."
149
+ )
133
150
 
134
151
  if response.status_code == 409:
135
152
  data = response.json() if response.text else {}
@@ -416,7 +433,7 @@ class Client:
416
433
  if name is None:
417
434
  name = os.path.basename(file_path)
418
435
  # Remove extension
419
- if name.endswith('.pptx') or name.endswith('.potx'):
436
+ if name.endswith(".pptx") or name.endswith(".potx"):
420
437
  name = name[:-5]
421
438
 
422
439
  # Step 1: Create deck to get presigned URL
@@ -496,7 +513,7 @@ class Client:
496
513
  # Use filename as name if not provided
497
514
  if name is None:
498
515
  name = os.path.basename(file_path)
499
- if name.endswith('.pptx') or name.endswith('.potx'):
516
+ if name.endswith(".pptx") or name.endswith(".potx"):
500
517
  name = name[:-5]
501
518
 
502
519
  # Step 1: Create deck to get presigned URL
@@ -649,6 +666,7 @@ class Client:
649
666
  flipV=transform_data.get("flipV"),
650
667
  ),
651
668
  preview_text=elem_data.get("previewText"),
669
+ rich_content=elem_data.get("richContent"),
652
670
  placeholder=placeholder,
653
671
  properties=elem_data.get("properties"),
654
672
  source=elem_data.get("source"),
@@ -800,9 +818,7 @@ class Client:
800
818
  return self._download(download_url)
801
819
 
802
820
  if status["status"] in ("failed", "error"):
803
- raise ExportError(
804
- status.get("error", "Export failed"), job_id
805
- )
821
+ raise ExportError(status.get("error", "Export failed"), job_id)
806
822
 
807
823
  time.sleep(poll_interval)
808
824
 
@@ -870,9 +886,7 @@ class Client:
870
886
  return self._download(image_url)
871
887
 
872
888
  if status["status"] in ("failed", "error"):
873
- raise RenderError(
874
- status.get("error", "Render failed"), job_id
875
- )
889
+ raise RenderError(status.get("error", "Render failed"), job_id)
876
890
 
877
891
  time.sleep(poll_interval)
878
892
 
@@ -87,14 +87,14 @@ class AddTextBox(Command):
87
87
  def validate(self) -> None:
88
88
  if self.slide_index < 0:
89
89
  raise ValidationError("slide_index must be non-negative", "slide_index")
90
- # Server Zod schema is z.number().int().positive() → rejects 0 and
91
- # negatives. Reject locally too so a stray Inches(0) raises with a
92
- # clear field name + value instead of becoming a generic
93
- # "commands.N.hEmu: Too small" 400 after the round-trip.
94
- if self.w_emu <= 0:
95
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
96
- if self.h_emu <= 0:
97
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
90
+ # OOXML `a:ext` cx/cy are ST_PositiveCoordinate (minInclusive=0), so
91
+ # zero-area shapes are valid (e.g. a degenerate divider/connector) and
92
+ # python-pptx accepts them. Only negatives are rejected, matching the
93
+ # server Zod schema (z.number().int().nonnegative()).
94
+ if self.w_emu < 0:
95
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
96
+ if self.h_emu < 0:
97
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
98
98
 
99
99
 
100
100
  @dataclass
@@ -155,14 +155,15 @@ class SetTransform(Command):
155
155
  def validate(self) -> None:
156
156
  if not self.shape_id:
157
157
  raise ValidationError("shape_id is required", "shape_id")
158
- # Server Zod schema: wEmu / hEmu are z.number().int().positive() —
159
- # rejects 0 and negatives. Mirror locally so a stray Inches(0) raises
160
- # synchronously with the offending field rather than failing
161
- # validation server-side after the round-trip.
162
- if self.w_emu is not None and self.w_emu <= 0:
163
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
164
- if self.h_emu is not None and self.h_emu <= 0:
165
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
158
+ # Server Zod schema: wEmu / hEmu are z.number().int().nonnegative() —
159
+ # zero is valid (OOXML a:ext cx/cy are ST_PositiveCoordinate,
160
+ # minInclusive=0; python-pptx accepts zero-area shapes). Only negatives
161
+ # are rejected, mirrored locally so they surface synchronously with the
162
+ # offending field rather than failing server-side after the round-trip.
163
+ if self.w_emu is not None and self.w_emu < 0:
164
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
165
+ if self.h_emu is not None and self.h_emu < 0:
166
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
166
167
 
167
168
 
168
169
  @dataclass
@@ -352,10 +353,10 @@ class AddShape(Command):
352
353
  raise ValidationError("slide_index must be non-negative", "slide_index")
353
354
  if not self.shape_type:
354
355
  raise ValidationError("shape_type is required", "shape_type")
355
- if self.w_emu <= 0:
356
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
357
- if self.h_emu <= 0:
358
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
356
+ if self.w_emu < 0:
357
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
358
+ if self.h_emu < 0:
359
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
359
360
 
360
361
 
361
362
  @dataclass
@@ -492,12 +493,13 @@ class AddPicture(Command):
492
493
  raise ValidationError(
493
494
  "image_format must be 'png', 'jpeg', 'gif', 'bmp', or 'tiff'", "image_format"
494
495
  )
495
- # Server schema treats wEmu / hEmu as positive() when present (omit
496
- # them entirely to fall back to the image's native size).
497
- if self.w_emu is not None and self.w_emu <= 0:
498
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
499
- if self.h_emu is not None and self.h_emu <= 0:
500
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
496
+ # Server schema treats wEmu / hEmu as nonnegative() when present (zero
497
+ # is allowed; omit them entirely to fall back to the image's native
498
+ # size). Only negatives are rejected.
499
+ if self.w_emu is not None and self.w_emu < 0:
500
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
501
+ if self.h_emu is not None and self.h_emu < 0:
502
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
501
503
 
502
504
 
503
505
  @dataclass
@@ -547,10 +549,10 @@ class AddOleObject(Command):
547
549
  def validate(self) -> None:
548
550
  if self.slide_index < 0:
549
551
  raise ValidationError("slide_index must be non-negative", "slide_index")
550
- if self.w_emu <= 0:
551
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
552
- if self.h_emu <= 0:
553
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
552
+ if self.w_emu < 0:
553
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
554
+ if self.h_emu < 0:
555
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
554
556
  if not self.preview_base64:
555
557
  raise ValidationError("preview_base64 is required", "preview_base64")
556
558
  if self.preview_format not in ("png", "jpeg"):
@@ -635,10 +637,10 @@ class AddLinkedOleObject(Command):
635
637
  def validate(self) -> None:
636
638
  if self.slide_index < 0:
637
639
  raise ValidationError("slide_index must be non-negative", "slide_index")
638
- if self.w_emu <= 0:
639
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
640
- if self.h_emu <= 0:
641
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
640
+ if self.w_emu < 0:
641
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
642
+ if self.h_emu < 0:
643
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
642
644
  if not isinstance(self.source_ref, dict) or not self.source_ref.get("id"):
643
645
  raise ValidationError(
644
646
  "source_ref must be an AssetReference dict with an 'id' field",
@@ -730,10 +732,10 @@ class AddLinkedTable(Command):
730
732
  def validate(self) -> None:
731
733
  if self.slide_index < 0:
732
734
  raise ValidationError("slide_index must be non-negative", "slide_index")
733
- if self.w_emu <= 0:
734
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
735
- if self.h_emu <= 0:
736
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
735
+ if self.w_emu < 0:
736
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
737
+ if self.h_emu < 0:
738
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
737
739
  if not isinstance(self.source_ref, dict) or not self.source_ref.get("id"):
738
740
  raise ValidationError(
739
741
  "source_ref must be an AssetReference dict with an 'id' field",
@@ -803,10 +805,10 @@ class AddTable(Command):
803
805
  raise ValidationError("rows must be between 1 and 100", "rows")
804
806
  if self.cols < 1 or self.cols > 26:
805
807
  raise ValidationError("cols must be between 1 and 26", "cols")
806
- if self.w_emu <= 0:
807
- raise ValidationError(f"width must be > 0 EMU (got {self.w_emu})", "w_emu")
808
- if self.h_emu <= 0:
809
- raise ValidationError(f"height must be > 0 EMU (got {self.h_emu})", "h_emu")
808
+ if self.w_emu < 0:
809
+ raise ValidationError(f"width must be >= 0 EMU (got {self.w_emu})", "w_emu")
810
+ if self.h_emu < 0:
811
+ raise ValidationError(f"height must be >= 0 EMU (got {self.h_emu})", "h_emu")
810
812
 
811
813
 
812
814
  @dataclass
@@ -1464,6 +1466,23 @@ class SetGradientFill(Command):
1464
1466
  "stops"
1465
1467
  )
1466
1468
 
1469
+ def to_dict(self) -> dict[str, Any]:
1470
+ """Serialize, camelCasing each stop's ``color_hex`` to ``colorHex`` for the server schema."""
1471
+ result = super().to_dict()
1472
+ if self.stops is not None:
1473
+ camel_stops: list[dict[str, Any]] = []
1474
+ for stop in self.stops:
1475
+ camel: dict[str, Any] = {
1476
+ "position": stop.get("position"),
1477
+ "colorHex": stop.get("color_hex"),
1478
+ }
1479
+ transparency = stop.get("transparency")
1480
+ if transparency is not None:
1481
+ camel["transparency"] = transparency
1482
+ camel_stops.append(camel)
1483
+ result["stops"] = camel_stops
1484
+ return result
1485
+
1467
1486
 
1468
1487
  @dataclass
1469
1488
  class SetShapeZOrder(Command):
@@ -2243,10 +2262,10 @@ class AddChart(Command):
2243
2262
  f"chart_type must be one of {sorted(valid_types)}",
2244
2263
  "chart_type",
2245
2264
  )
2246
- if self.w_emu <= 0:
2247
- raise ValidationError("w_emu must be positive", "w_emu")
2248
- if self.h_emu <= 0:
2249
- raise ValidationError("h_emu must be positive", "h_emu")
2265
+ if self.w_emu < 0:
2266
+ raise ValidationError("w_emu must be >= 0", "w_emu")
2267
+ if self.h_emu < 0:
2268
+ raise ValidationError("h_emu must be >= 0", "h_emu")
2250
2269
  if not self.series:
2251
2270
  raise ValidationError("at least one series is required", "series")
2252
2271
  if self.grouping is not None and self.grouping not in (
@@ -2559,10 +2578,10 @@ class AddChart2016(Command):
2559
2578
  f"chart_type must be one of {sorted(valid)} (got {self.chart_type!r})",
2560
2579
  "chart_type",
2561
2580
  )
2562
- if self.w_emu <= 0:
2563
- raise ValidationError("w_emu must be positive", "w_emu")
2564
- if self.h_emu <= 0:
2565
- raise ValidationError("h_emu must be positive", "h_emu")
2581
+ if self.w_emu < 0:
2582
+ raise ValidationError("w_emu must be >= 0", "w_emu")
2583
+ if self.h_emu < 0:
2584
+ raise ValidationError("h_emu must be >= 0", "h_emu")
2566
2585
  if not self.series:
2567
2586
  raise ValidationError("at least one series is required", "series")
2568
2587
 
@@ -2935,10 +2954,10 @@ class AddMovie(Command):
2935
2954
  def validate(self) -> None:
2936
2955
  if self.slide_index < 0:
2937
2956
  raise ValidationError("slide_index must be >= 0", "slide_index")
2938
- if self.w_emu <= 0:
2939
- raise ValidationError("w_emu must be positive", "w_emu")
2940
- if self.h_emu <= 0:
2941
- raise ValidationError("h_emu must be positive", "h_emu")
2957
+ if self.w_emu < 0:
2958
+ raise ValidationError("w_emu must be >= 0", "w_emu")
2959
+ if self.h_emu < 0:
2960
+ raise ValidationError("h_emu must be >= 0", "h_emu")
2942
2961
  # Reject empty strings — they look "set" but ship no payload.
2943
2962
  has_embedded = bool(self.movie_base64)
2944
2963
  has_url = bool(self.url)
@@ -2963,6 +2963,43 @@ class _ChartPlotCollection:
2963
2963
  # (rather than in their own file) so they're visible from the chart
2964
2964
  # adapters above.
2965
2965
 
2966
+ # python-pptx ``XL_DATA_LABEL_POSITION`` (IntEnum) -> the OOXML ``dLblPos``
2967
+ # string symbols the server accepts. Assigning the enum member directly
2968
+ # (``data_labels.position = XL_LABEL_POSITION.OUTSIDE_END``) must serialize to
2969
+ # ``'outEnd'`` rather than the raw int ``2``, otherwise the commands endpoint
2970
+ # rejects the patch with a 400. The accepted set is mirrored from
2971
+ # ``packages/chart-core/src/schema.ts`` (``'ctr'|'inEnd'|'inBase'|'outEnd'|
2972
+ # 'bestFit'|'t'|'b'|'l'|'r'``). Mirrors the ``Legend._POSITION_INT_TO_STR``
2973
+ # pattern.
2974
+ _DATA_LABEL_POSITION_INT_TO_STR = {
2975
+ -4108: 'ctr', # CENTER
2976
+ 3: 'inEnd', # INSIDE_END
2977
+ 4: 'inBase', # INSIDE_BASE
2978
+ 2: 'outEnd', # OUTSIDE_END
2979
+ 5: 'bestFit', # BEST_FIT
2980
+ 0: 't', # ABOVE
2981
+ 1: 'b', # BELOW
2982
+ -4131: 'l', # LEFT
2983
+ -4152: 'r', # RIGHT
2984
+ }
2985
+
2986
+
2987
+ def _coerce_data_label_position(value: Any) -> Optional[str]:
2988
+ """Normalize a data-label position to its OOXML ``dLblPos`` string.
2989
+
2990
+ Accepts an ``XL_DATA_LABEL_POSITION`` member (or raw int), in which case
2991
+ it maps to the OOXML symbol, or an already-valid string, which passes
2992
+ through unchanged. Unmappable ints resolve to ``None`` so no invalid
2993
+ patch is emitted (matching ``Legend.position``'s behavior)."""
2994
+ if value is None:
2995
+ return None
2996
+ if isinstance(value, bool):
2997
+ return None
2998
+ if isinstance(value, int):
2999
+ return _DATA_LABEL_POSITION_INT_TO_STR.get(int(value))
3000
+ return str(value)
3001
+
3002
+
2966
3003
  class DataLabel:
2967
3004
  """python-pptx parity for a single data label on a chart series.
2968
3005
 
@@ -3047,9 +3084,10 @@ class DataLabel:
3047
3084
 
3048
3085
  @position.setter
3049
3086
  def position(self, value: Optional[str]) -> None:
3050
- self._position = value
3051
- if value is not None:
3052
- self._emit(position=str(value))
3087
+ coerced = _coerce_data_label_position(value)
3088
+ self._position = coerced
3089
+ if coerced is not None:
3090
+ self._emit(position=coerced)
3053
3091
 
3054
3092
  @property
3055
3093
  @athena_extension(
@@ -3357,9 +3395,10 @@ class DataLabels:
3357
3395
 
3358
3396
  @position.setter
3359
3397
  def position(self, value: Optional[str]) -> None:
3360
- self._position = value
3361
- if value is not None:
3362
- self._emit_style(position=value)
3398
+ coerced = _coerce_data_label_position(value)
3399
+ self._position = coerced
3400
+ if coerced is not None:
3401
+ self._emit_style(position=coerced)
3363
3402
 
3364
3403
  @property
3365
3404
  def show_category_name(self) -> bool:
@@ -5292,6 +5331,7 @@ class Shape:
5292
5331
  element_type: str = "text",
5293
5332
  transform: Optional[Transform] = None,
5294
5333
  preview_text: Optional[str] = None,
5334
+ rich_content: Optional[dict[str, Any]] = None,
5295
5335
  properties: Optional[dict[str, Any]] = None,
5296
5336
  placeholder: Optional[PlaceholderSnapshot] = None,
5297
5337
  source: Optional[str] = None,
@@ -5310,7 +5350,10 @@ class Shape:
5310
5350
 
5311
5351
  # Auto-shapes also support text in PowerPoint (phase-1 behavior).
5312
5352
  if element_type in ("text", "shape"):
5313
- rich_content = properties.get("richContent") if properties else None
5353
+ if rich_content is None and properties:
5354
+ # Older snapshot experiments nested richContent under
5355
+ # properties. Current API snapshots send it top-level.
5356
+ rich_content = properties.get("richContent")
5314
5357
  self._text_frame = TextFrame(
5315
5358
  shape_id=shape_id,
5316
5359
  buffer=buffer,
@@ -5318,6 +5361,7 @@ class Shape:
5318
5361
  rich_content=rich_content,
5319
5362
  text_frame_properties=text_frame_properties,
5320
5363
  )
5364
+ self._text_frame._shape = self
5321
5365
 
5322
5366
  # Known misspelled/non-existent attributes with specific fix guidance
5323
5367
  _ATTRIBUTE_FIXES: dict[str, str] = {
@@ -7183,9 +7227,11 @@ class Shapes:
7183
7227
  element_type=elem.type,
7184
7228
  transform=elem.transform,
7185
7229
  preview_text=elem.preview_text,
7230
+ rich_content=elem.rich_content,
7186
7231
  properties=elem.properties,
7187
7232
  placeholder=elem.placeholder,
7188
7233
  source=elem.source,
7234
+ text_frame_properties=elem.text_frame_properties,
7189
7235
  )
7190
7236
  self._shapes_by_id[elem.id] = shape
7191
7237
  is_inherited = shape._is_inherited
@@ -9220,7 +9266,6 @@ class Shapes:
9220
9266
  properties=elem.properties,
9221
9267
  placeholder=elem.placeholder,
9222
9268
  source=elem.source,
9223
- text_frame_properties=elem.text_frame_properties,
9224
9269
  )
9225
9270
  elif elem.type == "connector":
9226
9271
  shape = Connector(
@@ -9232,7 +9277,6 @@ class Shapes:
9232
9277
  properties=elem.properties,
9233
9278
  placeholder=elem.placeholder,
9234
9279
  source=elem.source,
9235
- text_frame_properties=elem.text_frame_properties,
9236
9280
  )
9237
9281
  elif elem.type == "group":
9238
9282
  shape = GroupShape(
@@ -9244,7 +9288,6 @@ class Shapes:
9244
9288
  properties=elem.properties,
9245
9289
  placeholder=elem.placeholder,
9246
9290
  source=elem.source,
9247
- text_frame_properties=elem.text_frame_properties,
9248
9291
  )
9249
9292
  else:
9250
9293
  shape = Shape(
@@ -9254,6 +9297,7 @@ class Shapes:
9254
9297
  element_type=elem.type,
9255
9298
  transform=elem.transform,
9256
9299
  preview_text=elem.preview_text,
9300
+ rich_content=elem.rich_content,
9257
9301
  properties=elem.properties,
9258
9302
  placeholder=elem.placeholder,
9259
9303
  source=elem.source,
@@ -162,6 +162,7 @@ class Font:
162
162
  size: Optional[int] = None,
163
163
  name: Optional[str] = None,
164
164
  color_hex: Optional[str] = None,
165
+ font_ref: Optional[str] = None,
165
166
  spacing_pt: Optional[float] = None,
166
167
  strike: Optional[bool] = None,
167
168
  baseline: Optional[int] = None,
@@ -174,6 +175,7 @@ class Font:
174
175
  self._underline = underline
175
176
  self._size = size # In EMU
176
177
  self._name = name
178
+ self._font_ref = font_ref
177
179
  self._color_hex = color_hex
178
180
  self._spacing_pt = spacing_pt # Character spacing in points
179
181
  # OOXML theme color name (e.g. ``"accent1"``, ``"tx1"``) when the
@@ -258,6 +260,7 @@ class Font:
258
260
  @name.setter
259
261
  def name(self, value: str) -> None:
260
262
  self._name = value
263
+ self._font_ref = None
261
264
  self._emit_style_change()
262
265
 
263
266
  @property
@@ -428,6 +431,8 @@ class Font:
428
431
  style["underline"] = self._underline
429
432
  if self._name is not None:
430
433
  style["fontFamily"] = self._name
434
+ if self._font_ref is not None:
435
+ style["fontRef"] = self._font_ref
431
436
  if self._spacing_pt is not None:
432
437
  style["spacingPt"] = self._spacing_pt
433
438
  if self._strike is not None:
@@ -471,6 +476,8 @@ class Font:
471
476
  style["fontSizePt"] = self._size / EMU_PER_PT
472
477
  if self._name is not None:
473
478
  style["fontFamily"] = self._name
479
+ if self._font_ref is not None:
480
+ style["fontRef"] = self._font_ref
474
481
  if self._color_hex is not None:
475
482
  style["colorHex"] = self._color_hex
476
483
  if self._theme_color_name is not None:
@@ -510,13 +517,13 @@ class Font:
510
517
  ``000000`` / ``ENGLISH_US (1033)`` for the Athena blank-template
511
518
  baseline.
512
519
 
513
- Returns a dict with keys ``name``, ``size_pt``, ``bold``,
514
- ``italic``, ``underline``, ``strike``, ``color_hex`` (RGB hex
515
- or scheme name), ``language_id`` (int).
520
+ Returns a dict with keys ``name``, ``font_ref``, ``size_pt``,
521
+ ``bold``, ``italic``, ``underline``, ``strike``, ``color_hex``
522
+ (RGB hex or scheme name), ``language_id`` (int).
516
523
 
517
524
  Closes python-pptx#1063, #765, #883.
518
525
  """
519
- para = self._run._paragraph if self._run else None
526
+ para = self._run._paragraph if self._run is not None else None
520
527
  tf = para._text_frame if para else None
521
528
  shape = getattr(tf, "_shape", None) if tf else None
522
529
 
@@ -527,6 +534,9 @@ class Font:
527
534
  return None
528
535
 
529
536
  para_font = getattr(para, "font", None) if para is not None else None
537
+ paragraph_default: TextStyle = {}
538
+ if para is not None:
539
+ paragraph_default = getattr(para, "_default_run_style", {}) or {}
530
540
 
531
541
  # Shape-level font default (rare; populated for placeholder
532
542
  # snapshots that carry ``shape._properties["fontDefault"]``).
@@ -554,6 +564,8 @@ class Font:
554
564
  size_pt = self._size / EMU_PER_PT
555
565
  elif para_font is not None and getattr(para_font, "_size", None) is not None:
556
566
  size_pt = para_font._size / EMU_PER_PT
567
+ elif paragraph_default.get("fontSizePt") is not None:
568
+ size_pt = float(paragraph_default["fontSizePt"])
557
569
  elif shape_default.get("sizePt") is not None:
558
570
  size_pt = float(shape_default["sizePt"])
559
571
 
@@ -561,31 +573,42 @@ class Font:
561
573
  "name": _first_non_none(
562
574
  self._name,
563
575
  getattr(para_font, "_name", None) if para_font else None,
576
+ paragraph_default.get("fontFamily"),
564
577
  shape_default.get("name"),
565
578
  DEFAULTS["name"],
566
579
  ),
580
+ "font_ref": _first_non_none(
581
+ self._font_ref,
582
+ getattr(para_font, "_font_ref", None) if para_font else None,
583
+ paragraph_default.get("fontRef"),
584
+ shape_default.get("fontRef"),
585
+ ),
567
586
  "size_pt": _first_non_none(size_pt, DEFAULTS["size_pt"]),
568
587
  "bold": _first_non_none(
569
588
  self._bold,
570
589
  getattr(para_font, "_bold", None) if para_font else None,
590
+ paragraph_default.get("bold"),
571
591
  shape_default.get("bold"),
572
592
  DEFAULTS["bold"],
573
593
  ),
574
594
  "italic": _first_non_none(
575
595
  self._italic,
576
596
  getattr(para_font, "_italic", None) if para_font else None,
597
+ paragraph_default.get("italic"),
577
598
  shape_default.get("italic"),
578
599
  DEFAULTS["italic"],
579
600
  ),
580
601
  "underline": _first_non_none(
581
602
  self._underline,
582
603
  getattr(para_font, "_underline", None) if para_font else None,
604
+ paragraph_default.get("underline"),
583
605
  shape_default.get("underline"),
584
606
  DEFAULTS["underline"],
585
607
  ),
586
608
  "strike": _first_non_none(
587
609
  self._strike,
588
610
  getattr(para_font, "_strike", None) if para_font else None,
611
+ paragraph_default.get("strike"),
589
612
  shape_default.get("strike"),
590
613
  DEFAULTS["strike"],
591
614
  ),
@@ -594,13 +617,17 @@ class Font:
594
617
  self._theme_color_name,
595
618
  getattr(para_font, "_color_hex", None) if para_font else None,
596
619
  getattr(para_font, "_theme_color_name", None) if para_font else None,
620
+ paragraph_default.get("colorHex"),
621
+ paragraph_default.get("schemeColor"),
597
622
  shape_default.get("colorHex"),
623
+ shape_default.get("schemeColor"),
598
624
  shape_default.get("themeColor"),
599
625
  DEFAULTS["color_hex"],
600
626
  ),
601
627
  "language_id": _first_non_none(
602
628
  self._language_id,
603
629
  getattr(para_font, "_language_id", None) if para_font else None,
630
+ paragraph_default.get("languageId"),
604
631
  shape_default.get("languageId"),
605
632
  DEFAULTS["language_id"],
606
633
  ),
@@ -810,6 +837,7 @@ class Run:
810
837
  underline=style.get("underline") if style else None,
811
838
  size=int(style.get("fontSizePt", 0) * 12700) if style and style.get("fontSizePt") else None,
812
839
  name=style.get("fontFamily") if style else None,
840
+ font_ref=style.get("fontRef") if style else None,
813
841
  color_hex=style.get("colorHex") if style else None,
814
842
  spacing_pt=style.get("spacingPt") if style else None,
815
843
  )
@@ -1013,6 +1041,7 @@ class Run:
1013
1041
  'underline': self._font._underline,
1014
1042
  'size_emu': self._font._size,
1015
1043
  'font_name': self._font._name,
1044
+ 'font_ref': self._font._font_ref,
1016
1045
  'color_hex': self._font._color_hex,
1017
1046
  }
1018
1047
 
@@ -1110,6 +1139,7 @@ class Paragraph:
1110
1139
  space_after: Optional[int] = None,
1111
1140
  margin_left: Optional[int] = None,
1112
1141
  indent: Optional[int] = None,
1142
+ default_run_style: Optional[TextStyle] = None,
1113
1143
  ):
1114
1144
  self._text_frame = text_frame
1115
1145
  self._index = index
@@ -1123,6 +1153,7 @@ class Paragraph:
1123
1153
  self._space_after = space_after
1124
1154
  self._margin_left = margin_left
1125
1155
  self._indent = indent
1156
+ self._default_run_style = default_run_style or {}
1126
1157
 
1127
1158
  if runs:
1128
1159
  for i, run_data in enumerate(runs):
@@ -1761,6 +1792,7 @@ class Paragraph:
1761
1792
  'alignment': self._alignment,
1762
1793
  'level': self._level,
1763
1794
  'bullet': self._bullet,
1795
+ 'default_run_style': self._default_run_style,
1764
1796
  'run_count': len(self._runs),
1765
1797
  'runs': [run.to_dict() for run in self._runs],
1766
1798
  }
@@ -1831,6 +1863,7 @@ class TextFrame:
1831
1863
  self._shape_id = shape_id
1832
1864
  self._buffer = buffer
1833
1865
  self._preview_text = preview_text or ""
1866
+ self._shape: Any | None = None
1834
1867
  self._paragraphs: list[Paragraph] = []
1835
1868
 
1836
1869
  # Seed internal text-frame-properties state from the source snapshot so
@@ -1860,7 +1893,21 @@ class TextFrame:
1860
1893
  if rich_content and rich_content.get("paragraphs"):
1861
1894
  for i, para_data in enumerate(rich_content["paragraphs"]):
1862
1895
  self._paragraphs.append(
1863
- Paragraph(self, i, runs=para_data.get("runs"))
1896
+ Paragraph(
1897
+ self,
1898
+ i,
1899
+ runs=para_data.get("runs"),
1900
+ alignment=para_data.get("alignment"),
1901
+ level=para_data.get("level", 0),
1902
+ bullet=para_data.get("bullet"),
1903
+ bullet_color_hex=para_data.get("bulletColor"),
1904
+ line_spacing=para_data.get("lineSpacing"),
1905
+ space_before=para_data.get("spaceBeforeEmu"),
1906
+ space_after=para_data.get("spaceAfterEmu"),
1907
+ margin_left=para_data.get("marginLeftEmu"),
1908
+ indent=para_data.get("indentEmu"),
1909
+ default_run_style=para_data.get("defaultRunStyle"),
1910
+ )
1864
1911
  )
1865
1912
  elif preview_text:
1866
1913
  # Split plain text into paragraphs
@@ -1943,6 +1990,20 @@ class TextFrame:
1943
1990
  if len(para._runs) > 1:
1944
1991
  needs_rich = True
1945
1992
  break
1993
+ if (
1994
+ para._alignment is not None
1995
+ or bool(para._level)
1996
+ or para._bullet is not None
1997
+ or para._bullet_color_hex is not None
1998
+ or para._line_spacing is not None
1999
+ or para._space_before is not None
2000
+ or para._space_after is not None
2001
+ or para._margin_left is not None
2002
+ or para._indent is not None
2003
+ or bool(para._default_run_style)
2004
+ ):
2005
+ needs_rich = True
2006
+ break
1946
2007
  for run in para._runs:
1947
2008
  if run._hyperlink._address:
1948
2009
  needs_rich = True
@@ -1973,7 +2034,28 @@ class TextFrame:
1973
2034
  # regress to zero runs and break later SetRunStyle calls.
1974
2035
  if not runs:
1975
2036
  runs.append({"text": ""})
1976
- paragraphs.append({"runs": runs})
2037
+ paragraph_data: dict = {"runs": runs}
2038
+ if para._alignment is not None:
2039
+ paragraph_data["alignment"] = para._alignment
2040
+ if para._level:
2041
+ paragraph_data["level"] = para._level
2042
+ if para._bullet is not None:
2043
+ paragraph_data["bullet"] = para._bullet
2044
+ if para._bullet_color_hex is not None:
2045
+ paragraph_data["bulletColor"] = para._bullet_color_hex
2046
+ if para._line_spacing is not None:
2047
+ paragraph_data["lineSpacing"] = para._line_spacing
2048
+ if para._space_before is not None:
2049
+ paragraph_data["spaceBeforeEmu"] = para._space_before
2050
+ if para._space_after is not None:
2051
+ paragraph_data["spaceAfterEmu"] = para._space_after
2052
+ if para._margin_left is not None:
2053
+ paragraph_data["marginLeftEmu"] = para._margin_left
2054
+ if para._indent is not None:
2055
+ paragraph_data["indentEmu"] = para._indent
2056
+ if para._default_run_style:
2057
+ paragraph_data["defaultRunStyle"] = para._default_run_style
2058
+ paragraphs.append(paragraph_data)
1977
2059
 
1978
2060
  return {"paragraphs": paragraphs}
1979
2061
 
@@ -2789,6 +2871,7 @@ class TextFrame:
2789
2871
  'size_emu': run.font._size,
2790
2872
  'size_pt': run.font.size_pt,
2791
2873
  'font_name': run.font._name,
2874
+ 'font_ref': run.font._font_ref,
2792
2875
  'color_hex': run.font._color_hex,
2793
2876
  })
2794
2877
  return styled_runs
@@ -137,6 +137,9 @@ class TextStyle(TypedDict, total=False):
137
137
 
138
138
  fontSizePt: float
139
139
  fontFamily: str
140
+ # OOXML theme font reference (for example ``+mj-lt`` or ``+mn-lt``).
141
+ # When present, export preserves this over hardcoding fontFamily.
142
+ fontRef: str
140
143
  bold: bool
141
144
  italic: bool
142
145
  underline: bool
@@ -206,6 +209,7 @@ class ElementSnapshot:
206
209
  slide_id: SlideId
207
210
  transform: Transform
208
211
  preview_text: Optional[str] = None
212
+ rich_content: Optional[dict[str, Any]] = None
209
213
  placeholder: Optional[PlaceholderSnapshot] = None
210
214
  properties: Optional[dict[str, Any]] = None
211
215
  source: Optional[Literal["ingested", "sdk", "layout", "master"]] = None
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "athena-python-pptx"
7
- version = "0.3.1"
7
+ version = "0.4.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"