athena-python-pptx 0.1.74__tar.gz → 0.1.76__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 (83) hide show
  1. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/CHANGELOG.md +71 -0
  2. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/PKG-INFO +1 -1
  3. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/__init__.py +1 -1
  4. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/client.py +17 -0
  5. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/commands.py +7 -0
  6. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/__init__.py +78 -8
  7. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/text/__init__.py +25 -1
  8. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/typing.py +21 -0
  9. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/units.py +20 -0
  10. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pyproject.toml +1 -1
  11. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/.gitignore +0 -0
  12. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/API_PARITY_REPORT.md +0 -0
  13. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/CLAUDE.md +0 -0
  14. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/DEV-GUIDE.md +0 -0
  15. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/PARITY_QUESTIONS.md +0 -0
  16. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/PUBLISHING.md +0 -0
  17. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/README.md +0 -0
  18. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/docs/API_PARITY_EXCEPTIONS.md +0 -0
  19. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/docs/athena-api.json +0 -0
  20. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/docs/athena-api.md +0 -0
  21. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/_ptc.py +0 -0
  22. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/action.py +0 -0
  23. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/batching.py +0 -0
  24. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/__init__.py +0 -0
  25. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/axis.py +0 -0
  26. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/category.py +0 -0
  27. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/chart.py +0 -0
  28. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/data.py +0 -0
  29. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/datalabel.py +0 -0
  30. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/legend.py +0 -0
  31. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/marker.py +0 -0
  32. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/plot.py +0 -0
  33. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/point.py +0 -0
  34. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/series.py +0 -0
  35. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/chart/xlsx.py +0 -0
  36. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/decorators.py +0 -0
  37. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/dml/__init__.py +0 -0
  38. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/dml/chtfmt.py +0 -0
  39. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/dml/color.py +0 -0
  40. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/dml/effect.py +0 -0
  41. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/dml/fill.py +0 -0
  42. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/dml/line.py +0 -0
  43. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/docgen.py +0 -0
  44. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/__init__.py +0 -0
  45. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/action.py +0 -0
  46. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/chart.py +0 -0
  47. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/dml.py +0 -0
  48. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/lang.py +0 -0
  49. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/shapes.py +0 -0
  50. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/enum/text.py +0 -0
  51. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/errors.py +0 -0
  52. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/exc.py +0 -0
  53. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/media.py +0 -0
  54. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/package.py +0 -0
  55. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/__init__.py +0 -0
  56. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/_base.py +0 -0
  57. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/chart.py +0 -0
  58. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/coreprops.py +0 -0
  59. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/embeddedpackage.py +0 -0
  60. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/image.py +0 -0
  61. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/media.py +0 -0
  62. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/presentation.py +0 -0
  63. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/parts/slide.py +0 -0
  64. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/presentation.py +0 -0
  65. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/autoshape.py +0 -0
  66. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/base.py +0 -0
  67. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/connector.py +0 -0
  68. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/freeform.py +0 -0
  69. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/graphfrm.py +0 -0
  70. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/group.py +0 -0
  71. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/picture.py +0 -0
  72. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/placeholder.py +0 -0
  73. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shapes/shapetree.py +0 -0
  74. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/shared.py +0 -0
  75. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/slide.py +0 -0
  76. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/slides.py +0 -0
  77. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/spec.py +0 -0
  78. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/table.py +0 -0
  79. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/text/fonts.py +0 -0
  80. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/text/layout.py +0 -0
  81. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/text/text.py +0 -0
  82. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/types.py +0 -0
  83. {athena_python_pptx-0.1.74 → athena_python_pptx-0.1.76}/pptx/util.py +0 -0
@@ -2,6 +2,77 @@
2
2
 
3
3
  All notable changes to `athena-python-pptx` are documented in this file.
4
4
 
5
+ ## 0.1.76
6
+
7
+ **Table cell paragraph alignment now round-trips to OOXML (baseline-parity Gap A2).**
8
+
9
+ `cell.text_frame.paragraphs[0].alignment = PP_ALIGN.X` previously stored
10
+ the value locally for python-pptx parity but never reached the server.
11
+ The setter now translates `PP_PARAGRAPH_ALIGNMENT` enum members (and the
12
+ python-pptx lowercase string aliases) to the OOXML short form
13
+ (`l` / `ctr` / `r` / `just` / `dist` / `thaiDist` / `justLow`) and emits
14
+ a `SetTableCell` command carrying `paragraphAlignment`. The applier
15
+ writes it to every paragraph in the cell's text body; the export worker
16
+ emits a `SetTableCellText` intent whose paragraph alignment lands on
17
+ `<a:pPr algn="…">` in the exported PPTX.
18
+
19
+ Closes ~52 baseline-parity XML diffs against the 33 fixtures in
20
+ `pptx-studio/tests/baseline-parity/`.
21
+
22
+ ## 0.1.75
23
+
24
+ **Critical bug fix — invisible text from `font.size = Pt(X)`.**
25
+
26
+ A user surfaced that agent-generated decks rendered as blank
27
+ rectangles in the Olympus editor. Investigation traced the symptom to
28
+ `fontSizePt: 355600` (= 28 × 12700) landing on the wire instead of
29
+ `fontSizePt: 28` — making the renderer treat text as 355,600-point
30
+ glyphs and rendering them effectively invisible.
31
+
32
+ ### Root cause
33
+
34
+ `Length.__truediv__` returns a `Length` subclass — e.g.
35
+ `Pt(28) / EMU_PER_PT` returns a `Pt` instance with raw int value `28`
36
+ (meaning 28 EMU, semantically "28 points worth"). The SDK's font-size
37
+ serializer relies on this to convert EMU → points:
38
+
39
+ ```python
40
+ style["fontSizePt"] = self._size / EMU_PER_PT # Pt(355600) / 12700
41
+ ```
42
+
43
+ The trouble: `Command.to_dict()` calls `dataclasses.asdict()` which
44
+ **deep-copies all nested values**. `copy.deepcopy(Pt(28))` reconstructs
45
+ via `Pt.__new__(28)`, and `Pt.__new__` interprets its argument as
46
+ *points* (not EMU) and multiplies by 12700. So a Pt instance whose
47
+ internal int was 28 EMU came back out as 28 × 12700 = 355600 EMU.
48
+
49
+ By the time the JSON went over the wire, `fontSizePt` was 355600.
50
+ Server stored it, renderer rendered text at 355,600pt → invisible.
51
+
52
+ ### Fix
53
+
54
+ Override `Length.__reduce_ex__` so `copy.deepcopy` (and `pickle`)
55
+ reconstruct via the new module-level `_length_reconstruct`, which uses
56
+ `Length._from_emu` (preserves raw EMU value). Three lines in
57
+ `pptx/units.py`; behaviour-preserving for every existing call site.
58
+
59
+ ### Regression test
60
+
61
+ New `test_length_round_trips_through_deepcopy` in `tests/test_units.py`
62
+ asserts that `copy.deepcopy(Pt(28))` and `asdict({...Pt(28) / 12700...})`
63
+ both preserve the raw EMU value. Would have caught this bug on day one.
64
+
65
+ ### Impact
66
+
67
+ Every agent-authored deck where the agent set `run.font.size = Pt(X)`
68
+ through the SDK was silently shipping `fontSizePt: X * 12700` to the
69
+ server. Existing decks may need to be regenerated by the agent or
70
+ manually corrected. Future agent runs against v0.1.75+ will render
71
+ text correctly.
72
+
73
+ The new SDK was published to PyPI as 0.1.75 and the Daytona
74
+ `presentation-exec` snapshot bumped to v38.
75
+
5
76
  ## 0.1.74
6
77
 
7
78
  **True drop-in import-path parity.** A behavioural-fidelity audit found
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: athena-python-pptx
3
- Version: 0.1.74
3
+ Version: 0.1.76
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
@@ -127,7 +127,7 @@ def flush_all() -> None:
127
127
  _active_buffers[:] = alive
128
128
 
129
129
 
130
- __version__ = "0.1.74"
130
+ __version__ = "0.1.76"
131
131
 
132
132
  __all__ = [
133
133
  # Main entry point
@@ -542,6 +542,7 @@ class Client:
542
542
  MasterSnapshot,
543
543
  PlaceholderSnapshot,
544
544
  SlideSnapshot,
545
+ TextFramePropertiesSnapshot,
545
546
  ThemeHierarchySnapshot,
546
547
  Transform,
547
548
  )
@@ -577,6 +578,21 @@ class Client:
577
578
  local_transform_override=placeholder_data.get("localTransformOverride"),
578
579
  )
579
580
 
581
+ # Server stores margins under `marginLeftEmu` after any SDK update
582
+ # and under `marginLeft` immediately after ingest. Read both.
583
+ tfp_data = elem_data.get("textFrameProperties")
584
+ text_frame_properties: Optional[TextFramePropertiesSnapshot] = None
585
+ if isinstance(tfp_data, dict):
586
+ text_frame_properties = TextFramePropertiesSnapshot(
587
+ word_wrap=tfp_data.get("wordWrap"),
588
+ auto_size=tfp_data.get("autoSize"),
589
+ vertical_anchor=tfp_data.get("verticalAnchor"),
590
+ margin_left=tfp_data.get("marginLeftEmu", tfp_data.get("marginLeft")),
591
+ margin_right=tfp_data.get("marginRightEmu", tfp_data.get("marginRight")),
592
+ margin_top=tfp_data.get("marginTopEmu", tfp_data.get("marginTop")),
593
+ margin_bottom=tfp_data.get("marginBottomEmu", tfp_data.get("marginBottom")),
594
+ )
595
+
580
596
  elements[elem_id] = ElementSnapshot(
581
597
  id=elem_data["id"],
582
598
  type=elem_data["type"],
@@ -596,6 +612,7 @@ class Client:
596
612
  source=elem_data.get("source"),
597
613
  table_data=elem_data.get("tableData"),
598
614
  asset_id=elem_data.get("assetId"),
615
+ text_frame_properties=text_frame_properties,
599
616
  )
600
617
 
601
618
  theme_hierarchy_raw = data.get("themeHierarchy")
@@ -476,6 +476,12 @@ class SetTableCell(Command):
476
476
  col: Column index (0-based)
477
477
  text: Cell text content
478
478
  fill_color_hex: Cell background color
479
+ paragraph_alignment: Horizontal alignment of the cell's text body.
480
+ Mirrors python-pptx ``cell.text_frame.paragraphs[0].alignment``.
481
+ Accepts the OOXML short forms ``l``, ``ctr``, ``r``, ``just``,
482
+ ``dist``, ``thaiDist``. Translated from ``PP_ALIGN`` /
483
+ ``PP_PARAGRAPH_ALIGNMENT`` by the SDK before emit. Closes
484
+ baseline-parity Gap A2 (~52 diffs across table fixtures).
479
485
  """
480
486
 
481
487
  shape_id: ShapeId
@@ -486,6 +492,7 @@ class SetTableCell(Command):
486
492
  font_size_centipoints: Optional[int] = None
487
493
  bold: Optional[bool] = None
488
494
  font_color_hex: Optional[str] = None
495
+ paragraph_alignment: Optional[str] = None
489
496
 
490
497
  @property
491
498
  def command_type(self) -> str:
@@ -26,7 +26,7 @@ from ..chart.category import Categories
26
26
  from ..dml.color import RGBColor, ColorFormat
27
27
  from ..errors import UnsupportedFeatureError
28
28
  from ..text import TextFrame
29
- from ..typing import ElementSnapshot, ShapeId, Transform, PlaceholderSnapshot, PP_PLACEHOLDER
29
+ from ..typing import ElementSnapshot, ShapeId, Transform, PlaceholderSnapshot, PP_PLACEHOLDER, TextFramePropertiesSnapshot
30
30
  from ..units import Emu, Length, ensure_emu
31
31
 
32
32
  if TYPE_CHECKING:
@@ -56,6 +56,57 @@ _ELEMENT_TYPE_TO_MSO_SHAPE_TYPE: dict[str, MSO_SHAPE_TYPE] = {
56
56
  }
57
57
 
58
58
 
59
+ # Map PP_PARAGRAPH_ALIGNMENT enum members and python-pptx string aliases
60
+ # to OOXML <a:pPr algn="..."> short forms. Accepts the IntEnum int values
61
+ # directly so callers can pass ``PP_ALIGN.CENTER`` without importing the
62
+ # enum class. ``None`` clears the attribute.
63
+ _PP_ALIGN_INT_TO_OOXML: dict[int, str] = {
64
+ 1: "l",
65
+ 2: "ctr",
66
+ 3: "r",
67
+ 4: "just",
68
+ 5: "dist",
69
+ 6: "thaiDist",
70
+ 7: "justLow",
71
+ }
72
+ _PP_ALIGN_NAME_TO_OOXML: dict[str, str] = {
73
+ "left": "l",
74
+ "center": "ctr",
75
+ "right": "r",
76
+ "justify": "just",
77
+ "distribute": "dist",
78
+ "thai_distribute": "thaiDist",
79
+ "justify_low": "justLow",
80
+ # Already-short forms pass through.
81
+ "l": "l", "ctr": "ctr", "r": "r",
82
+ "just": "just", "dist": "dist", "thaiDist": "thaiDist", "justLow": "justLow",
83
+ }
84
+
85
+
86
+ def _normalize_pp_align(value: Any) -> Optional[str]:
87
+ """Translate a PP_ALIGN value to the OOXML short form used by SetTableCell.
88
+
89
+ Accepts ``PP_PARAGRAPH_ALIGNMENT`` (or any ``int``) and the lowercased
90
+ python-pptx string aliases used by ``pptx.text.PP_ALIGN``. Returns
91
+ ``None`` for ``None`` input so the setter can clear the attribute.
92
+ """
93
+ if value is None:
94
+ return None
95
+ if isinstance(value, int) and not isinstance(value, bool):
96
+ try:
97
+ return _PP_ALIGN_INT_TO_OOXML[int(value)]
98
+ except KeyError:
99
+ raise ValueError(f"Unsupported paragraph alignment value: {value!r}")
100
+ if isinstance(value, str):
101
+ try:
102
+ return _PP_ALIGN_NAME_TO_OOXML[value]
103
+ except KeyError:
104
+ raise ValueError(f"Unsupported paragraph alignment value: {value!r}")
105
+ raise TypeError(
106
+ f"alignment must be a PP_ALIGN enum, int, or string; got {type(value).__name__}"
107
+ )
108
+
109
+
59
110
  # Map XL_CHART_TYPE → (server chart_type, grouping). Only the chart types
60
111
  # author.ts supports today are listed here; anything else raises
61
112
  # UnsupportedFeatureError. Surface area mirrors python-pptx's accepted enum
@@ -2516,6 +2567,7 @@ class Shape:
2516
2567
  properties: Optional[dict[str, Any]] = None,
2517
2568
  placeholder: Optional[PlaceholderSnapshot] = None,
2518
2569
  source: Optional[str] = None,
2570
+ text_frame_properties: Optional[TextFramePropertiesSnapshot] = None,
2519
2571
  ):
2520
2572
  self._shape_id = shape_id
2521
2573
  self._slide = slide
@@ -2536,6 +2588,7 @@ class Shape:
2536
2588
  buffer=buffer,
2537
2589
  preview_text=preview_text,
2538
2590
  rich_content=rich_content,
2591
+ text_frame_properties=text_frame_properties,
2539
2592
  )
2540
2593
 
2541
2594
  # Known misspelled/non-existent attributes with specific fix guidance
@@ -5159,6 +5212,7 @@ class Shapes:
5159
5212
  properties=elem.properties,
5160
5213
  placeholder=elem.placeholder,
5161
5214
  source=elem.source,
5215
+ text_frame_properties=elem.text_frame_properties,
5162
5216
  )
5163
5217
  elif elem.type == "connector":
5164
5218
  shape = Connector(
@@ -5170,6 +5224,7 @@ class Shapes:
5170
5224
  properties=elem.properties,
5171
5225
  placeholder=elem.placeholder,
5172
5226
  source=elem.source,
5227
+ text_frame_properties=elem.text_frame_properties,
5173
5228
  )
5174
5229
  elif elem.type == "group":
5175
5230
  shape = GroupShape(
@@ -5181,6 +5236,7 @@ class Shapes:
5181
5236
  properties=elem.properties,
5182
5237
  placeholder=elem.placeholder,
5183
5238
  source=elem.source,
5239
+ text_frame_properties=elem.text_frame_properties,
5184
5240
  )
5185
5241
  else:
5186
5242
  shape = Shape(
@@ -5193,6 +5249,7 @@ class Shapes:
5193
5249
  properties=elem.properties,
5194
5250
  placeholder=elem.placeholder,
5195
5251
  source=elem.source,
5252
+ text_frame_properties=elem.text_frame_properties,
5196
5253
  )
5197
5254
  self._shapes.append(shape)
5198
5255
  self._shapes_by_id[elem.id] = shape
@@ -5808,15 +5865,13 @@ class _TableCellParagraph:
5808
5865
  For API parity with python-pptx, ``add_run()`` and ``clear()`` are
5809
5866
  supported but operate on the single underlying run — multiple
5810
5867
  ``add_run()`` calls return the *same* ``_TableCellRun`` object, and
5811
- only the last text/style assignment wins. ``alignment`` is accepted
5812
- for compatibility but doesn't yet round-trip to OOXML (no per-cell
5813
- paragraph-alignment command on the applier).
5868
+ only the last text/style assignment wins. ``alignment`` round-trips
5869
+ to ``<a:pPr algn="...">`` on the cell's text body via SetTableCell.
5814
5870
  """
5815
5871
 
5816
5872
  def __init__(self, cell: "TableCell"):
5817
5873
  self._cell = cell
5818
5874
  self._runs = [_TableCellRun(cell)]
5819
- self._alignment: Optional[Any] = None
5820
5875
 
5821
5876
  @property
5822
5877
  def runs(self) -> list[_TableCellRun]:
@@ -5832,12 +5887,22 @@ class _TableCellParagraph:
5832
5887
 
5833
5888
  @property
5834
5889
  def alignment(self) -> Optional[Any]:
5835
- return self._alignment
5890
+ """Return the cell's paragraph alignment as the original input value.
5891
+
5892
+ Stored on the parent cell so that text-body re-creation (which
5893
+ rebuilds the paragraph wrapper) doesn't lose the assignment.
5894
+ """
5895
+ return self._cell._paragraph_alignment_input
5836
5896
 
5837
5897
  @alignment.setter
5838
5898
  def alignment(self, value: Any) -> None:
5839
- # Accepted for python-pptx parity; not yet round-tripped to OOXML.
5840
- self._alignment = value
5899
+ # Translate PP_ALIGN enum / string to OOXML short form. Stored on
5900
+ # the cell so we can re-emit it inside SetTableCell. Keep the
5901
+ # original input around for the getter to return per python-pptx
5902
+ # semantics (callers expect the same value type back).
5903
+ self._cell._paragraph_alignment_input = value
5904
+ self._cell._paragraph_alignment = _normalize_pp_align(value)
5905
+ self._cell._emit_cell_change()
5841
5906
 
5842
5907
  def add_run(self) -> _TableCellRun:
5843
5908
  """Return the single underlying run.
@@ -5937,6 +6002,10 @@ class TableCell:
5937
6002
  self._margin_top = margin_top
5938
6003
  self._margin_bottom = margin_bottom
5939
6004
  self._vertical_anchor = vertical_anchor
6005
+ # OOXML short form (``l``/``ctr``/``r``/...) sent over the wire and
6006
+ # the caller's original input retained for the python-pptx getter.
6007
+ self._paragraph_alignment: Optional[str] = None
6008
+ self._paragraph_alignment_input: Any = None
5940
6009
 
5941
6010
  @property
5942
6011
  def text(self) -> str:
@@ -6312,6 +6381,7 @@ class TableCell:
6312
6381
  font_size_centipoints=int(self._font_size_pt * 100) if self._font_size_pt is not None else None,
6313
6382
  bold=self._bold,
6314
6383
  font_color_hex=self._font_color_hex,
6384
+ paragraph_alignment=self._paragraph_alignment,
6315
6385
  )
6316
6386
  self._table._buffer.add(cmd)
6317
6387
 
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Iterator, Optional, Union
10
10
  from ..commands import SetText, SetRunStyle, SetParagraphStyle, SetTextFrameProperties, FitText
11
11
  from ..dml.color import RGBColor, ColorFormat
12
12
  from ..errors import UnsupportedFeatureError
13
- from ..typing import ShapeId, TextStyle
13
+ from ..typing import ShapeId, TextStyle, TextFramePropertiesSnapshot
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from ..batching import CommandBuffer
@@ -1328,12 +1328,36 @@ class TextFrame:
1328
1328
  buffer: Optional[CommandBuffer],
1329
1329
  preview_text: Optional[str] = None,
1330
1330
  rich_content: Optional[dict[str, Any]] = None,
1331
+ text_frame_properties: Optional[TextFramePropertiesSnapshot] = None,
1331
1332
  ):
1332
1333
  self._shape_id = shape_id
1333
1334
  self._buffer = buffer
1334
1335
  self._preview_text = preview_text or ""
1335
1336
  self._paragraphs: list[Paragraph] = []
1336
1337
 
1338
+ # Seed internal text-frame-properties state from the source snapshot so
1339
+ # subsequent setter emits (which always rebroadcast the full state)
1340
+ # preserve the props the user hasn't touched. Without this, setting
1341
+ # `text_frame.margin_left = 0` would emit a SetTextFrameProperties
1342
+ # command without `autoSize`, the server's existing autoSize would be
1343
+ # the only thing keeping the box auto-fit — and it isn't there for
1344
+ # shapes created via AddTextBox, so the box renders without auto-fit.
1345
+ if text_frame_properties is not None:
1346
+ if text_frame_properties.word_wrap is not None:
1347
+ self._word_wrap = text_frame_properties.word_wrap
1348
+ if text_frame_properties.auto_size is not None:
1349
+ self._auto_size = text_frame_properties.auto_size
1350
+ if text_frame_properties.vertical_anchor is not None:
1351
+ self._vertical_anchor = text_frame_properties.vertical_anchor
1352
+ if text_frame_properties.margin_left is not None:
1353
+ self._margin_left = text_frame_properties.margin_left
1354
+ if text_frame_properties.margin_right is not None:
1355
+ self._margin_right = text_frame_properties.margin_right
1356
+ if text_frame_properties.margin_top is not None:
1357
+ self._margin_top = text_frame_properties.margin_top
1358
+ if text_frame_properties.margin_bottom is not None:
1359
+ self._margin_bottom = text_frame_properties.margin_bottom
1360
+
1337
1361
  # Initialize paragraphs from rich content or plain text
1338
1362
  if rich_content and rich_content.get("paragraphs"):
1339
1363
  for i, para_data in enumerate(rich_content["paragraphs"]):
@@ -162,6 +162,26 @@ class PlaceholderSnapshot:
162
162
  local_transform_override: Optional[bool] = None
163
163
 
164
164
 
165
+ @dataclass
166
+ class TextFramePropertiesSnapshot:
167
+ """Snapshot of a text frame's structural properties (auto-size, margins, etc.).
168
+
169
+ Carries the source PPTX's <a:bodyPr> state through `from_url` so the SDK's
170
+ TextFrame can populate its internal `_auto_size`/`_word_wrap`/`_margin_*`
171
+ fields. Without this, a user setter (`text_frame.margin_left = 0`) emits a
172
+ SetTextFrameProperties command that omits the un-touched props, the server
173
+ keeps whatever it has, and round-trip is lossy.
174
+ """
175
+
176
+ word_wrap: Optional[bool] = None
177
+ auto_size: Optional[Literal["none", "shape_to_fit_text", "text_to_fit_shape"]] = None
178
+ vertical_anchor: Optional[Literal["top", "middle", "bottom"]] = None
179
+ margin_left: Optional[int] = None
180
+ margin_right: Optional[int] = None
181
+ margin_top: Optional[int] = None
182
+ margin_bottom: Optional[int] = None
183
+
184
+
165
185
  @dataclass
166
186
  class ElementSnapshot:
167
187
  """Snapshot of an element's state."""
@@ -176,6 +196,7 @@ class ElementSnapshot:
176
196
  source: Optional[Literal["ingested", "sdk", "layout", "master"]] = None
177
197
  table_data: Optional[dict[str, Any]] = None
178
198
  asset_id: Optional[str] = None
199
+ text_frame_properties: Optional[TextFramePropertiesSnapshot] = None
179
200
 
180
201
 
181
202
  @dataclass
@@ -31,6 +31,17 @@ EMU_PER_CENTIPOINT = 127
31
31
  EMU_PER_PX = 9525 # At 96 DPI
32
32
 
33
33
 
34
+ def _length_reconstruct(cls: type, emu_value: int): # noqa: ANN201
35
+ """Top-level reconstructor for :class:`Length` subclasses.
36
+
37
+ Used by :meth:`Length.__reduce_ex__` so that ``copy.deepcopy`` (and
38
+ ``pickle``) round-trip a :class:`Length` subclass without going
39
+ through the unit-converting ``__new__``. Must be a module-level
40
+ function so pickle can locate it by qualified name.
41
+ """
42
+ return cls._from_emu(emu_value)
43
+
44
+
34
45
  class Length(int):
35
46
  """
36
47
  Length expressed in English Metric Units (EMU) — base unit class.
@@ -45,6 +56,15 @@ class Length(int):
45
56
  def __new__(cls, value: Union[int, float, "Length"]) -> "Length":
46
57
  return super().__new__(cls, int(round(value)))
47
58
 
59
+ # ``copy.deepcopy`` (used by ``dataclasses.asdict``) reconstructs ints
60
+ # by invoking ``Type(int_value)``. For our subclasses that means
61
+ # ``Pt(355600)`` etc., which interpret the value as user-units and
62
+ # multiply by the unit's EMU factor → ``355600 → 4,516,120,000``.
63
+ # Override the pickle/reduce protocol so deep-copy round-trips via
64
+ # ``_from_emu`` (treats the value as raw EMU), preserving identity.
65
+ def __reduce_ex__(self, protocol: int): # type: ignore[override]
66
+ return (_length_reconstruct, (type(self), int(self)))
67
+
48
68
  @property
49
69
  def inches(self) -> float:
50
70
  """Return value in inches."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "athena-python-pptx"
7
- version = "0.1.74"
7
+ version = "0.1.76"
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"