diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst
new file mode 100644
index 000000000..7d0776b8a
--- /dev/null
+++ b/docs/dev/analysis/features/cell-merge.rst
@@ -0,0 +1,249 @@
+
+Table Cells Merge
+=================
+
+In Word, table cells can be merged with the following restrictions:
+
+* Only rectangular selections are supported.
+* If the to-be-merged selection contains previously merged cells, then that
+ selection must extend the contained merged cells area.
+
+The area to be merged is determined by the two opposite corner cells of that
+area. The to-be-merged area can span across multiple rows and/or columns.
+
+For merging horizontally, the ``w:gridSpan`` table cell property of the
+leftmost cell of the area to be merged is set to a value of type
+``w:ST_DecimalNumber`` corresponding to the number of columns the cell
+should span across. Only that leftmost cell is preserved; the other cells
+of the merge selection are deleted. Note that having the ``w:gridSpan``
+element is only required if there exists another table row using a
+different column layout. When the same column layout is shared across all
+the rows, then the ``w:gridSpan`` can be replaced by a ``w:tcW`` element
+specifying the width of the column. For example, if the table consists of
+just one row and we merge all of its cells, then only the leftmost cell is
+kept, and its width is ajusted so that it equals the combined width of
+the cells merged.
+
+As an alternative to the previously described horizontal merging protocol,
+``w:hMerge`` element can be used to identify the merged cells instead of
+deleting them. This approach is prefered as it is non destructive and
+thus maintains the structure of the table, which in turns allows for more
+user-friendly cell addressing. This is the approach used by
+the python-docx merge method.
+
+
+For merging vertically, the ``w:vMerge`` table cell property of the
+uppermost cell of the column is set to the value "restart" of type
+``w:ST_Merge``. The following, lower cells included in the vertical merge
+must have the ``w:vMerge`` element present in their cell property
+(``w:TcPr``) element. Its value should be set to "continue", although it is
+not necessary to explicitely define it, as it is the default value. A
+vertical merge ends as soon as a cell ``w:TcPr`` element lacks the
+``w:vMerge`` element. Similarly to the ``w:gridSpan`` element, the
+``w:vMerge`` elements are only required when the table's layout is not
+uniform across its different columns. In the case it is, only the topmost
+cell is kept; the other lower cells in the merged area are deleted along
+with their ``w:vMerge`` elements and the ``w:trHeight`` table row property
+is used to specify the combined height of the merged cells.
+
+
+Word specific behavior
+~~~~~~~~~~~~~~~~~~~~~~
+
+Word cannot access the columns of a table if two or more cells from that
+table have been horizontally merged. Similarly, Word cannot access the rows
+of a table if two or more cells from that table have been vertically merged.
+
+Horizontally merged cells other than the leftmost cell are deleted and thus
+can no longer be accessed.
+
+Vertically merged cells marked by ``w:vMerge=continue`` are no longer
+accessible from Word. An exception with the message "The member of the
+collection does not exist" is raised.
+
+Word reports the length of a row or column containing merged cells as the
+visual length. For example, the reported length of a 3 columns rows which
+two first cells have been merged would be 2. Similarly, the reported length of
+a 2 rows column which two cells have been merged would be 1.
+
+Word resizes a table when a cell is refered by an out-of-bounds row index.
+If the column identifier is out of bounds, an exception is raised.
+
+An exception is raised when attempting to merge cells from different tables.
+
+
+python-docx API refinements over Word's
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Addressing some of the Word API deficiencies when dealing with merged cells,
+the following new features were introduced:
+
+* The length of any rows or columns remain available for report even when two
+ or more cells have been merged. The length is reported as the count of all
+ the normal (unmerged) cells, plus all the *master* merged cells. By *master*
+ merged cells, we understand the leftmost cell of an horizontally merged
+ area, the topmost cell of a vertically merged area, or the topleftmost cell
+ of two-ways merged area.
+
+* The same logic is applied to filter the iterable cells in a _ColumnCells or
+ _RowCells cells collection and a restricted access error message is written
+ when trying to access visually hidden, non master merged cells.
+
+* The smart filtering of hidden merged cells, dubbed *visual grid* can be
+ turned off to gain access to cells which would normally be restricted,
+ either via the ``Table.cell`` method's third argument, or by setting the
+ ``visual_grid`` static property of a ``_RowCells`` or ``_ColumnsCell``
+ instance to *False*.
+
+
+Candidate protocol -- cell.merge()
+----------------------------------
+
+The following interactive session demonstrates the protocol for merging table
+cells. The capability of reporting the length of merged cells collection is
+also demonstrated::
+
+ >>> table = doc.add_table(5, 5)
+ >>> table.cell(0, 0).merge(table.cell(3, 3))
+ >>> len(table.columns[2].cells)
+ 1
+ >>> cells = table.columns[2].cells
+ >>> cells.visual_grid = False
+ >>> len(cells)
+ 5
+
+Specimen XML
+------------
+
+.. highlight:: xml
+
+A 3 x 3 table where an area defined by the 2 x 2 topleft cells has been
+merged, demonstrating the combined use of the ``w:gridSpan`` as well as the
+``w:vMerge`` elements, as produced by Word::
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Schema excerpt
+--------------
+
+.. highlight:: xml
+
+::
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Ressources
+----------
+
+* `Cell.Merge Method on MSDN`_
+
+.. _`Cell.Merge Method on MSDN`:
+ http://msdn.microsoft.com/en-us/library/office/ff821310%28v=office.15%29.aspx
+
+Relevant sections in the ISO Spec
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+* 17.4.17 gridSpan (Grid Columns Spanned by Current Table Cell)
+* 17.4.84 vMerge (Vertically Merged Cell)
+* 17.18.57 ST_Merge (Merged Cell Type)
diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst
index 7e4d7589e..4b1b9c8ed 100644
--- a/docs/dev/analysis/index.rst
+++ b/docs/dev/analysis/index.rst
@@ -13,6 +13,7 @@ Feature Analysis
features/table
features/table-props
features/table-cell
+ features/cell-merge
features/par-alignment
features/run-content
features/numbering
diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py
index c5938c7c8..04f1c5242 100644
--- a/docx/oxml/__init__.py
+++ b/docx/oxml/__init__.py
@@ -115,9 +115,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None):
from docx.oxml.table import (
CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr,
- CT_TblWidth, CT_Tc, CT_TcPr
+ CT_TblWidth, CT_Tc, CT_TcPr, CT_HMerge, CT_VMerge
)
register_element_cls('w:gridCol', CT_TblGridCol)
+register_element_cls('w:gridSpan', CT_DecimalNumber)
register_element_cls('w:tbl', CT_Tbl)
register_element_cls('w:tblGrid', CT_TblGrid)
register_element_cls('w:tblLayout', CT_TblLayoutType)
@@ -127,6 +128,8 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None):
register_element_cls('w:tcPr', CT_TcPr)
register_element_cls('w:tcW', CT_TblWidth)
register_element_cls('w:tr', CT_Row)
+register_element_cls('w:hMerge', CT_HMerge)
+register_element_cls('w:vMerge', CT_VMerge)
from docx.oxml.text import (
CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline
diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py
index 07b51d533..24f5ec9c4 100644
--- a/docx/oxml/simpletypes.py
+++ b/docx/oxml/simpletypes.py
@@ -218,6 +218,17 @@ class ST_DrawingElementId(XsdUnsignedInt):
pass
+class ST_Merge(XsdString):
+
+ @classmethod
+ def validate(cls, value):
+ cls.validate_string(value)
+ valid_values = ('continue', 'restart')
+ if value not in valid_values:
+ raise ValueError(
+ "must be one of %s, got '%s'" % (valid_values, value)
+ )
+
class ST_OnOff(XsdBoolean):
@classmethod
diff --git a/docx/oxml/table.py b/docx/oxml/table.py
index f2fbd540f..0cfe27164 100644
--- a/docx/oxml/table.py
+++ b/docx/oxml/table.py
@@ -10,14 +10,13 @@
from .ns import nsdecls
from ..shared import Emu, Twips
from .simpletypes import (
- ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt
+ ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, ST_Merge, XsdInt
)
from .xmlchemy import (
BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute,
RequiredAttribute, ZeroOrOne, ZeroOrMore
)
-
class CT_Row(BaseOxmlElement):
"""
```` element
@@ -221,6 +220,54 @@ def width(self, value):
tcPr = self.get_or_add_tcPr()
tcPr.width = value
+ @property
+ def gridspan(self):
+ """
+ Return the decimal value represented in the ``./w:tcPr/w:gridSpan``
+ child element or |None| if not present.
+ """
+ tcPr = self.tcPr
+ if tcPr is None:
+ return None
+ return tcPr.gridspan
+
+ @gridspan.setter
+ def gridspan(self, value):
+ tcPr = self.get_or_add_tcPr()
+ tcPr.gridspan = value
+
+ @property
+ def hmerge(self):
+ """
+ Return the string value represented in the ``./w:tcPr/w:hMerge``
+ child element or |None| if not present.
+ """
+ tcPr = self.tcPr
+ if tcPr is None:
+ return None
+ return tcPr.hmerge
+
+ @hmerge.setter
+ def hmerge(self, value):
+ tcPr = self.get_or_add_tcPr()
+ tcPr.hmerge = value
+
+ @property
+ def vmerge(self):
+ """
+ Return the string value represented in the ``./w:tcPr/w:vMerge``
+ child element or |None| if not present.
+ """
+ tcPr = self.tcPr
+ if tcPr is None:
+ return None
+ return tcPr.vmerge
+
+ @vmerge.setter
+ def vmerge(self, value):
+ tcPr = self.get_or_add_tcPr()
+ tcPr.vmerge = value
+
class CT_TcPr(BaseOxmlElement):
"""
@@ -232,6 +279,24 @@ class CT_TcPr(BaseOxmlElement):
'w:hideMark', 'w:headers', 'w:cellIns', 'w:cellDel', 'w:cellMerge',
'w:tcPrChange'
))
+
+ gridSpan = ZeroOrOne('w:gridSpan', successors=(
+ 'w:hMerge', 'w:vMerge', 'w:tcBorders', 'w:shd', 'w:noWrap', 'w:tcMar',
+ 'w:textDirection', 'w:tcFitText', 'w:vAlign', 'w:hideMark',
+ 'w:headers', 'w:cellIns', 'w:cellDel', 'w:cellMerge', 'w:tcPrChange'
+ ))
+
+ hMerge = ZeroOrOne('w:hMerge', successors=(
+ 'w:vMerge', 'w:tcBorders', 'w:shd', 'w:noWrap', 'w:tcMar',
+ 'w:textDirection', 'w:tcFitText', 'w:vAlign', 'w:hideMark',
+ 'w:headers', 'w:cellIns', 'w:cellDel', 'w:cellMerge', 'w:tcPrChange'
+ ))
+
+ vMerge = ZeroOrOne('w:vMerge', successors=(
+ 'w:tcBorders', 'w:shd', 'w:noWrap', 'w:tcMar', 'w:textDirection',
+ 'w:tcFitText', 'w:vAlign', 'w:hideMark', 'w:headers', 'w:cellIns',
+ 'w:cellDel', 'w:cellMerge', 'w:tcPrChange'
+ ))
@property
def width(self):
@@ -248,3 +313,67 @@ def width(self):
def width(self, value):
tcW = self.get_or_add_tcW()
tcW.width = value
+
+ @property
+ def gridspan(self):
+ """
+ Return the value represented in the ```` child element or
+ |None| if not present.
+ """
+ gridSpan = self.gridSpan
+ if gridSpan is None:
+ return None
+ return gridSpan.val
+
+ @gridspan.setter
+ def gridspan(self, value):
+ gridSpan = self.get_or_add_gridSpan()
+ gridSpan.val = value
+
+ @property
+ def hmerge(self):
+ """
+ Return the value represented in the ```` child element or
+ |None| if not present.
+ """
+ hMerge = self.hMerge
+ if hMerge is None:
+ return None
+ return hMerge.val
+
+ @hmerge.setter
+ def hmerge(self, value):
+ hMerge = self.get_or_add_hMerge()
+ hMerge.val = value
+
+ @property
+ def vmerge(self):
+ """
+ Return the value represented in the ```` child element or
+ |None| if not present.
+ """
+ vMerge = self.vMerge
+ if vMerge is None:
+ return None
+ return vMerge.val
+
+ @vmerge.setter
+ def vmerge(self, value):
+ vMerge = self.get_or_add_vMerge()
+ vMerge.val = value
+
+
+class CT_HMerge(BaseOxmlElement):
+ """
+ ```` element, child of ````, defines an horizontally
+ merged cell.
+ """
+ val = OptionalAttribute('w:val', ST_Merge, 'continue')
+
+
+class CT_VMerge(BaseOxmlElement):
+ """
+ ```` element, child of ````, defines a vertically merged
+ cell.
+ """
+ val = OptionalAttribute('w:val', ST_Merge, 'continue')
diff --git a/docx/table.py b/docx/table.py
index 544553b1e..64cb5fee4 100644
--- a/docx/table.py
+++ b/docx/table.py
@@ -10,6 +10,7 @@
from .shared import lazyproperty, Parented, write_only_property
+
class Table(Parented):
"""
Proxy class for a WordprocessingML ```` element.
@@ -52,12 +53,13 @@ def autofit(self):
def autofit(self, value):
self._tblPr.autofit = value
- def cell(self, row_idx, col_idx):
+ def cell(self, row_idx, col_idx, visual_grid=True):
"""
Return |_Cell| instance correponding to table cell at *row_idx*,
*col_idx* intersection, where (0, 0) is the top, left-most cell.
"""
row = self.rows[row_idx]
+ row.cells.visual_grid = visual_grid
return row.cells[col_idx]
@lazyproperty
@@ -100,6 +102,18 @@ def __init__(self, tc, parent):
super(_Cell, self).__init__(tc, parent)
self._tc = tc
+ def _get_parent(self, instance_type):
+ """
+ Return a reference to the parent object of type `instance_type`, or
+ *None* if no match.
+ """
+ parent = self._parent
+ while parent is not None:
+ if isinstance(parent, instance_type):
+ return parent
+ parent = parent._parent
+ return None
+
def add_paragraph(self, text='', style=None):
"""
Return a paragraph newly added to the end of the content in this
@@ -124,16 +138,104 @@ def add_table(self, rows, cols):
new_table = super(_Cell, self).add_table(rows, cols)
self.add_paragraph()
return new_table
+
+ @property
+ def column_index(self):
+ """
+ The column index of the cell. Read-only.
+ """
+ if self._parent is None:
+ return 0
+ elif isinstance(self._parent, _RowCells):
+ return self._parent._tr.tc_lst.index(self._tc)
+ elif isinstance(self._parent, _ColumnCells):
+ return self._parent._col_idx
+ else:
+ msg = ('Could not get column index: unexpected cell parent '
+ 'type (%s).')
+ raise ValueError(msg % type(self._parent).__name__)
+ raise ValueError('Could not find the column index.')
+
+ def merge(self, cell):
+ """
+ Merge the rectangular area delimited by the current cell and another
+ cell passed as the argument.
+ """
+ def _horizontal_merge(tr, merge_start_idx, merge_stop_idx):
+ tr.tc_lst[merge_start_idx].hmerge = 'restart'
+ for tc in tr.tc_lst[merge_start_idx+1:merge_stop_idx+1]:
+ tc.hmerge = 'continue'
+
+ def _vertical_merge(column, merge_start_idx, merge_stop_idx):
+ column.cells[merge_start_idx]._tc.vmerge = 'restart'
+ for index in range(merge_start_idx + 1, merge_stop_idx + 1):
+ column.cells[index]._tc.vmerge = 'continue'
+
+ def _twoways_merge(table, topleft_coord, bottomright_coord):
+ for row_idx in range(topleft_coord[0], bottomright_coord[0] + 1):
+ tr = table.rows[row_idx]._tr
+ _horizontal_merge(tr, topleft_coord[1], bottomright_coord[1])
+ col = table.columns[topleft_coord[1]]
+ _vertical_merge(col, topleft_coord[0], bottomright_coord[0])
+
+ # Verify the cells to be merged are from the same table.
+ orig_table = self._get_parent(Table)
+ dest_table = cell._get_parent(Table)
+ if (orig_table is None) or (dest_table is None):
+ raise ValueError('Cannot merge cells without a Table parent.')
+ if orig_table._tbl is not dest_table._tbl:
+ raise ValueError('Cannot merge cells from different tables.')
+ table = orig_table
+ # Get the cells coordinates and reorganize them.
+ orig_row_idx = min(self.row_index, cell.row_index)
+ orig_col_idx = min(self.column_index, cell.column_index)
+ dest_row_idx = max(self.row_index, cell.row_index)
+ dest_col_idx = max(self.column_index, cell.column_index)
+ orig_coord = (orig_row_idx, orig_col_idx)
+ dest_coord = (dest_row_idx, dest_col_idx)
+ # Process the merge.
+ if (orig_row_idx == dest_row_idx) and (orig_col_idx != dest_col_idx):
+ tr = table.rows[orig_row_idx]._tr
+ _horizontal_merge(tr, orig_col_idx, dest_col_idx)
+ elif (orig_row_idx != dest_row_idx) and (orig_col_idx == dest_col_idx):
+ col = table.columns[orig_col_idx]
+ _vertical_merge(col, orig_row_idx, dest_row_idx)
+ elif (orig_row_idx != dest_row_idx) and (orig_col_idx != dest_col_idx):
+ _twoways_merge(table, orig_coord, dest_coord)
+ else: # orig_coord == dest_coord:
+ return
@property
def paragraphs(self):
"""
List of paragraphs in the cell. A table cell is required to contain
at least one block-level element and end with a paragraph. By
- default, a new cell contains a single paragraph. Read-only
+ default, a new cell contains a single paragraph. Read-only.
"""
return super(_Cell, self).paragraphs
+ @property
+ def row_index(self):
+ """
+ The row index of the cell. Read-only.
+ """
+ if self._parent is None:
+ return 0
+ if isinstance(self._parent, _RowCells):
+ parent_row = self._get_parent(_Row)
+ parent_rows = self._get_parent(_Rows)
+ if parent_row is None or parent_rows is None:
+ return 0
+ return parent_rows._tbl.tr_lst.index(parent_row._tr)
+ elif isinstance(self._parent, _ColumnCells):
+ for i, cell in enumerate(self._parent):
+ if self._tc is cell._tc:
+ return i
+ else:
+ msg = 'Cannot get row index: unexpected cell parent type (%s).'
+ raise ValueError(msg % type(self._parent).__name__)
+ raise ValueError('Could not find the row index.')
+
@property
def tables(self):
"""
@@ -200,6 +302,11 @@ class _ColumnCells(Parented):
Sequence of |_Cell| instances corresponding to the cells in a table
column.
"""
+ # The visual grid property defines how the merged cells are accounted in
+ # the rows and columns' length. It also restricts access to certain merged
+ # cells to protect against unintended modification.
+ visual_grid = True
+
def __init__(self, tbl, gridCol, parent):
super(_ColumnCells, self).__init__(parent)
self._tbl = tbl
@@ -215,14 +322,25 @@ def __getitem__(self, idx):
msg = "cell index [%d] is out of range" % idx
raise IndexError(msg)
tc = tr.tc_lst[self._col_idx]
+ if self.visual_grid:
+ if tc.hmerge == 'continue' or tc.vmerge == 'continue':
+ raise ValueError('Merged cell access is restricted.')
return _Cell(tc, self)
def __iter__(self):
for tr in self._tr_lst:
tc = tr.tc_lst[self._col_idx]
+ if self.visual_grid:
+ if tc.hmerge == 'continue' or tc.vmerge == 'continue':
+ continue
yield _Cell(tc, self)
def __len__(self):
+ if self.visual_grid:
+ cell_lst = []
+ for cell in self:
+ cell_lst.append(cell)
+ return len(cell_lst)
return len(self._tr_lst)
@property
@@ -293,6 +411,9 @@ class _RowCells(Parented):
"""
Sequence of |_Cell| instances corresponding to the cells in a table row.
"""
+ # See the equivalent static property description in _ColumnCells.
+ visual_grid = True
+
def __init__(self, tr, parent):
super(_RowCells, self).__init__(parent)
self._tr = tr
@@ -306,12 +427,24 @@ def __getitem__(self, idx):
except IndexError:
msg = "cell index [%d] is out of range" % idx
raise IndexError(msg)
+ if self.visual_grid:
+ if tc.hmerge == 'continue' or tc.vmerge == 'continue':
+ raise ValueError('Merged cell access is restricted.')
return _Cell(tc, self)
def __iter__(self):
- return (_Cell(tc, self) for tc in self._tr.tc_lst)
+ for tc in self._tr.tc_lst:
+ if self.visual_grid:
+ if tc.hmerge == 'continue' or tc.vmerge == 'continue':
+ continue
+ yield _Cell(tc, self)
def __len__(self):
+ if self.visual_grid:
+ cell_lst = []
+ for cell in self:
+ cell_lst.append(cell)
+ return len(cell_lst)
return len(self._tr.tc_lst)
diff --git a/features/cel-merge.feature b/features/cel-merge.feature
new file mode 100644
index 000000000..e65354aef
--- /dev/null
+++ b/features/cel-merge.feature
@@ -0,0 +1,35 @@
+Feature: Merge table cells
+ In order to format a table layout to my requirements
+ As an python-docx developer
+ I need a way to merge the cells of a table
+
+
+ Scenario: Merge cells horizontally
+ Given a 2 x 2 table
+ When I merge the 1 x 2 topleftmost cells
+ Then the cell collection length of the row(s) indexed by [0] is 1
+
+
+ Scenario: Merge cells vertically
+ Given a 2 x 2 table
+ When I merge the 2 x 1 topleftmost cells
+ Then the cell collection length of the column(s) indexed by [0] is 1
+
+
+ Scenario: Merge cells both horizontally and vertically
+ Given a 3 x 3 table
+ When I merge the 2 x 2 topleftmost cells
+ Then the cell collection length of the row(s) indexed by [0] is 2
+ And the cell collection length of the row(s) indexed by [1] is 1
+ And the cell collection length of the column(s) indexed by [0] is 2
+ And the cell collection length of the column(s) indexed by [1] is 1
+
+
+ Scenario Outline: Error when attempting to merge cells from different tables
+ Given two cells from two different tables
+ When I attempt to merge the cells
+ Then a exception is raised with a detailed
+
+ Examples: Exception type and error message variables
+ | exception-type | err-message |
+ | ValueError | Cannot merge cells from different tables. |
diff --git a/features/steps/cell.py b/features/steps/cell.py
index 6f588cd8c..35fb3f98c 100644
--- a/features/steps/cell.py
+++ b/features/steps/cell.py
@@ -29,11 +29,39 @@ def when_assign_string_to_cell_text_attribute(context):
context.expected_text = text
+@when('I attempt to merge the cells')
+def when_I_attempt_to_merge_the_cells(context):
+ cell1 = context.cell1
+ cell2 = context.cell2
+ try:
+ cell1.merge(cell2)
+ except Exception as e:
+ context.exception = e
+
+
+@when('I merge the {nrows} x {ncols} topleftmost cells')
+def when_I_merge_the_nrows_x_ncols_topleftmost_cells(context, nrows, ncols):
+ table = context.table_
+ row = int(nrows) - 1
+ col = int(ncols) - 1
+ table.cell(0, 0).merge(table.cell(row, col))
+
# then =====================================================
+@then('the cell row index value is {row_index_val}')
+def then_the_cell_row_index_value_is_row_index_val(context, row_index_val):
+ assert context.cell.row_index == int(row_index_val)
+
+
+@then('the cell column index value is {col_index_val}')
+def then_the_cell_column_index_value_is_col_index_val(context, col_index_val):
+ assert context.cell.column_index == int(col_index_val)
+
+
@then('the cell contains the string I assigned')
def then_cell_contains_string_assigned(context):
cell, expected_text = context.cell, context.expected_text
text = cell.paragraphs[0].runs[0].text
msg = "expected '%s', got '%s'" % (expected_text, text)
assert text == expected_text, msg
+
diff --git a/features/steps/shared.py b/features/steps/shared.py
index 5e82e24a6..39dda92ff 100644
--- a/features/steps/shared.py
+++ b/features/steps/shared.py
@@ -6,7 +6,7 @@
import os
-from behave import given, when
+from behave import given, when, then
from docx import Document
@@ -27,3 +27,11 @@ def when_save_document(context):
if os.path.isfile(saved_docx_path):
os.remove(saved_docx_path)
context.document.save(saved_docx_path)
+
+# then ====================================================
+
+@then('a {ex_type} exception is raised with a detailed {error_message}')
+def then_an_ex_is_raised_with_err_message(context, ex_type, error_message):
+ exception = context.exception
+ assert type(exception).__name__ == ex_type
+ assert exception.args[0] == error_message
diff --git a/features/steps/table.py b/features/steps/table.py
index 2d7aa37dc..ed540cef0 100644
--- a/features/steps/table.py
+++ b/features/steps/table.py
@@ -19,10 +19,9 @@
# given ===================================================
-@given('a 2 x 2 table')
-def given_a_2x2_table(context):
- context.table_ = Document().add_table(rows=2, cols=2)
-
+@given('a {nrows} x {ncols} table')
+def given_a_nrows_x_ncols_table(context, nrows, ncols):
+ context.table_ = Document().add_table(int(nrows), int(ncols))
@given('a column cell collection having two cells')
def given_a_column_cell_collection_having_two_cells(context):
@@ -125,6 +124,12 @@ def given_a_table_row_having_two_cells(context):
context.row = document.tables[0].rows[0]
+@given('two cells from two different tables')
+def given_two_cells_from_two_different_tables(context):
+ context.cell1 = Document().add_table(2, 2).cell(0, 0)
+ context.cell2 = Document().add_table(3, 3).cell(2, 1)
+
+
# when =====================================================
@when('I add a column to the table')
@@ -143,6 +148,12 @@ def when_add_row_to_table(context):
def when_apply_style_to_table(context):
table = context.table_
table.style = 'LightShading-Accent1'
+
+
+@when('I access the cell at the position ({row_index}, {column_index})')
+def when_I_access_the_cell_at_position(context, row_index, column_index):
+ table = context.table_
+ context.cell = table.cell(int(row_index), int(column_index))
@when('I set the cell width to {width}')
@@ -166,6 +177,7 @@ def when_I_set_the_table_autofit_to_setting(context, setting):
# then =====================================================
+
@then('I can access a cell using its row and column indices')
def then_can_access_cell_using_its_row_and_col_indices(context):
table = context.table_
@@ -318,6 +330,11 @@ def then_new_row_has_2_cells(context):
assert len(context.row.cells) == 2
+@then('the first row has 1 cell')
+def then_the_first_row_has_1_cell(context):
+ assert len(context.table_.rows[0].cells) == 1
+
+
@then('the reported autofit setting is {autofit}')
def then_the_reported_autofit_setting_is_autofit(context, autofit):
expected_value = {'autofit': True, 'fixed': False}[autofit]
@@ -333,6 +350,23 @@ def then_the_reported_column_width_is_width_emu(context, width_emu):
)
+@then('the cell collection length of the row(s) indexed by [{index}] is '
+ '{length}')
+def then_the_cell_collection_len_of_row_is_length(context, index, length):
+ table = context.table_
+ index_lst = index.split(',')
+ for i in index_lst:
+ assert len(table.rows[int(i)].cells) == int(length)
+
+@then('the cell collection length of the column(s) indexed by [{index}] is '
+ '{length}')
+def then_the_cell_collection_len_of_column_is_length(context, index, length):
+ table = context.table_
+ index_lst = index.split(',')
+ for i in index_lst:
+ assert len(table.columns[int(i)].cells) == int(length)
+
+
@then('the reported width of the cell is {width}')
def then_the_reported_width_of_the_cell_is_width(context, width):
expected_width = {'None': None, '1 inch': Inches(1)}[width]
@@ -349,14 +383,14 @@ def then_table_style_matches_name_applied(context):
assert table.style == 'LightShading-Accent1', tmpl % table.style
-@then('the table has {count} columns')
+@then('the table has {count} column(s)')
def then_table_has_count_columns(context, count):
column_count = int(count)
columns = context.table_.columns
assert len(columns) == column_count
-@then('the table has {count} rows')
+@then('the table has {count} row(s)')
def then_table_has_count_rows(context, count):
row_count = int(count)
rows = context.table_.rows
diff --git a/features/tbl-add-row-or-col.feature b/features/tbl-add-row-or-col.feature
index 22946085a..86edf3372 100644
--- a/features/tbl-add-row-or-col.feature
+++ b/features/tbl-add-row-or-col.feature
@@ -6,11 +6,11 @@ Feature: Add a row or column to a table
Scenario: Add a row to a table
Given a 2 x 2 table
When I add a row to the table
- Then the table has 3 rows
+ Then the table has 3 row(s)
And the new row has 2 cells
Scenario: Add a column to a table
Given a 2 x 2 table
When I add a column to the table
- Then the table has 3 columns
+ Then the table has 3 column(s)
And the new column has 2 cells
diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature
index 620a55092..4bd145405 100644
--- a/features/tbl-cell-props.feature
+++ b/features/tbl-cell-props.feature
@@ -19,7 +19,20 @@ Feature: Get and set table cell properties
When I set the cell width to
Then the reported width of the cell is
- Examples: table column width values
+ Examples: Table column width values
| width-setting | new-setting | reported-width |
| no explicit setting | 1 inch | 1 inch |
| 2 inches | 1 inch | 1 inch |
+
+
+ Scenario Outline: Get the row/column index of a cell
+ Given a 3 x 3 table
+ When I access the cell at the position (, )
+ Then the cell row index value is
+ And the cell column index value is
+
+ Examples: Cell position in the table
+ | row-index | column-index |
+ | 0 | 0 |
+ | 2 | 1 |
+ | 1 | 2 |
diff --git a/tests/test_table.py b/tests/test_table.py
index 4ecf55d19..f4b26b439 100644
--- a/tests/test_table.py
+++ b/tests/test_table.py
@@ -192,6 +192,27 @@ def it_can_replace_its_content_with_a_string_of_text(
cell, text, expected_xml = text_set_fixture
cell.text = text
assert cell._tc.xml == expected_xml
+
+ def it_knows_its_row_and_column_index(self, row_and_column_index_fixture):
+ tbl, single_row, single_cell = row_and_column_index_fixture
+ # test in table
+ for nrow, row in enumerate(tbl.rows):
+ for ncol, col in enumerate(tbl.columns):
+ # cell has _RowCells parent
+ cell = row.cells[ncol]
+ assert cell.row_index == nrow
+ assert cell.column_index == ncol
+ # cell has _ColumnCells parent
+ cell = col.cells[nrow]
+ assert cell.row_index == nrow
+ assert cell.column_index == ncol
+ # test in nrow
+ for ncol, cell in enumerate(single_row.cells):
+ assert cell.row_index == 0
+ assert cell.column_index == ncol
+ # test single cell
+ assert single_cell.row_index == 0
+ assert single_cell.column_index == 0
def it_knows_its_width_in_EMU(self, width_get_fixture):
cell, expected_width = width_get_fixture
@@ -202,6 +223,12 @@ def it_can_change_its_width(self, width_set_fixture):
cell.width = value
assert cell.width == value
assert cell._tc.xml == expected_xml
+
+ def it_can_be_merged(self, cell_merge_fixture):
+ (table, merge_from_coord, merge_to_coord,
+ expected_xml) = cell_merge_fixture
+ table.cell(*merge_from_coord).merge(table.cell(*merge_to_coord))
+ assert table._tbl.xml == expected_xml
# fixtures -------------------------------------------------------
@@ -231,6 +258,14 @@ def add_table_fixture(self, request):
expected_xml = xml(after_tc_cxml)
return cell, expected_xml
+ @pytest.fixture
+ def row_and_column_index_fixture(self):
+ tbl = Table(_tbl_bldr(4,4).element, None)
+ single_row = _Row(_tr_bldr(4).with_nsdecls().element, None)
+
+ single_cell = _Cell(element('w:tc'), None)
+ return tbl, single_row, single_cell
+
@pytest.fixture
def paragraphs_fixture(self):
return _Cell(element('w:tc/(w:p, w:p)'), None)
@@ -283,7 +318,37 @@ def width_set_fixture(self, request):
cell = _Cell(element(tc_cxml), None)
expected_xml = xml(expected_cxml)
return cell, new_value, expected_xml
-
+
+ @pytest.fixture(params=[
+ # Horizontal merge
+ ('w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),w:tr/(w:tc,w:tc),'
+ 'w:tr/(w:tc,w:tc))', (0, 0), (0, 1),
+ 'w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),'
+ 'w:tr/(w:tc/w:tcPr/w:hMerge{w:val=restart},'
+ 'w:tc/w:tcPr/w:hMerge),w:tr/(w:tc,w:tc))'),
+ # Vertical merge
+ ('w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),w:tr/(w:tc,w:tc),'
+ 'w:tr/(w:tc,w:tc))', (0, 0), (1, 0),
+ 'w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),'
+ 'w:tr/(w:tc/w:tcPr/w:vMerge{w:val=restart},w:tc),'
+ 'w:tr/(w:tc/w:tcPr/w:vMerge,w:tc))'),
+ # Two-ways merge
+ ('w:tbl/(w:tblGrid/(w:gridCol,w:gridCol,w:gridCol),'
+ 'w:tr/(w:tc,w:tc,w:tc),w:tr/(w:tc,w:tc,w:tc),w:tr/(w:tc,w:tc,w:tc))',
+ (0, 0), (1, 1),
+ 'w:tbl/(w:tblGrid/(w:gridCol,w:gridCol,w:gridCol),'
+ 'w:tr/(w:tc/w:tcPr/(w:hMerge{w:val=restart},w:vMerge{w:val=restart}),'
+ 'w:tc/w:tcPr/w:hMerge,w:tc),'
+ 'w:tr/(w:tc/w:tcPr/(w:hMerge{w:val=restart},w:vMerge),'
+ 'w:tc/w:tcPr/w:hMerge,w:tc),'
+ 'w:tr/(w:tc,w:tc,w:tc))'),
+ ])
+ def cell_merge_fixture(self, request):
+ (tbl_cxml, merge_from_coord, merge_to_coord,
+ expected_cxml) = request.param
+ table = Table(element(tbl_cxml), None)
+ expected_xml = xml(expected_cxml)
+ return table, merge_from_coord, merge_to_coord, expected_xml
class Describe_Column(object):