diff --git a/features/steps/tbl_styles.py b/features/steps/tbl_styles.py new file mode 100644 index 000000000..b3d5d3c2e --- /dev/null +++ b/features/steps/tbl_styles.py @@ -0,0 +1,73 @@ +"""Gherkin step implementations for Table style API (issue #12 Phase 2).""" + +from __future__ import annotations + +import io + +import pytest +from behave import then, when + +from pptx import Presentation + + +# when ==================================================== + + +@when('I call table.apply_style("{style_name_or_guid}")') +def when_apply_style(context, style_name_or_guid): + context.table_.apply_style(style_name_or_guid) + + +@when("I set table.style_id to None") +def when_set_style_id_to_none(context): + context.table_.style_id = None + + +@when("I save and reload the presentation via stream") +def when_save_and_reload_via_stream(context): + buf = io.BytesIO() + context.prs.save(buf) + buf.seek(0) + context.prs_reloaded = Presentation(buf) + context.table_reloaded = next( + shp for shp in context.prs_reloaded.slides[0].shapes if shp.has_table + ).table + + +# then ==================================================== + + +@then('table.style_id is "{guid}"') +def then_style_id_is(context, guid): + assert context.table_.style_id == guid, (context.table_.style_id, guid) + + +@then("table.style_id is None") +def then_style_id_is_none(context): + assert context.table_.style_id is None, context.table_.style_id + + +@then('table.style_name is "{name}"') +def then_style_name_is(context, name): + assert context.table_.style_name == name, (context.table_.style_name, name) + + +@then("table.style_name is None") +def then_style_name_is_none(context): + assert context.table_.style_name is None, context.table_.style_name + + +@then('calling table.apply_style("{name}") raises ValueError') +def then_apply_style_raises_ValueError(context, name): + with pytest.raises(ValueError): + context.table_.apply_style(name) + + +@then('the reloaded table has style_id "{guid}"') +def then_reloaded_style_id_is(context, guid): + assert context.table_reloaded.style_id == guid + + +@then('the reloaded table has style_name "{name}"') +def then_reloaded_style_name_is(context, name): + assert context.table_reloaded.style_name == name diff --git a/features/tbl-styles.feature b/features/tbl-styles.feature new file mode 100644 index 000000000..cd092ad74 --- /dev/null +++ b/features/tbl-styles.feature @@ -0,0 +1,50 @@ +Feature: Table style API — apply built-in PowerPoint table styles + In order to render tables in the chosen built-in PowerPoint style + As a developer using python-pptx + I need to read, set, and clear a table's style id by name or GUID + + + Scenario: A newly added table reports the default style + Given a 2x2 table on a fresh slide + Then table.style_id is "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" + And table.style_name is "Medium Style 2 - Accent 1" + + + Scenario: Apply a style by friendly name + Given a 2x2 table on a fresh slide + When I call table.apply_style("Medium Style 2 - Accent 3") + Then table.style_id is "{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}" + And table.style_name is "Medium Style 2 - Accent 3" + + + Scenario: Apply a style by raw GUID + Given a 2x2 table on a fresh slide + When I call table.apply_style("{2D5ABB26-0587-4C30-8999-92F81FD0307C}") + Then table.style_id is "{2D5ABB26-0587-4C30-8999-92F81FD0307C}" + And table.style_name is "No Style, No Grid" + + + Scenario: Apply by name is case-insensitive + Given a 2x2 table on a fresh slide + When I call table.apply_style("light style 2 - accent 4") + Then table.style_id is "{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}" + + + Scenario: Apply an unknown name raises ValueError + Given a 2x2 table on a fresh slide + Then calling table.apply_style("Bogus Name") raises ValueError + + + Scenario: Clear the style by setting style_id to None + Given a 2x2 table on a fresh slide + When I set table.style_id to None + Then table.style_id is None + And table.style_name is None + + + Scenario: Round-trip preserves style_id through save/reload + Given a 2x2 table on a fresh slide + When I call table.apply_style("Light Style 2 - Accent 4") + And I save and reload the presentation via stream + Then the reloaded table has style_id "{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}" + And the reloaded table has style_name "Light Style 2 - Accent 4" diff --git a/src/pptx/enum/table.py b/src/pptx/enum/table.py new file mode 100644 index 000000000..7a45ce09b --- /dev/null +++ b/src/pptx/enum/table.py @@ -0,0 +1,146 @@ +"""Built-in PowerPoint table style registry. + +PowerPoint ships a fixed catalog of built-in table styles, each identified by a +GUID written into ``a:tbl/a:tblPr/a:tableStyleId``. The actual style +definitions live inside the PowerPoint application binary, not in the +``.pptx`` file — only the GUID reference is persisted, and PowerPoint +resolves it at render time. + +This module exposes the GUIDs as a frozen mapping plus helper functions so +callers can apply a style by friendly name, e.g. ``"Medium Style 2 - Accent +1"``, instead of memorizing GUIDs. The registry covers the English-locale +names; PowerPoint stores the localized name in any saved +``ppt/tableStyles.xml`` it generates, but the GUID is locale-independent and +is what we round-trip. + +Coverage notes: + +- Phase 2 ships the most-cited subset harvested from real PowerPoint-authored + decks (~39 entries), spanning the No Style / Themed / Light 1-3 / Medium + 1-3 / Dark 1-2 families. +- ``apply_style`` accepts either a friendly name (resolved against this + registry) or a raw GUID string (passed through verbatim) — so any style not + yet in the registry is still reachable via its GUID. +- Use ``register_table_style(name, guid)`` to extend the registry at runtime + for styles not covered here (custom corp themes, additional Office + built-ins discovered later). + +References: + +- ECMA-376 §21.1.3.15 (``CT_TableProperties``) — schema for ``tableStyleId``. +- The default ``ppt/tableStyles.xml`` shipped by PowerPoint contains only a + single ``def`` GUID; the body of each built-in style is resolved + internally. GUIDs in this file were harvested from multiple real + PowerPoint-saved ``tableStyles.xml`` parts to ensure correctness. +""" + +from __future__ import annotations + +from typing import Mapping + +# ---name -> GUID. GUIDs use canonical PowerPoint shape (brace-wrapped, +# ---upper-case hex). Keys are the English-locale `styleName` values +# ---PowerPoint writes into `tableStyles.xml`. +_BUILT_IN_TABLE_STYLES: Mapping[str, str] = { + # No Style + "No Style, No Grid": "{2D5ABB26-0587-4C30-8999-92F81FD0307C}", + "No Style, Table Grid": "{5940675A-B579-460E-94D1-54222C63F5DA}", + # Themed Styles + "Themed Style 1 - Accent 1": "{3C2FFA5D-87B4-456A-9821-1D502468CF0F}", + "Themed Style 2 - Accent 1": "{D113A9D2-9D6B-4929-AA2D-F23B5EE8CBE7}", + # Light Style 1 + accents + "Light Style 1": "{9D7B26C5-4107-4FEC-AEDC-1716B250A1EF}", + "Light Style 1 - Accent 1": "{3B4B98B0-60AC-42C2-AFA5-B58CD77FA1E5}", + "Light Style 1 - Accent 2": "{0E3FDE45-AF77-4B5C-9715-49D594BDF05E}", + "Light Style 1 - Accent 3": "{C083E6E3-FA7D-4D7B-A595-EF9225AFEA82}", + "Light Style 1 - Accent 4": "{D27102A9-8310-4765-A935-A1911B00CA55}", + "Light Style 1 - Accent 5": "{5FD0F851-EC5A-4D38-B0AD-8093EC10F338}", + "Light Style 1 - Accent 6": "{68D230F3-CF80-4859-8CE7-A43EE81993B5}", + # Light Style 2 + accents + "Light Style 2": "{7E9639D4-E3E2-4D34-9284-5A2195B3D0D7}", + "Light Style 2 - Accent 1": "{69012ECD-51FC-41F1-AA8D-1B2483CD663E}", + "Light Style 2 - Accent 2": "{72833802-FEF1-4C79-8D5D-14CF1EAF98D9}", + "Light Style 2 - Accent 3": "{F2DE63D5-997A-4646-A377-4702673A728D}", + "Light Style 2 - Accent 4": "{17292A2E-F333-43FB-9621-5CBBE7FDCDCB}", + "Light Style 2 - Accent 5": "{5A111915-BE36-4E01-A7E5-04B1672EAD32}", + "Light Style 2 - Accent 6": "{912C8C85-51F0-491E-9774-3900AFEF0FD7}", + # Light Style 3 + (partial) accents + "Light Style 3": "{616DA210-FB5B-4158-B5E0-FEB733F419BA}", + "Light Style 3 - Accent 1": "{BC89EF96-8CEA-46FF-86C4-4CE0E7609802}", + "Light Style 3 - Accent 6": "{E8B1032C-EA38-4F05-BA0D-38AFFFC7BED3}", + # Medium Style 1 + (partial) accents + "Medium Style 1 - Accent 1": "{B301B821-A1FF-4177-AEE7-76D212191A09}", + "Medium Style 1 - Accent 2": "{9DCAF9ED-07DC-4A11-8D7F-57B35C25682E}", + "Medium Style 1 - Accent 3": "{1FECB4D8-DB02-4DC6-A0A2-4F2EBAE1DC90}", + "Medium Style 1 - Accent 6": "{10A1B5D5-9B99-4C35-A422-299274C87663}", + # Medium Style 2 + accents (the big one — Accent 1 is the python-pptx + # default; Accents 1-6 are the most-cited entries in scanny#27) + "Medium Style 2": "{073A0DAA-6AF3-43AB-8588-CEC1D06C72B9}", + "Medium Style 2 - Accent 1": "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}", + "Medium Style 2 - Accent 2": "{21E4AEA4-8DFA-4A89-87EB-49C32662AFE0}", + "Medium Style 2 - Accent 3": "{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}", + "Medium Style 2 - Accent 4": "{00A15C55-8517-42AA-B614-E9B94910E393}", + "Medium Style 2 - Accent 5": "{7DF18680-E054-41AD-8BC1-D1AEF772440D}", + "Medium Style 2 - Accent 6": "{93296810-A885-4BE3-A3E7-6D5BEEA58F35}", + # Medium Style 3 + (partial) accents + "Medium Style 3": "{8EC20E35-A176-4012-BC5E-935CFFF8708E}", + "Medium Style 3 - Accent 4": "{EB9631B5-78F2-41C9-869B-9F39066F8104}", + "Medium Style 3 - Accent 5": "{74C1A8A3-306A-4EB7-A6B1-4F7E0EB9C5D6}", + # Dark Style 1/2 + "Dark Style 1": "{E8034E78-7F5D-4C2E-B375-FC64B27BC917}", + "Dark Style 2": "{5202B0CA-FC54-4496-8BCA-5EF66A818D29}", + "Dark Style 2 - Accent 1/Accent 2": "{0660B408-B3CF-4A94-85FC-2B1E0A45F4A2}", +} + + +# ---PP_TABLE_STYLE is the public name. Exposed as a read-only mapping; +# ---callers extend via register_table_style(), not by mutating this dict. +PP_TABLE_STYLE: Mapping[str, str] = dict(_BUILT_IN_TABLE_STYLES) + + +# ---reverse-lookup table built once at import; mutable so register_table_style +# ---can keep both directions in sync. +_GUID_TO_NAME: dict[str, str] = {guid: name for name, guid in _BUILT_IN_TABLE_STYLES.items()} +# ---name lookup is case-insensitive: both directions stored lower-cased +_NAME_LOWER_TO_NAME: dict[str, str] = {name.lower(): name for name in _BUILT_IN_TABLE_STYLES} + + +def lookup_table_style(name: str) -> str: + """Return the GUID for the built-in table style named `name`. + + Comparison is case-insensitive. Raises |ValueError| if `name` is not a + known built-in style. Pass a raw GUID directly to ``Table.style_id`` / + ``Table.apply_style`` instead of routing through this helper if your + style isn't covered. + """ + canonical = _NAME_LOWER_TO_NAME.get(name.lower()) + if canonical is None: + raise ValueError("'%s' is not a known built-in table style name" % name) + # ---refer to the live dict so register_table_style updates take effect + return PP_TABLE_STYLE[canonical] # pyright: ignore[reportIndexIssue] + + +def style_name_for(guid: str) -> str | None: + """Return the friendly name registered for `guid`, or |None| when unknown. + + Comparison is exact (the registry stores GUIDs in canonical + brace-wrapped upper-case-hex shape). Lossless fallback: callers can + still use ``Table.style_id`` to read the raw GUID when no name is + registered. + """ + return _GUID_TO_NAME.get(guid) + + +def register_table_style(name: str, guid: str) -> None: + """Add (or overwrite) a name → GUID entry in the public registry. + + Use this for built-in styles not yet covered by the shipped registry, or + for custom ``tableStyles.xml`` entries embedded in your own templates. + Both ``lookup_table_style(name)`` and ``style_name_for(guid)`` see the + new entry immediately. + """ + # ---PP_TABLE_STYLE is typed as read-only Mapping in public surface but + # ---the underlying object is a dict; cast for the in-place update + PP_TABLE_STYLE[name] = guid # type: ignore[index] + _GUID_TO_NAME[guid] = name + _NAME_LOWER_TO_NAME[name.lower()] = name diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 3531db14c..b91fa72fa 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -498,12 +498,14 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): CT_TableGrid, CT_TableProperties, CT_TableRow, + CT_TableStyleId, ) register_element_cls("a:gridCol", CT_TableCol) register_element_cls("a:tbl", CT_Table) register_element_cls("a:tblGrid", CT_TableGrid) register_element_cls("a:tblPr", CT_TableProperties) +register_element_cls("a:tableStyleId", CT_TableStyleId) register_element_cls("a:tc", CT_TableCell) register_element_cls("a:tcPr", CT_TableCellProperties) register_element_cls("a:tr", CT_TableRow) diff --git a/src/pptx/oxml/table.py b/src/pptx/oxml/table.py index a642eafb4..78aa9d607 100644 --- a/src/pptx/oxml/table.py +++ b/src/pptx/oxml/table.py @@ -503,6 +503,22 @@ def remove_gridCol_at(self, idx: int) -> None: class CT_TableProperties(BaseOxmlElement): """`a:tblPr` custom element class.""" + get_or_add_tableStyleId: Callable[[], "CT_TableStyleId"] + _add_tableStyleId: Callable[[], "CT_TableStyleId"] + _remove_tableStyleId: Callable[[], None] + + # ---ECMA-376 §21.1.3.15 sequence for ``: a `tableStyle | tableStyleId` + # ---choice followed by `extLst`. `tableStyleId` must therefore come BEFORE + # ---any `extLst` sibling that a PowerPoint-authored deck may already carry — + # ---xmlchemy inserts new children before the named successor, so listing + # ---`a:extLst` here keeps `tableStyleId` ahead of it. The inline-definition + # ---variant `` is intentionally not modeled in Phase 2 (decks + # ---with inline style definitions on `tblPr` will still round-trip the raw + # ---element via lxml, but `Table.style_id`/`style_name` will report `None`). + tableStyleId: "CT_TableStyleId | None" = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:tableStyleId", successors=("a:extLst",) + ) + bandRow = OptionalAttribute("bandRow", XsdBoolean, default=False) bandCol = OptionalAttribute("bandCol", XsdBoolean, default=False) firstRow = OptionalAttribute("firstRow", XsdBoolean, default=False) @@ -510,6 +526,46 @@ class CT_TableProperties(BaseOxmlElement): lastRow = OptionalAttribute("lastRow", XsdBoolean, default=False) lastCol = OptionalAttribute("lastCol", XsdBoolean, default=False) + @property + def style_id(self) -> str | None: + """GUID string from `` child, or |None| when absent. + + Returns the canonical brace-wrapped upper-case-hex shape PowerPoint + emits, e.g. ``"{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"``. + """ + elm = self.tableStyleId + if elm is None: + return None + return elm.value + + @style_id.setter + def style_id(self, value: str | None) -> None: + """Set the `` text to `value`, or remove the element when |None|.""" + if value is None: + if self.tableStyleId is not None: + self._remove_tableStyleId() + return + tableStyleId = self.get_or_add_tableStyleId() + tableStyleId.value = value + + +class CT_TableStyleId(BaseOxmlElement): + """`a:tableStyleId` custom element class. + + Element with simple GUID text content referencing a table style by id — + typically a built-in style like ``{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}`` + (Medium Style 2 - Accent 1). + """ + + @property + def value(self) -> str: + """The GUID string held in this element's text content.""" + return self.text or "" + + @value.setter + def value(self, val: str) -> None: + self.text = val + class CT_TableRow(BaseOxmlElement): """`a:tr` custom element class.""" diff --git a/src/pptx/table.py b/src/pptx/table.py index cb7f34215..be2194a29 100644 --- a/src/pptx/table.py +++ b/src/pptx/table.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Iterator from pptx.dml.fill import FillFormat @@ -165,6 +166,85 @@ def vert_banding(self) -> bool: def vert_banding(self, value: bool): self._tbl.bandCol = value + @property + def style_id(self) -> str | None: + """The GUID identifying this table's built-in style, or |None|. + + Read/write. Maps to ``a:tbl/a:tblPr/a:tableStyleId``. PowerPoint + emits the GUID in canonical brace-wrapped upper-case-hex shape, e.g. + ``"{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"`` for "Medium Style 2 - + Accent 1". Setting to |None| removes the element. + """ + tblPr = self._tbl.tblPr + if tblPr is None: + return None + return tblPr.style_id + + @style_id.setter + def style_id(self, value: str | None) -> None: + if value is None: + tblPr = self._tbl.tblPr + if tblPr is None: + return + tblPr.style_id = None + return + tblPr = self._tbl.get_or_add_tblPr() + tblPr.style_id = value + + @property + def style_name(self) -> str | None: + """Friendly name of the current built-in style, or |None|. + + Returns the friendly name (e.g. ``"Medium Style 2 - Accent 1"``) + when the current ``style_id`` is in the built-in registry. Returns + |None| when no style is set, or when the GUID is set but not + recognized — the GUID is still readable via ``style_id`` in that + case (lossless fallback). + """ + from pptx.enum.table import style_name_for + + guid = self.style_id + if guid is None: + return None + return style_name_for(guid) + + def apply_style(self, name_or_guid: str) -> None: + """Set this table's style by friendly name or raw GUID. + + ``name_or_guid`` may be: + + - A built-in style name like ``"Medium Style 2 - Accent 1"`` — + resolved against ``pptx.enum.table.PP_TABLE_STYLE`` + (case-insensitive). Raises |ValueError| if the name is not in the + registry. + - A GUID string in canonical brace-wrapped form (e.g. + ``"{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"``) — written through + verbatim, allowing styles not yet in the registry (custom + ``tableStyles.xml`` entries, additional Office built-ins) to be + applied directly. + + For the GUID form the registry is not consulted — the value is + treated as opaque and lossless. Use ``style_name`` to read back the + friendly name when one is registered. + """ + from pptx.enum.table import lookup_table_style + + if _looks_like_guid(name_or_guid): + self.style_id = name_or_guid + return + # ---friendly name path: registry lookup, ValueError on miss--- + self.style_id = lookup_table_style(name_or_guid) + + +_GUID_RE = re.compile( + r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$" +) + + +def _looks_like_guid(value: str) -> bool: + """True when `value` matches the canonical brace-wrapped GUID shape.""" + return bool(_GUID_RE.match(value)) + class _BorderEdge: """Adapter providing a `LineFormat`-compatible interface for one edge of a cell border. diff --git a/tests/test_tables_phase2.py b/tests/test_tables_phase2.py new file mode 100644 index 000000000..7efcd6c13 --- /dev/null +++ b/tests/test_tables_phase2.py @@ -0,0 +1,396 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for Tables 2.0 Phase 2 — table style API. + +Covers: + +- New oxml: `CT_TableStyleId` element class plus `CT_TableProperties.style_id` + property (read/write/clear) routed through a `ZeroOrOne` `` + child element. +- Public API on |Table|: `style_id` (r/w GUID), `style_name` (reverse lookup), + `apply_style(name_or_guid)` accepting either a friendly name (case-insensitive + registry lookup) or a raw brace-wrapped GUID. +- Built-in registry in `pptx.enum.table`: `PP_TABLE_STYLE`, + `lookup_table_style`, `style_name_for`, `register_table_style`. +- Round-trip: save a presentation with a custom style applied, reopen it, + assert the GUID survived. +- Anti-criteria: existing toggles (`first_row` etc.) still round-trip; the + default style remains `Medium Style 2 - Accent 1` for newly-added tables; + the GUID emitted always uses the canonical brace-wrapped upper-case-hex + shape. + +Issue: https://github.com/MHoroszowski/python-pptx/issues/12 (Phase 2). +""" + +from __future__ import annotations + +import io +import re + +import pytest + +from pptx import Presentation +from pptx.enum.table import ( + PP_TABLE_STYLE, + lookup_table_style, + register_table_style, + style_name_for, +) +from pptx.oxml.table import CT_TableProperties, CT_TableStyleId +from pptx.table import Table, _looks_like_guid +from pptx.util import Inches + +from .unitutil.cxml import element + +DEFAULT_GUID = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" +ALT_GUID = "{F5AB1C69-6EDB-4FF4-983F-18BD219EF322}" # Medium Style 2 - Accent 3 +NO_GRID_GUID = "{2D5ABB26-0587-4C30-8999-92F81FD0307C}" # No Style, No Grid + +CANONICAL_GUID_RE = re.compile( + r"^\{[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\}$" +) + + +# --------------------------------------------------------------------------- +# OXML LAYER — CT_TableStyleId + CT_TableProperties.style_id +# --------------------------------------------------------------------------- + + +class DescribeCT_TableStyleId(object): + """Unit-test suite for `CT_TableStyleId.value` round-trip.""" + + def it_reads_value_from_element_text(self): + elm = element("a:tableStyleId") + assert isinstance(elm, CT_TableStyleId) + elm.text = DEFAULT_GUID + + assert elm.value == DEFAULT_GUID + + def it_writes_value_to_element_text(self): + elm = element("a:tableStyleId") + elm.value = DEFAULT_GUID + + assert elm.text == DEFAULT_GUID + + def it_returns_empty_string_when_no_text(self): + elm = element("a:tableStyleId") + assert elm.value == "" + + +class DescribeCT_TableProperties_StyleId(object): + """Unit-test suite for `CT_TableProperties.style_id` property.""" + + def it_reads_None_when_tableStyleId_absent(self): + tblPr = element("a:tblPr") + assert isinstance(tblPr, CT_TableProperties) + assert tblPr.style_id is None + + def it_reads_guid_when_tableStyleId_present(self): + tblPr = element("a:tblPr") + styleId_elm = tblPr.get_or_add_tableStyleId() + styleId_elm.text = DEFAULT_GUID + + assert tblPr.style_id == DEFAULT_GUID + + def it_creates_tableStyleId_on_set(self): + tblPr = element("a:tblPr") + + tblPr.style_id = DEFAULT_GUID + + assert tblPr.tableStyleId is not None + assert tblPr.tableStyleId.text == DEFAULT_GUID + + def it_updates_existing_tableStyleId_on_set(self): + tblPr = element("a:tblPr") + tblPr.style_id = DEFAULT_GUID + + tblPr.style_id = ALT_GUID + + assert tblPr.style_id == ALT_GUID + + def it_removes_tableStyleId_on_set_None(self): + tblPr = element("a:tblPr") + tblPr.style_id = DEFAULT_GUID + assert tblPr.tableStyleId is not None + + tblPr.style_id = None + + assert tblPr.tableStyleId is None + + def it_is_a_no_op_to_clear_when_already_absent(self): + tblPr = element("a:tblPr") + assert tblPr.tableStyleId is None + + tblPr.style_id = None # ---should not raise--- + + assert tblPr.tableStyleId is None + + def it_emits_tableStyleId_as_first_child_of_tblPr(self): + # ---ECMA-376 §21.1.3.15 sequence rule--- + tblPr = element("a:tblPr") + tblPr.style_id = DEFAULT_GUID + + children = list(tblPr) + + assert len(children) == 1 + assert children[0].tag.endswith("}tableStyleId") + + def it_inserts_tableStyleId_before_extLst_when_tblPr_has_extLst(self): + # ---regression for ECMA-376 §21.1.3.15: `tableStyleId` must come + # ---BEFORE `extLst`. PowerPoint-authored decks may carry an + # ---existing `` on ``; setting style_id must not + # ---append after it. + tblPr = element("a:tblPr/a:extLst") + + tblPr.style_id = DEFAULT_GUID + + children = list(tblPr) + assert len(children) == 2 + assert children[0].tag.endswith("}tableStyleId") + assert children[1].tag.endswith("}extLst") + + +# --------------------------------------------------------------------------- +# REGISTRY — pptx.enum.table +# --------------------------------------------------------------------------- + + +class DescribePP_TABLE_STYLE_Registry(object): + """Unit-test suite for `PP_TABLE_STYLE` and helper functions.""" + + def it_includes_the_python_pptx_default_style(self): + assert PP_TABLE_STYLE["Medium Style 2 - Accent 1"] == DEFAULT_GUID + + def it_includes_all_six_medium_style_2_accents(self): + # ---scanny#27's 37 commenters most often asked about Medium Style 2 --- + # ---and its accents; verify all six are present and unique GUIDs--- + guids = [PP_TABLE_STYLE["Medium Style 2 - Accent %d" % n] for n in range(1, 7)] + assert len(set(guids)) == 6 + for g in guids: + assert CANONICAL_GUID_RE.match(g), g + + def it_uses_canonical_brace_upper_hex_shape_for_every_guid(self): + for name, guid in PP_TABLE_STYLE.items(): + assert CANONICAL_GUID_RE.match(guid), "%s: %r" % (name, guid) + + def it_has_no_duplicate_guid_entries(self): + guids = list(PP_TABLE_STYLE.values()) + assert len(guids) == len(set(guids)) + + def it_includes_the_no_style_no_grid_entry(self): + assert PP_TABLE_STYLE["No Style, No Grid"] == NO_GRID_GUID + + +class Describe_lookup_table_style(object): + """Unit-test suite for `lookup_table_style`.""" + + def it_returns_guid_for_canonical_name(self): + assert lookup_table_style("Medium Style 2 - Accent 1") == DEFAULT_GUID + + def it_is_case_insensitive(self): + assert lookup_table_style("medium style 2 - accent 1") == DEFAULT_GUID + assert lookup_table_style("MEDIUM STYLE 2 - ACCENT 1") == DEFAULT_GUID + + def it_raises_ValueError_on_unknown_name(self): + with pytest.raises(ValueError) as excinfo: + lookup_table_style("Bogus Style") + assert "Bogus Style" in str(excinfo.value) + + +class Describe_style_name_for(object): + """Unit-test suite for `style_name_for`.""" + + def it_returns_friendly_name_for_known_guid(self): + assert style_name_for(DEFAULT_GUID) == "Medium Style 2 - Accent 1" + + def it_returns_None_for_unknown_guid(self): + assert style_name_for("{00000000-0000-0000-0000-000000000000}") is None + + +class Describe_register_table_style(object): + """Unit-test suite for `register_table_style` extensibility.""" + + def it_allows_runtime_extension(self): + custom_guid = "{ABCDEF01-2345-6789-ABCD-EF0123456789}" + register_table_style("CorpStyle Custom", custom_guid) + try: + assert lookup_table_style("CorpStyle Custom") == custom_guid + assert style_name_for(custom_guid) == "CorpStyle Custom" + assert lookup_table_style("corpstyle custom") == custom_guid + finally: + # ---test isolation: clean up the registry--- + del PP_TABLE_STYLE["CorpStyle Custom"] # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# PUBLIC API — Table.style_id / style_name / apply_style +# --------------------------------------------------------------------------- + + +@pytest.fixture +def table_fixture(): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + gf = slide.shapes.add_table(2, 2, Inches(1), Inches(1), Inches(4), Inches(2)) + return gf.table + + +class DescribeTable_StyleAPI(object): + """Unit-test suite for `Table.style_id`, `Table.style_name`, `Table.apply_style`.""" + + def it_returns_default_style_id_for_a_new_table(self, table_fixture): + # ---fork's `_tbl_tmpl` bakes Medium Style 2 - Accent 1 as default--- + assert table_fixture.style_id == DEFAULT_GUID + + def it_returns_default_style_name_for_a_new_table(self, table_fixture): + assert table_fixture.style_name == "Medium Style 2 - Accent 1" + + def it_can_set_style_id_directly(self, table_fixture): + table_fixture.style_id = ALT_GUID + + assert table_fixture.style_id == ALT_GUID + + def it_can_clear_style_id_with_None(self, table_fixture): + table_fixture.style_id = None + + assert table_fixture.style_id is None + assert table_fixture.style_name is None + + def it_returns_None_style_name_when_guid_is_unregistered(self, table_fixture): + table_fixture.style_id = "{00000000-0000-0000-0000-000000000000}" + + assert table_fixture.style_id == "{00000000-0000-0000-0000-000000000000}" + assert table_fixture.style_name is None + + def it_applies_a_style_by_friendly_name(self, table_fixture): + table_fixture.apply_style("Medium Style 2 - Accent 3") + + assert table_fixture.style_id == ALT_GUID + assert table_fixture.style_name == "Medium Style 2 - Accent 3" + + def it_applies_a_style_by_raw_guid(self, table_fixture): + table_fixture.apply_style(NO_GRID_GUID) + + assert table_fixture.style_id == NO_GRID_GUID + assert table_fixture.style_name == "No Style, No Grid" + + def it_resolves_friendly_name_case_insensitively(self, table_fixture): + table_fixture.apply_style("medium style 2 - accent 3") + + assert table_fixture.style_id == ALT_GUID + + def it_raises_ValueError_on_unknown_friendly_name(self, table_fixture): + with pytest.raises(ValueError): + table_fixture.apply_style("not-a-real-style") + + def it_lets_a_user_round_trip_a_custom_guid_losslessly(self, table_fixture): + # ---a GUID not in the registry passes through verbatim, name returns None--- + custom_guid = "{12345678-1234-1234-1234-123456789ABC}" + table_fixture.apply_style(custom_guid) + + assert table_fixture.style_id == custom_guid + assert table_fixture.style_name is None + + +# --------------------------------------------------------------------------- +# Helper — _looks_like_guid +# --------------------------------------------------------------------------- + + +class Describe_looks_like_guid(object): + """Unit-test suite for the GUID-shape detector used by apply_style.""" + + @pytest.mark.parametrize( + "value", + [ + "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}", + "{abcdef01-2345-6789-abcd-ef0123456789}", # ---lowercase OK--- + "{ABCDEF01-2345-6789-ABCD-EF0123456789}", + ], + ) + def it_accepts_canonical_guid_shapes(self, value): + assert _looks_like_guid(value) is True + + @pytest.mark.parametrize( + "value", + [ + "Medium Style 2 - Accent 1", # ---name, not GUID--- + "5C22544A-7EE6-4342-B048-85BDC9FD1C3A", # ---no braces--- + "{5C22544A}", # ---wrong length--- + "", + "{NOT-A-GU-ID00-0000-000000000000}", # ---invalid hex--- + ], + ) + def it_rejects_non_guid_strings(self, value): + assert _looks_like_guid(value) is False + + +# --------------------------------------------------------------------------- +# Round-trip — save/reload preserves style_id +# --------------------------------------------------------------------------- + + +class DescribeStyle_RoundTrip(object): + """Save a presentation with a non-default style and reload — GUID survives.""" + + def it_preserves_style_id_through_save_and_reload(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + gf = slide.shapes.add_table(2, 2, Inches(1), Inches(1), Inches(4), Inches(2)) + gf.table.apply_style("Light Style 2 - Accent 4") + applied_guid = gf.table.style_id + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + # ---first slide, first shape is the table--- + tbl2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert tbl2.style_id == applied_guid + assert tbl2.style_name == "Light Style 2 - Accent 4" + + def it_preserves_a_cleared_style_through_save_and_reload(self): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + gf = slide.shapes.add_table(2, 2, Inches(1), Inches(1), Inches(4), Inches(2)) + gf.table.style_id = None + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + tbl2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert tbl2.style_id is None + + +# --------------------------------------------------------------------------- +# Anti / Regression +# --------------------------------------------------------------------------- + + +class DescribePhase2_Regression(object): + """Anti-criteria: existing surfaces unchanged after Phase 2 additions.""" + + def it_keeps_first_row_toggle_working(self, table_fixture): + assert table_fixture.first_row is True # ---template default--- + table_fixture.first_row = False + assert table_fixture.first_row is False + table_fixture.first_row = True + assert table_fixture.first_row is True + + def it_keeps_horz_banding_toggle_working(self, table_fixture): + assert table_fixture.horz_banding is True # ---template default--- + table_fixture.horz_banding = False + assert table_fixture.horz_banding is False + + def it_keeps_vert_banding_toggle_working(self, table_fixture): + assert table_fixture.vert_banding is False + table_fixture.vert_banding = True + assert table_fixture.vert_banding is True + + def it_keeps_default_style_unchanged_for_newly_added_tables(self, table_fixture): + # ---no regression in the existing _tbl_tmpl baked-in default--- + assert table_fixture.style_id == DEFAULT_GUID + assert isinstance(table_fixture, Table)