Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions features/steps/tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Gherkin step implementations for Table row/column CRUD (issue #12 Phase 1)."""

from __future__ import annotations

import pytest
from behave import given, then, when

from pptx import Presentation
from pptx.util import Inches


# given ===================================================


def _seed_table(context, rows: int, cols: int):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
shape = slide.shapes.add_table(rows, cols, Inches(1), Inches(1), Inches(6), Inches(2))
context.prs = prs
context.table_ = shape.table


@given("a 2x3 table on a fresh slide")
def given_a_2x3_table(context):
_seed_table(context, 2, 3)


@given("a 3x2 table on a fresh slide")
def given_a_3x2_table(context):
_seed_table(context, 3, 2)


@given("a 2x2 table on a fresh slide")
def given_a_2x2_table(context):
_seed_table(context, 2, 2)


@given("a 3x2 table on a fresh slide with a vertical merge between rows 0 and 1")
def given_3x2_with_vmerge(context):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
shape = slide.shapes.add_table(3, 2, Inches(1), Inches(1), Inches(6), Inches(2))
table = shape.table
table.cell(0, 0).merge(table.cell(1, 0))
context.prs = prs
context.table_ = table


@given("a 2x3 table on a fresh slide with a horizontal merge between columns 0 and 1")
def given_2x3_with_hmerge(context):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
shape = slide.shapes.add_table(2, 3, Inches(1), Inches(1), Inches(6), Inches(2))
table = shape.table
table.cell(0, 0).merge(table.cell(0, 1))
context.prs = prs
context.table_ = table


# when ====================================================


@when("I call table.rows.add()")
def when_table_rows_add(context):
context.new_row = context.table_.rows.add()


@when("I call table.rows.add(at={at:d})")
def when_table_rows_add_at(context, at):
context.new_row = context.table_.rows.add(at=at)


@when("I call table.columns.add()")
def when_table_columns_add(context):
context.new_column = context.table_.columns.add()


@when("I call table.rows.remove({idx:d})")
def when_table_rows_remove(context, idx):
context.table_.rows.remove(idx)


@when("I call table.columns.remove({idx:d})")
def when_table_columns_remove(context, idx):
context.table_.columns.remove(idx)


# then ====================================================


@then("the table has {n:d} rows")
def then_table_has_n_rows(context, n):
assert len(context.table_.rows) == n, f"expected {n} rows, got {len(context.table_.rows)}"


@then("the table has {n:d} columns")
def then_table_has_n_columns(context, n):
assert len(context.table_.columns) == n, (
f"expected {n} columns, got {len(context.table_.columns)}"
)


@then("the new row is at index {idx:d}")
def then_new_row_is_at_index(context, idx):
assert context.table_.rows[idx]._tr is context.new_row._tr


@then("every row has {n:d} cells")
def then_every_row_has_n_cells(context, n):
for row in context.table_.rows:
assert len(row.cells) == n


@then("calling table.rows.remove({idx:d}) raises ValueError")
def then_rows_remove_raises_value_error(context, idx):
with pytest.raises(ValueError):
context.table_.rows.remove(idx)


@then("calling table.columns.remove({idx:d}) raises ValueError")
def then_columns_remove_raises_value_error(context, idx):
with pytest.raises(ValueError):
context.table_.columns.remove(idx)
48 changes: 48 additions & 0 deletions features/tbl-crud.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
Feature: Table row/column CRUD — add and remove
In order to assemble tables programmatically without lxml hacks
As a developer using python-pptx
I need to add and remove rows and columns on existing tables


Scenario: Append a row to a table
Given a 2x3 table on a fresh slide
When I call table.rows.add()
Then the table has 3 rows
And the table has 3 columns


Scenario: Insert a row at index 1
Given a 2x3 table on a fresh slide
When I call table.rows.add(at=1)
Then the table has 3 rows
And the new row is at index 1


Scenario: Append a column to a table
Given a 2x3 table on a fresh slide
When I call table.columns.add()
Then the table has 4 columns
And every row has 4 cells


Scenario: Remove a row by index
Given a 3x2 table on a fresh slide
When I call table.rows.remove(1)
Then the table has 2 rows


Scenario: Remove a column by index
Given a 2x3 table on a fresh slide
When I call table.columns.remove(1)
Then the table has 2 columns
And every row has 2 cells


Scenario: Removing a row with vertical merge raises ValueError
Given a 3x2 table on a fresh slide with a vertical merge between rows 0 and 1
Then calling table.rows.remove(0) raises ValueError


Scenario: Removing a column with horizontal merge raises ValueError
Given a 2x3 table on a fresh slide with a horizontal merge between columns 0 and 1
Then calling table.columns.remove(0) raises ValueError
94 changes: 94 additions & 0 deletions src/pptx/oxml/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ def add_tr(self, height: Length) -> CT_TableRow:
"""Return a newly created `a:tr` child element having its `h` attribute set to `height`."""
return self._add_tr(h=height)

def insert_tr_at(self, idx: int, height: Length) -> CT_TableRow:
"""Insert a new `a:tr` at zero-based position `idx` with no cells.

Caller is responsible for populating the new row with `add_tc()` calls
to match the table's column count. `idx` may equal `len(self.tr_lst)`
to append. Raises `IndexError` if `idx` is out of range.
"""
if idx < 0 or idx > len(self.tr_lst):
raise IndexError("row index out of range")
new_tr = self.add_tr(height)
if idx < len(self.tr_lst) - 1:
self.tr_lst[idx].addprevious(new_tr)
return new_tr

def remove_tr_at(self, idx: int) -> None:
"""Remove the `a:tr` at zero-based position `idx`.

Raises `IndexError` if `idx` is out of range.
"""
if idx < 0 or idx >= len(self.tr_lst):
raise IndexError("row index out of range")
self.remove(self.tr_lst[idx])

@property
def bandCol(self) -> bool:
return self._get_boolean_property("bandCol")
Expand Down Expand Up @@ -136,6 +159,21 @@ def tc(self, row_idx: int, col_idx: int) -> CT_TableCell:
"""Return `a:tc` element at `row_idx`, `col_idx`."""
return self.tr_lst[row_idx].tc_lst[col_idx]

def column_has_cross_column_merge(self, col_idx: int) -> bool:
"""True if column `col_idx` contains a cell in a multi-column merge.

Specifically: any tc at this column with `gridSpan > 1` (origin of
horizontal merge), or any tc with `hMerge=True` (target). Used to
decide whether `Table.columns.remove(col_idx)` is safe.
"""
for tr in self.tr_lst:
if col_idx >= len(tr.tc_lst):
continue
tc = tr.tc_lst[col_idx]
if tc.gridSpan > 1 or tc.hMerge:
return True
return False

def _get_boolean_property(self, propname: str) -> bool:
"""Generalized getter for the boolean properties on the `a:tblPr` child element.

Expand Down Expand Up @@ -439,6 +477,28 @@ def add_gridCol(self, width: Length) -> CT_TableCol:
"""A newly appended `a:gridCol` child element having its `w` attribute set to `width`."""
return self._add_gridCol(w=width)

def insert_gridCol_at(self, idx: int, width: Length) -> CT_TableCol:
"""Insert a new `a:gridCol` at zero-based position `idx`.

`idx` may equal `len(self.gridCol_lst)` to append. Raises
`IndexError` if `idx` is out of range.
"""
if idx < 0 or idx > len(self.gridCol_lst):
raise IndexError("column index out of range")
new_gridCol = self.add_gridCol(width)
if idx < len(self.gridCol_lst) - 1:
self.gridCol_lst[idx].addprevious(new_gridCol)
return new_gridCol

def remove_gridCol_at(self, idx: int) -> None:
"""Remove the `a:gridCol` at zero-based position `idx`.

Raises `IndexError` if `idx` is out of range.
"""
if idx < 0 or idx >= len(self.gridCol_lst):
raise IndexError("column index out of range")
self.remove(self.gridCol_lst[idx])


class CT_TableProperties(BaseOxmlElement):
"""`a:tblPr` custom element class."""
Expand All @@ -464,6 +524,40 @@ def add_tc(self) -> CT_TableCell:
"""A newly added minimal valid `a:tc` child element."""
return self._add_tc()

def insert_tc_at(self, idx: int) -> CT_TableCell:
"""Insert a new minimal valid `a:tc` child element at zero-based position `idx`.

`idx` may equal `len(self.tc_lst)` to append. Raises `IndexError` if
`idx` is out of range.
"""
if idx < 0 or idx > len(self.tc_lst):
raise IndexError("cell index out of range")
new_tc = self.add_tc()
if idx < len(self.tc_lst) - 1:
self.tc_lst[idx].addprevious(new_tc)
return new_tc

def remove_tc_at(self, idx: int) -> None:
"""Remove the `a:tc` at zero-based position `idx`.

Raises `IndexError` if `idx` is out of range.
"""
if idx < 0 or idx >= len(self.tc_lst):
raise IndexError("cell index out of range")
self.remove(self.tc_lst[idx])

@property
def has_cross_row_merge(self) -> bool:
"""True if this row contains a cell that participates in a multi-row merge.

Specifically: any `<a:tc>` with `rowSpan > 1` (origin of vertical
merge), or any `<a:tc>` with `vMerge=True` (target of vertical
merge anchored elsewhere). Used to decide whether
`Table.rows.remove(idx)` is safe — removing a row that crosses a
vertical merge would orphan the rest of the merge.
"""
return any(tc.rowSpan > 1 or tc.vMerge for tc in self.tc_lst)

@property
def row_idx(self) -> int:
"""Offset of this row in its table."""
Expand Down
Loading
Loading