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):