From 8278cbbc1bbf9337ecc1f84f38a3e7fd4fb679fb Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 4 Aug 2014 00:24:13 -0400 Subject: [PATCH 01/45] Add support for the table cell property gridSpan element, which is useful for merging cells. --- docx/oxml/table.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index f2fbd540f..53bea6788 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -17,7 +17,6 @@ RequiredAttribute, ZeroOrOne, ZeroOrMore ) - class CT_Row(BaseOxmlElement): """ ```` element @@ -221,7 +220,30 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + @property + def gridspan(self): + """ + Return the int 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 + +class CT_TcGridSpan(BaseOxmlElement): + """ + ```` element, defining a single cell span. + """ + val = RequiredAttribute('w:val', XsdInt) + + class CT_TcPr(BaseOxmlElement): """ ```` element, defining table cell properties @@ -232,6 +254,12 @@ 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' + )) @property def width(self): @@ -248,3 +276,19 @@ def width(self): def width(self, value): tcW = self.get_or_add_tcW() tcW.width = value + + @property + def gridspan(self): + """ + Return 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 \ No newline at end of file From 06360187f69099807d2f5702b897076f742e77ad Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 4 Aug 2014 00:28:31 -0400 Subject: [PATCH 02/45] Register a newly added CT_TcGridSpan (w:gridSpan) class/element. --- docx/oxml/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index c5938c7c8..2c64f2616 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,7 +115,7 @@ 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_TcGridSpan, CT_TcPr ) register_element_cls('w:gridCol', CT_TblGridCol) register_element_cls('w:tbl', CT_Tbl) @@ -124,6 +124,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tblPr', CT_TblPr) register_element_cls('w:tblStyle', CT_String) register_element_cls('w:tc', CT_Tc) +register_element_cls('w:gridSpan', CT_TcGridSpan) register_element_cls('w:tcPr', CT_TcPr) register_element_cls('w:tcW', CT_TblWidth) register_element_cls('w:tr', CT_Row) From b1b37ae995e91e14837ccceb7bcf021661f912da Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 4 Aug 2014 00:44:14 -0400 Subject: [PATCH 03/45] Add support for merging the cells of a single row. --- docx/table.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docx/table.py b/docx/table.py index 544553b1e..e2903156b 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. @@ -288,6 +289,17 @@ def cells(self): """ return _RowCells(self._tr, self) + def merge_cells(self): + """ + Merge the cells of this row. + """ + cells_count = len(self._tr.tc_lst) + # Delete all except the first cell of the row. + for tc in self._tr.tc_lst[1:]: + self._tr.remove(tc) + # Set the gridSpan value of the remaining cell. + self._tr.tc_lst[0].gridspan = cells_count + class _RowCells(Parented): """ From 90fbf3b1972ceef661058b12b20a7771a8993465 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 4 Aug 2014 14:10:02 -0400 Subject: [PATCH 04/45] Add mergeStart, mergeStop args to merge_cells() to make it more flexible. --- docx/table.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docx/table.py b/docx/table.py index e2903156b..2ec76bfa9 100644 --- a/docx/table.py +++ b/docx/table.py @@ -289,16 +289,17 @@ def cells(self): """ return _RowCells(self._tr, self) - def merge_cells(self): + def merge_cells(self, mergeStart=0, mergeStop=None): """ - Merge the cells of this row. + Merge the cells of this row indexed by `mergeStart` to `mergeStop`. + The default behavior is to merge all the cells of the row. """ - cells_count = len(self._tr.tc_lst) - # Delete all except the first cell of the row. - for tc in self._tr.tc_lst[1:]: + merged_cells_count = len(self._tr.tc_lst[mergeStart:mergeStop]) + # Delete the merged cells to the right of the mergeStart indexed cell. + for tc in self._tr.tc_lst[mergeStart+1:mergeStop]: self._tr.remove(tc) - # Set the gridSpan value of the remaining cell. - self._tr.tc_lst[0].gridspan = cells_count + # Set the gridSpan value of the mergeStart indexed cell. + self._tr.tc_lst[0].gridspan = merged_cells_count class _RowCells(Parented): From 69f492bf84b29891eced79af954e2448fea652a7 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 8 Aug 2014 09:58:45 -0400 Subject: [PATCH 05/45] Fix wrong index in merge_cells method (the gridSpan was set to the cell indexed by 0 instead of mergeStart). --- docx/table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 2ec76bfa9..b51d17221 100644 --- a/docx/table.py +++ b/docx/table.py @@ -299,7 +299,7 @@ def merge_cells(self, mergeStart=0, mergeStop=None): for tc in self._tr.tc_lst[mergeStart+1:mergeStop]: self._tr.remove(tc) # Set the gridSpan value of the mergeStart indexed cell. - self._tr.tc_lst[0].gridspan = merged_cells_count + self._tr.tc_lst[mergeStart].gridspan = merged_cells_count class _RowCells(Parented): From b27fb26e7fec0d249d823cc196fbf4ff9a705bb6 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 8 Aug 2014 12:45:55 -0400 Subject: [PATCH 06/45] Removed the previously added CT_TcPrGridSpan class, as we are now using CT_DecimalNumber. --- docx/oxml/__init__.py | 4 ++-- docx/oxml/table.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 2c64f2616..c79bae33f 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,16 +115,16 @@ 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_TcGridSpan, CT_TcPr + CT_TblWidth, CT_Tc, CT_TcPr ) 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) register_element_cls('w:tblPr', CT_TblPr) register_element_cls('w:tblStyle', CT_String) register_element_cls('w:tc', CT_Tc) -register_element_cls('w:gridSpan', CT_TcGridSpan) register_element_cls('w:tcPr', CT_TcPr) register_element_cls('w:tcW', CT_TblWidth) register_element_cls('w:tr', CT_Row) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 53bea6788..5b62714ec 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -235,14 +235,6 @@ def gridspan(self): def gridspan(self, value): tcPr = self.get_or_add_tcPr() tcPr.gridspan = value - - -class CT_TcGridSpan(BaseOxmlElement): - """ - ```` element, defining a single cell span. - """ - val = RequiredAttribute('w:val', XsdInt) - class CT_TcPr(BaseOxmlElement): """ From b11faacb56c7364dffd54a60373c05fd9622df78 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 8 Aug 2014 16:45:25 -0400 Subject: [PATCH 07/45] Register CT_VMerge. --- docx/oxml/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index c79bae33f..b397a1b46 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,7 +115,7 @@ 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_VMerge ) register_element_cls('w:gridCol', CT_TblGridCol) register_element_cls('w:gridSpan', CT_DecimalNumber) @@ -128,6 +128,7 @@ 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:vMerge', CT_VMerge) from docx.oxml.text import ( CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline From 8a99e1f4e6f0b5cace64843c7502009de6a32c15 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 8 Aug 2014 16:49:38 -0400 Subject: [PATCH 08/45] Add ST_Merge class. --- docx/oxml/simpletypes.py | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From 4a61381f71bc463c5c1c353665669bcbb0e29635 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 8 Aug 2014 16:51:37 -0400 Subject: [PATCH 09/45] Add vmerge methods to CT_Tc and CT_TcPr classes. --- docx/oxml/table.py | 53 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 5b62714ec..f3ae96a89 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -10,7 +10,7 @@ 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, @@ -223,7 +223,7 @@ def width(self, value): @property def gridspan(self): """ - Return the int value represented in the ``./w:tcPr/w:gridSpan`` + Return the decimal value represented in the ``./w:tcPr/w:gridSpan`` child element or |None| if not present. """ tcPr = self.tcPr @@ -235,6 +235,22 @@ def gridspan(self): def gridspan(self, value): tcPr = self.get_or_add_tcPr() tcPr.gridspan = 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): """ @@ -252,6 +268,12 @@ class CT_TcPr(BaseOxmlElement): '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): @@ -272,7 +294,7 @@ def width(self, value): @property def gridspan(self): """ - Return value represented in the ```` child element or + Return the value represented in the ```` child element or |None| if not present. """ gridSpan = self.gridSpan @@ -283,4 +305,27 @@ def gridspan(self): @gridspan.setter def gridspan(self, value): gridSpan = self.get_or_add_gridSpan() - gridSpan.val = value \ No newline at end of file + gridSpan.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_VMerge(BaseOxmlElement): + """ + ```` element, child of ````, defines a vertically merged + cell. + """ + val = OptionalAttribute('w:val', ST_Merge) From bd14d538a43d8ddce9effb094580a794350c0f1d Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 11 Aug 2014 00:32:17 -0400 Subject: [PATCH 10/45] Detail the cell merge feature. --- docs/dev/analysis/features/cell-merge.rst | 173 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 174 insertions(+) create mode 100644 docs/dev/analysis/features/cell-merge.rst diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst new file mode 100644 index 000000000..da0ab1a9d --- /dev/null +++ b/docs/dev/analysis/features/cell-merge.rst @@ -0,0 +1,173 @@ + +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`` is replaced by a ``w:tcW`` element specifying the width +of each 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 is equal to the combined width of the cells merged. + +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 Word uses empty valued +``w:vMerge`` elements. 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 heigth of the +merged cells. + +Candidate protocol -- cell.merge() +---------------------------------- + +The following interactive session demonstrates the protocol for merging table +cells:: + + >>> table = doc.add_table(5,5) + >>> table.rows[0].cells[0].merge(table.rows[3].cells[3]) + +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`_ +* `w:gridSpan reference from Datypic`_ +* `w:vMerge reference from Datypic`_ +* `w:CT_VMerge reference from Datypic`_ +* `w:ST_Merge reference from Datypic`_ + +.. _`Cell.Merge Method on MSDN`: + http://msdn.microsoft.com/en-us/library/office/ff821310%28v=office.15%29.aspx + +.. _`w:gridSpan reference from Datypic`: + http://www.datypic.com/sc/ooxml/e-w_gridSpan-1.html + +.. _`w:vMerge reference from Datypic`: + http://www.datypic.com/sc/ooxml/e-w_vMerge-1.html + +.. _`w:CT_VMerge reference from Datypic`: + http://www.datypic.com/sc/ooxml/t-w_CT_VMerge.html + +.. _`w:ST_Merge reference from Datypic`: + http://www.datypic.com/sc/ooxml/t-w_ST_Merge.html + + +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 From df16549a4fa76bdc70e9d84c533e3113b45fbaf6 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 11 Aug 2014 11:40:15 -0400 Subject: [PATCH 11/45] doc: Improve cell-merge formatting and add additionnal notes. --- docs/dev/analysis/features/cell-merge.rst | 64 ++++++++++++++--------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index da0ab1a9d..2c634fa12 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -1,7 +1,7 @@ Table Cells Merge ================= - + In Word, table cells can be merged with the following restrictions: * Only rectangular selections are supported. @@ -11,29 +11,44 @@ In Word, table cells can be merged with the following restrictions: 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`` is replaced by a ``w:tcW`` element specifying the width -of each 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 is equal to the combined width of the cells merged. - -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 Word uses empty valued -``w:vMerge`` elements. 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 heigth of the -merged cells. +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`` is replaced by a ``w:tcW`` element +specifying the width of each 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 is equal to the combined width of +the cells merged. + +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 Word +uses empty valued ``w:vMerge`` elements. 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 heigth of +the merged cells. + + +Additionnal notes +~~~~~~~~~~~~~~~~~ + +Word cannot report how many cells a specific column contains if one or more +cells in this column have a different width due to having been merged with +another cell. + +Similarly, Word cannot report how many cells a specific row contains if one or +more cells of that row have been vertically merged. + Candidate protocol -- cell.merge() ---------------------------------- @@ -44,6 +59,7 @@ cells:: >>> table = doc.add_table(5,5) >>> table.rows[0].cells[0].merge(table.rows[3].cells[3]) + Specimen XML ------------ From 10d28fc0e2c0e5c69db6811c3b7b31dae71e124c Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 11 Aug 2014 15:52:22 -0400 Subject: [PATCH 12/45] doc: add some details regarding merging and word behavior. --- docs/dev/analysis/features/cell-merge.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 2c634fa12..68b5aedb8 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -39,15 +39,16 @@ area are deleted along with their ``w:vMerge`` elements and the the merged cells. -Additionnal notes -~~~~~~~~~~~~~~~~~ +Word behavior particularities +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Word cannot report how many cells a specific column contains if one or more cells in this column have a different width due to having been merged with -another cell. +another cell. Similarly, Word cannot report how many cells a specific row +contains if one or more cells of that row have been vertically merged. -Similarly, Word cannot report how many cells a specific row contains if one or -more cells of that row have been vertically merged. +Word resize 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. Candidate protocol -- cell.merge() From c98827a58a409125c2c3fbcd0225b9281af8db5b Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 12 Aug 2014 09:59:27 -0400 Subject: [PATCH 13/45] doc: Improve Word specific behavior description. --- docs/dev/analysis/features/cell-merge.rst | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 68b5aedb8..618acba7b 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -42,12 +42,11 @@ the merged cells. Word behavior particularities ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Word cannot report how many cells a specific column contains if one or more -cells in this column have a different width due to having been merged with -another cell. Similarly, Word cannot report how many cells a specific row -contains if one or more cells of that row have been vertically merged. +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. -Word resize a table when a cell is refered by an out-of-bounds row index. +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. From 98c0ab00e96e4fa2e86720f6624b5be7cf133e1a Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 12 Aug 2014 15:26:16 -0400 Subject: [PATCH 14/45] doc: cell-merge.rst retouches. --- docs/dev/analysis/features/cell-merge.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 618acba7b..d90e4e2d0 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -18,12 +18,18 @@ 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`` is replaced by a ``w:tcW`` element -specifying the width of each column. For example, if the table consists of +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 is equal to the combined width of +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 removes +the hurdle of dealing with ``w:tcW`` elements. 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 @@ -39,8 +45,8 @@ area are deleted along with their ``w:vMerge`` elements and the the merged cells. -Word behavior particularities -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +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 From cb4130cd685d20215a86afb4ef6497b440dd2393 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 12 Aug 2014 21:15:40 -0400 Subject: [PATCH 15/45] Add default value, 'continue', to CT_VMerge object. --- docx/oxml/table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index f3ae96a89..63b9f4f5b 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -328,4 +328,4 @@ class CT_VMerge(BaseOxmlElement): ```` element, child of ````, defines a vertically merged cell. """ - val = OptionalAttribute('w:val', ST_Merge) + val = OptionalAttribute('w:val', ST_Merge, 'continue') From d3cb2558356abd2c50fac8f9a9232b872d7f4705 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 12 Aug 2014 21:22:23 -0400 Subject: [PATCH 16/45] doc: Add the information about the w:vMerge attribute having it's default value set to 'continue'. --- docs/dev/analysis/features/cell-merge.rst | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index d90e4e2d0..a78c4e380 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -34,23 +34,23 @@ 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 Word -uses empty valued ``w:vMerge`` elements. 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 heigth of -the merged cells. +(``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 heigth 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. +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. 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. From b2e20f1af548020ab7d26ff568027f89bf648e1f Mon Sep 17 00:00:00 2001 From: Apteryks Date: Wed, 13 Aug 2014 09:55:18 -0400 Subject: [PATCH 17/45] doc: Add some schemas to cell-merge.rst, removed web references. --- docs/dev/analysis/features/cell-merge.rst | 194 +++++++++++----------- 1 file changed, 99 insertions(+), 95 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index a78c4e380..3fed8304c 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -42,7 +42,7 @@ vertical merge ends as soon as a cell ``w:TcPr`` element lacks the 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 heigth of the merged cells. +is used to specify the combined height of the merged cells. Word specific behavior @@ -75,73 +75,61 @@ 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 @@ -151,42 +139,58 @@ Schema excerpt :: - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - Ressources ---------- * `Cell.Merge Method on MSDN`_ -* `w:gridSpan reference from Datypic`_ -* `w:vMerge reference from Datypic`_ -* `w:CT_VMerge reference from Datypic`_ -* `w:ST_Merge reference from Datypic`_ .. _`Cell.Merge Method on MSDN`: http://msdn.microsoft.com/en-us/library/office/ff821310%28v=office.15%29.aspx - -.. _`w:gridSpan reference from Datypic`: - http://www.datypic.com/sc/ooxml/e-w_gridSpan-1.html - -.. _`w:vMerge reference from Datypic`: - http://www.datypic.com/sc/ooxml/e-w_vMerge-1.html - -.. _`w:CT_VMerge reference from Datypic`: - http://www.datypic.com/sc/ooxml/t-w_CT_VMerge.html - -.. _`w:ST_Merge reference from Datypic`: - http://www.datypic.com/sc/ooxml/t-w_ST_Merge.html - Relevant sections in the ISO Spec ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 482ecea594c72c0216eeca2630d83e64c5276458 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Wed, 13 Aug 2014 10:03:15 -0400 Subject: [PATCH 18/45] acpt: add cell-merge.feature --- features/cell-merge.feature | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 features/cell-merge.feature diff --git a/features/cell-merge.feature b/features/cell-merge.feature new file mode 100644 index 000000000..d209df8b6 --- /dev/null +++ b/features/cell-merge.feature @@ -0,0 +1,42 @@ +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 cells of the first row + Then the lenght of the first row cells collection is reported as 1 + + + Scenario: Merge cells vertically + Given a 2 x 2 table + When I merge the cells of the first column + Then the length of the first column cells collection is reported as 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 length of the first two rows cells collection is reported as 2 + And the length of the first two columns cells collection is reported as 2 + + + Scenario: Merge a previously merged area + Given a 4 x 4 table with the 2 x 2 topleftmost cells already merged + When I merge the 3 x 3 topleftmost cells + Then the length of the first three rows cells collection is reported as 2 + And the length of the first three columns cells collection is reported + as 2 + + + Scenario: Unsupported merge of a previously merged area + Given a 2 x 2 cells table with the first row cells already merged + When I try to merge the cells from the first column + Then an exception is raised with a detailed error message + + Scenario: Merge resulting in a table reduction (simplification) + Given a 2 x 2 table + When I merge all the cells of the table + Then the resulting table is contains exactly one cell \ No newline at end of file From e5a157800d724522d17fb5830defe5c3ad40596d Mon Sep 17 00:00:00 2001 From: Apteryks Date: Wed, 13 Aug 2014 13:57:59 -0400 Subject: [PATCH 19/45] acpt: Refine the cell-merge feature scenarios --- features/cell-merge.feature | 39 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/features/cell-merge.feature b/features/cell-merge.feature index d209df8b6..98d83bc9d 100644 --- a/features/cell-merge.feature +++ b/features/cell-merge.feature @@ -6,37 +6,40 @@ Feature: Merge table cells Scenario: Merge cells horizontally Given a 2 x 2 table - When I merge the cells of the first row - Then the lenght of the first row cells collection is reported as 1 + 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 cells of the first column - Then the length of the first column cells collection is reported as 1 + 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 length of the first two rows cells collection is reported as 2 - And the length of the first two columns cells collection is reported as 2 + Then the cell collection length of the row(s) indexed by [0,1] is 2 + And the cell collection length of the column(s) indexed by [0,1] is 2 - Scenario: Merge a previously merged area - Given a 4 x 4 table with the 2 x 2 topleftmost cells already merged - When I merge the 3 x 3 topleftmost cells - Then the length of the first three rows cells collection is reported as 2 - And the length of the first three columns cells collection is reported - as 2 + Scenario: Merging an already merged area + Given a 4 x 4 table + When I merge the 2 x 2 topleftmost cells + And I merge the 3 x 3 topleftmost cells + Then the cell collection length of the row(s) indexed by [0,1,2] is 2 + And the cell collection length of the column(s) indexed by [0,1,2] is 2 + +# Scenario: Unsupported merge of an already merged area +# Given a 2 x 2 table +# When I merge the 1 x 2 topleftmost cells +# And I merge the 2 x 1 topleftmost cells +# Then an exception is raised with a detailed error message - Scenario: Unsupported merge of a previously merged area - Given a 2 x 2 cells table with the first row cells already merged - When I try to merge the cells from the first column - Then an exception is raised with a detailed error message Scenario: Merge resulting in a table reduction (simplification) Given a 2 x 2 table - When I merge all the cells of the table - Then the resulting table is contains exactly one cell \ No newline at end of file + When I merge the 2 x 2 topleftmost cells + Then the table has 1 row(s) + And the table has 1 column(s) \ No newline at end of file From 3ad233fe4a0f6c0e661e0a5d644cb83bb33f158e Mon Sep 17 00:00:00 2001 From: Apteryks Date: Wed, 13 Aug 2014 13:58:53 -0400 Subject: [PATCH 20/45] acpt: Add/modify steps for the cell-merge feature --- features/steps/table.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/features/steps/table.py b/features/steps/table.py index 2d7aa37dc..d38977a32 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): @@ -143,7 +142,12 @@ def when_add_row_to_table(context): def when_apply_style_to_table(context): table = context.table_ table.style = 'LightShading-Accent1' + +@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_ + table.rows[0].cells[0].merge(table.rows[nrows-1].cells[ncols-1]) @when('I set the cell width to {width}') def when_I_set_the_cell_width_to_width(context, width): @@ -318,6 +322,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 +342,21 @@ 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_ + for i in index: + assert len(table.rows[i].cells) == 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_ + for i in index: + assert len(table.columns[i].cells) == 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 +373,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 From bc4a1b0cc128e2bf4e6b6077b95f5f9a9bd48f30 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Wed, 13 Aug 2014 14:04:45 -0400 Subject: [PATCH 21/45] acpt: Update some step names to reflect changes in the table steps. --- features/tbl-add-row-or-col.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 4deb7f653b13e99a61c755b08c620ca54900e5d9 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Thu, 14 Aug 2014 08:49:30 -0400 Subject: [PATCH 22/45] acpt: Bring back the 'Unsuprted merge of an alrdy merged area' scenario. --- features/cell-merge.feature | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/features/cell-merge.feature b/features/cell-merge.feature index 98d83bc9d..f924aea37 100644 --- a/features/cell-merge.feature +++ b/features/cell-merge.feature @@ -23,7 +23,7 @@ Feature: Merge table cells And the cell collection length of the column(s) indexed by [0,1] is 2 - Scenario: Merging an already merged area + Scenario: Merge an already merged area Given a 4 x 4 table When I merge the 2 x 2 topleftmost cells And I merge the 3 x 3 topleftmost cells @@ -31,11 +31,11 @@ Feature: Merge table cells And the cell collection length of the column(s) indexed by [0,1,2] is 2 -# Scenario: Unsupported merge of an already merged area -# Given a 2 x 2 table -# When I merge the 1 x 2 topleftmost cells -# And I merge the 2 x 1 topleftmost cells -# Then an exception is raised with a detailed error message + Scenario: Unsupported merge of an already merged area + Given a 2 x 2 table + When I merge the 1 x 2 topleftmost cells + And I merge the 2 x 1 topleftmost cells + Then an exception is raised with a detailed error message Scenario: Merge resulting in a table reduction (simplification) From 1dfb8fe34a41cb2d4ff6da2655318dbb4b18c0f1 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Thu, 14 Aug 2014 11:17:02 -0400 Subject: [PATCH 23/45] acpt: implement unsupported merge scenario and steps. --- features/cell-merge.feature | 8 ++++++-- features/steps/table.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/features/cell-merge.feature b/features/cell-merge.feature index f924aea37..b7edd0555 100644 --- a/features/cell-merge.feature +++ b/features/cell-merge.feature @@ -31,11 +31,15 @@ Feature: Merge table cells And the cell collection length of the column(s) indexed by [0,1,2] is 2 - Scenario: Unsupported merge of an already merged area + Scenario Outline: Unsupported merge of an already merged area Given a 2 x 2 table When I merge the 1 x 2 topleftmost cells And I merge the 2 x 1 topleftmost cells - Then an exception is raised with a detailed error message + Then a exception is raised with a detailed + + Examples: Exception type and error message variables + | exception-type | err-message | + | ValueError | Cannot partially merge an already merged area. | Scenario: Merge resulting in a table reduction (simplification) diff --git a/features/steps/table.py b/features/steps/table.py index d38977a32..628c89a07 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -147,7 +147,11 @@ def when_apply_style_to_table(context): @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_ - table.rows[0].cells[0].merge(table.rows[nrows-1].cells[ncols-1]) + try: + table.cell(0, 0).merge(table.cell(nrows-1, ncols-1)) + except Exception as e: + context.exception = e + raise @when('I set the cell width to {width}') def when_I_set_the_cell_width_to_width(context, width): @@ -170,6 +174,13 @@ def when_I_set_the_table_autofit_to_setting(context, setting): # 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 + + @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_ From f28891d469a07aa015ad5ed94721842b549352b1 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 13:22:11 -0400 Subject: [PATCH 24/45] acpt: row/column index cell property --- features/tbl-cell-props.feature | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 | From 5d415dfb82214436c3b2882f3f6e56f63e9a8acc Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 13:23:40 -0400 Subject: [PATCH 25/45] steps: Add steps for the row/column cell properties --- features/steps/cell.py | 11 +++++++++++ features/steps/table.py | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/features/steps/cell.py b/features/steps/cell.py index 6f588cd8c..8b469db59 100644 --- a/features/steps/cell.py +++ b/features/steps/cell.py @@ -31,9 +31,20 @@ def when_assign_string_to_cell_text_attribute(context): # 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/table.py b/features/steps/table.py index 628c89a07..7204ccf40 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -144,6 +144,12 @@ def when_apply_style_to_table(context): 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 merge the {nrows} x {ncols} topleftmost cells') def when_I_merge_the_nrows_x_ncols_topleftmost_cells(context, nrows, ncols): table = context.table_ From 17fdeabb82d9462233bd7e998a6a53c281ad05af Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 13:29:25 -0400 Subject: [PATCH 26/45] tests: Add unit test for row/column index property --- tests/test_table.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/test_table.py b/tests/test_table.py index 4ecf55d19..a0b42af15 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -192,6 +192,20 @@ 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 + for row in range(len(tbl.rows)): + for col in range(len(tbl.columns)): + cell = tbl.cell(row,col) + assert cell.row_index == row + assert cell.column_index == col + for col in range(len(single_row.cells)): + cell = single_row.cells[col] + assert cell.row_index == 0 + assert cell.column_index == col + 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 +216,11 @@ 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_to_coord, expected_xml = cell_merge_fixture + table.cell(0, 0).merge(table.cell(*merge_to_coord)) + assert table._tbl.xml == expected_xml # fixtures ------------------------------------------------------- @@ -231,6 +250,13 @@ 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 +309,18 @@ 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=[ + ('w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),w:tr/(w:tc,w:tc),' + 'w:tr/(w:tc,w:tc))', (0, 1), + 'w:tbl/(w:tblGrid/(w:gridCol,w:gridCol),' + 'w:tr/w:tc/w:tcPr/w:gridSpan{w:val=2},w:tr/(w:tc,w:tc))'), + ]) + def cell_merge_fixture(self, request): + tbl_cxml, merge_to_coord, expected_cxml = request.param + table = Table(element(tbl_cxml), None) + expected_xml = xml(expected_cxml) + return table, merge_to_coord, expected_xml class Describe_Column(object): From 35fdd59a0d58b176dfce74cbb00a7f2b0d2eb73a Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 13:30:27 -0400 Subject: [PATCH 27/45] cell: Add column/row index property (read-only). --- docx/table.py | 56 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/docx/table.py b/docx/table.py index b51d17221..cc7a19d3c 100644 --- a/docx/table.py +++ b/docx/table.py @@ -125,16 +125,58 @@ 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 + return self._parent._tr.tc_lst.index(self._tc) + + def merge(self, cell): + """ + Merge the rectangular area delimited by the current cell and another + cell passed as the argument. + """ + + pass + #merged_cells_count = len(self._tr.tc_lst[mergeStart:mergeStop]) + # Delete the merged cells to the right of the mergeStart indexed cell. + #for tc in self._tr.tc_lst[mergeStart+1:mergeStop]: + # self._tr.remove(tc) + # Set the gridSpan value of the mergeStart indexed cell. + #self._tr.tc_lst[mergeStart].gridspan = merged_cells_count + @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. + """ + parent = self._parent + parent_row = None + parent_rows = None + while parent is not None: + if isinstance(parent, _Row): + parent_row = parent + elif isinstance(parent, _Rows): + parent_rows = parent + break + parent = parent._parent + if (parent_row is None) or (parent_rows is None): + return 0 + return parent_rows._tbl.tr_lst.index(parent_row._tr) + @property def tables(self): """ @@ -288,18 +330,6 @@ def cells(self): Supports ``len()``, iteration and indexed access. """ return _RowCells(self._tr, self) - - def merge_cells(self, mergeStart=0, mergeStop=None): - """ - Merge the cells of this row indexed by `mergeStart` to `mergeStop`. - The default behavior is to merge all the cells of the row. - """ - merged_cells_count = len(self._tr.tc_lst[mergeStart:mergeStop]) - # Delete the merged cells to the right of the mergeStart indexed cell. - for tc in self._tr.tc_lst[mergeStart+1:mergeStop]: - self._tr.remove(tc) - # Set the gridSpan value of the mergeStart indexed cell. - self._tr.tc_lst[mergeStart].gridspan = merged_cells_count class _RowCells(Parented): From c4a9de1a3657a124e4361fd7cfcde939b4a4de33 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 13:32:17 -0400 Subject: [PATCH 28/45] Rename cell-merge.feature to cel-merge-feature --- features/cel-merge.feature | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 features/cel-merge.feature diff --git a/features/cel-merge.feature b/features/cel-merge.feature new file mode 100644 index 000000000..b7edd0555 --- /dev/null +++ b/features/cel-merge.feature @@ -0,0 +1,49 @@ +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,1] is 2 + And the cell collection length of the column(s) indexed by [0,1] is 2 + + + Scenario: Merge an already merged area + Given a 4 x 4 table + When I merge the 2 x 2 topleftmost cells + And I merge the 3 x 3 topleftmost cells + Then the cell collection length of the row(s) indexed by [0,1,2] is 2 + And the cell collection length of the column(s) indexed by [0,1,2] is 2 + + + Scenario Outline: Unsupported merge of an already merged area + Given a 2 x 2 table + When I merge the 1 x 2 topleftmost cells + And I merge the 2 x 1 topleftmost cells + Then a exception is raised with a detailed + + Examples: Exception type and error message variables + | exception-type | err-message | + | ValueError | Cannot partially merge an already merged area. | + + + Scenario: Merge resulting in a table reduction (simplification) + Given a 2 x 2 table + When I merge the 2 x 2 topleftmost cells + Then the table has 1 row(s) + And the table has 1 column(s) \ No newline at end of file From 798ee84af11da93a797aa461e3de37ac8e4d624f Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 13:37:28 -0400 Subject: [PATCH 29/45] Rename to cel-merge.feature --- features/cell-merge.feature | 49 ------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 features/cell-merge.feature diff --git a/features/cell-merge.feature b/features/cell-merge.feature deleted file mode 100644 index b7edd0555..000000000 --- a/features/cell-merge.feature +++ /dev/null @@ -1,49 +0,0 @@ -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,1] is 2 - And the cell collection length of the column(s) indexed by [0,1] is 2 - - - Scenario: Merge an already merged area - Given a 4 x 4 table - When I merge the 2 x 2 topleftmost cells - And I merge the 3 x 3 topleftmost cells - Then the cell collection length of the row(s) indexed by [0,1,2] is 2 - And the cell collection length of the column(s) indexed by [0,1,2] is 2 - - - Scenario Outline: Unsupported merge of an already merged area - Given a 2 x 2 table - When I merge the 1 x 2 topleftmost cells - And I merge the 2 x 1 topleftmost cells - Then a exception is raised with a detailed - - Examples: Exception type and error message variables - | exception-type | err-message | - | ValueError | Cannot partially merge an already merged area. | - - - Scenario: Merge resulting in a table reduction (simplification) - Given a 2 x 2 table - When I merge the 2 x 2 topleftmost cells - Then the table has 1 row(s) - And the table has 1 column(s) \ No newline at end of file From 125fecb002dc1d2c8b93bf171b79ebe1ae6bb04b Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 14:10:01 -0400 Subject: [PATCH 30/45] doc: Add behavior of different tables cells merge attempt. --- docs/dev/analysis/features/cell-merge.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 3fed8304c..9a883b50c 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -55,6 +55,8 @@ of a table if two or more cells from that table have been vertically merged. 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. + Candidate protocol -- cell.merge() ---------------------------------- From 542758aa121dce324e67992316a6c92dd1d202d7 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 15 Aug 2014 15:34:59 -0400 Subject: [PATCH 31/45] acpt: Reorganize in which files the scenario are defined & various fixes --- features/cel-merge.feature | 14 ++++++++++++-- features/steps/cell.py | 22 ++++++++++++++++++++++ features/steps/shared.py | 10 +++++++++- features/steps/table.py | 25 ++++++++----------------- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/features/cel-merge.feature b/features/cel-merge.feature index b7edd0555..ff6ed409f 100644 --- a/features/cel-merge.feature +++ b/features/cel-merge.feature @@ -3,7 +3,7 @@ Feature: Merge table cells As an python-docx developer I need a way to merge the cells of a table - + @wip Scenario: Merge cells horizontally Given a 2 x 2 table When I merge the 1 x 2 topleftmost cells @@ -46,4 +46,14 @@ Feature: Merge table cells Given a 2 x 2 table When I merge the 2 x 2 topleftmost cells Then the table has 1 row(s) - And the table has 1 column(s) \ No newline at end of file + And the table has 1 column(s) + + @wip + 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 of different tables. | diff --git a/features/steps/cell.py b/features/steps/cell.py index 8b469db59..67f8f95fd 100644 --- a/features/steps/cell.py +++ b/features/steps/cell.py @@ -29,6 +29,28 @@ 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 + raise + + +@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 + try: + table.cell(0, 0).merge(table.cell(row, col)) + except Exception as e: + context.exception = e + raise + # then ===================================================== @then('the cell row index value is {row_index_val}') 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 7204ccf40..d82b468a3 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -124,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') @@ -150,15 +156,6 @@ def when_I_access_the_cell_at_position(context, row_index, column_index): context.cell = table.cell(int(row_index), int(column_index)) -@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_ - try: - table.cell(0, 0).merge(table.cell(nrows-1, ncols-1)) - except Exception as e: - context.exception = e - raise - @when('I set the cell width to {width}') def when_I_set_the_cell_width_to_width(context, width): new_value = {'1 inch': Inches(1)}[width] @@ -180,12 +177,6 @@ def when_I_set_the_table_autofit_to_setting(context, setting): # 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 - @then('I can access a cell using its row and column indices') def then_can_access_cell_using_its_row_and_col_indices(context): @@ -364,14 +355,14 @@ def then_the_reported_column_width_is_width_emu(context, width_emu): def then_the_cell_collection_len_of_row_is_length(context, index, length): table = context.table_ for i in index: - assert len(table.rows[i].cells) == length + 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_ for i in index: - assert len(table.columns[i].cells) == length + assert len(table.columns[int(i)].cells) == int(length) @then('the reported width of the cell is {width}') From e8fd91e431756338da6871895932a46e58e94613 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sat, 16 Aug 2014 20:29:35 -0400 Subject: [PATCH 32/45] cell: Refine row/column index properties test and impl. --- docx/table.py | 103 ++++++++++++++++++++++++++++++++++++++------ tests/test_table.py | 24 +++++++---- 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/docx/table.py b/docx/table.py index cc7a19d3c..7ac4d019b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -101,6 +101,24 @@ def __init__(self, tc, parent): super(_Cell, self).__init__(tc, parent) self._tc = tc + def _get_parent_row_and_rows(self): + """ + Return a tuple with the parents `_Row` and `_Rows` object + references if available. When no such parent are found, the + reference values are set to *None*. + """ + parent = self._parent + parent_row = None + parent_rows = None + while parent is not None: + if isinstance(parent, _Row): + parent_row = parent + elif isinstance(parent, _Rows): + parent_rows = parent + break + parent = parent._parent + return parent_row, parent_rows + def add_paragraph(self, text='', style=None): """ Return a paragraph newly added to the end of the content in this @@ -131,16 +149,72 @@ def column_index(self): """ The column index of the cell. Read-only. """ - if self._parent is None: return 0 - return self._parent._tr.tc_lst.index(self._tc) + 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: + tmpl = 'Cannot get column index: unexpected cell parent type (%s).' + raise ValueError(tmpl % 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. """ + # Get the cells parents. + if (self._parent is None) or (cell._parent is None): + raise ValueError('Cannot merge orphaned cells.') + + orig_rowcells_parent = self._parent + dest_rowcells_parent = cell._parent + + orig_row_parent, orig_rows_parent = self._get_parent_row_and_rows() + dest_row_parent, dest_rows_parent = cell._get_parent_row_and_rows() + + # Get the cells coordinates. + orig_row = self.row_index + orig_col = self.column_index + dest_row = cell.row_index + dest_col = cell.column_index + + # Determine the type of merge and make sure it's possible to process + # the merge. + tmpl_diff_rows = 'Cannot horizontally merge cells from different rows.' + tmpl_diff_tables = 'Cannot merge cells from different tables.' + if (orig_row == dest_row) and (orig_col != dest_col): + merge_type = 'horizontal_merge' + if orig_rowcells_parent is not dest_rowcells_parent: + raise ValueError(tmpl_diff_rows) + #_horizontal_merge(orig_rowcells_parent) + elif (orig_row != dest_row) and (orig_col == dest_col): + merge_type = 'vertical_merge' + if orig_rows_parent is not dest_rows_parent: + raise ValueError(tmpl_diff_tables) + elif (orig_row != dest_row) and (orig_col != dest_col): + merge_type = 'twoways_merge' + if orig_rows_parent is not dest_rows_parent: + raise ValueError(tmpl_diff_tables) + else: # (orig_row == dest_row) and (orig_col == dest_col) + merge_type = None + if orig_rowcells_parent is not dest_rowcells_parent: + raise ValueError(tmpl_diff_rows) + if orig_rows_parent is not dest_rows_parent: + raise ValueError(tmpl_diff_tables) + # NOOP + return + # Find out spatial cell arrangements. + + # Process the horizontal merge. + + + # Process the vertical merge. + pass #merged_cells_count = len(self._tr.tc_lst[mergeStart:mergeStop]) # Delete the merged cells to the right of the mergeStart indexed cell. @@ -163,19 +237,20 @@ def row_index(self): """ The row index of the cell. Read-only. """ - parent = self._parent - parent_row = None - parent_rows = None - while parent is not None: - if isinstance(parent, _Row): - parent_row = parent - elif isinstance(parent, _Rows): - parent_rows = parent - break - parent = parent._parent - if (parent_row is None) or (parent_rows is None): + if self._parent is None: return 0 - return parent_rows._tbl.tr_lst.index(parent_row._tr) + elif isinstance(self._parent, _RowCells): + parent_row, parent_rows = self._get_parent_row_and_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): diff --git a/tests/test_table.py b/tests/test_table.py index a0b42af15..8760624de 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -195,15 +195,22 @@ def it_can_replace_its_content_with_a_string_of_text( def it_knows_its_row_and_column_index(self, row_and_column_index_fixture): tbl, single_row, single_cell = row_and_column_index_fixture - for row in range(len(tbl.rows)): - for col in range(len(tbl.columns)): - cell = tbl.cell(row,col) - assert cell.row_index == row - assert cell.column_index == col - for col in range(len(single_row.cells)): - cell = single_row.cells[col] + # 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 == col + assert cell.column_index == ncol + # test single cell assert single_cell.row_index == 0 assert single_cell.column_index == 0 @@ -254,6 +261,7 @@ def add_table_fixture(self, request): 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 From f89dc1bd4be5445b5ded176f967369a864afad5e Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sun, 17 Aug 2014 00:38:55 -0400 Subject: [PATCH 33/45] cel-merge: Add support for horizontal merge. --- docx/table.py | 93 +++++++++++++++++++------------------- features/cel-merge.feature | 6 +-- features/steps/cell.py | 1 - 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/docx/table.py b/docx/table.py index 7ac4d019b..e8fabf40b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -101,23 +101,17 @@ def __init__(self, tc, parent): super(_Cell, self).__init__(tc, parent) self._tc = tc - def _get_parent_row_and_rows(self): + def _get_parent(self, instance_type): """ - Return a tuple with the parents `_Row` and `_Rows` object - references if available. When no such parent are found, the - reference values are set to *None*. + Return a reference to the parent object of type `instance_type`, or + *None* if no match. """ parent = self._parent - parent_row = None - parent_rows = None while parent is not None: - if isinstance(parent, _Row): - parent_row = parent - elif isinstance(parent, _Rows): - parent_rows = parent - break + if isinstance(parent, instance_type): + return parent parent = parent._parent - return parent_row, parent_rows + return None def add_paragraph(self, text='', style=None): """ @@ -156,8 +150,9 @@ def column_index(self): elif isinstance(self._parent, _ColumnCells): return self._parent._col_idx else: - tmpl = 'Cannot get column index: unexpected cell parent type (%s).' - raise ValueError(tmpl % type(self._parent).__name__) + 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): @@ -165,21 +160,24 @@ def merge(self, cell): Merge the rectangular area delimited by the current cell and another cell passed as the argument. """ - # Get the cells parents. if (self._parent is None) or (cell._parent is None): raise ValueError('Cannot merge orphaned cells.') - - orig_rowcells_parent = self._parent - dest_rowcells_parent = cell._parent - orig_row_parent, orig_rows_parent = self._get_parent_row_and_rows() - dest_row_parent, dest_rows_parent = cell._get_parent_row_and_rows() - # Get the cells coordinates. orig_row = self.row_index orig_col = self.column_index dest_row = cell.row_index dest_col = cell.column_index + + # Harmonize cells ancestry and retrieve parents + if isinstance(self._parent, _ColumnCells): + self = Table(self._parent._tbl).rows[orig_row].cells[orig_col] + if isinstance(cell._parent, _ColumnCells): + cell = Table(self._parent._tbl).rows[orig_row].cells[orig_col] + orig_rowcells_parent = self._parent + orig_rows_parent = self._get_parent(_Rows) + dest_rowcells_parent = cell._parent + dest_rows_parent = cell._get_parent(_Rows) # Determine the type of merge and make sure it's possible to process # the merge. @@ -187,41 +185,40 @@ def merge(self, cell): tmpl_diff_tables = 'Cannot merge cells from different tables.' if (orig_row == dest_row) and (orig_col != dest_col): merge_type = 'horizontal_merge' - if orig_rowcells_parent is not dest_rowcells_parent: + if orig_rowcells_parent._tr is not dest_rowcells_parent._tr: raise ValueError(tmpl_diff_rows) - #_horizontal_merge(orig_rowcells_parent) elif (orig_row != dest_row) and (orig_col == dest_col): merge_type = 'vertical_merge' - if orig_rows_parent is not dest_rows_parent: + if orig_rows_parent._tbl is not dest_rows_parent._tbl: raise ValueError(tmpl_diff_tables) elif (orig_row != dest_row) and (orig_col != dest_col): merge_type = 'twoways_merge' - if orig_rows_parent is not dest_rows_parent: + if orig_rows_parent._tbl is not dest_rows_parent._tbl: raise ValueError(tmpl_diff_tables) else: # (orig_row == dest_row) and (orig_col == dest_col) merge_type = None - if orig_rowcells_parent is not dest_rowcells_parent: + if orig_rowcells_parent._tr is not dest_rowcells_parent._tr: raise ValueError(tmpl_diff_rows) - if orig_rows_parent is not dest_rows_parent: + if orig_rows_parent._tbl is not dest_rows_parent._tbl: raise ValueError(tmpl_diff_tables) # NOOP return - - # Find out spatial cell arrangements. - - # Process the horizontal merge. - - - # Process the vertical merge. - - pass - #merged_cells_count = len(self._tr.tc_lst[mergeStart:mergeStop]) - # Delete the merged cells to the right of the mergeStart indexed cell. - #for tc in self._tr.tc_lst[mergeStart+1:mergeStop]: - # self._tr.remove(tc) - # Set the gridSpan value of the mergeStart indexed cell. - #self._tr.tc_lst[mergeStart].gridspan = merged_cells_count + # Process the merge + if merge_type == 'horizontal_merge': + left_cell_index = min(orig_col, dest_col) + right_cell_index = max(orig_col, dest_col) + tr = orig_rowcells_parent._tr + merged_cells_count = right_cell_index - left_cell_index + 1 + # Delete the merged cells to the right of the leftmost cell. + for tc in tr.tc_lst[left_cell_index+1:right_cell_index+1]: + tr.remove(tc) + # Set the gridSpan value of the mergeStart indexed cell. + tr.tc_lst[left_cell_index].gridspan = merged_cells_count + elif merge_type == 'vertical_merge': + raise NotImplementedError('Vertical merge is not yet implemented.') + elif merge_type == 'twoways_merge': + raise NotImplementedError('Two-ways merge is not yet implemented.') @property def paragraphs(self): @@ -237,15 +234,17 @@ def row_index(self): """ The row index of the cell. Read-only. """ - if self._parent is None: + if self._parent is None: return 0 - elif isinstance(self._parent, _RowCells): - parent_row, parent_rows = self._get_parent_row_and_rows() - if (parent_row is None) or (parent_rows 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: + if self._tc is cell._tc: return i else: msg = 'Cannot get row index: unexpected cell parent type (%s).' diff --git a/features/cel-merge.feature b/features/cel-merge.feature index ff6ed409f..a9e3aa92e 100644 --- a/features/cel-merge.feature +++ b/features/cel-merge.feature @@ -9,7 +9,7 @@ Feature: Merge table cells When I merge the 1 x 2 topleftmost cells Then the cell collection length of the row(s) indexed by [0] is 1 - + @wip Scenario: Merge cells vertically Given a 2 x 2 table When I merge the 2 x 1 topleftmost cells @@ -55,5 +55,5 @@ Feature: Merge table cells Then a exception is raised with a detailed Examples: Exception type and error message variables - | exception-type | err-message | - | ValueError | Cannot merge cells of different tables. | + | exception-type | err-message | + | ValueError | Cannot merge cells from different tables. | diff --git a/features/steps/cell.py b/features/steps/cell.py index 67f8f95fd..e6364c731 100644 --- a/features/steps/cell.py +++ b/features/steps/cell.py @@ -37,7 +37,6 @@ def when_I_attempt_to_merge_the_cells(context): cell1.merge(cell2) except Exception as e: context.exception = e - raise @when('I merge the {nrows} x {ncols} topleftmost cells') From 8b865fc59d7356177362ecf8ad515a7f837ad96b Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sun, 17 Aug 2014 00:46:00 -0400 Subject: [PATCH 34/45] doc: Removed the paragraph about python-docx using hMerge. --- docs/dev/analysis/features/cell-merge.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 9a883b50c..3fe55b31e 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -24,12 +24,6 @@ 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 removes -the hurdle of dealing with ``w:tcW`` elements. 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 From 16dd3f82700a34e4efadf3d8e6a4432ce69f9ee2 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 18 Aug 2014 10:56:02 -0400 Subject: [PATCH 35/45] doc: Update the Word specific behavior section of cell-merge.rst --- docs/dev/analysis/features/cell-merge.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 3fe55b31e..26476d776 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -46,6 +46,18 @@ 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. From 8610703f2db35e0b050d7d1126276990689261f8 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 18 Aug 2014 11:09:44 -0400 Subject: [PATCH 36/45] merge: Add vertical merge support. --- docx/table.py | 46 +++++++++++++++++++++++++++++++++++++++------ tests/test_table.py | 12 ++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/docx/table.py b/docx/table.py index e8fabf40b..14a5d06d1 100644 --- a/docx/table.py +++ b/docx/table.py @@ -53,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 @@ -213,13 +214,20 @@ def merge(self, cell): # Delete the merged cells to the right of the leftmost cell. for tc in tr.tc_lst[left_cell_index+1:right_cell_index+1]: tr.remove(tc) - # Set the gridSpan value of the mergeStart indexed cell. + # Set the gridSpan value of the leftmost merged cell. tr.tc_lst[left_cell_index].gridspan = merged_cells_count elif merge_type == 'vertical_merge': - raise NotImplementedError('Vertical merge is not yet implemented.') + top_cell_index = min(orig_row, dest_row) + bot_cell_index = max(orig_row, dest_row) + col = Table(orig_rows_parent._tbl, None).columns[orig_col] + col.cells[top_cell_index]._tc.vmerge = 'restart' + for row_index in range(top_cell_index + 1, bot_cell_index + 1): + col.cells[row_index]._tc.vmerge = 'continue' elif merge_type == 'twoways_merge': raise NotImplementedError('Two-ways merge is not yet implemented.') - + else: + raise Exception('Unexpected error.') + @property def paragraphs(self): """ @@ -317,6 +325,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 @@ -332,14 +345,23 @@ 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 and 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 and 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 @@ -404,12 +426,14 @@ def cells(self): Supports ``len()``, iteration and indexed access. """ return _RowCells(self._tr, self) - + class _RowCells(Parented): """ Sequence of |_Cell| instances corresponding to the cells in a table row. """ + visual_grid = True + def __init__(self, tr, parent): super(_RowCells, self).__init__(parent) self._tr = tr @@ -423,12 +447,22 @@ def __getitem__(self, idx): except IndexError: msg = "cell index [%d] is out of range" % idx raise IndexError(msg) + if self.visual_grid and 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 and 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/tests/test_table.py b/tests/test_table.py index 8760624de..5942a7a48 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -319,13 +319,21 @@ def width_set_fixture(self, request): 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, 1), + '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:gridSpan{w:val=2},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))'), ]) def cell_merge_fixture(self, request): - tbl_cxml, merge_to_coord, expected_cxml = request.param + (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_to_coord, expected_xml From 0f1813eceea698942d1d6383b016c71fdbb04f36 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 10:06:49 -0400 Subject: [PATCH 37/45] doc: Add more detail, CT_HMerge schema excerpt --- docs/dev/analysis/features/cell-merge.rst | 56 ++++++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst index 26476d776..7d0776b8a 100644 --- a/docs/dev/analysis/features/cell-merge.rst +++ b/docs/dev/analysis/features/cell-merge.rst @@ -24,6 +24,14 @@ 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 @@ -64,15 +72,45 @@ 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:: - - >>> table = doc.add_table(5,5) - >>> table.rows[0].cells[0].merge(table.rows[3].cells[3]) - +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 ------------ @@ -175,15 +213,19 @@ Schema excerpt - + - + + + + + From 0babb6cbabbbc887a091fd3c4c8f95b900bf1ebd Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 10:08:19 -0400 Subject: [PATCH 38/45] merge: Register CT_HMerge element --- docx/oxml/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index b397a1b46..04f1c5242 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,7 +115,7 @@ 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_VMerge + CT_TblWidth, CT_Tc, CT_TcPr, CT_HMerge, CT_VMerge ) register_element_cls('w:gridCol', CT_TblGridCol) register_element_cls('w:gridSpan', CT_DecimalNumber) @@ -128,6 +128,7 @@ 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 ( From dbab7675cd5b630180aee016310d1e30f9d95afc Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 10:10:01 -0400 Subject: [PATCH 39/45] cell: Add hmerge property support --- docx/oxml/table.py | 52 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 63b9f4f5b..0cfe27164 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -230,12 +230,28 @@ def gridspan(self): 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): """ @@ -251,7 +267,8 @@ def vmerge(self): def vmerge(self, value): tcPr = self.get_or_add_tcPr() tcPr.vmerge = value - + + class CT_TcPr(BaseOxmlElement): """ ```` element, defining table cell properties @@ -269,6 +286,12 @@ class CT_TcPr(BaseOxmlElement): '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', @@ -307,6 +330,22 @@ 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): """ @@ -323,6 +362,15 @@ 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 From fe88588bdaceddad06c3f1e2dd0a3a55b3d803a0 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 10:53:44 -0400 Subject: [PATCH 40/45] test: add new fixture params to cell merge test --- tests/test_table.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/test_table.py b/tests/test_table.py index 5942a7a48..f4b26b439 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -225,8 +225,9 @@ def it_can_change_its_width(self, width_set_fixture): assert cell._tc.xml == expected_xml def it_can_be_merged(self, cell_merge_fixture): - table, merge_to_coord, expected_xml = cell_merge_fixture - table.cell(0, 0).merge(table.cell(*merge_to_coord)) + (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 ------------------------------------------------------- @@ -323,20 +324,31 @@ def width_set_fixture(self, request): ('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:gridSpan{w:val=2},w:tr/(w:tc,w:tc))'), + '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))'), + '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_to_coord, expected_xml + return table, merge_from_coord, merge_to_coord, expected_xml class Describe_Column(object): From 143e97c6a41b20fd8bde7b5507e2c000d114c871 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 10:55:59 -0400 Subject: [PATCH 41/45] acpt: update two-ways merge scenario and steps --- features/cel-merge.feature | 10 +++++++--- features/steps/table.py | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/features/cel-merge.feature b/features/cel-merge.feature index a9e3aa92e..64eaebf54 100644 --- a/features/cel-merge.feature +++ b/features/cel-merge.feature @@ -15,12 +15,16 @@ Feature: Merge table cells When I merge the 2 x 1 topleftmost cells Then the cell collection length of the column(s) indexed by [0] is 1 - + @wip 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,1] is 2 - And the cell collection length of the column(s) indexed by [0,1] is 2 + 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 + But the cell collection length of the row(s) indexed by [2] is 3 + And the cell collection length of the column(s) indexed by [2] is 3 Scenario: Merge an already merged area diff --git a/features/steps/table.py b/features/steps/table.py index d82b468a3..ed540cef0 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -354,14 +354,16 @@ def then_the_reported_column_width_is_width_emu(context, width_emu): '{length}') def then_the_cell_collection_len_of_row_is_length(context, index, length): table = context.table_ - for i in index: + 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_ - for i in index: + index_lst = index.split(',') + for i in index_lst: assert len(table.columns[int(i)].cells) == int(length) From 98596d792dce3d0b31ff84a60f4fa535e6a3ad6c Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 10:59:16 -0400 Subject: [PATCH 42/45] merge: add two-ways merge support Also in this commit, the horizontal merge was refactored to use the hMerge property rather than gridSpan. The visual grid system was modified to reflect the changes. --- docx/table.py | 103 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/docx/table.py b/docx/table.py index 14a5d06d1..75af10353 100644 --- a/docx/table.py +++ b/docx/table.py @@ -163,7 +163,6 @@ def merge(self, cell): """ if (self._parent is None) or (cell._parent is None): raise ValueError('Cannot merge orphaned cells.') - # Get the cells coordinates. orig_row = self.row_index orig_col = self.column_index @@ -174,57 +173,82 @@ def merge(self, cell): if isinstance(self._parent, _ColumnCells): self = Table(self._parent._tbl).rows[orig_row].cells[orig_col] if isinstance(cell._parent, _ColumnCells): - cell = Table(self._parent._tbl).rows[orig_row].cells[orig_col] + cell = Table(cell._parent._tbl).rows[dest_row].cells[dest_col] orig_rowcells_parent = self._parent orig_rows_parent = self._get_parent(_Rows) dest_rowcells_parent = cell._parent dest_rows_parent = cell._get_parent(_Rows) + def _check_for_diff_rowcells_parent(rowcells_par1, rowcells_par2): + tmpl_diff_rows = ('Cannot horizontally merge cells from different ' + 'rows.') + if orig_rowcells_parent._tr is not dest_rowcells_parent._tr: + raise ValueError(tmpl_diff_rows) + + def _check_for_missing_or_diff_rows_parent(row_par1, row_par2): + tmpl_missing_par = 'Could not merge cells: missing _Rows parent.' + tmpl_diff_tables = 'Cannot merge cells from different tables.' + if (orig_rows_parent is None) or (dest_rows_parent is None): + raise ValueError(tmpl_missing_par) + if orig_rows_parent._tbl is not dest_rows_parent._tbl: + raise ValueError(tmpl_diff_tables) + # Determine the type of merge and make sure it's possible to process # the merge. - tmpl_diff_rows = 'Cannot horizontally merge cells from different rows.' - tmpl_diff_tables = 'Cannot merge cells from different tables.' if (orig_row == dest_row) and (orig_col != dest_col): merge_type = 'horizontal_merge' - if orig_rowcells_parent._tr is not dest_rowcells_parent._tr: - raise ValueError(tmpl_diff_rows) + _check_for_diff_rowcells_parent(orig_rowcells_parent, + dest_rowcells_parent) elif (orig_row != dest_row) and (orig_col == dest_col): merge_type = 'vertical_merge' - if orig_rows_parent._tbl is not dest_rows_parent._tbl: - raise ValueError(tmpl_diff_tables) + _check_for_missing_or_diff_rows_parent(orig_rows_parent, + dest_rows_parent) elif (orig_row != dest_row) and (orig_col != dest_col): merge_type = 'twoways_merge' - if orig_rows_parent._tbl is not dest_rows_parent._tbl: - raise ValueError(tmpl_diff_tables) + _check_for_missing_or_diff_rows_parent(orig_rows_parent, + dest_rows_parent) else: # (orig_row == dest_row) and (orig_col == dest_col) merge_type = None - if orig_rowcells_parent._tr is not dest_rowcells_parent._tr: - raise ValueError(tmpl_diff_rows) - if orig_rows_parent._tbl is not dest_rows_parent._tbl: - raise ValueError(tmpl_diff_tables) + _check_for_diff_rowcells_parent(orig_rowcells_parent, + dest_rowcells_parent) + _check_for_missing_or_diff_rows_parent(orig_rows_parent, + dest_rows_parent) # NOOP return - # Process the merge + 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, top_row_idx, left_col_idx, bot_row_idx, + right_col_idx): + for row_idx in range(top_row_idx, bot_row_idx + 1): + tr = table.rows[row_idx]._tr + _horizontal_merge(tr, left_col_idx, right_col_idx) + col = table.columns[left_col_idx] + _vertical_merge(col, top_row_idx, bot_row_idx) + + # Process merge. + top_row_idx = min(self.row_index, cell.row_index) + left_col_idx = min(self.column_index, cell.column_index) + bot_row_idx = max(self.row_index, cell.row_index) + right_col_idx = max(self.column_index, cell.column_index) if merge_type == 'horizontal_merge': - left_cell_index = min(orig_col, dest_col) - right_cell_index = max(orig_col, dest_col) tr = orig_rowcells_parent._tr - merged_cells_count = right_cell_index - left_cell_index + 1 - # Delete the merged cells to the right of the leftmost cell. - for tc in tr.tc_lst[left_cell_index+1:right_cell_index+1]: - tr.remove(tc) - # Set the gridSpan value of the leftmost merged cell. - tr.tc_lst[left_cell_index].gridspan = merged_cells_count + _horizontal_merge(tr, left_col_idx, right_col_idx) elif merge_type == 'vertical_merge': - top_cell_index = min(orig_row, dest_row) - bot_cell_index = max(orig_row, dest_row) col = Table(orig_rows_parent._tbl, None).columns[orig_col] - col.cells[top_cell_index]._tc.vmerge = 'restart' - for row_index in range(top_cell_index + 1, bot_cell_index + 1): - col.cells[row_index]._tc.vmerge = 'continue' + _vertical_merge(col, top_row_idx, bot_row_idx) elif merge_type == 'twoways_merge': - raise NotImplementedError('Two-ways merge is not yet implemented.') + table = Table(orig_rows_parent._tbl, None) + _twoways_merge(table, top_row_idx, left_col_idx, bot_row_idx, + right_col_idx) else: raise Exception('Unexpected error.') @@ -345,15 +369,17 @@ 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 and tc.vmerge == 'continue': - raise ValueError('Merged cell access is restricted.') + 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 and tc.vmerge == 'continue': - continue + if self.visual_grid: + if tc.hmerge == 'continue' or tc.vmerge == 'continue': + continue yield _Cell(tc, self) def __len__(self): @@ -432,6 +458,7 @@ 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): @@ -447,14 +474,16 @@ def __getitem__(self, idx): except IndexError: msg = "cell index [%d] is out of range" % idx raise IndexError(msg) - if self.visual_grid and tc.vmerge == 'continue': - raise ValueError('Merged cell access is restricted.') + 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 tc in self._tr.tc_lst: - if self.visual_grid and tc.vmerge == 'continue': - continue + if self.visual_grid: + if tc.hmerge == 'continue' or tc.vmerge == 'continue': + continue yield _Cell(tc, self) def __len__(self): From a3188f2b9568ae4df2800359386fb73929f05c9c Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 17:14:03 -0400 Subject: [PATCH 43/45] merge: cleanup, branch freeze pending integration. --- docx/table.py | 111 +++++++++++++++----------------------------------- 1 file changed, 32 insertions(+), 79 deletions(-) diff --git a/docx/table.py b/docx/table.py index 75af10353..64cb5fee4 100644 --- a/docx/table.py +++ b/docx/table.py @@ -161,61 +161,6 @@ def merge(self, cell): Merge the rectangular area delimited by the current cell and another cell passed as the argument. """ - if (self._parent is None) or (cell._parent is None): - raise ValueError('Cannot merge orphaned cells.') - # Get the cells coordinates. - orig_row = self.row_index - orig_col = self.column_index - dest_row = cell.row_index - dest_col = cell.column_index - - # Harmonize cells ancestry and retrieve parents - if isinstance(self._parent, _ColumnCells): - self = Table(self._parent._tbl).rows[orig_row].cells[orig_col] - if isinstance(cell._parent, _ColumnCells): - cell = Table(cell._parent._tbl).rows[dest_row].cells[dest_col] - orig_rowcells_parent = self._parent - orig_rows_parent = self._get_parent(_Rows) - dest_rowcells_parent = cell._parent - dest_rows_parent = cell._get_parent(_Rows) - - def _check_for_diff_rowcells_parent(rowcells_par1, rowcells_par2): - tmpl_diff_rows = ('Cannot horizontally merge cells from different ' - 'rows.') - if orig_rowcells_parent._tr is not dest_rowcells_parent._tr: - raise ValueError(tmpl_diff_rows) - - def _check_for_missing_or_diff_rows_parent(row_par1, row_par2): - tmpl_missing_par = 'Could not merge cells: missing _Rows parent.' - tmpl_diff_tables = 'Cannot merge cells from different tables.' - if (orig_rows_parent is None) or (dest_rows_parent is None): - raise ValueError(tmpl_missing_par) - if orig_rows_parent._tbl is not dest_rows_parent._tbl: - raise ValueError(tmpl_diff_tables) - - # Determine the type of merge and make sure it's possible to process - # the merge. - if (orig_row == dest_row) and (orig_col != dest_col): - merge_type = 'horizontal_merge' - _check_for_diff_rowcells_parent(orig_rowcells_parent, - dest_rowcells_parent) - elif (orig_row != dest_row) and (orig_col == dest_col): - merge_type = 'vertical_merge' - _check_for_missing_or_diff_rows_parent(orig_rows_parent, - dest_rows_parent) - elif (orig_row != dest_row) and (orig_col != dest_col): - merge_type = 'twoways_merge' - _check_for_missing_or_diff_rows_parent(orig_rows_parent, - dest_rows_parent) - else: # (orig_row == dest_row) and (orig_col == dest_col) - merge_type = None - _check_for_diff_rowcells_parent(orig_rowcells_parent, - dest_rowcells_parent) - _check_for_missing_or_diff_rows_parent(orig_rows_parent, - dest_rows_parent) - # NOOP - return - 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]: @@ -226,31 +171,39 @@ def _vertical_merge(column, merge_start_idx, merge_stop_idx): for index in range(merge_start_idx + 1, merge_stop_idx + 1): column.cells[index]._tc.vmerge = 'continue' - def _twoways_merge(table, top_row_idx, left_col_idx, bot_row_idx, - right_col_idx): - for row_idx in range(top_row_idx, bot_row_idx + 1): + 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, left_col_idx, right_col_idx) - col = table.columns[left_col_idx] - _vertical_merge(col, top_row_idx, bot_row_idx) - - # Process merge. - top_row_idx = min(self.row_index, cell.row_index) - left_col_idx = min(self.column_index, cell.column_index) - bot_row_idx = max(self.row_index, cell.row_index) - right_col_idx = max(self.column_index, cell.column_index) - if merge_type == 'horizontal_merge': - tr = orig_rowcells_parent._tr - _horizontal_merge(tr, left_col_idx, right_col_idx) - elif merge_type == 'vertical_merge': - col = Table(orig_rows_parent._tbl, None).columns[orig_col] - _vertical_merge(col, top_row_idx, bot_row_idx) - elif merge_type == 'twoways_merge': - table = Table(orig_rows_parent._tbl, None) - _twoways_merge(table, top_row_idx, left_col_idx, bot_row_idx, - right_col_idx) - else: - raise Exception('Unexpected error.') + _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): From 1730940e130b55a30543f8cdbfa98ae935f49074 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 17:20:33 -0400 Subject: [PATCH 44/45] merge: small cleanup in cell.py step file pending pull request. --- features/steps/cell.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/features/steps/cell.py b/features/steps/cell.py index e6364c731..35fb3f98c 100644 --- a/features/steps/cell.py +++ b/features/steps/cell.py @@ -44,11 +44,7 @@ def when_I_merge_the_nrows_x_ncols_topleftmost_cells(context, nrows, ncols): table = context.table_ row = int(nrows) - 1 col = int(ncols) - 1 - try: - table.cell(0, 0).merge(table.cell(row, col)) - except Exception as e: - context.exception = e - raise + table.cell(0, 0).merge(table.cell(row, col)) # then ===================================================== From 6316d044a2881de085eaf41ee0073f1ea0214a60 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Tue, 19 Aug 2014 17:21:47 -0400 Subject: [PATCH 45/45] merge: cleanup cel-merge.feature from incompleted scenarios. --- features/cel-merge.feature | 34 +++------------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/features/cel-merge.feature b/features/cel-merge.feature index 64eaebf54..e65354aef 100644 --- a/features/cel-merge.feature +++ b/features/cel-merge.feature @@ -3,19 +3,19 @@ Feature: Merge table cells As an python-docx developer I need a way to merge the cells of a table - @wip + 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 - @wip + 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 - @wip + Scenario: Merge cells both horizontally and vertically Given a 3 x 3 table When I merge the 2 x 2 topleftmost cells @@ -23,36 +23,8 @@ Feature: Merge table cells 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 - But the cell collection length of the row(s) indexed by [2] is 3 - And the cell collection length of the column(s) indexed by [2] is 3 - - - Scenario: Merge an already merged area - Given a 4 x 4 table - When I merge the 2 x 2 topleftmost cells - And I merge the 3 x 3 topleftmost cells - Then the cell collection length of the row(s) indexed by [0,1,2] is 2 - And the cell collection length of the column(s) indexed by [0,1,2] is 2 - - - Scenario Outline: Unsupported merge of an already merged area - Given a 2 x 2 table - When I merge the 1 x 2 topleftmost cells - And I merge the 2 x 1 topleftmost cells - Then a exception is raised with a detailed - - Examples: Exception type and error message variables - | exception-type | err-message | - | ValueError | Cannot partially merge an already merged area. | - - Scenario: Merge resulting in a table reduction (simplification) - Given a 2 x 2 table - When I merge the 2 x 2 topleftmost cells - Then the table has 1 row(s) - And the table has 1 column(s) - @wip 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