athena-python-pptx 0.4.0__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 (86) hide show
  1. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/PKG-INFO +1 -1
  2. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/docs/API_PARITY_EXCEPTIONS.md +24 -13
  3. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/__init__.py +1 -1
  4. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/client.py +1 -0
  5. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/commands.py +56 -54
  6. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/__init__.py +9 -4
  7. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/text/__init__.py +89 -6
  8. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/typing.py +4 -0
  9. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pyproject.toml +1 -1
  10. athena_python_pptx-0.4.0/uv.lock +0 -1163
  11. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/.gitignore +0 -0
  12. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/API_PARITY_REPORT.md +0 -0
  13. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/CHANGELOG.md +0 -0
  14. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/CLAUDE.md +0 -0
  15. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/DEV-GUIDE.md +0 -0
  16. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/PARITY_QUESTIONS.md +0 -0
  17. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/PUBLISHING.md +0 -0
  18. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/README.md +0 -0
  19. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/docs/athena-api.json +0 -0
  20. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/docs/athena-api.md +0 -0
  21. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/_athena_extension.py +0 -0
  22. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/_ptc.py +0 -0
  23. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/_references.py +0 -0
  24. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/action.py +0 -0
  25. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/batching.py +0 -0
  26. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/__init__.py +0 -0
  27. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/axis.py +0 -0
  28. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/category.py +0 -0
  29. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/chart.py +0 -0
  30. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/data.py +0 -0
  31. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/datalabel.py +0 -0
  32. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/legend.py +0 -0
  33. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/marker.py +0 -0
  34. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/plot.py +0 -0
  35. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/point.py +0 -0
  36. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/series.py +0 -0
  37. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/chart/xlsx.py +0 -0
  38. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/decorators.py +0 -0
  39. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/dml/__init__.py +0 -0
  40. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/dml/chtfmt.py +0 -0
  41. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/dml/color.py +0 -0
  42. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/dml/effect.py +0 -0
  43. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/dml/fill.py +0 -0
  44. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/dml/line.py +0 -0
  45. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/docgen.py +0 -0
  46. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/__init__.py +0 -0
  47. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/action.py +0 -0
  48. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/chart.py +0 -0
  49. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/dml.py +0 -0
  50. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/lang.py +0 -0
  51. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/shapes.py +0 -0
  52. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/enum/text.py +0 -0
  53. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/errors.py +0 -0
  54. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/exc.py +0 -0
  55. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/media.py +0 -0
  56. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/package.py +0 -0
  57. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/__init__.py +0 -0
  58. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/_base.py +0 -0
  59. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/chart.py +0 -0
  60. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/coreprops.py +0 -0
  61. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/embeddedpackage.py +0 -0
  62. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/image.py +0 -0
  63. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/media.py +0 -0
  64. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/presentation.py +0 -0
  65. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/parts/slide.py +0 -0
  66. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/presentation.py +0 -0
  67. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/autoshape.py +0 -0
  68. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/base.py +0 -0
  69. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/connector.py +0 -0
  70. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/freeform.py +0 -0
  71. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/graphfrm.py +0 -0
  72. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/group.py +0 -0
  73. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/picture.py +0 -0
  74. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/placeholder.py +0 -0
  75. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shapes/shapetree.py +0 -0
  76. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/shared.py +0 -0
  77. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/slide.py +0 -0
  78. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/slides.py +0 -0
  79. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/spec.py +0 -0
  80. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/table.py +0 -0
  81. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/text/fonts.py +0 -0
  82. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/text/layout.py +0 -0
  83. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/text/text.py +0 -0
  84. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/types.py +0 -0
  85. {athena_python_pptx-0.4.0 → athena_python_pptx-0.4.1}/pptx/units.py +0 -0
  86. {athena_python_pptx-0.4.0 → 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.4.0
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.4.0"
135
+ __version__ = "0.4.1"
136
136
 
137
137
  __all__ = [
138
138
  # Main entry point
@@ -666,6 +666,7 @@ class Client:
666
666
  flipV=transform_data.get("flipV"),
667
667
  ),
668
668
  preview_text=elem_data.get("previewText"),
669
+ rich_content=elem_data.get("richContent"),
669
670
  placeholder=placeholder,
670
671
  properties=elem_data.get("properties"),
671
672
  source=elem_data.get("source"),
@@ -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
@@ -2260,10 +2262,10 @@ class AddChart(Command):
2260
2262
  f"chart_type must be one of {sorted(valid_types)}",
2261
2263
  "chart_type",
2262
2264
  )
2263
- if self.w_emu <= 0:
2264
- raise ValidationError("w_emu must be positive", "w_emu")
2265
- if self.h_emu <= 0:
2266
- 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")
2267
2269
  if not self.series:
2268
2270
  raise ValidationError("at least one series is required", "series")
2269
2271
  if self.grouping is not None and self.grouping not in (
@@ -2576,10 +2578,10 @@ class AddChart2016(Command):
2576
2578
  f"chart_type must be one of {sorted(valid)} (got {self.chart_type!r})",
2577
2579
  "chart_type",
2578
2580
  )
2579
- if self.w_emu <= 0:
2580
- raise ValidationError("w_emu must be positive", "w_emu")
2581
- if self.h_emu <= 0:
2582
- 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")
2583
2585
  if not self.series:
2584
2586
  raise ValidationError("at least one series is required", "series")
2585
2587
 
@@ -2952,10 +2954,10 @@ class AddMovie(Command):
2952
2954
  def validate(self) -> None:
2953
2955
  if self.slide_index < 0:
2954
2956
  raise ValidationError("slide_index must be >= 0", "slide_index")
2955
- if self.w_emu <= 0:
2956
- raise ValidationError("w_emu must be positive", "w_emu")
2957
- if self.h_emu <= 0:
2958
- 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")
2959
2961
  # Reject empty strings — they look "set" but ship no payload.
2960
2962
  has_embedded = bool(self.movie_base64)
2961
2963
  has_url = bool(self.url)
@@ -5331,6 +5331,7 @@ class Shape:
5331
5331
  element_type: str = "text",
5332
5332
  transform: Optional[Transform] = None,
5333
5333
  preview_text: Optional[str] = None,
5334
+ rich_content: Optional[dict[str, Any]] = None,
5334
5335
  properties: Optional[dict[str, Any]] = None,
5335
5336
  placeholder: Optional[PlaceholderSnapshot] = None,
5336
5337
  source: Optional[str] = None,
@@ -5349,7 +5350,10 @@ class Shape:
5349
5350
 
5350
5351
  # Auto-shapes also support text in PowerPoint (phase-1 behavior).
5351
5352
  if element_type in ("text", "shape"):
5352
- 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")
5353
5357
  self._text_frame = TextFrame(
5354
5358
  shape_id=shape_id,
5355
5359
  buffer=buffer,
@@ -5357,6 +5361,7 @@ class Shape:
5357
5361
  rich_content=rich_content,
5358
5362
  text_frame_properties=text_frame_properties,
5359
5363
  )
5364
+ self._text_frame._shape = self
5360
5365
 
5361
5366
  # Known misspelled/non-existent attributes with specific fix guidance
5362
5367
  _ATTRIBUTE_FIXES: dict[str, str] = {
@@ -7222,9 +7227,11 @@ class Shapes:
7222
7227
  element_type=elem.type,
7223
7228
  transform=elem.transform,
7224
7229
  preview_text=elem.preview_text,
7230
+ rich_content=elem.rich_content,
7225
7231
  properties=elem.properties,
7226
7232
  placeholder=elem.placeholder,
7227
7233
  source=elem.source,
7234
+ text_frame_properties=elem.text_frame_properties,
7228
7235
  )
7229
7236
  self._shapes_by_id[elem.id] = shape
7230
7237
  is_inherited = shape._is_inherited
@@ -9259,7 +9266,6 @@ class Shapes:
9259
9266
  properties=elem.properties,
9260
9267
  placeholder=elem.placeholder,
9261
9268
  source=elem.source,
9262
- text_frame_properties=elem.text_frame_properties,
9263
9269
  )
9264
9270
  elif elem.type == "connector":
9265
9271
  shape = Connector(
@@ -9271,7 +9277,6 @@ class Shapes:
9271
9277
  properties=elem.properties,
9272
9278
  placeholder=elem.placeholder,
9273
9279
  source=elem.source,
9274
- text_frame_properties=elem.text_frame_properties,
9275
9280
  )
9276
9281
  elif elem.type == "group":
9277
9282
  shape = GroupShape(
@@ -9283,7 +9288,6 @@ class Shapes:
9283
9288
  properties=elem.properties,
9284
9289
  placeholder=elem.placeholder,
9285
9290
  source=elem.source,
9286
- text_frame_properties=elem.text_frame_properties,
9287
9291
  )
9288
9292
  else:
9289
9293
  shape = Shape(
@@ -9293,6 +9297,7 @@ class Shapes:
9293
9297
  element_type=elem.type,
9294
9298
  transform=elem.transform,
9295
9299
  preview_text=elem.preview_text,
9300
+ rich_content=elem.rich_content,
9296
9301
  properties=elem.properties,
9297
9302
  placeholder=elem.placeholder,
9298
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.4.0"
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"