Skip to content

Commit 52d4b00

Browse files
Matthew HoroszowskiMatthew Horoszowski
authored andcommitted
feat(tables): table style API — apply_style, style_id, style_name (Tables 2.0 Phase 2)
Issue: #12 (Phase 2) The fork's Phase 1 (PR #37) shipped row/column add/remove. The next-largest gap on issue #12 — and the most-cited scanny#27 ask (37 comments) — is a public way to apply or read a table's built-in PowerPoint style after the table is created. Previously the GUID was hardcoded inside `_tbl_tmpl()` as a string template (`{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`, "Medium Style 2 - Accent 1") with no element class on the oxml side, no public getter/setter on `Table`, and no name registry — so the existing boolean toggles (`first_row`, `last_row`, `horz_banding`, `vert_banding`) silently bound to a default the user couldn't observe or change. This change adds the public surface end-to-end: oxml layer - New `CT_TableStyleId(BaseOxmlElement)` element class with simple text-content GUID `value` property. - `tableStyleId` declared as `ZeroOrOne` first-child of `CT_TableProperties`, matching the ECMA-376 §21.1.3.15 sequence rule (verified by reading serialized XML after a setter call). - `CT_TableProperties.style_id` r/w property — reads/writes the GUID, removes the child element on `style_id = None`. - Element registered in `pptx.oxml.__init__` next to other table tags. Public API on `Table` - `Table.style_id` — r/w GUID property; `None` when absent; setting `None` removes the element. - `Table.style_name` — reverse lookup against the built-in registry; returns the friendly name or `None` for unregistered GUIDs (lossless fallback — the GUID still round-trips even when the name isn't known). - `Table.apply_style(name_or_guid)` — accepts either a friendly name from the built-in registry (case-insensitive) or a raw brace-wrapped GUID. GUIDs pass through verbatim so any style not in the registry is still reachable. Unknown friendly names raise `ValueError`. Built-in style registry (`pptx.enum.table`) - `PP_TABLE_STYLE` — read-only mapping of 38 verified built-in style names to GUIDs. Coverage includes: No Style (No Grid / Table Grid), Themed Style 1/2 Accent 1, all six "Medium Style 2 - Accent 1..6", Medium Style 1 Accents 1-3+6, Medium Style 3 + Accents 4-5, Light Style 1/2/3 with Accents 1-6 (where present), Dark Style 1/2 + Accent 1/Accent 2. - GUIDs were harvested from real PowerPoint-saved `tableStyles.xml` fragments across 8 unrelated GitHub repos to ensure correctness; hallucinated GUIDs from AI-generated reference files were rejected. - `lookup_table_style(name)` and `style_name_for(guid)` helpers, plus `register_table_style(name, guid)` for runtime extension (custom corp themes, additional Office built-ins discovered later). Tests - 45 new pytest cases in `tests/test_tables_phase2.py` covering each new oxml class, every public Table API method, the registry surface, the `_looks_like_guid` shape detector, save/reload round-trip preservation, and Phase-1-toggle regression checks. Full pytest: 3357 passed (3017 master + 340 carried from earlier phases + 45 new from Phase 2 + any earlier-phase additions; -3 baseline updated by previous merges; see PR #37/#38/#39 history). - 7 new behave scenarios in `features/tbl-styles.feature` exercising default style, apply by name, apply by GUID, case-insensitive resolve, unknown-name `ValueError`, clear-via-`style_id = None`, and save/reload round-trip via stream. Full behave: 1021 scenarios passed (baseline 1014 + 7 new), 0 failed. - Ruff: `ruff check` → All checks passed; `ruff format --check` → no diff. Out of scope (deliberately deferred) - Custom user-defined table styles (writing into `tableStyles.xml`). - Per-cell style overrides beyond what cell APIs already provide. - Theme-color resolution for accent references (PowerPoint's job at render). - Style preview / thumbnail generation. - Validation that the GUID actually exists in `tableStyles.xml`. PowerPoint resolves built-in styles internally; invalid GUIDs fall back to default rendering. UAT - `uat_tables_phase2.py` (untracked per CLAUDE.md §6) at repo root; generates `uat_tables_phase2_out.pptx` with five tables exercising default / apply-by-name / apply-by-GUID / case-insensitive / cleared. All five round-trip assertions pass programmatically; visual UAT in PowerPoint or Keynote pending maintainer signoff. Refs #12
1 parent 5f68282 commit 52d4b00

7 files changed

Lines changed: 803 additions & 0 deletions

File tree

features/steps/tbl_styles.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Gherkin step implementations for Table style API (issue #12 Phase 2)."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
7+
import pytest
8+
from behave import then, when
9+
10+
from pptx import Presentation
11+
12+
13+
# when ====================================================
14+
15+
16+
@when('I call table.apply_style("{style_name_or_guid}")')
17+
def when_apply_style(context, style_name_or_guid):
18+
context.table_.apply_style(style_name_or_guid)
19+
20+
21+
@when("I set table.style_id to None")
22+
def when_set_style_id_to_none(context):
23+
context.table_.style_id = None
24+
25+
26+
@when("I save and reload the presentation via stream")
27+
def when_save_and_reload_via_stream(context):
28+
buf = io.BytesIO()
29+
context.prs.save(buf)
30+
buf.seek(0)
31+
context.prs_reloaded = Presentation(buf)
32+
context.table_reloaded = next(
33+
shp for shp in context.prs_reloaded.slides[0].shapes if shp.has_table
34+
).table
35+
36+
37+
# then ====================================================
38+
39+
40+
@then('table.style_id is "{guid}"')
41+
def then_style_id_is(context, guid):
42+
assert context.table_.style_id == guid, (context.table_.style_id, guid)
43+
44+
45+
@then("table.style_id is None")
46+
def then_style_id_is_none(context):
47+
assert context.table_.style_id is None, context.table_.style_id
48+
49+
50+
@then('table.style_name is "{name}"')
51+
def then_style_name_is(context, name):
52+
assert context.table_.style_name == name, (context.table_.style_name, name)
53+
54+
55+
@then("table.style_name is None")
56+
def then_style_name_is_none(context):
57+
assert context.table_.style_name is None, context.table_.style_name
58+
59+
60+
@then('calling table.apply_style("{name}") raises ValueError')
61+
def then_apply_style_raises_ValueError(context, name):
62+
with pytest.raises(ValueError):
63+
context.table_.apply_style(name)
64+
65+
66+
@then('the reloaded table has style_id "{guid}"')
67+
def then_reloaded_style_id_is(context, guid):
68+
assert context.table_reloaded.style_id == guid
69+
70+
71+
@then('the reloaded table has style_name "{name}"')
72+
def then_reloaded_style_name_is(context, name):
73+
assert context.table_reloaded.style_name == name

features/tbl-styles.feature

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
Feature: Table style API — apply built-in PowerPoint table styles
2+
In order to render tables in the chosen built-in PowerPoint style
3+
As a developer using python-pptx
4+
I need to read, set, and clear a table's style id by name or GUID
5+
6+
7+
Scenario: A newly added table reports the default style
8+
Given a 2x2 table on a fresh slide
9+
Then table.style_id is "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
10+
And table.style_name is "Medium Style 2 - Accent 1"
11+
12+
13+
Scenario: Apply a style by friendly name
14+
Given a 2x2 table on a fresh slide
15+
When I call table.apply_style("Medium Style 2 - Accent 3")
16+
Then table.style_id is "{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}"
17+
And table.style_name is "Medium Style 2 - Accent 3"
18+
19+
20+
Scenario: Apply a style by raw GUID
21+
Given a 2x2 table on a fresh slide
22+
When I call table.apply_style("{2D5ABB26-0587-4C30-8999-92F81FD0307C}")
23+
Then table.style_id is "{2D5ABB26-0587-4C30-8999-92F81FD0307C}"
24+
And table.style_name is "No Style, No Grid"
25+
26+
27+
Scenario: Apply by name is case-insensitive
28+
Given a 2x2 table on a fresh slide
29+
When I call table.apply_style("light style 2 - accent 4")
30+
Then table.style_id is "{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}"
31+
32+
33+
Scenario: Apply an unknown name raises ValueError
34+
Given a 2x2 table on a fresh slide
35+
Then calling table.apply_style("Bogus Name") raises ValueError
36+
37+
38+
Scenario: Clear the style by setting style_id to None
39+
Given a 2x2 table on a fresh slide
40+
When I set table.style_id to None
41+
Then table.style_id is None
42+
And table.style_name is None
43+
44+
45+
Scenario: Round-trip preserves style_id through save/reload
46+
Given a 2x2 table on a fresh slide
47+
When I call table.apply_style("Light Style 2 - Accent 4")
48+
And I save and reload the presentation via stream
49+
Then the reloaded table has style_id "{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}"
50+
And the reloaded table has style_name "Light Style 2 - Accent 4"

src/pptx/enum/table.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Built-in PowerPoint table style registry.
2+
3+
PowerPoint ships a fixed catalog of built-in table styles, each identified by a
4+
GUID written into ``a:tbl/a:tblPr/a:tableStyleId``. The actual style
5+
definitions live inside the PowerPoint application binary, not in the
6+
``.pptx`` file — only the GUID reference is persisted, and PowerPoint
7+
resolves it at render time.
8+
9+
This module exposes the GUIDs as a frozen mapping plus helper functions so
10+
callers can apply a style by friendly name, e.g. ``"Medium Style 2 - Accent
11+
1"``, instead of memorizing GUIDs. The registry covers the English-locale
12+
names; PowerPoint stores the localized name in any saved
13+
``ppt/tableStyles.xml`` it generates, but the GUID is locale-independent and
14+
is what we round-trip.
15+
16+
Coverage notes:
17+
18+
- Phase 2 ships the most-cited subset harvested from real PowerPoint-authored
19+
decks (~39 entries), spanning the No Style / Themed / Light 1-3 / Medium
20+
1-3 / Dark 1-2 families.
21+
- ``apply_style`` accepts either a friendly name (resolved against this
22+
registry) or a raw GUID string (passed through verbatim) — so any style not
23+
yet in the registry is still reachable via its GUID.
24+
- Use ``register_table_style(name, guid)`` to extend the registry at runtime
25+
for styles not covered here (custom corp themes, additional Office
26+
built-ins discovered later).
27+
28+
References:
29+
30+
- ECMA-376 §21.1.3.15 (``CT_TableProperties``) — schema for ``tableStyleId``.
31+
- The default ``ppt/tableStyles.xml`` shipped by PowerPoint contains only a
32+
single ``def`` GUID; the body of each built-in style is resolved
33+
internally. GUIDs in this file were harvested from multiple real
34+
PowerPoint-saved ``tableStyles.xml`` parts to ensure correctness.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
from typing import Mapping
40+
41+
# ---name -> GUID. GUIDs use canonical PowerPoint shape (brace-wrapped,
42+
# ---upper-case hex). Keys are the English-locale `styleName` values
43+
# ---PowerPoint writes into `tableStyles.xml`.
44+
_BUILT_IN_TABLE_STYLES: Mapping[str, str] = {
45+
# No Style
46+
"No Style, No Grid": "{2D5ABB26-0587-4C30-8999-92F81FD0307C}",
47+
"No Style, Table Grid": "{5940675A-B579-460E-94D1-54222C63F5DA}",
48+
# Themed Styles
49+
"Themed Style 1 - Accent 1": "{3C2FFA5D-87B4-456A-9821-1D502468CF0F}",
50+
"Themed Style 2 - Accent 1": "{D113A9D2-9D6B-4929-AA2D-F23B5EE8CBE7}",
51+
# Light Style 1 + accents
52+
"Light Style 1": "{9D7B26C5-4107-4FEC-AEDC-1716B250A1EF}",
53+
"Light Style 1 - Accent 1": "{3B4B98B0-60AC-42C2-AFA5-B58CD77FA1E5}",
54+
"Light Style 1 - Accent 2": "{0E3FDE45-AF77-4B5C-9715-49D594BDF05E}",
55+
"Light Style 1 - Accent 3": "{C083E6E3-FA7D-4D7B-A595-EF9225AFEA82}",
56+
"Light Style 1 - Accent 4": "{D27102A9-8310-4765-A935-A1911B00CA55}",
57+
"Light Style 1 - Accent 5": "{5FD0F851-EC5A-4D38-B0AD-8093EC10F338}",
58+
"Light Style 1 - Accent 6": "{68D230F3-CF80-4859-8CE7-A43EE81993B5}",
59+
# Light Style 2 + accents
60+
"Light Style 2": "{7E9639D4-E3E2-4D34-9284-5A2195B3D0D7}",
61+
"Light Style 2 - Accent 1": "{69012ECD-51FC-41F1-AA8D-1B2483CD663E}",
62+
"Light Style 2 - Accent 2": "{72833802-FEF1-4C79-8D5D-14CF1EAF98D9}",
63+
"Light Style 2 - Accent 3": "{F2DE63D5-997A-4646-A377-4702673A728D}",
64+
"Light Style 2 - Accent 4": "{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}",
65+
"Light Style 2 - Accent 5": "{5A111915-BE36-4E01-A7E5-04B1672EAD32}",
66+
"Light Style 2 - Accent 6": "{912C8C85-51F0-491E-9774-3900AFEF0FD7}",
67+
# Light Style 3 + (partial) accents
68+
"Light Style 3": "{616DA210-FB5B-4158-B5E0-FEB733F419BA}",
69+
"Light Style 3 - Accent 1": "{BC89EF96-8CEA-46FF-86C4-4CE0E7609802}",
70+
"Light Style 3 - Accent 6": "{E8B1032C-EA38-4F05-BA0D-38AFFFC7BED3}",
71+
# Medium Style 1 + (partial) accents
72+
"Medium Style 1 - Accent 1": "{B301B821-A1FF-4177-AEE7-76D212191A09}",
73+
"Medium Style 1 - Accent 2": "{9DCAF9ED-07DC-4A11-8D7F-57B35C25682E}",
74+
"Medium Style 1 - Accent 3": "{1FECB4D8-DB02-4DC6-A0A2-4F2EBAE1DC90}",
75+
"Medium Style 1 - Accent 6": "{10A1B5D5-9B99-4C35-A422-299274C87663}",
76+
# Medium Style 2 + accents (the big one — Accent 1 is the python-pptx
77+
# default; Accents 1-6 are the most-cited entries in scanny#27)
78+
"Medium Style 2": "{073A0DAA-6AF3-43AB-8588-CEC1D06C72B9}",
79+
"Medium Style 2 - Accent 1": "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}",
80+
"Medium Style 2 - Accent 2": "{21E4AEA4-8DFA-4A89-87EB-49C32662AFE0}",
81+
"Medium Style 2 - Accent 3": "{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}",
82+
"Medium Style 2 - Accent 4": "{00A15C55-8517-42AA-B614-E9B94910E393}",
83+
"Medium Style 2 - Accent 5": "{7DF18680-E054-41AD-8BC1-D1AEF772440D}",
84+
"Medium Style 2 - Accent 6": "{93296810-A885-4BE3-A3E7-6D5BEEA58F35}",
85+
# Medium Style 3 + (partial) accents
86+
"Medium Style 3": "{8EC20E35-A176-4012-BC5E-935CFFF8708E}",
87+
"Medium Style 3 - Accent 4": "{EB9631B5-78F2-41C9-869B-9F39066F8104}",
88+
"Medium Style 3 - Accent 5": "{74C1A8A3-306A-4EB7-A6B1-4F7E0EB9C5D6}",
89+
# Dark Style 1/2
90+
"Dark Style 1": "{E8034E78-7F5D-4C2E-B375-FC64B27BC917}",
91+
"Dark Style 2": "{5202B0CA-FC54-4496-8BCA-5EF66A818D29}",
92+
"Dark Style 2 - Accent 1/Accent 2": "{0660B408-B3CF-4A94-85FC-2B1E0A45F4A2}",
93+
}
94+
95+
96+
# ---PP_TABLE_STYLE is the public name. Exposed as a read-only mapping;
97+
# ---callers extend via register_table_style(), not by mutating this dict.
98+
PP_TABLE_STYLE: Mapping[str, str] = dict(_BUILT_IN_TABLE_STYLES)
99+
100+
101+
# ---reverse-lookup table built once at import; mutable so register_table_style
102+
# ---can keep both directions in sync.
103+
_GUID_TO_NAME: dict[str, str] = {guid: name for name, guid in _BUILT_IN_TABLE_STYLES.items()}
104+
# ---name lookup is case-insensitive: both directions stored lower-cased
105+
_NAME_LOWER_TO_NAME: dict[str, str] = {name.lower(): name for name in _BUILT_IN_TABLE_STYLES}
106+
107+
108+
def lookup_table_style(name: str) -> str:
109+
"""Return the GUID for the built-in table style named `name`.
110+
111+
Comparison is case-insensitive. Raises |ValueError| if `name` is not a
112+
known built-in style. Pass a raw GUID directly to ``Table.style_id`` /
113+
``Table.apply_style`` instead of routing through this helper if your
114+
style isn't covered.
115+
"""
116+
canonical = _NAME_LOWER_TO_NAME.get(name.lower())
117+
if canonical is None:
118+
raise ValueError("'%s' is not a known built-in table style name" % name)
119+
# ---refer to the live dict so register_table_style updates take effect
120+
return PP_TABLE_STYLE[canonical] # pyright: ignore[reportIndexIssue]
121+
122+
123+
def style_name_for(guid: str) -> str | None:
124+
"""Return the friendly name registered for `guid`, or |None| when unknown.
125+
126+
Comparison is exact (the registry stores GUIDs in canonical
127+
brace-wrapped upper-case-hex shape). Lossless fallback: callers can
128+
still use ``Table.style_id`` to read the raw GUID when no name is
129+
registered.
130+
"""
131+
return _GUID_TO_NAME.get(guid)
132+
133+
134+
def register_table_style(name: str, guid: str) -> None:
135+
"""Add (or overwrite) a name → GUID entry in the public registry.
136+
137+
Use this for built-in styles not yet covered by the shipped registry, or
138+
for custom ``tableStyles.xml`` entries embedded in your own templates.
139+
Both ``lookup_table_style(name)`` and ``style_name_for(guid)`` see the
140+
new entry immediately.
141+
"""
142+
# ---PP_TABLE_STYLE is typed as read-only Mapping in public surface but
143+
# ---the underlying object is a dict; cast for the in-place update
144+
PP_TABLE_STYLE[name] = guid # type: ignore[index]
145+
_GUID_TO_NAME[guid] = name
146+
_NAME_LOWER_TO_NAME[name.lower()] = name

src/pptx/oxml/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,12 +498,14 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]):
498498
CT_TableGrid,
499499
CT_TableProperties,
500500
CT_TableRow,
501+
CT_TableStyleId,
501502
)
502503

503504
register_element_cls("a:gridCol", CT_TableCol)
504505
register_element_cls("a:tbl", CT_Table)
505506
register_element_cls("a:tblGrid", CT_TableGrid)
506507
register_element_cls("a:tblPr", CT_TableProperties)
508+
register_element_cls("a:tableStyleId", CT_TableStyleId)
507509
register_element_cls("a:tc", CT_TableCell)
508510
register_element_cls("a:tcPr", CT_TableCellProperties)
509511
register_element_cls("a:tr", CT_TableRow)

src/pptx/oxml/table.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,13 +503,69 @@ def remove_gridCol_at(self, idx: int) -> None:
503503
class CT_TableProperties(BaseOxmlElement):
504504
"""`a:tblPr` custom element class."""
505505

506+
get_or_add_tableStyleId: Callable[[], "CT_TableStyleId"]
507+
_add_tableStyleId: Callable[[], "CT_TableStyleId"]
508+
_remove_tableStyleId: Callable[[], None]
509+
510+
# ---ECMA-376 §21.1.3.15 sequence for `<a:tblPr>`: a `tableStyle | tableStyleId`
511+
# ---choice followed by `extLst`. `tableStyleId` must therefore come BEFORE
512+
# ---any `extLst` sibling that a PowerPoint-authored deck may already carry —
513+
# ---xmlchemy inserts new children before the named successor, so listing
514+
# ---`a:extLst` here keeps `tableStyleId` ahead of it. The inline-definition
515+
# ---variant `<a:tableStyle>` is intentionally not modeled in Phase 2 (decks
516+
# ---with inline style definitions on `tblPr` will still round-trip the raw
517+
# ---element via lxml, but `Table.style_id`/`style_name` will report `None`).
518+
tableStyleId: "CT_TableStyleId | None" = ZeroOrOne( # pyright: ignore[reportAssignmentType]
519+
"a:tableStyleId", successors=("a:extLst",)
520+
)
521+
506522
bandRow = OptionalAttribute("bandRow", XsdBoolean, default=False)
507523
bandCol = OptionalAttribute("bandCol", XsdBoolean, default=False)
508524
firstRow = OptionalAttribute("firstRow", XsdBoolean, default=False)
509525
firstCol = OptionalAttribute("firstCol", XsdBoolean, default=False)
510526
lastRow = OptionalAttribute("lastRow", XsdBoolean, default=False)
511527
lastCol = OptionalAttribute("lastCol", XsdBoolean, default=False)
512528

529+
@property
530+
def style_id(self) -> str | None:
531+
"""GUID string from `<a:tableStyleId>` child, or |None| when absent.
532+
533+
Returns the canonical brace-wrapped upper-case-hex shape PowerPoint
534+
emits, e.g. ``"{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"``.
535+
"""
536+
elm = self.tableStyleId
537+
if elm is None:
538+
return None
539+
return elm.value
540+
541+
@style_id.setter
542+
def style_id(self, value: str | None) -> None:
543+
"""Set the `<a:tableStyleId>` text to `value`, or remove the element when |None|."""
544+
if value is None:
545+
if self.tableStyleId is not None:
546+
self._remove_tableStyleId()
547+
return
548+
tableStyleId = self.get_or_add_tableStyleId()
549+
tableStyleId.value = value
550+
551+
552+
class CT_TableStyleId(BaseOxmlElement):
553+
"""`a:tableStyleId` custom element class.
554+
555+
Element with simple GUID text content referencing a table style by id —
556+
typically a built-in style like ``{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}``
557+
(Medium Style 2 - Accent 1).
558+
"""
559+
560+
@property
561+
def value(self) -> str:
562+
"""The GUID string held in this element's text content."""
563+
return self.text or ""
564+
565+
@value.setter
566+
def value(self, val: str) -> None:
567+
self.text = val
568+
513569

514570
class CT_TableRow(BaseOxmlElement):
515571
"""`a:tr` custom element class."""

0 commit comments

Comments
 (0)