From b155177f6282cdbf1b1aa3d68044ad5571e152a3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 18 Aug 2014 20:48:16 -0700 Subject: [PATCH 001/615] config: move flake8 targets to tox.ini --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index d463f248f..014fd8dda 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,9 @@ # # Configuration for tox and pytest +[flake8] +exclude = dist,docs,*.egg-info,.git,ref,_scratch,.tox + [pytest] norecursedirs = doc docx *.egg-info features .git ref _scratch .tox python_files = test_*.py From 58b323938644f04e94e63928b1c937b16ee4c808 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 23 Sep 2014 22:33:56 -0700 Subject: [PATCH 002/615] fix: correct error message template --- docx/oxml/parts/styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index ed3054f13..7fea25a01 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -32,4 +32,4 @@ def style_having_styleId(self, styleId): try: return self.xpath(xpath)[0] except IndexError: - raise KeyError('no element with styleId %d' % styleId) + raise KeyError('no element with styleId %s' % styleId) From acc4e3e390b988b96a371cf0494e8b12344b5d87 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 11 Aug 2014 00:32:17 -0400 Subject: [PATCH 003/615] doc: add cell.merge feature analysis --- docs/dev/analysis/features/cell-merge.rst | 572 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 573 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..2b432dfbf --- /dev/null +++ b/docs/dev/analysis/features/cell-merge.rst @@ -0,0 +1,572 @@ + +Table - Merge Cells +=================== + +Word allows contiguous table cells to be merged, such that two or more cells +appear to be a single cell. Cells can be merged horizontally (spanning +multple columns) or vertically (spanning multiple rows). Cells can also be +merged both horizontally and vertically at the same time, producing a cell +that spans both rows and columns. Only rectangular ranges of cells can be +merged. + + +Table diagrams +-------------- + +Diagrams like the one below are used to depict tables in this analysis. +Horizontal spans are depicted as a continuous horizontal cell without +vertical dividers within the span. Vertical spans are depicted as a vertical +sequence of cells of the same width where continuation cells are separated by +a dashed top border and contain a caret ('^') to symbolize the continuation +of the cell above. Cell 'addresses' are depicted at the column and row grid +lines. This is conceptually convenient as it reuses the notion of list +indices (and slices) and makes certain operations more intuitive to specify. +The merged cell `A` below has top, left, bottom, and right values of 0, 0, 2, +and 2 respectively:: + + \ 0 1 2 3 + 0 +---+---+---+ + | A | | + 1 + - - - +---+ + | ^ | | + 2 +---+---+---+ + | | | | + 3 +---+---+---+ + + +Basic cell access protocol +-------------------------- + +There are three ways to access a table cell: + +* ``Table.cell(row_idx, col_idx)`` +* ``Row.cells[col_idx]`` +* ``Column.cells[col_idx]`` + + +Accessing the middle cell of a 3 x 3 table:: + + >>> table = document.add_table(3, 3) + >>> middle_cell = table.cell(1, 1) + >>> table.rows[1].cells[1] == middle_cell + True + >>> table.columns[1].cells[1] == middle_cell + True + + +Basic merge protocol +-------------------- + +A merge is specified using two diagonal cells:: + + >>> table = document.add_table(3, 3) + >>> a = table.cells(0, 0) + >>> b = table.cells(1, 1) + >>> A = a.merge(b) + +:: + + \ 0 1 2 3 + 0 +---+---+---+ +---+---+---+ + | a | | | | A | | + 1 +---+---+---+ + - - - +---+ + | | b | | --> | ^ | | + 2 +---+---+---+ +---+---+---+ + | | | | | | | | + 3 +---+---+---+ +---+---+---+ + + +Accessing a merged cell +----------------------- + +A cell is accessed by its "layout grid" position regardless of any spans that +may be present. A grid address that falls in a span returns the top-leftmost +cell in that span. This means a span has as many addresses as layout grid +cells it spans. For example, the merged cell `A` above can be addressed as +(0, 0), (0, 1), (1, 0), or (1, 1). This addressing scheme leads to desirable +access behaviors when spans are present in the table. + +The length of Row.cells is always equal to the number of grid columns, +regardless of any spans that are present. Likewise, the length of +Column.cells is always equal to the number of table rows, regardless of any +spans. + +:: + + >>> table = document.add_table(2, 3) + >>> row = table.rows[0] + >>> len(row.cells) + 3 + >>> row.cells[0] == row.cells[1] + False + + >>> a, b = row.cells[:2] + >>> a.merge(b) + + >>> len(row.cells) + 3 + >>> row.cells[0] == row.cells[1] + True + +:: + + \ 0 1 2 3 + 0 +---+---+---+ +---+---+---+ + | a | b | | | A | | + 1 +---+---+---+ --> +---+---+---+ + | | | | | | | | + 2 +---+---+---+ +---+---+---+ + + +Cell content behavior on merge +------------------------------ + +When two or more cells are merged, any existing content is concatenated and +placed in the resulting merged cell. Content from each original cell is +separated from that in the prior original cell by a paragraph mark. An +original cell having no content is skipped in the contatenation process. In +Python, the procedure would look roughly like this:: + + merged_cell_text = '\n'.join( + cell.text for cell in original_cells if cell.text + ) + +Merging four cells with content ``'a'``, ``'b'``, ``''``, and ``'d'`` +respectively results in a merged cell having text ``'a\nb\nd'``. + + +Cell size behavior on merge +--------------------------- + +Cell width and height, if present, are added when cells are merged:: + + >>> a, b = row.cells[:2] + >>> a.width.inches, b.width.inches + (1.0, 1.0) + >>> A = a.merge(b) + >>> A.width.inches + 2.0 + + +Removing a redundant row or column +---------------------------------- + +**Collapsing a column.** When all cells in a grid column share the same +``w:gridSpan`` specification, the spanned columns can be collapsed into +a single column by removing the ``w:gridSpan`` attributes. + + +Word behavior +------------- + +* Row and Column access in the MS API just plain breaks when the table is not + uniform. `Table.Rows(n)` and `Cell.Row` raise `EnvironmentError` when + a table contains a vertical span, and `Table.Columns(n)` and `Cell.Column` + unconditionally raise `EnvironmentError` when the table contains + a horizontal span. We can do better. + +* `Table.Cell(n, m)` works on any non-uniform table, although it uses + a *visual grid* that greatly complicates access. It raises an error for `n` + or `m` out of visual range, and provides no way other than try/except to + determine what that visual range is, since `Row.Count` and `Column.Count` + are unavailable. + +* In a merge operation, the text of the continuation cells is appended to + that of the origin cell as separate paragraph(s). + +* If a merge range contains previously merged cells, the range must + completely enclose the merged cells. + +* Word resizes a table (adds rows) when a cell is referenced by an + out-of-bounds row index. If the column identifier is out of bounds, an + exception is raised. This behavior will not be implemented in |docx|. + + +Glossary +-------- + +layout grid + The regular two-dimensional matrix of rows and columns that determines + the layout of cells in the table. The grid is primarily defined by the + `w:gridCol` elements that define the layout columns for the table. Each + row essentially duplicates that layout for an additional row, although + its height can differ from other rows. Every actual cell in the table + must begin and end on a layout grid "line", whether the cell is merged or + not. + +span + The single "combined" cell occupying the area of a set of merged cells. + +skipped cell + The WordprocessingML (WML) spec allows for 'skipped' cells, where + a layout cell location contains no actual cell. I can't find a way to + make a table like this using the Word UI and haven't experimented yet to + see whether Word will load one constructed by hand in the XML. + +uniform table + A table in which each cell corresponds exactly to a layout cell. + A uniform table contains no spans or skipped cells. + +non-uniform table + A table that contains one or more spans, such that not every cell + corresponds to a single layout cell. I suppose it would apply when there + was one or more skipped cells too, but in this analysis the term is only + used to indicate a table with one or more spans. + +uniform cell + A cell not part of a span, occupying a single cell in the layout grid. + +origin cell + The top-leftmost cell in a span. Contrast with *continuation cell*. + +continuation cell + A layout cell that has been subsumed into a span. A continuation cell is + mostly an abstract concept, although a actual `w:tc` element will always + exist in the XML for each continuation cell in a vertical span. + + +Understanding merge XML intuitively +----------------------------------- + +A key insight is that merged cells always look like the diagram below. +Horizontal spans are accomplished with a single `w:tc` element in each row, +using the `gridSpan` attribute to span additional grid columns. Vertical +spans are accomplished with an identical cell in each continuation row, +having the same `gridSpan` value, and having vMerge set to `continue` (the +default). These vertical continuation cells are depicted in the diagrams +below with a dashed top border and a caret ('^') in the left-most grid column +to symbolize the continuation of the cell above.:: + + \ 0 1 2 3 + 0 +---+---+---+ + | A | | + 1 + - - - +---+ + | ^ | | + 2 +---+---+---+ + | | | | + 3 +---+---+---+ + +.. highlight:: xml + +The table depicted above corresponds to this XML (minimized for clarity):: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +XML Semantics +------------- + +In a horizontal merge, the ```` attribute indicates the +number of columns the cell should span. Only the leftmost cell is preserved; +the remaining cells in the merge are deleted. + +For merging vertically, the ``w:vMerge`` table cell property of the uppermost +cell of the column is set to the value "restart" of type ``w:ST_Merge``. The +following, lower cells included in the vertical merge must have the +``w:vMerge`` element present in their cell property (``w:TcPr``) element. Its +value should be set to "continue", although it is not necessary to +explicitely define it, as it is the default value. A vertical merge ends as +soon as a cell ``w:TcPr`` element lacks the ``w:vMerge`` element. Similarly +to the ``w:gridSpan`` element, the ``w:vMerge`` elements are only required +when the table's layout is not uniform across its different columns. In the +case it is, only the topmost cell is kept; the other lower cells in the +merged area are deleted along with their ``w:vMerge`` elements and the +``w:trHeight`` table row property is used to specify the combined height of +the merged cells. + + +len() implementation for Row.cells and Column.cells +--------------------------------------------------- + +Each ``Row`` and ``Column`` object provides access to the collection of cells +it contains. The length of these cell collections is unaffected by the +presence of merged cells. + +`len()` always bases its count on the layout grid, as though there were no +merged cells. + +* ``len(Table.columns)`` is the number of `w:gridCol` elements, representing + the number of grid columns, without regard to the presence of merged cells + in the table. + +* ``len(Table.rows)`` is the number of `w:tr` elements, regardless of any + merged cells that may be present in the table. + +* ``len(Row.cells)`` is the number of grid columns, regardless of whether any + cells in the row are merged. + +* ``len(Column.cells)`` is the number of rows in the table, regardless of + whether any cells in the column are merged. + + +Merging a cell already containing a span +---------------------------------------- + +One or both of the "diagonal corner" cells in a merge operation may itself be +a merged cell, as long as the specified region is rectangular. + +For example:: + + \ 0 1 2 3 + +---+---+---+---+ +---+---+---+---+ + 0 | a | b | | | a\nb\nC | | + + - - - +---+---+ + - - - - - +---+ + 1 | ^ | C | | | ^ | | + +---+---+---+---+ --> +---+---+---+---+ + 2 | | | | | | | | | | + +---+---+---+---+ +---+---+---+---+ + 3 | | | | | | | | | | + +---+---+---+---+ +---+---+---+---+ + + cell(0, 0).merge(cell(1, 2)) + +or:: + + 0 1 2 3 4 + +---+---+---+---+---+ +---+---+---+---+---+ + 0 | a | b | c | | | abcD | | + + - - - +---+---+---+ + - - - - - - - +---+ + 1 | ^ | D | | | ^ | | + +---+---+---+---+---+ --> +---+---+---+---+---+ + 2 | | | | | | | | | | | | + +---+ - - - +---+---+ +---+---+---+---+---+ + 3 | | | | | | | | | | | | + +---+---+---+---+---+ +---+---+---+---+---+ + + cell(0, 0).merge(cell(1, 2)) + + +Conversely, either of these two merge operations would be illegal:: + + \ 0 1 2 3 4 0 1 2 3 4 + 0 +---+---+---+---+ 0 +---+---+---+---+ + | | | b | | | | | | | + 1 +---+---+ - +---+ 1 +---+---+---+---+ + | | a | ^ | | | | a | | | + 2 +---+---+ - +---+ 2 +---+---+---+---+ + | | | ^ | | | b | | + 3 +---+---+---+---+ 3 +---+---+---+---+ + | | | | | | | | | | + 4 +---+---+---+---+ 4 +---+---+---+---+ + + a.merge(b) + + +General algorithm +~~~~~~~~~~~~~~~~~ + +* find top-left and target width, height +* for each tr in target height, tc.grow_right(target_width) + + +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 + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Open Issues +----------- + +* Does Word allow "skipped" cells at the beginning of a row (`w:gridBefore` + element)? These are described in the spec, but I don't see a way in the + Word UI to create such a table. + + +Ressources +---------- + +* `Cell.Merge Method on MSDN`_ + +.. _`Cell.Merge Method on MSDN`: + http://msdn.microsoft.com/en-us/library/office/ff821310%28v=office.15%29.aspx + +Relevant sections in the ISO Spec +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* 17.4.17 gridSpan (Grid Columns Spanned by Current Table Cell) +* 17.4.84 vMerge (Vertically Merged Cell) +* 17.18.57 ST_Merge (Merged Cell Type) diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 7e4d7589e..49cdeda8e 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/cell-merge features/table features/table-props features/table-cell From 210d7f124dcf8c84edbd39d168f47d81cc925bfd Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sat, 1 Nov 2014 00:44:03 -0700 Subject: [PATCH 004/615] acpt: add scenarios for cell access * refactor tbl-item-access to remove cell access scenarios --- features/steps/table.py | 135 +++++------------- .../steps/test_files/tbl-cell-access.docx | Bin 0 -> 36051 bytes features/tbl-cell-access.feature | 42 ++++++ features/tbl-item-access.feature | 30 +--- 4 files changed, 78 insertions(+), 129 deletions(-) create mode 100644 features/steps/test_files/tbl-cell-access.docx create mode 100644 features/tbl-cell-access.feature diff --git a/features/steps/table.py b/features/steps/table.py index 2d7aa37dc..19c08c31d 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -10,9 +10,7 @@ from docx import Document from docx.shared import Inches -from docx.table import ( - _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows -) +from docx.table import _Column, _Columns, _Row, _Rows from helpers import test_docx @@ -24,11 +22,16 @@ def given_a_2x2_table(context): context.table_ = Document().add_table(rows=2, cols=2) -@given('a column cell collection having two cells') -def given_a_column_cell_collection_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.cells = document.tables[0].columns[0].cells +@given('a 3x3 table having {span_state}') +def given_a_3x3_table_having_span_state(context, span_state): + table_idx = { + 'only uniform cells': 0, + 'a horizontal span': 1, + 'a vertical span': 2, + 'a combined span': 3, + }[span_state] + document = Document(test_docx('tbl-cell-access')) + context.table_ = document.tables[table_idx] @given('a column collection having two columns') @@ -38,13 +41,6 @@ def given_a_column_collection_having_two_columns(context): context.columns = document.tables[0].columns -@given('a row cell collection having two cells') -def given_a_row_cell_collection_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.cells = document.tables[0].rows[0].cells - - @given('a row collection having two rows') def given_a_row_collection_having_two_rows(context): docx_path = test_docx('blk-containing-table') @@ -111,20 +107,6 @@ def given_a_table_having_two_rows(context): context.table_ = document.tables[0] -@given('a table column having two cells') -def given_a_table_column_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.column = document.tables[0].columns[0] - - -@given('a table row having two cells') -def given_a_table_row_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.row = document.tables[0].rows[0] - - # when ===================================================== @when('I add a column to the table') @@ -166,15 +148,6 @@ def when_I_set_the_table_autofit_to_setting(context, setting): # then ===================================================== -@then('I can access a cell using its row and column indices') -def then_can_access_cell_using_its_row_and_col_indices(context): - table = context.table_ - for row_idx in range(2): - for col_idx in range(2): - cell = table.cell(row_idx, col_idx) - assert isinstance(cell, _Cell) - - @then('I can access a collection column by index') def then_can_access_collection_column_by_index(context): columns = context.columns @@ -191,36 +164,6 @@ def then_can_access_collection_row_by_index(context): assert isinstance(row, _Row) -@then('I can access a column cell by index') -def then_can_access_column_cell_by_index(context): - cells = context.cells - for idx in range(2): - cell = cells[idx] - assert isinstance(cell, _Cell) - - -@then('I can access a row cell by index') -def then_can_access_row_cell_by_index(context): - cells = context.cells - for idx in range(2): - cell = cells[idx] - assert isinstance(cell, _Cell) - - -@then('I can access the cell collection of the column') -def then_can_access_cell_collection_of_column(context): - column = context.column - cells = column.cells - assert isinstance(cells, _ColumnCells) - - -@then('I can access the cell collection of the row') -def then_can_access_cell_collection_of_row(context): - row = context.row - cells = row.cells - assert isinstance(cells, _RowCells) - - @then('I can access the column collection of the table') def then_can_access_column_collection_of_table(context): table = context.table_ @@ -235,20 +178,6 @@ def then_can_access_row_collection_of_table(context): assert isinstance(rows, _Rows) -@then('I can get the length of the column cell collection') -def then_can_get_length_of_column_cell_collection(context): - column = context.column - cells = column.cells - assert len(cells) == 2 - - -@then('I can get the length of the row cell collection') -def then_can_get_length_of_row_cell_collection(context): - row = context.row - cells = row.cells - assert len(cells) == 2 - - @then('I can get the table style name') def then_can_get_table_style_name(context): table = context.table_ @@ -256,16 +185,6 @@ def then_can_get_table_style_name(context): assert table.style == 'LightShading-Accent1', msg -@then('I can iterate over the column cells') -def then_can_iterate_over_the_column_cells(context): - cells = context.cells - actual_count = 0 - for cell in cells: - actual_count += 1 - assert isinstance(cell, _Cell) - assert actual_count == 2 - - @then('I can iterate over the column collection') def then_can_iterate_over_column_collection(context): columns = context.columns @@ -276,16 +195,6 @@ def then_can_iterate_over_column_collection(context): assert actual_count == 2 -@then('I can iterate over the row cells') -def then_can_iterate_over_the_row_cells(context): - cells = context.cells - actual_count = 0 - for cell in cells: - actual_count += 1 - assert isinstance(cell, _Cell) - assert actual_count == 2 - - @then('I can iterate over the row collection') def then_can_iterate_over_row_collection(context): rows = context.rows @@ -296,6 +205,21 @@ def then_can_iterate_over_row_collection(context): assert actual_count == 2 +@then('table.cell({row}, {col}).text is {expected_text}') +def then_table_cell_row_col_text_is_text(context, row, col, expected_text): + table = context.table_ + row_idx, col_idx = int(row), int(col) + cell_text = table.cell(row_idx, col_idx).text + assert cell_text == expected_text, 'got %s' % cell_text + + +@then('the column cells text is {expected_text}') +def then_the_column_cells_text_is_expected_text(context, expected_text): + table = context.table_ + cells_text = ' '.join(c.text for col in table.columns for c in col.cells) + assert cells_text == expected_text, 'got %s' % cells_text + + @then('the length of the column collection is 2') def then_len_of_column_collection_is_2(context): columns = context.table_.columns @@ -342,6 +266,13 @@ def then_the_reported_width_of_the_cell_is_width(context, width): ) +@then('the row cells text is {expected_text}') +def then_the_row_cells_text_is_expected_text(context, expected_text): + table = context.table_ + cells_text = ' '.join(c.text for row in table.rows for c in row.cells) + assert cells_text == expected_text, 'got %s' % cells_text + + @then('the table style matches the name I applied') def then_table_style_matches_name_applied(context): table = context.table_ diff --git a/features/steps/test_files/tbl-cell-access.docx b/features/steps/test_files/tbl-cell-access.docx new file mode 100644 index 0000000000000000000000000000000000000000..b3c90d94b042afa939e9a09e8b1298d8c98caee0 GIT binary patch literal 36051 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 B39A4A literal 0 HcmV?d00001 diff --git a/features/tbl-cell-access.feature b/features/tbl-cell-access.feature new file mode 100644 index 000000000..7dbd1a6b0 --- /dev/null +++ b/features/tbl-cell-access.feature @@ -0,0 +1,42 @@ +Feature: Access table cells + In order to access individual cells in a table + As a developer using python-docx + I need a way to access a cell from a table, row, or column + + @wip + Scenario Outline: Access cell sequence of a row + Given a 3x3 table having + Then the row cells text is + + Examples: Reported row cell contents + | span-state | expected-text | + | only uniform cells | 1 2 3 4 5 6 7 8 9 | + | a horizontal span | 1 2 3 4 4 6 7 8 9 | + | a vertical span | 1 2 3 4 5 6 7 5 9 | + | a combined span | 1 2 3 4 4 6 4 4 9 | + + + @wip + Scenario Outline: Access cell sequence of a column + Given a 3x3 table having + Then the column cells text is + + Examples: Reported column cell contents + | span-state | expected-text | + | only uniform cells | 1 4 7 2 5 8 3 6 9 | + | a horizontal span | 1 4 7 2 4 8 3 6 9 | + | a vertical span | 1 4 7 2 5 5 3 6 9 | + | a combined span | 1 4 4 2 4 4 3 6 9 | + + + @wip + Scenario Outline: Access cell by row and column index + Given a 3x3 table having + Then table.cell(, ).text is + + Examples: Reported cell text + | span-state | row | col | expected-text | + | only uniform cells | 1 | 1 | 5 | + | a horizontal span | 1 | 1 | 4 | + | a vertical span | 2 | 1 | 5 | + | a combined span | 2 | 1 | 4 | diff --git a/features/tbl-item-access.feature b/features/tbl-item-access.feature index 52c696535..8bfbec508 100644 --- a/features/tbl-item-access.feature +++ b/features/tbl-item-access.feature @@ -1,7 +1,7 @@ -Feature: Access table rows, columns, and cells +Feature: Access table rows and columns In order to query and modify individual table items - As an python-docx developer - I need the ability to access table rows, columns, and cells + As a developer using python-docx + I need the ability to access table rows and columns Scenario: Access table row collection Given a table having two rows @@ -22,27 +22,3 @@ Feature: Access table rows, columns, and cells Given a column collection having two columns Then I can iterate over the column collection And I can access a collection column by index - - Scenario: Access cell collection of table column - Given a table column having two cells - Then I can access the cell collection of the column - And I can get the length of the column cell collection - - Scenario: Access cell collection of table row - Given a table row having two cells - Then I can access the cell collection of the row - And I can get the length of the row cell collection - - Scenario: Access cell in column cell collection - Given a column cell collection having two cells - Then I can iterate over the column cells - And I can access a column cell by index - - Scenario: Access cell in row cell collection - Given a row cell collection having two cells - Then I can iterate over the row cells - And I can access a row cell by index - - Scenario: Access cell in table - Given a table having two rows - Then I can access a cell using its row and column indices From 725df2761c3fd4aba3f2ce98fafe5c0b19e8d245 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 20 Nov 2014 22:57:48 -0800 Subject: [PATCH 005/615] tbl: add _Cell.text getter --- docx/table.py | 13 +++++++++++-- tests/test_table.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docx/table.py b/docx/table.py index 544553b1e..54771993b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from .blkcntnr import BlockItemContainer -from .shared import lazyproperty, Parented, write_only_property +from .shared import lazyproperty, Parented class Table(Parented): @@ -141,7 +141,16 @@ def tables(self): """ return super(_Cell, self).tables - @write_only_property + @property + def text(self): + """ + The entire contents of this cell as a string of text. Assigning + a string to this property replaces all existing content with a single + paragraph containing the assigned text in a single run. + """ + return '\n'.join(p.text for p in self.paragraphs) + + @text.setter def text(self, text): """ Write-only. Set entire contents of cell to the string *text*. Any diff --git a/tests/test_table.py b/tests/test_table.py index 4ecf55d19..9c901a3d7 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -187,6 +187,11 @@ def it_provides_access_to_the_tables_it_contains(self, tables_fixture): count += 1 assert count == expected_count + def it_knows_what_text_it_contains(self, text_get_fixture): + cell, expected_text = text_get_fixture + text = cell.text + assert text == expected_text + def it_can_replace_its_content_with_a_string_of_text( self, text_set_fixture): cell, text, expected_xml = text_set_fixture @@ -247,6 +252,19 @@ def tables_fixture(self, request): cell = _Cell(element(cell_cxml), None) return cell, expected_count + @pytest.fixture(params=[ + ('w:tc', ''), + ('w:tc/w:p/w:r/w:t"foobar"', 'foobar'), + ('w:tc/(w:p/w:r/w:t"foo",w:p/w:r/w:t"bar")', 'foo\nbar'), + ('w:tc/(w:tcPr,w:p/w:r/w:t"foobar")', 'foobar'), + ('w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', + 'fo\tob\nar\n'), + ]) + def text_get_fixture(self, request): + tc_cxml, expected_text = request.param + cell = _Cell(element(tc_cxml), None) + return cell, expected_text + @pytest.fixture(params=[ ('w:tc/w:p', 'foobar', 'w:tc/w:p/w:r/w:t"foobar"'), From 3de03c880290e3e372a196aab4c9453d07a8b124 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 20 Nov 2014 23:38:49 -0800 Subject: [PATCH 006/615] tbl: reimplement _Row.cells Temporarily named _Row.cells_new --- docx/table.py | 27 +++++++++++++++++++++++++++ features/steps/table.py | 2 +- tests/test_table.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 54771993b..3b8b35465 100644 --- a/docx/table.py +++ b/docx/table.py @@ -67,6 +67,12 @@ def columns(self): """ return _Columns(self._tbl, self) + def row_cells(self, row_idx): + """ + Sequence of cells in the row at *row_idx* in this table. + """ + raise NotImplementedError + @lazyproperty def rows(self): """ @@ -297,6 +303,27 @@ def cells(self): """ return _RowCells(self._tr, self) + @property + def cells_new(self): + """ + Sequence of |_Cell| instances corresponding to cells in this row. + """ + return tuple(self.table.row_cells(self._index)) + + @property + def table(self): + """ + Reference to the |Table| object this row belongs to. + """ + raise NotImplementedError + + @property + def _index(self): + """ + Index of this row in its table, starting from zero. + """ + raise NotImplementedError + class _RowCells(Parented): """ diff --git a/features/steps/table.py b/features/steps/table.py index 19c08c31d..ddec02e80 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -269,7 +269,7 @@ def then_the_reported_width_of_the_cell_is_width(context, width): @then('the row cells text is {expected_text}') def then_the_row_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join(c.text for row in table.rows for c in row.cells) + cells_text = ' '.join(c.text for row in table.rows for c in row.cells_new) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index 9c901a3d7..5ad6df1f4 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -17,6 +17,7 @@ from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml +from .unitutil.mock import instance_mock, property_mock class DescribeTable(object): @@ -429,11 +430,41 @@ def columns_fixture(self): class Describe_Row(object): + def it_provides_access_to_its_cells(self, cells_fixture): + row, row_idx, expected_cells = cells_fixture + cells = row.cells_new + row.table.row_cells.assert_called_once_with(row_idx) + assert cells == expected_cells + def it_provides_access_to_the_row_cells(self): row = _Row(element('w:tr'), None) cells = row.cells assert isinstance(cells, _RowCells) + # fixtures ------------------------------------------------------- + + @pytest.fixture + def cells_fixture(self, _index_, table_prop_, table_): + row = _Row(None, None) + _index_.return_value = row_idx = 6 + expected_cells = (1, 2, 3) + table_.row_cells.return_value = list(expected_cells) + return row, row_idx, expected_cells + + # fixture components --------------------------------------------- + + @pytest.fixture + def _index_(self, request): + return property_mock(request, _Row, '_index') + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + + @pytest.fixture + def table_prop_(self, request, table_): + return property_mock(request, _Row, 'table', return_value=table_) + class Describe_RowCells(object): From acf76f46640a8a49e7bff73a19d10357ad9f8c2a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 00:24:18 -0800 Subject: [PATCH 007/615] tbl: add _Row.table --- docx/table.py | 12 +++++++++++- tests/test_table.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 3b8b35465..bd5fda53f 100644 --- a/docx/table.py +++ b/docx/table.py @@ -93,6 +93,16 @@ def style(self): def style(self, value): self._tblPr.style = value + @property + def table(self): + """ + Provide child objects with reference to the |Table| object they + belong to, without them having to know their direct parent is + a |Table| object. This is the terminus of a series of `parent._table` + calls from an arbitrary child through its ancestors. + """ + raise NotImplementedError + @property def _tblPr(self): return self._tbl.tblPr @@ -315,7 +325,7 @@ def table(self): """ Reference to the |Table| object this row belongs to. """ - raise NotImplementedError + return self._parent.table @property def _index(self): diff --git a/tests/test_table.py b/tests/test_table.py index 5ad6df1f4..c27e64773 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -441,6 +441,10 @@ def it_provides_access_to_the_row_cells(self): cells = row.cells assert isinstance(cells, _RowCells) + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + row, table_ = table_fixture + assert row.table is table_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -451,12 +455,22 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.row_cells.return_value = list(expected_cells) return row, row_idx, expected_cells + @pytest.fixture + def table_fixture(self, parent_, table_): + row = _Row(None, parent_) + parent_.table = table_ + return row, table_ + # fixture components --------------------------------------------- @pytest.fixture def _index_(self, request): return property_mock(request, _Row, '_index') + @pytest.fixture + def parent_(self, request): + return instance_mock(request, Table) + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From f0ade55524fc52c583e55bc44847fc813a2a275d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 1 Nov 2014 01:48:58 -0700 Subject: [PATCH 008/615] tbl: add _Rows.table --- docx/table.py | 7 +++++++ tests/test_table.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docx/table.py b/docx/table.py index bd5fda53f..af382ef9c 100644 --- a/docx/table.py +++ b/docx/table.py @@ -386,3 +386,10 @@ def __iter__(self): def __len__(self): return len(self._tbl.tr_lst) + + @property + def table(self): + """ + Reference to the |Table| object this row collection belongs to. + """ + return self._parent.table diff --git a/tests/test_table.py b/tests/test_table.py index c27e64773..f0260f5a7 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -547,6 +547,10 @@ def it_raises_on_indexed_access_out_of_range(self, rows_fixture): too_high = row_count rows[too_high] + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + rows, table_ = table_fixture + assert rows.table is table_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -556,6 +560,18 @@ def rows_fixture(self): rows = _Rows(tbl, None) return rows, row_count + @pytest.fixture + def table_fixture(self, table_): + rows = _Rows(None, table_) + table_.table = table_ + return rows, table_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + # fixtures ----------------------------------------------------------- From b5c4a5eec42c80efd3f73b908946b615bfc54a37 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 1 Nov 2014 01:41:46 -0700 Subject: [PATCH 009/615] tbl: add Table.table --- docx/table.py | 2 +- tests/test_table.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index af382ef9c..cd4c7d190 100644 --- a/docx/table.py +++ b/docx/table.py @@ -101,7 +101,7 @@ def table(self): a |Table| object. This is the terminus of a series of `parent._table` calls from an arbitrary child through its ancestors. """ - raise NotImplementedError + return self @property def _tblPr(self): diff --git a/tests/test_table.py b/tests/test_table.py index f0260f5a7..af5c2abe2 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -71,6 +71,10 @@ def it_can_change_its_autofit_setting(self, autofit_set_fixture): table.autofit = new_value assert table._tbl.xml == expected_xml + def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): + table = table_fixture + assert table.table is table + # fixtures ------------------------------------------------------- @pytest.fixture @@ -116,6 +120,11 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture + def table_fixture(self): + table = Table(None, None) + return table + @pytest.fixture(params=[ ('w:tbl/w:tblPr', None), ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', 'foobar'), From f9197850641f5ad6baca6a35d7fd31e88242b814 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 19:52:27 -0800 Subject: [PATCH 010/615] tbl: add _Row._index --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 2 +- tests/test_table.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index f2fbd540f..27704f196 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -24,6 +24,14 @@ class CT_Row(BaseOxmlElement): """ tc = ZeroOrMore('w:tc') + @property + def tr_idx(self): + """ + The index of this ```` element within its parent ```` + element. + """ + return self.getparent().tr_lst.index(self) + def _new_tc(self): return CT_Tc.new() diff --git a/docx/table.py b/docx/table.py index cd4c7d190..4be08389b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -332,7 +332,7 @@ def _index(self): """ Index of this row in its table, starting from zero. """ - raise NotImplementedError + return self._tr.tr_idx class _RowCells(Parented): diff --git a/tests/test_table.py b/tests/test_table.py index af5c2abe2..383afa4d9 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -454,6 +454,10 @@ def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): row, table_ = table_fixture assert row.table is table_ + def it_knows_its_index_in_table_to_help(self, idx_fixture): + row, expected_idx = idx_fixture + assert row._index == expected_idx + # fixtures ------------------------------------------------------- @pytest.fixture @@ -464,6 +468,13 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.row_cells.return_value = list(expected_cells) return row, row_idx, expected_cells + @pytest.fixture + def idx_fixture(self): + tbl = element('w:tbl/(w:tr,w:tr,w:tr)') + tr, expected_idx = tbl[1], 1 + row = _Row(tr, None) + return row, expected_idx + @pytest.fixture def table_fixture(self, parent_, table_): row = _Row(None, parent_) From ce3ecf788aa7545d0f41080a94405abc0a1b938c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 20:30:05 -0800 Subject: [PATCH 011/615] tbl: add Table.row_cells() --- docx/table.py | 21 ++++++++++++++++++++- tests/test_table.py | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 4be08389b..6bfad0180 100644 --- a/docx/table.py +++ b/docx/table.py @@ -71,7 +71,10 @@ def row_cells(self, row_idx): """ Sequence of cells in the row at *row_idx* in this table. """ - raise NotImplementedError + column_count = self._column_count + start = row_idx * column_count + end = start + column_count + return self._cells[start:end] @lazyproperty def rows(self): @@ -103,6 +106,22 @@ def table(self): """ return self + @property + def _cells(self): + """ + A sequence of |_Cell| objects, one for each cell of the layout grid. + If the table contains a span, one or more |_Cell| object references + are repeated. + """ + raise NotImplementedError + + @property + def _column_count(self): + """ + The number of grid columns in this table. + """ + raise NotImplementedError + @property def _tblPr(self): return self._tbl.tblPr diff --git a/tests/test_table.py b/tests/test_table.py index 383afa4d9..87d04bcf3 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -39,6 +39,11 @@ def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): tc = tr.tc_lst[col_idx] assert tc is cell._tc + def it_provides_access_to_the_cells_in_a_row(self, row_cells_fixture): + table, row_idx, expected_cells = row_cells_fixture + row_cells = table.row_cells(row_idx) + assert row_cells == expected_cells + def it_can_add_a_row(self, add_row_fixture): table, expected_xml = add_row_fixture row = table.add_row() @@ -120,6 +125,15 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture + def row_cells_fixture(self, _cells_, _column_count_): + table = Table(None, None) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + row_idx = 1 + expected_cells = [3, 4, 5] + return table, row_idx, expected_cells + @pytest.fixture def table_fixture(self): table = Table(None, None) @@ -152,6 +166,14 @@ def table_style_set_fixture(self, request): # fixture components --------------------------------------------- + @pytest.fixture + def _cells_(self, request): + return property_mock(request, Table, '_cells') + + @pytest.fixture + def _column_count_(self, request): + return property_mock(request, Table, '_column_count') + @pytest.fixture def table(self): tbl = _tbl_bldr(rows=2, cols=2).element From 7fb29b155218ff6fc2cc1369f55eef48926dcfc2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 20:44:18 -0800 Subject: [PATCH 012/615] tbl: add Table._column_count --- docx/oxml/table.py | 7 +++++++ docx/table.py | 2 +- tests/test_table.py | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 27704f196..8f94449d6 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -53,6 +53,13 @@ def new(cls): tbl = parse_xml(cls._tbl_xml()) return tbl + @property + def col_count(self): + """ + The number of grid columns in this table. + """ + return len(self.tblGrid.gridCol_lst) + @classmethod def _tbl_xml(cls): return ( diff --git a/docx/table.py b/docx/table.py index 6bfad0180..f6eb803b5 100644 --- a/docx/table.py +++ b/docx/table.py @@ -120,7 +120,7 @@ def _column_count(self): """ The number of grid columns in this table. """ - raise NotImplementedError + return self._tbl.col_count @property def _tblPr(self): diff --git a/tests/test_table.py b/tests/test_table.py index 87d04bcf3..cb7e90eea 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -80,6 +80,11 @@ def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): table = table_fixture assert table.table is table + def it_knows_its_column_count_to_help(self, column_count_fixture): + table, expected_value = column_count_fixture + column_count = table._column_count + assert column_count == expected_value + # fixtures ------------------------------------------------------- @pytest.fixture @@ -125,6 +130,13 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture + def column_count_fixture(self): + tbl_cxml = 'w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)' + expected_value = 3 + table = Table(element(tbl_cxml), None) + return table, expected_value + @pytest.fixture def row_cells_fixture(self, _cells_, _column_count_): table = Table(None, None) From 316b0039edfdcf9a135018fea6683559a16111cb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 21:09:31 -0800 Subject: [PATCH 013/615] test: add snippet infrastructure --- tests/unitutil/file.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unitutil/file.py b/tests/unitutil/file.py index 968a47d1d..8462e6c42 100644 --- a/tests/unitutil/file.py +++ b/tests/unitutil/file.py @@ -27,6 +27,33 @@ def docx_path(name): return absjoin(test_file_dir, '%s.docx' % name) +def snippet_seq(name, offset=0, count=1024): + """ + Return a tuple containing the unicode text snippets read from the snippet + file having *name*. Snippets are delimited by a blank line. If specified, + *count* snippets starting at *offset* are returned. + """ + path = os.path.join(test_file_dir, 'snippets', '%s.txt' % name) + with open(path, 'rb') as f: + text = f.read().decode('utf-8') + snippets = text.split('\n\n') + start, end = offset, offset+count + return tuple(snippets[start:end]) + + +def snippet_text(snippet_file_name): + """ + Return the unicode text read from the test snippet file having + *snippet_file_name*. + """ + snippet_file_path = os.path.join( + test_file_dir, 'snippets', '%s.txt' % snippet_file_name + ) + with open(snippet_file_path, 'rb') as f: + snippet_bytes = f.read() + return snippet_bytes.decode('utf-8') + + def test_file(name): """ Return the absolute path to test file having *name*. From 22752a9aac98e8886eee7202f385f195ac0f175e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 22:39:29 -0800 Subject: [PATCH 014/615] tbl: add Table._cells --- docx/oxml/__init__.py | 4 +- docx/oxml/simpletypes.py | 43 +++++-- docx/oxml/table.py | 103 +++++++++++++--- docx/table.py | 13 +- features/tbl-cell-access.feature | 1 - tests/test_files/snippets/tbl-cells.txt | 157 ++++++++++++++++++++++++ tests/test_table.py | 25 ++++ 7 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 tests/test_files/snippets/tbl-cells.txt diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index c5938c7c8..b397a1b46 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,9 +115,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from docx.oxml.table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, - CT_TblWidth, CT_Tc, CT_TcPr + CT_TblWidth, CT_Tc, CT_TcPr, CT_VMerge ) register_element_cls('w:gridCol', CT_TblGridCol) +register_element_cls('w:gridSpan', CT_DecimalNumber) register_element_cls('w:tbl', CT_Tbl) register_element_cls('w:tblGrid', CT_TblGrid) register_element_cls('w:tblLayout', CT_TblLayoutType) @@ -127,6 +128,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 diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 07b51d533..95fdcca7b 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -54,34 +54,45 @@ def validate_string(cls, value): ) -class BaseStringType(BaseSimpleType): +class BaseIntType(BaseSimpleType): @classmethod def convert_from_xml(cls, str_value): - return str_value + return int(str_value) @classmethod def convert_to_xml(cls, value): - return value + return str(value) @classmethod def validate(cls, value): - cls.validate_string(value) + cls.validate_int(value) -class BaseIntType(BaseSimpleType): +class BaseStringType(BaseSimpleType): @classmethod def convert_from_xml(cls, str_value): - return int(str_value) + return str_value @classmethod def convert_to_xml(cls, value): - return str(value) + return value @classmethod def validate(cls, value): - cls.validate_int(value) + cls.validate_string(value) + + +class BaseStringEnumerationType(BaseStringType): + + @classmethod + def validate(cls, value): + cls.validate_string(value) + if value not in cls._members: + raise ValueError( + "must be one of %s, got '%s'" % (cls._members, value) + ) class XsdAnyUri(BaseStringType): @@ -144,6 +155,12 @@ class XsdString(BaseStringType): pass +class XsdStringEnumeration(BaseStringEnumerationType): + """ + Set of enumerated xsd:string values. + """ + + class XsdToken(BaseStringType): """ xsd:string with whitespace collapsing, e.g. multiple spaces reduced to @@ -218,6 +235,16 @@ class ST_DrawingElementId(XsdUnsignedInt): pass +class ST_Merge(XsdStringEnumeration): + """ + Valid values for attribute + """ + CONTINUE = 'continue' + RESTART = 'restart' + + _members = (CONTINUE, RESTART) + + class ST_OnOff(XsdBoolean): @classmethod diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 8f94449d6..cfc9a23be 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_Merge, ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt ) from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, @@ -44,6 +44,16 @@ class CT_Tbl(BaseOxmlElement): tblGrid = OneAndOnlyOne('w:tblGrid') tr = ZeroOrMore('w:tr') + def iter_tcs(self): + """ + Generate each of the `w:tc` elements in this table, left to right and + top to bottom. Each cell in the first row is generated, followed by + each cell in the second row, etc. + """ + for tr in self.tr_lst: + for tc in tr.tc_lst: + yield tc + @classmethod def new(cls): """ @@ -182,18 +192,6 @@ class CT_Tc(BaseOxmlElement): p = OneOrMore('w:p') tbl = OneOrMore('w:tbl') - def _insert_tcPr(self, tcPr): - """ - ``tcPr`` has a bunch of successors, but it comes first if it appears, - so just overriding and using insert(0, ...) rather than spelling out - successors. - """ - self.insert(0, tcPr) - return tcPr - - def _new_tbl(self): - return CT_Tbl.new() - def clear_content(self): """ Remove all content child elements, preserving the ```` @@ -208,6 +206,17 @@ def clear_content(self): new_children.append(tcPr) self[:] = new_children + @property + def grid_span(self): + """ + The integer number of columns this cell spans. Determined by + ./w:tcPr/w:gridSpan/@val, it defaults to 1. + """ + tcPr = self.tcPr + if tcPr is None: + return 1 + return tcPr.grid_span + @classmethod def new(cls): """ @@ -220,6 +229,17 @@ def new(cls): '' % nsdecls('w') ) + @property + def vMerge(self): + """ + The value of the ./w:tcPr/w:vMerge/@val attribute, or |None| if the + w:vMerge element is not present. + """ + tcPr = self.tcPr + if tcPr is None: + return None + return tcPr.vMerge_val + @property def width(self): """ @@ -236,17 +256,55 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + def _insert_tcPr(self, tcPr): + """ + ``tcPr`` has a bunch of successors, but it comes first if it appears, + so just overriding and using insert(0, ...) rather than spelling out + successors. + """ + self.insert(0, tcPr) + return tcPr + + def _new_tbl(self): + return CT_Tbl.new() + class CT_TcPr(BaseOxmlElement): """ ```` element, defining table cell properties """ - tcW = ZeroOrOne('w:tcW', successors=( - 'w:gridSpan', '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' - )) + _tag_seq = ( + 'w:cnfStyle', 'w:tcW', 'w:gridSpan', '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' + ) + tcW = ZeroOrOne('w:tcW', successors=_tag_seq[2:]) + gridSpan = ZeroOrOne('w:gridSpan', successors=_tag_seq[3:]) + vMerge = ZeroOrOne('w:vMerge', successors=_tag_seq[5:]) + del _tag_seq + + @property + def grid_span(self): + """ + The integer number of columns this cell spans. Determined by + ./w:gridSpan/@val, it defaults to 1. + """ + gridSpan = self.gridSpan + if gridSpan is None: + return 1 + return gridSpan.val + + @property + def vMerge_val(self): + """ + The value of the ./w:vMerge/@val attribute, or |None| if the + w:vMerge element is not present. + """ + vMerge = self.vMerge + if vMerge is None: + return None + return vMerge.val @property def width(self): @@ -263,3 +321,10 @@ def width(self): def width(self, value): tcW = self.get_or_add_tcW() tcW.width = value + + +class CT_VMerge(BaseOxmlElement): + """ + ```` element, specifying vertical merging behavior of a cell. + """ + val = OptionalAttribute('w:val', ST_Merge, default=ST_Merge.CONTINUE) diff --git a/docx/table.py b/docx/table.py index f6eb803b5..28d8d1823 100644 --- a/docx/table.py +++ b/docx/table.py @@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from .blkcntnr import BlockItemContainer +from .oxml.simpletypes import ST_Merge from .shared import lazyproperty, Parented @@ -113,7 +114,17 @@ def _cells(self): If the table contains a span, one or more |_Cell| object references are repeated. """ - raise NotImplementedError + col_count = self._column_count + cells = [] + for tc in self._tbl.iter_tcs(): + for grid_span_idx in range(tc.grid_span): + if tc.vMerge == ST_Merge.CONTINUE: + cells.append(cells[-col_count]) + elif grid_span_idx > 0: + cells.append(cells[-1]) + else: + cells.append(_Cell(tc, self)) + return cells @property def _column_count(self): diff --git a/features/tbl-cell-access.feature b/features/tbl-cell-access.feature index 7dbd1a6b0..160387491 100644 --- a/features/tbl-cell-access.feature +++ b/features/tbl-cell-access.feature @@ -3,7 +3,6 @@ Feature: Access table cells As a developer using python-docx I need a way to access a cell from a table, row, or column - @wip Scenario Outline: Access cell sequence of a row Given a 3x3 table having Then the row cells text is diff --git a/tests/test_files/snippets/tbl-cells.txt b/tests/test_files/snippets/tbl-cells.txt new file mode 100644 index 000000000..5f1b8281b --- /dev/null +++ b/tests/test_files/snippets/tbl-cells.txt @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_table.py b/tests/test_table.py index cb7e90eea..366ce3065 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -8,6 +8,7 @@ import pytest +from docx.oxml import parse_xml from docx.shared import Inches from docx.table import ( _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows, Table @@ -17,6 +18,7 @@ from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml +from .unitutil.file import snippet_seq from .unitutil.mock import instance_mock, property_mock @@ -85,6 +87,16 @@ def it_knows_its_column_count_to_help(self, column_count_fixture): column_count = table._column_count assert column_count == expected_value + def it_provides_access_to_its_cells_to_help(self, cells_fixture): + table, cell_count, unique_count, matches = cells_fixture + cells = table._cells + assert len(cells) == cell_count + assert len(set(cells)) == unique_count + for matching_idxs in matches: + comparator_idx = matching_idxs[0] + for idx in matching_idxs[1:]: + assert cells[idx] is cells[comparator_idx] + # fixtures ------------------------------------------------------- @pytest.fixture @@ -130,6 +142,19 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture(params=[ + (0, 9, 9, ()), + (1, 9, 8, ((0, 1),)), + (2, 9, 8, ((1, 4),)), + (3, 9, 6, ((0, 1, 3, 4),)), + (4, 9, 4, ((0, 1), (3, 6), (4, 5, 7, 8))), + ]) + def cells_fixture(self, request): + snippet_idx, cell_count, unique_count, matches = request.param + tbl_xml = snippet_seq('tbl-cells')[snippet_idx] + table = Table(parse_xml(tbl_xml), None) + return table, cell_count, unique_count, matches + @pytest.fixture def column_count_fixture(self): tbl_cxml = 'w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)' From ba3c7578014df5a4030d5d2669d9fda1897e5ed1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 22:44:47 -0800 Subject: [PATCH 015/615] tbl: remove now-dead code * remove _RowCells and tests * make _Row.cells_new _Row.cells and remove prior version --- docx/table.py | 36 +------------------------------ features/steps/table.py | 2 +- tests/test_table.py | 47 ++--------------------------------------- 3 files changed, 4 insertions(+), 81 deletions(-) diff --git a/docx/table.py b/docx/table.py index 28d8d1823..b68787446 100644 --- a/docx/table.py +++ b/docx/table.py @@ -335,16 +335,8 @@ def __init__(self, tr, parent): super(_Row, self).__init__(parent) self._tr = tr - @lazyproperty - def cells(self): - """ - Sequence of |_Cell| instances corresponding to cells in this row. - Supports ``len()``, iteration and indexed access. - """ - return _RowCells(self._tr, self) - @property - def cells_new(self): + def cells(self): """ Sequence of |_Cell| instances corresponding to cells in this row. """ @@ -365,32 +357,6 @@ def _index(self): return self._tr.tr_idx -class _RowCells(Parented): - """ - Sequence of |_Cell| instances corresponding to the cells in a table row. - """ - def __init__(self, tr, parent): - super(_RowCells, self).__init__(parent) - self._tr = tr - - def __getitem__(self, idx): - """ - Provide indexed access, (e.g. 'cells[0]') - """ - try: - tc = self._tr.tc_lst[idx] - except IndexError: - msg = "cell index [%d] is out of range" % idx - raise IndexError(msg) - return _Cell(tc, self) - - def __iter__(self): - return (_Cell(tc, self) for tc in self._tr.tc_lst) - - def __len__(self): - return len(self._tr.tc_lst) - - class _Rows(Parented): """ Sequence of |_Row| instances corresponding to the rows in a table. diff --git a/features/steps/table.py b/features/steps/table.py index ddec02e80..19c08c31d 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -269,7 +269,7 @@ def then_the_reported_width_of_the_cell_is_width(context, width): @then('the row cells text is {expected_text}') def then_the_row_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join(c.text for row in table.rows for c in row.cells_new) + cells_text = ' '.join(c.text for row in table.rows for c in row.cells) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index 366ce3065..86a6a702f 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -11,7 +11,7 @@ from docx.oxml import parse_xml from docx.shared import Inches from docx.table import ( - _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows, Table + _Cell, _Column, _ColumnCells, _Columns, _Row, _Rows, Table ) from docx.text import Paragraph @@ -500,15 +500,10 @@ class Describe_Row(object): def it_provides_access_to_its_cells(self, cells_fixture): row, row_idx, expected_cells = cells_fixture - cells = row.cells_new + cells = row.cells row.table.row_cells.assert_called_once_with(row_idx) assert cells == expected_cells - def it_provides_access_to_the_row_cells(self): - row = _Row(element('w:tr'), None) - cells = row.cells - assert isinstance(cells, _RowCells) - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): row, table_ = table_fixture assert row.table is table_ @@ -559,44 +554,6 @@ def table_prop_(self, request, table_): return property_mock(request, _Row, 'table', return_value=table_) -class Describe_RowCells(object): - - def it_knows_how_many_cells_it_contains(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - assert len(cells) == cell_count - - def it_can_iterate_over_its__Cell_instances(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - actual_count = 0 - for cell in cells: - assert isinstance(cell, _Cell) - actual_count += 1 - assert actual_count == cell_count - - def it_provides_indexed_access_to_cells(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - for idx in range(-cell_count, cell_count): - cell = cells[idx] - assert isinstance(cell, _Cell) - - def it_raises_on_indexed_access_out_of_range(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - too_low = -1 - cell_count - too_high = cell_count - with pytest.raises(IndexError): - cells[too_low] - with pytest.raises(IndexError): - cells[too_high] - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def cell_count_fixture(self): - cells = _RowCells(element('w:tr/(w:tc, w:tc)'), None) - cell_count = 2 - return cells, cell_count - - class Describe_Rows(object): def it_knows_how_many_rows_it_contains(self, rows_fixture): From f6158779f263d5b8a504430aedd9a94f71cc5cb0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 20 Nov 2014 20:57:37 -0800 Subject: [PATCH 016/615] acpt: fix paste-repeated typo in several features --- features/blk-add-paragraph.feature | 2 +- features/blk-add-table.feature | 2 +- features/cel-text.feature | 2 +- features/par-set-style.feature | 2 +- features/run-char-style.feature | 2 +- features/shp-inline-shape-access.feature | 2 +- features/tbl-add-row-or-col.feature | 2 +- features/tbl-cell-props.feature | 2 +- features/tbl-col-props.feature | 2 +- features/tbl-props.feature | 2 +- features/tbl-style.feature | 2 +- features/txt-add-break.feature | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/features/blk-add-paragraph.feature b/features/blk-add-paragraph.feature index 73e42c4c2..f873b3775 100644 --- a/features/blk-add-paragraph.feature +++ b/features/blk-add-paragraph.feature @@ -1,6 +1,6 @@ Feature: Add a paragraph of text In order to populate the text of a document - As an python-docx developer + As a developer using python-docx I need the ability to add a paragraph Scenario: Add a paragraph using low-level text API diff --git a/features/blk-add-table.feature b/features/blk-add-table.feature index 3e3696a0f..e13143e56 100644 --- a/features/blk-add-table.feature +++ b/features/blk-add-table.feature @@ -1,6 +1,6 @@ Feature: Add a table In order to fulfill a requirement for a table in a document - As an python-docx developer + As a developer using python-docx I need the ability to add a table Scenario: Access a table diff --git a/features/cel-text.feature b/features/cel-text.feature index 2bd8fb055..8373f8ae7 100644 --- a/features/cel-text.feature +++ b/features/cel-text.feature @@ -1,6 +1,6 @@ Feature: Set table cell text In order to quickly populate a table cell with regular text - As an python-docx developer working with a table + As a developer using python-docx I need the ability to set the text of a table cell Scenario: Set table cell text diff --git a/features/par-set-style.feature b/features/par-set-style.feature index ca9303b4d..9b9f0e90c 100644 --- a/features/par-set-style.feature +++ b/features/par-set-style.feature @@ -1,6 +1,6 @@ Feature: Each paragraph has a read/write style In order to use the stylesheet capability built into Word - As an python-docx developer + As a developer using python-docx I need the ability to get and set the style of a paragraph Scenario: Set the style of a paragraph diff --git a/features/run-char-style.feature b/features/run-char-style.feature index 914025658..6d108a23c 100644 --- a/features/run-char-style.feature +++ b/features/run-char-style.feature @@ -1,6 +1,6 @@ Feature: Each run has a read/write style In order to use the stylesheet capability built into Word - As an python-docx developer + As a developer using python-docx I need the ability to get and set the character style of a run diff --git a/features/shp-inline-shape-access.feature b/features/shp-inline-shape-access.feature index 5c9c0efad..c001ac526 100644 --- a/features/shp-inline-shape-access.feature +++ b/features/shp-inline-shape-access.feature @@ -1,6 +1,6 @@ Feature: Access inline shapes in document In order to query or manipulate inline shapes in a document - As an python-docx developer + As a developer using python-docx I need the ability to access the inline shapes in a document Scenario: Access inline shapes collection of document diff --git a/features/tbl-add-row-or-col.feature b/features/tbl-add-row-or-col.feature index 22946085a..74b3a9da1 100644 --- a/features/tbl-add-row-or-col.feature +++ b/features/tbl-add-row-or-col.feature @@ -1,6 +1,6 @@ Feature: Add a row or column to a table In order to extend an existing table - As an python-docx developer + As a developer using python-docx I need methods to add a row or column Scenario: Add a row to a table diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature index 620a55092..32e59dfbe 100644 --- a/features/tbl-cell-props.feature +++ b/features/tbl-cell-props.feature @@ -1,6 +1,6 @@ Feature: Get and set table cell properties In order to format a table cell to my requirements - As an python-docx developer + As a developer using python-docx I need a way to get and set the properties of a table cell diff --git a/features/tbl-col-props.feature b/features/tbl-col-props.feature index 30f5aaca5..3409b6591 100644 --- a/features/tbl-col-props.feature +++ b/features/tbl-col-props.feature @@ -1,6 +1,6 @@ Feature: Get and set table column widths In order to produce properly formatted tables - As an python-docx developer + As a developer using python-docx I need a way to get and set the width of a table's columns diff --git a/features/tbl-props.feature b/features/tbl-props.feature index 613f2299c..05a018cdc 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -1,6 +1,6 @@ Feature: Get and set table properties In order to format a table to my requirements - As an python-docx developer + As a developer using python-docx I need a way to get and set a table's properties diff --git a/features/tbl-style.feature b/features/tbl-style.feature index 702fccd6f..b72c255ee 100644 --- a/features/tbl-style.feature +++ b/features/tbl-style.feature @@ -1,6 +1,6 @@ Feature: Query and apply a table style In order to maintain consistent formatting of tables - As an python-docx developer + As a developer using python-docx I need the ability to query and apply a table style Scenario: Access table style diff --git a/features/txt-add-break.feature b/features/txt-add-break.feature index 14a761993..db5bda819 100644 --- a/features/txt-add-break.feature +++ b/features/txt-add-break.feature @@ -1,6 +1,6 @@ Feature: Add a line, page, or column break In order to control the flow of text in a document - As an python-docx developer + As a developer using python-docx I need the ability to add a line, page, or column break Scenario: Add a line break From cbc2d60edf17f5890bf1b2fa12a152762ae5dd90 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:23:22 -0800 Subject: [PATCH 017/615] tbl: reimplement _Column.cells --- docx/table.py | 27 +++++++++++++++++++++++++++ features/steps/table.py | 4 +++- tests/test_table.py | 31 +++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docx/table.py b/docx/table.py index b68787446..35b77f270 100644 --- a/docx/table.py +++ b/docx/table.py @@ -61,6 +61,12 @@ def cell(self, row_idx, col_idx): row = self.rows[row_idx] return row.cells[col_idx] + def column_cells(self, column_idx): + """ + Sequence of cells in the column at *column_idx* in this table. + """ + raise NotImplementedError + @lazyproperty def columns(self): """ @@ -237,6 +243,20 @@ def cells(self): """ return _ColumnCells(self._tbl, self._gridCol, self) + @property + def cells_new(self): + """ + Sequence of |_Cell| instances corresponding to cells in this column. + """ + return tuple(self.table.column_cells(self._index)) + + @property + def table(self): + """ + Reference to the |Table| object this column belongs to. + """ + raise NotImplementedError + @property def width(self): """ @@ -249,6 +269,13 @@ def width(self): def width(self, value): self._gridCol.w = value + @property + def _index(self): + """ + Index of this column in its table, starting from zero. + """ + raise NotImplementedError + class _ColumnCells(Parented): """ diff --git a/features/steps/table.py b/features/steps/table.py index 19c08c31d..82c01605f 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -216,7 +216,9 @@ def then_table_cell_row_col_text_is_text(context, row, col, expected_text): @then('the column cells text is {expected_text}') def then_the_column_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join(c.text for col in table.columns for c in col.cells) + cells_text = ' '.join( + c.text for col in table.columns for c in col.cells_new + ) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index 86a6a702f..261859746 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -374,10 +374,11 @@ def width_set_fixture(self, request): class Describe_Column(object): - def it_provides_access_to_the_column_cells(self): - column = _Column(None, None, None) - cells = column.cells - assert isinstance(cells, _ColumnCells) + def it_provides_access_to_its_cells(self, cells_fixture): + column, column_idx, expected_cells = cells_fixture + cells = column.cells_new + column.table.column_cells.assert_called_once_with(column_idx) + assert cells == expected_cells def it_knows_its_width_in_EMU(self, width_get_fixture): column, expected_width = width_get_fixture @@ -391,6 +392,14 @@ def it_can_change_its_width(self, width_set_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture + def cells_fixture(self, _index_, table_prop_, table_): + column = _Column(None, None, None) + _index_.return_value = column_idx = 4 + expected_cells = (3, 2, 1) + table_.column_cells.return_value = list(expected_cells) + return column, column_idx, expected_cells + @pytest.fixture(params=[ ('w:gridCol{w:w=4242}', 2693670), ('w:gridCol{w:w=1440}', 914400), @@ -416,6 +425,20 @@ def width_set_fixture(self, request): expected_xml = xml(expected_cxml) return column, new_value, expected_xml + # fixture components --------------------------------------------- + + @pytest.fixture + def _index_(self, request): + return property_mock(request, _Column, '_index') + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + + @pytest.fixture + def table_prop_(self, request, table_): + return property_mock(request, _Column, 'table', return_value=table_) + class Describe_ColumnCells(object): From dc3e8e7088661ef966f46983881691e1fe605c90 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 21 Nov 2014 23:26:47 -0800 Subject: [PATCH 018/615] tbl: add _Column.table --- docx/table.py | 2 +- tests/test_table.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 35b77f270..44abbb82c 100644 --- a/docx/table.py +++ b/docx/table.py @@ -255,7 +255,7 @@ def table(self): """ Reference to the |Table| object this column belongs to. """ - raise NotImplementedError + return self._parent.table @property def width(self): diff --git a/tests/test_table.py b/tests/test_table.py index 261859746..69dbb7da0 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -380,6 +380,10 @@ def it_provides_access_to_its_cells(self, cells_fixture): column.table.column_cells.assert_called_once_with(column_idx) assert cells == expected_cells + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + column, table_ = table_fixture + assert column.table is table_ + def it_knows_its_width_in_EMU(self, width_get_fixture): column, expected_width = width_get_fixture assert column.width == expected_width @@ -400,6 +404,12 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.column_cells.return_value = list(expected_cells) return column, column_idx, expected_cells + @pytest.fixture + def table_fixture(self, parent_, table_): + column = _Column(None, None, parent_) + parent_.table = table_ + return column, table_ + @pytest.fixture(params=[ ('w:gridCol{w:w=4242}', 2693670), ('w:gridCol{w:w=1440}', 914400), @@ -431,6 +441,10 @@ def width_set_fixture(self, request): def _index_(self, request): return property_mock(request, _Column, '_index') + @pytest.fixture + def parent_(self, request): + return instance_mock(request, Table) + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From 7e22dc8ab544901868e7e7ed84f61c40d64b5aaf Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 21 Nov 2014 23:29:46 -0800 Subject: [PATCH 019/615] tbl: add _Columns.table --- docx/table.py | 7 +++++++ tests/test_table.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docx/table.py b/docx/table.py index 44abbb82c..1cc56e06e 100644 --- a/docx/table.py +++ b/docx/table.py @@ -344,6 +344,13 @@ def __iter__(self): def __len__(self): return len(self._gridCol_lst) + @property + def table(self): + """ + Reference to the |Table| object this column collection belongs to. + """ + return self._parent.table + @property def _gridCol_lst(self): """ diff --git a/tests/test_table.py b/tests/test_table.py index 69dbb7da0..286fa3c79 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -523,6 +523,10 @@ def it_raises_on_indexed_access_out_of_range(self, columns_fixture): with pytest.raises(IndexError): columns[too_high] + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + columns, table_ = table_fixture + assert columns.table is table_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -532,6 +536,18 @@ def columns_fixture(self): columns = _Columns(tbl, None) return columns, column_count + @pytest.fixture + def table_fixture(self, table_): + columns = _Columns(None, table_) + table_.table = table_ + return columns, table_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + class Describe_Row(object): From 0e3e2be51edda6e0aa96486216d4d8b6c8fccbbc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:39:26 -0800 Subject: [PATCH 020/615] tbl: add _Column._index --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 2 +- tests/test_table.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index cfc9a23be..8224d4937 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -97,6 +97,14 @@ class CT_TblGridCol(BaseOxmlElement): """ w = OptionalAttribute('w:w', ST_TwipsMeasure) + @property + def gridCol_idx(self): + """ + The index of this ```` element within its parent + ```` element. + """ + return self.getparent().gridCol_lst.index(self) + class CT_TblLayoutType(BaseOxmlElement): """ diff --git a/docx/table.py b/docx/table.py index 1cc56e06e..78a289d8d 100644 --- a/docx/table.py +++ b/docx/table.py @@ -274,7 +274,7 @@ def _index(self): """ Index of this column in its table, starting from zero. """ - raise NotImplementedError + return self._gridCol.gridCol_idx class _ColumnCells(Parented): diff --git a/tests/test_table.py b/tests/test_table.py index 286fa3c79..47c896140 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -394,6 +394,10 @@ def it_can_change_its_width(self, width_set_fixture): assert column.width == value assert column._gridCol.xml == expected_xml + def it_knows_its_index_in_table_to_help(self, index_fixture): + column, expected_idx = index_fixture + assert column._index == expected_idx + # fixtures ------------------------------------------------------- @pytest.fixture @@ -404,6 +408,13 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.column_cells.return_value = list(expected_cells) return column, column_idx, expected_cells + @pytest.fixture + def index_fixture(self): + tbl = element('w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)') + gridCol, expected_idx = tbl.tblGrid[1], 1 + column = _Column(gridCol, tbl, None) + return column, expected_idx + @pytest.fixture def table_fixture(self, parent_, table_): column = _Column(None, None, parent_) From 2fda57040311a565a04bdfe4b21b0c5764c67d77 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:49:07 -0800 Subject: [PATCH 021/615] tbl: add Table.column_cells() --- docx/table.py | 4 +++- features/tbl-cell-access.feature | 2 -- tests/test_table.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docx/table.py b/docx/table.py index 78a289d8d..2732c3851 100644 --- a/docx/table.py +++ b/docx/table.py @@ -65,7 +65,9 @@ def column_cells(self, column_idx): """ Sequence of cells in the column at *column_idx* in this table. """ - raise NotImplementedError + cells = self._cells + idxs = range(column_idx, len(cells), self._column_count) + return [cells[idx] for idx in idxs] @lazyproperty def columns(self): diff --git a/features/tbl-cell-access.feature b/features/tbl-cell-access.feature index 160387491..06f1aea31 100644 --- a/features/tbl-cell-access.feature +++ b/features/tbl-cell-access.feature @@ -15,7 +15,6 @@ Feature: Access table cells | a combined span | 1 2 3 4 4 6 4 4 9 | - @wip Scenario Outline: Access cell sequence of a column Given a 3x3 table having Then the column cells text is @@ -28,7 +27,6 @@ Feature: Access table cells | a combined span | 1 4 4 2 4 4 3 6 9 | - @wip Scenario Outline: Access cell by row and column index Given a 3x3 table having Then table.cell(, ).text is diff --git a/tests/test_table.py b/tests/test_table.py index 47c896140..b3f7a4c6d 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -41,6 +41,11 @@ def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): tc = tr.tc_lst[col_idx] assert tc is cell._tc + def it_provides_access_to_the_cells_in_a_column(self, col_cells_fixture): + table, column_idx, expected_cells = col_cells_fixture + column_cells = table.column_cells(column_idx) + assert column_cells == expected_cells + def it_provides_access_to_the_cells_in_a_row(self, row_cells_fixture): table, row_idx, expected_cells = row_cells_fixture row_cells = table.row_cells(row_idx) @@ -155,6 +160,15 @@ def cells_fixture(self, request): table = Table(parse_xml(tbl_xml), None) return table, cell_count, unique_count, matches + @pytest.fixture + def col_cells_fixture(self, _cells_, _column_count_): + table = Table(None, None) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + column_idx = 1 + expected_cells = [1, 4, 7] + return table, column_idx, expected_cells + @pytest.fixture def column_count_fixture(self): tbl_cxml = 'w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)' From 8a9fdfcdff9af6b82debac25156ea977b372e3b9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:52:41 -0800 Subject: [PATCH 022/615] tbl: remove now-dead code --- docx/table.py | 50 +---------------------------------------- features/steps/table.py | 4 +--- tests/test_table.py | 46 ++----------------------------------- 3 files changed, 4 insertions(+), 96 deletions(-) diff --git a/docx/table.py b/docx/table.py index 2732c3851..7fff61c55 100644 --- a/docx/table.py +++ b/docx/table.py @@ -237,16 +237,8 @@ def __init__(self, gridCol, tbl, parent): self._gridCol = gridCol self._tbl = tbl - @lazyproperty - def cells(self): - """ - Sequence of |_Cell| instances corresponding to cells in this column. - Supports ``len()``, iteration and indexed access. - """ - return _ColumnCells(self._tbl, self._gridCol, self) - @property - def cells_new(self): + def cells(self): """ Sequence of |_Cell| instances corresponding to cells in this column. """ @@ -279,46 +271,6 @@ def _index(self): return self._gridCol.gridCol_idx -class _ColumnCells(Parented): - """ - Sequence of |_Cell| instances corresponding to the cells in a table - column. - """ - def __init__(self, tbl, gridCol, parent): - super(_ColumnCells, self).__init__(parent) - self._tbl = tbl - self._gridCol = gridCol - - def __getitem__(self, idx): - """ - Provide indexed access, (e.g. 'cells[0]') - """ - try: - tr = self._tr_lst[idx] - except IndexError: - msg = "cell index [%d] is out of range" % idx - raise IndexError(msg) - tc = tr.tc_lst[self._col_idx] - return _Cell(tc, self) - - def __iter__(self): - for tr in self._tr_lst: - tc = tr.tc_lst[self._col_idx] - yield _Cell(tc, self) - - def __len__(self): - return len(self._tr_lst) - - @property - def _col_idx(self): - gridCol_lst = self._tbl.tblGrid.gridCol_lst - return gridCol_lst.index(self._gridCol) - - @property - def _tr_lst(self): - return self._tbl.tr_lst - - class _Columns(Parented): """ Sequence of |_Column| instances corresponding to the columns in a table. diff --git a/features/steps/table.py b/features/steps/table.py index 82c01605f..19c08c31d 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -216,9 +216,7 @@ def then_table_cell_row_col_text_is_text(context, row, col, expected_text): @then('the column cells text is {expected_text}') def then_the_column_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join( - c.text for col in table.columns for c in col.cells_new - ) + cells_text = ' '.join(c.text for col in table.columns for c in col.cells) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index b3f7a4c6d..fce71c437 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -10,9 +10,7 @@ from docx.oxml import parse_xml from docx.shared import Inches -from docx.table import ( - _Cell, _Column, _ColumnCells, _Columns, _Row, _Rows, Table -) +from docx.table import _Cell, _Column, _Columns, _Row, _Rows, Table from docx.text import Paragraph from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr @@ -390,7 +388,7 @@ class Describe_Column(object): def it_provides_access_to_its_cells(self, cells_fixture): column, column_idx, expected_cells = cells_fixture - cells = column.cells_new + cells = column.cells column.table.column_cells.assert_called_once_with(column_idx) assert cells == expected_cells @@ -479,46 +477,6 @@ def table_prop_(self, request, table_): return property_mock(request, _Column, 'table', return_value=table_) -class Describe_ColumnCells(object): - - def it_knows_how_many_cells_it_contains(self, cells_fixture): - cells, cell_count = cells_fixture - assert len(cells) == cell_count - - def it_can_iterate_over_its__Cell_instances(self, cells_fixture): - cells, cell_count = cells_fixture - actual_count = 0 - for cell in cells: - assert isinstance(cell, _Cell) - actual_count += 1 - assert actual_count == cell_count - - def it_provides_indexed_access_to_cells(self, cells_fixture): - cells, cell_count = cells_fixture - for idx in range(-cell_count, cell_count): - cell = cells[idx] - assert isinstance(cell, _Cell) - - def it_raises_on_indexed_access_out_of_range(self, cells_fixture): - cells, cell_count = cells_fixture - too_low = -1 - cell_count - too_high = cell_count - with pytest.raises(IndexError): - cells[too_low] - with pytest.raises(IndexError): - cells[too_high] - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def cells_fixture(self): - cell_count = 2 - tbl = _tbl_bldr(rows=cell_count, cols=1).element - gridCol = tbl.tblGrid.gridCol_lst[0] - cells = _ColumnCells(tbl, gridCol, None) - return cells, cell_count - - class Describe_Columns(object): def it_knows_how_many_columns_it_contains(self, columns_fixture): From a8a49e1c5e0ee9f295d9f19b261b4ff5184f5214 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 2 Nov 2014 00:46:46 -0700 Subject: [PATCH 023/615] tbl: refactor Table.cell() --- docx/table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docx/table.py b/docx/table.py index 7fff61c55..5a450fb49 100644 --- a/docx/table.py +++ b/docx/table.py @@ -58,8 +58,8 @@ def cell(self, row_idx, col_idx): 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] - return row.cells[col_idx] + cell_idx = col_idx + (row_idx * self._column_count) + return self._cells[cell_idx] def column_cells(self, column_idx): """ From 3d1534a112ce397859af1330dcfe4fee9f5cde75 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 23 Nov 2014 21:03:44 -0800 Subject: [PATCH 024/615] acpt: add scenarios for _Cell.merge() --- features/steps/table.py | 30 +++++++++++++++-- features/tbl-merge-cells.feature | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 features/tbl-merge-cells.feature diff --git a/features/steps/table.py b/features/steps/table.py index 19c08c31d..243f22d7b 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -4,7 +4,9 @@ Step implementations for table-related features """ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) from behave import given, then, when @@ -127,6 +129,17 @@ def when_apply_style_to_table(context): table.style = 'LightShading-Accent1' +@when('I merge from cell {origin} to cell {other}') +def when_I_merge_from_cell_origin_to_cell_other(context, origin, other): + def cell(table, idx): + row, col = idx // 3, idx % 3 + return table.cell(row, col) + a_idx, b_idx = int(origin) - 1, int(other) - 1 + table = context.table_ + a, b = cell(table, a_idx), cell(table, b_idx) + a.merge(b) + + @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] @@ -266,8 +279,9 @@ def then_the_reported_width_of_the_cell_is_width(context, width): ) -@then('the row cells text is {expected_text}') -def then_the_row_cells_text_is_expected_text(context, expected_text): +@then('the row cells text is {encoded_text}') +def then_the_row_cells_text_is_expected_text(context, encoded_text): + expected_text = encoded_text.replace('\\', '\n') table = context.table_ cells_text = ' '.join(c.text for row in table.rows for c in row.cells) assert cells_text == expected_text, 'got %s' % cells_text @@ -292,3 +306,13 @@ def then_table_has_count_rows(context, count): row_count = int(count) rows = context.table_.rows assert len(rows) == row_count + + +@then('the width of cell {n_str} is {inches_str} inches') +def then_the_width_of_cell_n_is_x_inches(context, n_str, inches_str): + def _cell(table, idx): + row, col = idx // 3, idx % 3 + return table.cell(row, col) + idx, inches = int(n_str) - 1, float(inches_str) + cell = _cell(context.table_, idx) + assert cell.width == Inches(inches), 'got %s' % cell.width.inches diff --git a/features/tbl-merge-cells.feature b/features/tbl-merge-cells.feature new file mode 100644 index 000000000..3249f81e7 --- /dev/null +++ b/features/tbl-merge-cells.feature @@ -0,0 +1,57 @@ +Feature: Merge table cells + In order to form a table cell spanning multiple rows and/or columns + As a developer using python-docx + I need a way to merge a range of cells + + @wip + Scenario Outline: Merge cells + Given a 3x3 table having only uniform cells + When I merge from cell to cell + Then the row cells text is + + Examples: Reported row cell contents + | origin | other | expected-text | + | 1 | 2 | 1\2 1\2 3 4 5 6 7 8 9 | + | 2 | 5 | 1 2\5 3 4 2\5 6 7 8 9 | + | 5 | 9 | 1 2 3 4 5\6\8\9 5\6\8\9 7 5\6\8\9 5\6\8\9 | + + + @wip + Scenario Outline: Merge horizontal span with other cell + Given a 3x3 table having a horizontal span + When I merge from cell to cell + Then the row cells text is + + Examples: Reported row cell contents + | origin | other | expected-text | + | 4 | 8 | 1 2 3 4\7\8 4\7\8 6 4\7\8 4\7\8 9 | + | 4 | 6 | 1 2 3 4\6 4\6 4\6 7 8 9 | + | 2 | 4 | 1\2\4 1\2\4 3 1\2\4 1\2\4 6 7 8 9 | + + + @wip + Scenario Outline: Merge vertical span with other cell + Given a 3x3 table having a vertical span + When I merge from cell to cell + Then the row cells text is + + Examples: Reported row cell contents + | origin | other | expected-text | + | 5 | 9 | 1 2 3 4 5\6\9 5\6\9 7 5\6\9 5\6\9 | + | 2 | 5 | 1 2\5 3 4 2\5 6 7 2\5 9 | + | 7 | 5 | 1 2 3 4\5\7 4\5\7 6 4\5\7 4\5\7 9 | + + + @wip + Scenario Outline: Horizontal span adds cell widths + Given a 3x3 table having + When I merge from cell to cell + Then the width of cell is inches + + Examples: Reported row cell contents + | span-state | origin | other | merged | width | + | only uniform cells | 1 | 2 | 1 | 2.0 | + | only uniform cells | 1 | 5 | 1 | 2.0 | + | a horizontal span | 4 | 6 | 4 | 3.0 | + | a vertical span | 5 | 2 | 2 | 1.0 | + | a vertical span | 5 | 7 | 5 | 2.0 | From b347549a3216bc354d2ffbd226c3e3d2fa8f5ec5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 23 Nov 2014 22:00:42 -0800 Subject: [PATCH 025/615] tbl: reorder methods in Describe_Cell No code changes, just changing ordering of test methods. --- tests/test_table.py | 50 ++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/test_table.py b/tests/test_table.py index fce71c437..e84c62e11 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -232,17 +232,26 @@ def table(self): class Describe_Cell(object): - def it_can_add_a_paragraph(self, add_paragraph_fixture): - cell, expected_xml = add_paragraph_fixture - p = cell.add_paragraph() + def it_knows_what_text_it_contains(self, text_get_fixture): + cell, expected_text = text_get_fixture + text = cell.text + assert text == expected_text + + def it_can_replace_its_content_with_a_string_of_text( + self, text_set_fixture): + cell, text, expected_xml = text_set_fixture + cell.text = text assert cell._tc.xml == expected_xml - assert isinstance(p, Paragraph) - def it_can_add_a_table(self, add_table_fixture): - cell, expected_xml = add_table_fixture - table = cell.add_table(rows=0, cols=0) + def it_knows_its_width_in_EMU(self, width_get_fixture): + cell, expected_width = width_get_fixture + assert cell.width == expected_width + + def it_can_change_its_width(self, width_set_fixture): + cell, value, expected_xml = width_set_fixture + cell.width = value + assert cell.width == value assert cell._tc.xml == expected_xml - assert isinstance(table, Table) def it_provides_access_to_the_paragraphs_it_contains( self, paragraphs_fixture): @@ -268,26 +277,17 @@ def it_provides_access_to_the_tables_it_contains(self, tables_fixture): count += 1 assert count == expected_count - def it_knows_what_text_it_contains(self, text_get_fixture): - cell, expected_text = text_get_fixture - text = cell.text - assert text == expected_text - - def it_can_replace_its_content_with_a_string_of_text( - self, text_set_fixture): - cell, text, expected_xml = text_set_fixture - cell.text = text + def it_can_add_a_paragraph(self, add_paragraph_fixture): + cell, expected_xml = add_paragraph_fixture + p = cell.add_paragraph() assert cell._tc.xml == expected_xml + assert isinstance(p, Paragraph) - def it_knows_its_width_in_EMU(self, width_get_fixture): - cell, expected_width = width_get_fixture - assert cell.width == expected_width - - def it_can_change_its_width(self, width_set_fixture): - cell, value, expected_xml = width_set_fixture - cell.width = value - assert cell.width == value + def it_can_add_a_table(self, add_table_fixture): + cell, expected_xml = add_table_fixture + table = cell.add_table(rows=0, cols=0) assert cell._tc.xml == expected_xml + assert isinstance(table, Table) # fixtures ------------------------------------------------------- From 3a447d37d97b016bb7741f7a6d085bcd966d834d Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sun, 23 Nov 2014 22:52:51 -0800 Subject: [PATCH 026/615] tbl: add _Cell.merge() --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 11 +++++++++++ tests/test_table.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 8224d4937..d697cf243 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -225,6 +225,14 @@ def grid_span(self): return 1 return tcPr.grid_span + def merge(self, other_tc): + """ + Return the top-left ```` element of the span formed by merging + the rectangular region defined by using this tc element and + *other_tc* as diagonal corners. + """ + raise NotImplementedError + @classmethod def new(cls): """ diff --git a/docx/table.py b/docx/table.py index 5a450fb49..533f2b47b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -179,6 +179,17 @@ def add_table(self, rows, cols): self.add_paragraph() return new_table + def merge(self, other_cell): + """ + Return a merged cell created by spanning the rectangular region + demarcated by using the extents of this cell and *other_cell* as + diagonal corners. Raises |InvalidSpanError| if the cells do not + define a rectangular region. + """ + tc, tc_2 = self._tc, other_cell._tc + merged_tc = tc.merge(tc_2) + return _Cell(merged_tc, self._parent) + @property def paragraphs(self): """ diff --git a/tests/test_table.py b/tests/test_table.py index e84c62e11..c4e626402 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -9,6 +9,7 @@ import pytest from docx.oxml import parse_xml +from docx.oxml.table import CT_Tc from docx.shared import Inches from docx.table import _Cell, _Column, _Columns, _Row, _Rows, Table from docx.text import Paragraph @@ -289,6 +290,14 @@ def it_can_add_a_table(self, add_table_fixture): assert cell._tc.xml == expected_xml assert isinstance(table, Table) + def it_can_merge_itself_with_other_cells(self, merge_fixture): + cell, other_cell, merged_tc_ = merge_fixture + merged_cell = cell.merge(other_cell) + cell._tc.merge.assert_called_once_with(other_cell._tc) + assert isinstance(merged_cell, _Cell) + assert merged_cell._tc is merged_tc_ + assert merged_cell._parent is cell._parent + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -317,6 +326,12 @@ def add_table_fixture(self, request): expected_xml = xml(after_tc_cxml) return cell, expected_xml + @pytest.fixture + def merge_fixture(self, tc_, tc_2_, parent_, merged_tc_): + cell, other_cell = _Cell(tc_, parent_), _Cell(tc_2_, parent_) + tc_.merge.return_value = merged_tc_ + return cell, other_cell, merged_tc_ + @pytest.fixture def paragraphs_fixture(self): return _Cell(element('w:tc/(w:p, w:p)'), None) @@ -383,6 +398,24 @@ def width_set_fixture(self, request): expected_xml = xml(expected_cxml) return cell, new_value, expected_xml + # fixture components --------------------------------------------- + + @pytest.fixture + def merged_tc_(self, request): + return instance_mock(request, CT_Tc) + + @pytest.fixture + def parent_(self, request): + return instance_mock(request, Table) + + @pytest.fixture + def tc_(self, request): + return instance_mock(request, CT_Tc) + + @pytest.fixture + def tc_2_(self, request): + return instance_mock(request, CT_Tc) + class Describe_Column(object): From 538ed191cece9699cf49f009ff5cf2b7a1e2ca12 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 24 Nov 2014 01:14:36 -0800 Subject: [PATCH 027/615] tbl: add CT_Tc.merge() --- docx/oxml/table.py | 39 +++++++++++++++++++++++-- tests/oxml/test_table.py | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/oxml/test_table.py diff --git a/docx/oxml/table.py b/docx/oxml/table.py index d697cf243..72258de16 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -24,6 +24,13 @@ class CT_Row(BaseOxmlElement): """ tc = ZeroOrMore('w:tc') + def tc_at_grid_col(self, idx): + """ + The ```` element appearing at grid column *idx*. Raises + |ValueError| if no ``w:tc`` element begins at that grid column. + """ + raise NotImplementedError + @property def tr_idx(self): """ @@ -227,11 +234,14 @@ def grid_span(self): def merge(self, other_tc): """ - Return the top-left ```` element of the span formed by merging - the rectangular region defined by using this tc element and + Return the top-left ```` element of a new span formed by + merging the rectangular region defined by using this tc element and *other_tc* as diagonal corners. """ - raise NotImplementedError + top, left, height, width = self._span_dimensions(other_tc) + top_tc = self._tbl.tr_lst[top].tc_at_grid_col(left) + top_tc._grow_to(width, height) + return top_tc @classmethod def new(cls): @@ -272,6 +282,14 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + def _grow_to(self, width, height, top_tc=None): + """ + Grow this cell to *width* grid columns and *height* rows by expanding + horizontal spans and creating continuation cells to form vertical + spans. + """ + raise NotImplementedError + def _insert_tcPr(self, tcPr): """ ``tcPr`` has a bunch of successors, but it comes first if it appears, @@ -284,6 +302,21 @@ def _insert_tcPr(self, tcPr): def _new_tbl(self): return CT_Tbl.new() + def _span_dimensions(self, other_tc): + """ + Return a (top, left, height, width) 4-tuple specifying the extents of + the merged cell formed by using this tc and *other_tc* as opposite + corner extents. + """ + raise NotImplementedError + + @property + def _tbl(self): + """ + The tbl element this tc element appears in. + """ + raise NotImplementedError + class CT_TcPr(BaseOxmlElement): """ diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py new file mode 100644 index 000000000..135c2efeb --- /dev/null +++ b/tests/oxml/test_table.py @@ -0,0 +1,61 @@ +# encoding: utf-8 + +""" +Test suite for the docx.oxml.text module. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.oxml.table import CT_Row, CT_Tc + +from ..unitutil.cxml import element +from ..unitutil.mock import instance_mock, method_mock, property_mock + + +class DescribeCT_Tc(object): + + def it_can_merge_to_another_tc(self, merge_fixture): + tc, other_tc, top_tr_, top_tc_, left, height, width = merge_fixture + merged_tc = tc.merge(other_tc) + tc._span_dimensions.assert_called_once_with(other_tc) + top_tr_.tc_at_grid_col.assert_called_once_with(left) + top_tc_._grow_to.assert_called_once_with(width, height) + assert merged_tc is top_tc_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def merge_fixture( + self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_): + tc, other_tc = element('w:tc'), element('w:tc') + top, left, height, width = 0, 1, 2, 3 + _span_dimensions_.return_value = top, left, height, width + _tbl_.return_value.tr_lst = [tr_] + tr_.tc_at_grid_col.return_value = top_tc_ + return tc, other_tc, tr_, top_tc_, left, height, width + + # fixture components --------------------------------------------- + + @pytest.fixture + def _grow_to_(self, request): + return method_mock(request, CT_Tc, '_grow_to') + + @pytest.fixture + def _span_dimensions_(self, request): + return method_mock(request, CT_Tc, '_span_dimensions') + + @pytest.fixture + def _tbl_(self, request): + return property_mock(request, CT_Tc, '_tbl') + + @pytest.fixture + def top_tc_(self, request): + return instance_mock(request, CT_Tc) + + @pytest.fixture + def tr_(self, request): + return instance_mock(request, CT_Row) From 64ef552f190eb5c6eb887ba3282e699747127c2a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 16:05:24 -0800 Subject: [PATCH 028/615] tbl: add CT_Tc.top, .left, .bottom, and .right --- docx/oxml/table.py | 117 ++++++++++++++++++++++++++++++++++++++- tests/oxml/test_table.py | 52 +++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 72258de16..ae96c160a 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -29,7 +29,14 @@ def tc_at_grid_col(self, idx): The ```` element appearing at grid column *idx*. Raises |ValueError| if no ``w:tc`` element begins at that grid column. """ - raise NotImplementedError + grid_col = 0 + for tc in self.tc_lst: + if grid_col == idx: + return tc + grid_col += tc.grid_span + if grid_col > idx: + raise ValueError('no cell on grid column %d' % idx) + raise ValueError('index out of bounds') @property def tr_idx(self): @@ -207,6 +214,20 @@ class CT_Tc(BaseOxmlElement): p = OneOrMore('w:p') tbl = OneOrMore('w:tbl') + @property + def bottom(self): + """ + The row index that marks the bottom extent of the vertical span of + this cell. This is one greater than the index of the bottom-most row + of the span, similar to how a slice of the cell's rows would be + specified. + """ + if self.vMerge is not None: + tc_below = self._tc_below + if tc_below is not None and tc_below.vMerge == ST_Merge.CONTINUE: + return tc_below.bottom + return self._tr_idx + 1 + def clear_content(self): """ Remove all content child elements, preserving the ```` @@ -232,6 +253,13 @@ def grid_span(self): return 1 return tcPr.grid_span + @property + def left(self): + """ + The grid column index at which this ```` element appears. + """ + return self._grid_col + def merge(self, other_tc): """ Return the top-left ```` element of a new span formed by @@ -255,6 +283,25 @@ def new(cls): '' % nsdecls('w') ) + @property + def right(self): + """ + The grid column index that marks the right-side extent of the + horizontal span of this cell. This is one greater than the index of + the right-most column of the span, similar to how a slice of the + cell's columns would be specified. + """ + return self._grid_col + self.grid_span + + @property + def top(self): + """ + The top-most row index in the vertical span of this cell. + """ + if self.vMerge is None or self.vMerge == ST_Merge.RESTART: + return self._tr_idx + return self._tc_above.top + @property def vMerge(self): """ @@ -282,6 +329,16 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + @property + def _grid_col(self): + """ + The grid column at which this cell begins. + """ + tr = self._tr + idx = tr.tc_lst.index(self) + preceding_tcs = tr.tc_lst[:idx] + return sum(tc.grid_span for tc in preceding_tcs) + def _grow_to(self, width, height, top_tc=None): """ Grow this cell to *width* grid columns and *height* rows by expanding @@ -315,7 +372,63 @@ def _tbl(self): """ The tbl element this tc element appears in. """ - raise NotImplementedError + return self.xpath('./ancestor::w:tbl')[0] + + @property + def _tc_above(self): + """ + The `w:tc` element immediately above this one in its grid column. + """ + return self._tr_above.tc_at_grid_col(self._grid_col) + + @property + def _tc_below(self): + """ + The tc element immediately below this one in its grid column. + """ + tr_below = self._tr_below + if tr_below is None: + return None + return tr_below.tc_at_grid_col(self._grid_col) + + @property + def _tr(self): + """ + The tr element this tc element appears in. + """ + return self.xpath('./ancestor::w:tr')[0] + + @property + def _tr_above(self): + """ + The tr element prior in sequence to the tr this cell appears in. + Raises |ValueError| if called on a cell in the top-most row. + """ + tr_lst = self._tbl.tr_lst + tr_idx = tr_lst.index(self._tr) + if tr_idx == 0: + raise ValueError('no tr above topmost tr') + return tr_lst[tr_idx-1] + + @property + def _tr_below(self): + """ + The tr element next in sequence after the tr this cell appears in, or + |None| if this cell appears in the last row. + """ + tr_lst = self._tbl.tr_lst + tr_idx = tr_lst.index(self._tr) + try: + return tr_lst[tr_idx+1] + except IndexError: + return None + + @property + def _tr_idx(self): + """ + The row index of the tr element this tc element appears in. + """ + return self._tbl.tr_lst.index(self._tr) class CT_TcPr(BaseOxmlElement): diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 135c2efeb..39bce8632 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -10,12 +10,31 @@ import pytest +from docx.oxml import parse_xml from docx.oxml.table import CT_Row, CT_Tc from ..unitutil.cxml import element +from ..unitutil.file import snippet_seq from ..unitutil.mock import instance_mock, method_mock, property_mock +class DescribeCT_Row(object): + + def it_raises_on_tc_at_grid_col(self, tc_raise_fixture): + tr, idx = tc_raise_fixture + with pytest.raises(ValueError): + tr.tc_at_grid_col(idx) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[(0, 0, 3), (1, 0, 1)]) + def tc_raise_fixture(self, request): + snippet_idx, row_idx, col_idx = request.param + tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tr = tbl.tr_lst[row_idx] + return tr, col_idx + + class DescribeCT_Tc(object): def it_can_merge_to_another_tc(self, merge_fixture): @@ -26,8 +45,34 @@ def it_can_merge_to_another_tc(self, merge_fixture): top_tc_._grow_to.assert_called_once_with(width, height) assert merged_tc is top_tc_ + def it_knows_its_extents_to_help(self, extents_fixture): + tc, attr_name, expected_value = extents_fixture + extent = getattr(tc, attr_name) + assert extent == expected_value + + def it_raises_on_tr_above(self, tr_above_raise_fixture): + tc = tr_above_raise_fixture + with pytest.raises(ValueError): + tc._tr_above + # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + (0, 0, 0, 'top', 0), (2, 0, 1, 'top', 0), + (2, 1, 1, 'top', 0), (4, 2, 1, 'top', 1), + (0, 0, 0, 'left', 0), (1, 0, 1, 'left', 2), + (3, 1, 0, 'left', 0), (3, 1, 1, 'left', 2), + (0, 0, 0, 'bottom', 1), (1, 0, 0, 'bottom', 1), + (2, 0, 1, 'bottom', 2), (4, 1, 1, 'bottom', 3), + (0, 0, 0, 'right', 1), (1, 0, 0, 'right', 2), + (0, 0, 0, 'right', 1), (4, 2, 1, 'right', 3), + ]) + def extents_fixture(self, request): + snippet_idx, row, col, attr_name, expected_value = request.param + tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tc = tbl.tr_lst[row].tc_lst[col] + return tc, attr_name, expected_value + @pytest.fixture def merge_fixture( self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_): @@ -38,6 +83,13 @@ def merge_fixture( tr_.tc_at_grid_col.return_value = top_tc_ return tc, other_tc, tr_, top_tc_, left, height, width + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) + def tr_above_raise_fixture(self, request): + snippet_idx, row_idx, col_idx = request.param + tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tc = tbl.tr_lst[row_idx].tc_lst[col_idx] + return tc + # fixture components --------------------------------------------- @pytest.fixture From 1f52c20830979a8e15f09fa43e595ac756a37cb0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 18:51:48 -0800 Subject: [PATCH 029/615] tbl: add CT_Tc._span_dimensions() --- docx/exceptions.py | 7 +++ docx/oxml/table.py | 26 ++++++++- tests/oxml/test_table.py | 56 ++++++++++++++++++- tests/test_files/snippets/tbl-cells.txt | 72 +++++++++++++++++++++++-- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/docx/exceptions.py b/docx/exceptions.py index 00215615b..7a8b99c81 100644 --- a/docx/exceptions.py +++ b/docx/exceptions.py @@ -13,6 +13,13 @@ class PythonDocxError(Exception): """ +class InvalidSpanError(PythonDocxError): + """ + Raised when an invalid merge region is specified in a request to merge + table cells. + """ + + class InvalidXmlError(PythonDocxError): """ Raised when invalid XML is encountered, such as on attempt to access a diff --git a/docx/oxml/table.py b/docx/oxml/table.py index ae96c160a..d92ed89d6 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from . import parse_xml +from ..exceptions import InvalidSpanError from .ns import nsdecls from ..shared import Emu, Twips from .simpletypes import ( @@ -365,7 +366,30 @@ def _span_dimensions(self, other_tc): the merged cell formed by using this tc and *other_tc* as opposite corner extents. """ - raise NotImplementedError + def raise_on_inverted_L(a, b): + if a.top == b.top and a.bottom != b.bottom: + raise InvalidSpanError('requested span not rectangular') + if a.left == b.left and a.right != b.right: + raise InvalidSpanError('requested span not rectangular') + + def raise_on_tee_shaped(a, b): + top_most, other = (a, b) if a.top < b.top else (b, a) + if top_most.top < other.top and top_most.bottom > other.bottom: + raise InvalidSpanError('requested span not rectangular') + + left_most, other = (a, b) if a.left < b.left else (b, a) + if left_most.left < other.left and left_most.right > other.right: + raise InvalidSpanError('requested span not rectangular') + + raise_on_inverted_L(self, other_tc) + raise_on_tee_shaped(self, other_tc) + + top = min(self.top, other_tc.top) + left = min(self.left, other_tc.left) + bottom = max(self.bottom, other_tc.bottom) + right = max(self.right, other_tc.right) + + return top, left, bottom - top, right - left @property def _tbl(self): diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 39bce8632..487a54e1c 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -10,6 +10,7 @@ import pytest +from docx.exceptions import InvalidSpanError from docx.oxml import parse_xml from docx.oxml.table import CT_Row, CT_Tc @@ -50,6 +51,16 @@ def it_knows_its_extents_to_help(self, extents_fixture): extent = getattr(tc, attr_name) assert extent == expected_value + def it_calculates_the_dimensions_of_a_span_to_help(self, span_fixture): + tc, other_tc, expected_dimensions = span_fixture + dimensions = tc._span_dimensions(other_tc) + assert dimensions == expected_dimensions + + def it_raises_on_invalid_span(self, span_raise_fixture): + tc, other_tc = span_raise_fixture + with pytest.raises(InvalidSpanError): + tc._span_dimensions(other_tc) + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -69,7 +80,7 @@ def it_raises_on_tr_above(self, tr_above_raise_fixture): ]) def extents_fixture(self, request): snippet_idx, row, col, attr_name, expected_value = request.param - tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] return tc, attr_name, expected_value @@ -83,6 +94,42 @@ def merge_fixture( tr_.tc_at_grid_col.return_value = top_tc_ return tc, other_tc, tr_, top_tc_, left, height, width + @pytest.fixture(params=[ + (0, 0, 0, 0, 1, (0, 0, 1, 2)), + (0, 0, 1, 2, 1, (0, 1, 3, 1)), + (0, 2, 2, 1, 1, (1, 1, 2, 2)), + (0, 1, 2, 1, 0, (1, 0, 1, 3)), + (1, 0, 0, 1, 1, (0, 0, 2, 2)), + (1, 0, 1, 0, 0, (0, 0, 1, 3)), + (2, 0, 1, 2, 1, (0, 1, 3, 1)), + (2, 0, 1, 1, 0, (0, 0, 2, 2)), + (2, 1, 2, 0, 1, (0, 1, 2, 2)), + (4, 0, 1, 0, 0, (0, 0, 1, 3)), + ]) + def span_fixture(self, request): + snippet_idx, row, col, row_2, col_2, expected_value = request.param + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] + return tc, tc_2, expected_value + + @pytest.fixture(params=[ + (1, 0, 0, 1, 0), # inverted-L horz + (1, 1, 0, 0, 0), # same in opposite order + (2, 0, 2, 0, 1), # inverted-L vert + (5, 0, 1, 1, 0), # tee-shape horz bar + (5, 1, 0, 2, 1), # same, opposite side + (6, 1, 0, 0, 1), # tee-shape vert bar + (6, 0, 1, 1, 2), # same, opposite side + ]) + def span_raise_fixture(self, request): + snippet_idx, row, col, row_2, col_2 = request.param + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] + print(tc.top, tc_2.top, tc.bottom, tc_2.bottom) + return tc, tc_2 + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) def tr_above_raise_fixture(self, request): snippet_idx, row_idx, col_idx = request.param @@ -100,6 +147,13 @@ def _grow_to_(self, request): def _span_dimensions_(self, request): return method_mock(request, CT_Tc, '_span_dimensions') + def _snippet_tbl(self, idx): + """ + Return a element for snippet at *idx* in 'tbl-cells' snippet + file. + """ + return parse_xml(snippet_seq('tbl-cells')[idx]) + @pytest.fixture def _tbl_(self, request): return property_mock(request, CT_Tc, '_tbl') diff --git a/tests/test_files/snippets/tbl-cells.txt b/tests/test_files/snippets/tbl-cells.txt index 5f1b8281b..9f2176a05 100644 --- a/tests/test_files/snippets/tbl-cells.txt +++ b/tests/test_files/snippets/tbl-cells.txt @@ -1,4 +1,4 @@ - + @@ -22,7 +22,7 @@ - + @@ -49,7 +49,7 @@ - + @@ -81,7 +81,7 @@ - + @@ -113,7 +113,7 @@ - + @@ -155,3 +155,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 954301dcadba39180df5832b6def288b1238eea5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 21:46:16 -0800 Subject: [PATCH 030/615] tbl: Add CT_Tc._grow_to() --- docx/oxml/table.py | 25 +++++++- tests/oxml/test_table.py | 33 +++++++++- tests/test_files/snippets/tbl-cells.txt | 84 ++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 9 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index d92ed89d6..74a7f8e10 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -346,7 +346,17 @@ def _grow_to(self, width, height, top_tc=None): horizontal spans and creating continuation cells to form vertical spans. """ - raise NotImplementedError + def vMerge_val(top_tc): + if top_tc is not self: + return ST_Merge.CONTINUE + if height == 1: + return None + return ST_Merge.RESTART + + top_tc = self if top_tc is None else top_tc + self._span_to_width(width, top_tc, vMerge_val(top_tc)) + if height > 1: + self._tc_below._grow_to(width, height-1, top_tc) def _insert_tcPr(self, tcPr): """ @@ -391,6 +401,19 @@ def raise_on_tee_shaped(a, b): return top, left, bottom - top, right - left + def _span_to_width(self, grid_width, top_tc, vMerge): + """ + Incorporate and then remove `w:tc` elements to the right of this one + until this cell spans *grid_width*. Raises |ValueError| if + *grid_width* cannot be exactly achieved, such as when a merged cell + would drive the span width greater than *grid_width* or if not enough + grid columns are available to make this cell that wide. All content + from incorporated cells is appended to *top_tc*. The val attribute of + the vMerge element on the single remaining cell is set to *vMerge*. + If *vMerge* is |None|, the vMerge element is removed if present. + """ + raise NotImplementedError + @property def _tbl(self): """ diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 487a54e1c..e6f95e22d 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -16,7 +16,7 @@ from ..unitutil.cxml import element from ..unitutil.file import snippet_seq -from ..unitutil.mock import instance_mock, method_mock, property_mock +from ..unitutil.mock import call, instance_mock, method_mock, property_mock class DescribeCT_Row(object): @@ -61,6 +61,11 @@ def it_raises_on_invalid_span(self, span_raise_fixture): with pytest.raises(InvalidSpanError): tc._span_dimensions(other_tc) + def it_can_grow_itself_to_help_merge(self, grow_to_fixture): + tc, width, height, top_tc, expected_calls = grow_to_fixture + tc._grow_to(width, height, top_tc) + assert tc._span_to_width.call_args_list == expected_calls + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -84,6 +89,28 @@ def extents_fixture(self, request): tc = tbl.tr_lst[row].tc_lst[col] return tc, attr_name, expected_value + @pytest.fixture(params=[ + (0, 0, 0, 2, 1), + (0, 0, 1, 1, 2), + (0, 1, 1, 2, 2), + (1, 0, 0, 2, 2), + (2, 0, 0, 2, 2), + (2, 1, 2, 1, 2), + ]) + def grow_to_fixture(self, request, _span_to_width_): + snippet_idx, row, col, width, height = request.param + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + start = 0 if height == 1 else 1 + end = start + height + expected_calls = [ + call(width, tc, None), + call(width, tc, 'restart'), + call(width, tc, 'continue'), + call(width, tc, 'continue'), + ][start:end] + return tc, width, height, None, expected_calls + @pytest.fixture def merge_fixture( self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_): @@ -147,6 +174,10 @@ def _grow_to_(self, request): def _span_dimensions_(self, request): return method_mock(request, CT_Tc, '_span_dimensions') + @pytest.fixture + def _span_to_width_(self, request): + return method_mock(request, CT_Tc, '_span_to_width') + def _snippet_tbl(self, idx): """ Return a element for snippet at *idx* in 'tbl-cells' snippet diff --git a/tests/test_files/snippets/tbl-cells.txt b/tests/test_files/snippets/tbl-cells.txt index 9f2176a05..f1d773100 100644 --- a/tests/test_files/snippets/tbl-cells.txt +++ b/tests/test_files/snippets/tbl-cells.txt @@ -1,4 +1,14 @@ - + @@ -22,7 +32,17 @@ - + @@ -49,7 +69,17 @@ - + @@ -81,7 +111,17 @@ - + @@ -113,7 +153,17 @@ - + @@ -156,7 +206,17 @@ - + @@ -182,7 +242,17 @@ - + From 25f1519ec0c52f2788b12d2f300353b0d410d419 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 22:42:39 -0800 Subject: [PATCH 031/615] tbl: add CT_Tc._span_to_width() --- docx/oxml/table.py | 33 +++++++++++++++++++++++++++++++++ tests/oxml/test_table.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 74a7f8e10..1051c3159 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -314,6 +314,11 @@ def vMerge(self): return None return tcPr.vMerge_val + @vMerge.setter + def vMerge(self, value): + tcPr = self.get_or_add_tcPr() + tcPr.vMerge_val = value + @property def width(self): """ @@ -367,6 +372,13 @@ def _insert_tcPr(self, tcPr): self.insert(0, tcPr) return tcPr + def _move_content_to(self, other_tc): + """ + Append the content of this cell to *other_tc*, leaving this cell with + a single empty ```` element. + """ + raise NotImplementedError + def _new_tbl(self): return CT_Tbl.new() @@ -412,6 +424,21 @@ def _span_to_width(self, grid_width, top_tc, vMerge): the vMerge element on the single remaining cell is set to *vMerge*. If *vMerge* is |None|, the vMerge element is removed if present. """ + self._move_content_to(top_tc) + while self.grid_span < grid_width: + self._swallow_next_tc(grid_width, top_tc) + self.vMerge = vMerge + + def _swallow_next_tc(self, grid_width, top_tc): + """ + Extend the horizontal span of this `w:tc` element to incorporate the + following `w:tc` element in the row and then delete that following + `w:tc` element. Any content in the following `w:tc` element is + appended to the content of *top_tc*. The width of the following + `w:tc` element is added to this one, if present. Raises + |InvalidSpanError| if the width of the resulting cell is greater than + *grid_width* or if there is no next `` element in the row. + """ raise NotImplementedError @property @@ -515,6 +542,12 @@ def vMerge_val(self): return None return vMerge.val + @vMerge_val.setter + def vMerge_val(self, value): + self._remove_vMerge() + if value is not None: + self._add_vMerge().val = value + @property def width(self): """ diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index e6f95e22d..dc265c23c 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -66,6 +66,13 @@ def it_can_grow_itself_to_help_merge(self, grow_to_fixture): tc._grow_to(width, height, top_tc) assert tc._span_to_width.call_args_list == expected_calls + def it_can_extend_its_horz_span_to_help_merge(self, span_width_fixture): + tc, grid_width, top_tc, vMerge, expected_calls = span_width_fixture + tc._span_to_width(grid_width, top_tc, vMerge) + tc._move_content_to.assert_called_once_with(top_tc) + assert tc._swallow_next_tc.call_args_list == expected_calls + assert tc.vMerge == vMerge + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -154,9 +161,20 @@ def span_raise_fixture(self, request): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] - print(tc.top, tc_2.top, tc.bottom, tc_2.bottom) return tc, tc_2 + @pytest.fixture + def span_width_fixture( + self, top_tc_, grid_span_, _move_content_to_, _swallow_next_tc_): + tc = element('w:tc') + grid_span_.side_effect = [1, 3, 4] + grid_width, vMerge = 4, 'continue' + expected_calls = [ + call(grid_width, top_tc_), + call(grid_width, top_tc_) + ] + return tc, grid_width, top_tc_, vMerge, expected_calls + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) def tr_above_raise_fixture(self, request): snippet_idx, row_idx, col_idx = request.param @@ -166,10 +184,18 @@ def tr_above_raise_fixture(self, request): # fixture components --------------------------------------------- + @pytest.fixture + def grid_span_(self, request): + return property_mock(request, CT_Tc, 'grid_span') + @pytest.fixture def _grow_to_(self, request): return method_mock(request, CT_Tc, '_grow_to') + @pytest.fixture + def _move_content_to_(self, request): + return method_mock(request, CT_Tc, '_move_content_to') + @pytest.fixture def _span_dimensions_(self, request): return method_mock(request, CT_Tc, '_span_dimensions') @@ -185,6 +211,10 @@ def _snippet_tbl(self, idx): """ return parse_xml(snippet_seq('tbl-cells')[idx]) + @pytest.fixture + def _swallow_next_tc_(self, request): + return method_mock(request, CT_Tc, '_swallow_next_tc') + @pytest.fixture def _tbl_(self, request): return property_mock(request, CT_Tc, '_tbl') From cd09fab79fb625be49c77ed5df20d3f94e1c92ee Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 25 Nov 2014 00:25:01 -0800 Subject: [PATCH 032/615] tbl: add CT_Tc._move_content_to() --- docx/oxml/table.py | 50 ++++++++++++++++++++++++++++++++++++++-- tests/oxml/test_table.py | 29 ++++++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 1051c3159..43079a5d7 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -8,7 +8,7 @@ from . import parse_xml from ..exceptions import InvalidSpanError -from .ns import nsdecls +from .ns import nsdecls, qn from ..shared import Emu, Twips from .simpletypes import ( ST_Merge, ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt @@ -254,6 +254,16 @@ def grid_span(self): return 1 return tcPr.grid_span + def iter_block_items(self): + """ + Generate a reference to each of the block-level content elements in + this cell, in the order they appear. + """ + block_item_tags = (qn('w:p'), qn('w:tbl'), qn('w:sdt')) + for child in self: + if child.tag in block_item_tags: + yield child + @property def left(self): """ @@ -372,16 +382,52 @@ def _insert_tcPr(self, tcPr): self.insert(0, tcPr) return tcPr + @property + def _is_empty(self): + """ + True if this cell contains only a single empty ```` element. + """ + block_items = list(self.iter_block_items()) + if len(block_items) > 1: + return False + p = block_items[0] # cell must include at least one element + if len(p.r_lst) == 0: + return True + return False + def _move_content_to(self, other_tc): """ Append the content of this cell to *other_tc*, leaving this cell with a single empty ```` element. """ - raise NotImplementedError + if other_tc is self: + return + if self._is_empty: + return + other_tc._remove_trailing_empty_p() + # appending moves each element from self to other_tc + for block_element in self.iter_block_items(): + other_tc.append(block_element) + # add back the required minimum single empty element + self.append(self._new_p()) def _new_tbl(self): return CT_Tbl.new() + def _remove_trailing_empty_p(self): + """ + Remove the last content element from this cell if it is an empty + ```` element. + """ + block_items = list(self.iter_block_items()) + last_content_elm = block_items[-1] + if last_content_elm.tag != qn('w:p'): + return + p = last_content_elm + if len(p.r_lst) > 0: + return + self.remove(p) + def _span_dimensions(self, other_tc): """ Return a (top, left, height, width) 4-tuple specifying the extents of diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index dc265c23c..13635beea 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -14,7 +14,7 @@ from docx.oxml import parse_xml from docx.oxml.table import CT_Row, CT_Tc -from ..unitutil.cxml import element +from ..unitutil.cxml import element, xml from ..unitutil.file import snippet_seq from ..unitutil.mock import call, instance_mock, method_mock, property_mock @@ -73,6 +73,12 @@ def it_can_extend_its_horz_span_to_help_merge(self, span_width_fixture): assert tc._swallow_next_tc.call_args_list == expected_calls assert tc.vMerge == vMerge + def it_can_move_its_content_to_help_merge(self, move_fixture): + tc, tc_2, expected_tc_xml, expected_tc_2_xml = move_fixture + tc._move_content_to(tc_2) + assert tc.xml == expected_tc_xml + assert tc_2.xml == expected_tc_2_xml + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -128,6 +134,27 @@ def merge_fixture( tr_.tc_at_grid_col.return_value = top_tc_ return tc, other_tc, tr_, top_tc_, left, height, width + @pytest.fixture(params=[ + ('w:tc/w:p', 'w:tc/w:p', + 'w:tc/w:p', 'w:tc/w:p'), + ('w:tc/w:p', 'w:tc/w:p/w:r', + 'w:tc/w:p', 'w:tc/w:p/w:r'), + ('w:tc/w:p/w:r', 'w:tc/w:p', + 'w:tc/w:p', 'w:tc/w:p/w:r'), + ('w:tc/(w:p/w:r,w:sdt)', 'w:tc/w:p', + 'w:tc/w:p', 'w:tc/(w:p/w:r,w:sdt)'), + ('w:tc/(w:p/w:r,w:sdt)', 'w:tc/(w:tbl,w:p)', + 'w:tc/w:p', 'w:tc/(w:tbl,w:p/w:r,w:sdt)'), + ]) + def move_fixture(self, request): + tc_cxml, tc_2_cxml, expected_tc_cxml, expected_tc_2_cxml = ( + request.param + ) + tc, tc_2 = element(tc_cxml), element(tc_2_cxml) + expected_tc_xml = xml(expected_tc_cxml) + expected_tc_2_xml = xml(expected_tc_2_cxml) + return tc, tc_2, expected_tc_xml, expected_tc_2_xml + @pytest.fixture(params=[ (0, 0, 0, 0, 1, (0, 0, 1, 2)), (0, 0, 1, 2, 1, (0, 1, 3, 1)), From b0061eb0bc219391e23c2007d331f310b5c911b3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 25 Nov 2014 20:39:34 -0800 Subject: [PATCH 033/615] tbl: add CT_Tc._swallow_next_tc() --- docx/oxml/table.py | 47 ++++++++++++++++++++- features/tbl-merge-cells.feature | 4 -- tests/oxml/test_table.py | 71 ++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 43079a5d7..9fde25de8 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -254,6 +254,11 @@ def grid_span(self): return 1 return tcPr.grid_span + @grid_span.setter + def grid_span(self, value): + tcPr = self.get_or_add_tcPr() + tcPr.grid_span = value + def iter_block_items(self): """ Generate a reference to each of the block-level content elements in @@ -345,6 +350,14 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + def _add_width_of(self, other_tc): + """ + Add the width of *other_tc* to this cell. Does nothing if either this + tc or *other_tc* does not have a specified width. + """ + if self.width and other_tc.width: + self.width += other_tc.width + @property def _grid_col(self): """ @@ -414,6 +427,21 @@ def _move_content_to(self, other_tc): def _new_tbl(self): return CT_Tbl.new() + @property + def _next_tc(self): + """ + The `w:tc` element immediately following this one in this row, or + |None| if this is the last `w:tc` element in the row. + """ + following_tcs = self.xpath('./following-sibling::w:tc') + return following_tcs[0] if following_tcs else None + + def _remove(self): + """ + Remove this `w:tc` element from the XML tree. + """ + self.getparent().remove(self) + def _remove_trailing_empty_p(self): """ Remove the last content element from this cell if it is an empty @@ -485,7 +513,18 @@ def _swallow_next_tc(self, grid_width, top_tc): |InvalidSpanError| if the width of the resulting cell is greater than *grid_width* or if there is no next `` element in the row. """ - raise NotImplementedError + def raise_on_invalid_swallow(next_tc): + if next_tc is None: + raise InvalidSpanError('not enough grid columns') + if self.grid_span + next_tc.grid_span > grid_width: + raise InvalidSpanError('span is not rectangular') + + next_tc = self._next_tc + raise_on_invalid_swallow(next_tc) + next_tc._move_content_to(top_tc) + self._add_width_of(next_tc) + self.grid_span += next_tc.grid_span + next_tc._remove() @property def _tbl(self): @@ -577,6 +616,12 @@ def grid_span(self): return 1 return gridSpan.val + @grid_span.setter + def grid_span(self, value): + self._remove_gridSpan() + if value > 1: + self.get_or_add_gridSpan().val = value + @property def vMerge_val(self): """ diff --git a/features/tbl-merge-cells.feature b/features/tbl-merge-cells.feature index 3249f81e7..8c8b69eef 100644 --- a/features/tbl-merge-cells.feature +++ b/features/tbl-merge-cells.feature @@ -3,7 +3,6 @@ Feature: Merge table cells As a developer using python-docx I need a way to merge a range of cells - @wip Scenario Outline: Merge cells Given a 3x3 table having only uniform cells When I merge from cell to cell @@ -16,7 +15,6 @@ Feature: Merge table cells | 5 | 9 | 1 2 3 4 5\6\8\9 5\6\8\9 7 5\6\8\9 5\6\8\9 | - @wip Scenario Outline: Merge horizontal span with other cell Given a 3x3 table having a horizontal span When I merge from cell to cell @@ -29,7 +27,6 @@ Feature: Merge table cells | 2 | 4 | 1\2\4 1\2\4 3 1\2\4 1\2\4 6 7 8 9 | - @wip Scenario Outline: Merge vertical span with other cell Given a 3x3 table having a vertical span When I merge from cell to cell @@ -42,7 +39,6 @@ Feature: Merge table cells | 7 | 5 | 1 2 3 4\5\7 4\5\7 6 4\5\7 4\5\7 9 | - @wip Scenario Outline: Horizontal span adds cell widths Given a 3x3 table having When I merge from cell to cell diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 13635beea..5a7f68634 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -73,6 +73,21 @@ def it_can_extend_its_horz_span_to_help_merge(self, span_width_fixture): assert tc._swallow_next_tc.call_args_list == expected_calls assert tc.vMerge == vMerge + def it_can_swallow_the_next_tc_help_merge(self, swallow_fixture): + tc, grid_width, top_tc, tr, expected_xml = swallow_fixture + tc._swallow_next_tc(grid_width, top_tc) + assert tr.xml == expected_xml + + def it_adds_cell_widths_on_swallow(self, add_width_fixture): + tc, grid_width, top_tc, tr, expected_xml = add_width_fixture + tc._swallow_next_tc(grid_width, top_tc) + assert tr.xml == expected_xml + + def it_raises_on_invalid_swallow(self, swallow_raise_fixture): + tc, grid_width, top_tc, tr = swallow_raise_fixture + with pytest.raises(InvalidSpanError): + tc._swallow_next_tc(grid_width, top_tc) + def it_can_move_its_content_to_help_merge(self, move_fixture): tc, tc_2, expected_tc_xml, expected_tc_2_xml = move_fixture tc._move_content_to(tc_2) @@ -86,6 +101,32 @@ def it_raises_on_tr_above(self, tr_above_raise_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + # both cells have a width + ('w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),' + 'w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))', 0, 2, + 'w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa},' + 'w:gridSpan{w:val=2}),w:p))'), + # neither have a width + ('w:tr/(w:tc/w:p,w:tc/w:p)', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + # only second one has a width + ('w:tr/(w:tc/w:p,' + 'w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + # only first one has a width + ('w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),' + 'w:tc/w:p)', 0, 2, + 'w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa},' + 'w:gridSpan{w:val=2}),w:p))'), + ]) + def add_width_fixture(self, request): + tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param + tr = element(tr_cxml) + tc = top_tc = tr[tc_idx] + expected_tr_xml = xml(expected_tr_cxml) + return tc, grid_width, top_tc, tr, expected_tr_xml + @pytest.fixture(params=[ (0, 0, 0, 'top', 0), (2, 0, 1, 'top', 0), (2, 1, 1, 'top', 0), (4, 2, 1, 'top', 1), @@ -202,6 +243,36 @@ def span_width_fixture( ] return tc, grid_width, top_tc_, vMerge, expected_calls + @pytest.fixture(params=[ + ('w:tr/(w:tc/w:p,w:tc/w:p)', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + ('w:tr/(w:tc/w:p,w:tc/w:p,w:tc/w:p)', 1, 2, + 'w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + ('w:tr/(w:tc/w:p/w:r/w:t"a",w:tc/w:p/w:r/w:t"b")', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' + 'w:p/w:r/w:t"b"))'), + ('w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p),w:tc/w:p)', 0, 3, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=3},w:p))'), + ('w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))', 0, 3, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=3},w:p))'), + ]) + def swallow_fixture(self, request): + tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param + tr = element(tr_cxml) + tc = top_tc = tr[tc_idx] + expected_tr_xml = xml(expected_tr_cxml) + return tc, grid_width, top_tc, tr, expected_tr_xml + + @pytest.fixture(params=[ + ('w:tr/w:tc/w:p', 0, 2), + ('w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))', 0, 2), + ]) + def swallow_raise_fixture(self, request): + tr_cxml, tc_idx, grid_width = request.param + tr = element(tr_cxml) + tc = top_tc = tr[tc_idx] + return tc, grid_width, top_tc, tr + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) def tr_above_raise_fixture(self, request): snippet_idx, row_idx, col_idx = request.param From dbba25003ae7d4ab2dfde7baa8de5700bcb33ac1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 26 Nov 2014 03:48:52 -0500 Subject: [PATCH 034/615] docs: update documentation for merge() --- docs/api/table.rst | 1 + docs/conf.py | 2 ++ docx/table.py | 5 ++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/api/table.rst b/docs/api/table.rst index e3c9da952..215bf807c 100644 --- a/docs/api/table.rst +++ b/docs/api/table.rst @@ -15,6 +15,7 @@ Table objects are constructed using the ``add_table()`` method on |Document|. .. autoclass:: Table :members: + :exclude-members: table |_Cell| objects diff --git a/docs/conf.py b/docs/conf.py index 5fb91ca12..8dac74384 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,8 @@ .. |InlineShapes| replace:: :class:`.InlineShapes` +.. |InvalidSpanError| replace:: :class:`.InvalidSpanError` + .. |int| replace:: :class:`int` .. |Length| replace:: :class:`.Length` diff --git a/docx/table.py b/docx/table.py index 533f2b47b..53139d328 100644 --- a/docx/table.py +++ b/docx/table.py @@ -182,9 +182,8 @@ def add_table(self, rows, cols): def merge(self, other_cell): """ Return a merged cell created by spanning the rectangular region - demarcated by using the extents of this cell and *other_cell* as - diagonal corners. Raises |InvalidSpanError| if the cells do not - define a rectangular region. + having this cell and *other_cell* as diagonal corners. Raises + |InvalidSpanError| if the cells do not define a rectangular region. """ tc, tc_2 = self._tc, other_cell._tc merged_tc = tc.merge(tc_2) From e9345cc0d32ea81be9b715b757777fcdca762137 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 28 Nov 2014 01:45:57 -0500 Subject: [PATCH 035/615] tbl: remove dead tbl param from _Column.__init__ --- docx/table.py | 9 ++++----- tests/test_table.py | 10 +++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docx/table.py b/docx/table.py index 53139d328..5123457b1 100644 --- a/docx/table.py +++ b/docx/table.py @@ -27,7 +27,7 @@ def add_column(self): gridCol = tblGrid.add_gridCol() for tr in self._tbl.tr_lst: tr.add_tc() - return _Column(gridCol, self._tbl, self) + return _Column(gridCol, self) def add_row(self): """ @@ -242,10 +242,9 @@ class _Column(Parented): """ Table column """ - def __init__(self, gridCol, tbl, parent): + def __init__(self, gridCol, parent): super(_Column, self).__init__(parent) self._gridCol = gridCol - self._tbl = tbl @property def cells(self): @@ -299,11 +298,11 @@ def __getitem__(self, idx): except IndexError: msg = "column index [%d] is out of range" % idx raise IndexError(msg) - return _Column(gridCol, self._tbl, self) + return _Column(gridCol, self) def __iter__(self): for gridCol in self._gridCol_lst: - yield _Column(gridCol, self._tbl, self) + yield _Column(gridCol, self) def __len__(self): return len(self._gridCol_lst) diff --git a/tests/test_table.py b/tests/test_table.py index c4e626402..0aeb46529 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -447,7 +447,7 @@ def it_knows_its_index_in_table_to_help(self, index_fixture): @pytest.fixture def cells_fixture(self, _index_, table_prop_, table_): - column = _Column(None, None, None) + column = _Column(None, None) _index_.return_value = column_idx = 4 expected_cells = (3, 2, 1) table_.column_cells.return_value = list(expected_cells) @@ -457,12 +457,12 @@ def cells_fixture(self, _index_, table_prop_, table_): def index_fixture(self): tbl = element('w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)') gridCol, expected_idx = tbl.tblGrid[1], 1 - column = _Column(gridCol, tbl, None) + column = _Column(gridCol, None) return column, expected_idx @pytest.fixture def table_fixture(self, parent_, table_): - column = _Column(None, None, parent_) + column = _Column(None, parent_) parent_.table = table_ return column, table_ @@ -476,7 +476,7 @@ def table_fixture(self, parent_, table_): ]) def width_get_fixture(self, request): gridCol_cxml, expected_width = request.param - column = _Column(element(gridCol_cxml), None, None) + column = _Column(element(gridCol_cxml), None) return column, expected_width @pytest.fixture(params=[ @@ -487,7 +487,7 @@ def width_get_fixture(self, request): ]) def width_set_fixture(self, request): gridCol_cxml, new_value, expected_cxml = request.param - column = _Column(element(gridCol_cxml), None, None) + column = _Column(element(gridCol_cxml), None) expected_xml = xml(expected_cxml) return column, new_value, expected_xml From 611e87757aff60eeba987f63f7bc364f2b90f190 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 29 Nov 2014 00:10:48 -0500 Subject: [PATCH 036/615] release: prepare v0.7.5 release --- HISTORY.rst | 6 ++++++ docx/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 925cd95be..0e0537148 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ Release History --------------- +0.7.5 (2014-11-29) +++++++++++++++++++ + +- Add feature #65: _Cell.merge() + + 0.7.4 (2014-07-18) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 4e4fdfda0..a738c0781 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.4' +__version__ = '0.7.5' # register custom Part classes with opc package reader From 537f7304116be7d5d5d5bde79aa7c004c578655f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 17:39:50 -0500 Subject: [PATCH 037/615] docs: document Table.alignment analysis --- docs/dev/analysis/features/table-props.rst | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/dev/analysis/features/table-props.rst b/docs/dev/analysis/features/table-props.rst index b2f8fbeba..8485c7bc8 100644 --- a/docs/dev/analysis/features/table-props.rst +++ b/docs/dev/analysis/features/table-props.rst @@ -3,6 +3,23 @@ Table Properties ================ +Alignment +--------- + +Word allows a table to be aligned between the page margins either left, +right, or center. + +The read/write :attr:`Table.alignment` property specifies the alignment for +a table:: + + >>> table = document.add_table(rows=2, cols=2) + >>> table.alignment + None + >>> table.alignment = WD_TABLE_ALIGNMENT.RIGHT + >>> table.alignment + RIGHT (2) + + Autofit ------- @@ -28,12 +45,13 @@ Specimen XML .. highlight:: xml -The following XML is generated by Word when inserting a 2x2 table:: +The following XML represents a 2x2 table:: + @@ -151,6 +169,22 @@ Schema Definitions + + + + + + + + + + + + + + + + From e22465d048a05a587c2c7c0f7d33600a2bb25e75 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 18:23:22 -0500 Subject: [PATCH 038/615] acpt: add scenarios for Table.alignment --- docs/api/enum/WdRowAlignment.rst | 24 ++++++++++++++ docs/api/enum/index.rst | 1 + docx/enum/table.py | 38 +++++++++++++++++++++++ features/steps/table.py | 38 +++++++++++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 13981 -> 20094 bytes features/tbl-props.feature | 27 ++++++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 docs/api/enum/WdRowAlignment.rst create mode 100644 docx/enum/table.py diff --git a/docs/api/enum/WdRowAlignment.rst b/docs/api/enum/WdRowAlignment.rst new file mode 100644 index 000000000..4459df5d3 --- /dev/null +++ b/docs/api/enum/WdRowAlignment.rst @@ -0,0 +1,24 @@ +.. _WdRowAlignment: + +``WD_TABLE_ALIGNMENT`` +====================== + +Specifies table justification type. + +Example:: + + from docx.enum.table import WD_TABLE_ALIGNMENT + + table = document.add_table(3, 3) + table.alignment = WD_TABLE_ALIGNMENT.CENTER + +---- + +LEFT + Left-aligned + +CENTER + Center-aligned. + +RIGHT + Right-aligned. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst index 576f45856..826994660 100644 --- a/docs/api/enum/index.rst +++ b/docs/api/enum/index.rst @@ -11,4 +11,5 @@ can be found here: WdAlignParagraph WdOrientation WdSectionStart + WdRowAlignment WdUnderline diff --git a/docx/enum/table.py b/docx/enum/table.py new file mode 100644 index 000000000..624d41a6f --- /dev/null +++ b/docx/enum/table.py @@ -0,0 +1,38 @@ +# encoding: utf-8 + +""" +Enumerations related to tables in WordprocessingML files +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .base import XmlEnumeration, XmlMappedEnumMember + + +class WD_TABLE_ALIGNMENT(XmlEnumeration): + """ + Specifies table justification type. + + Example:: + + from docx.enum.table import WD_TABLE_ALIGNMENT + + table = document.add_table(3, 3) + table.alignment = WD_TABLE_ALIGNMENT.CENTER + """ + + __ms_name__ = 'WdRowAlignment' + + __url__ = ' http://office.microsoft.com/en-us/word-help/HV080607259.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'LEFT', 0, 'left', 'Left-aligned' + ), + XmlMappedEnumMember( + 'CENTER', 1, 'center', 'Center-aligned.' + ), + XmlMappedEnumMember( + 'RIGHT', 2, 'right', 'Right-aligned.' + ), + ) diff --git a/features/steps/table.py b/features/steps/table.py index 243f22d7b..938f38875 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -11,6 +11,7 @@ from behave import given, then, when from docx import Document +from docx.enum.table import WD_TABLE_ALIGNMENT from docx.shared import Inches from docx.table import _Column, _Columns, _Row, _Rows @@ -75,6 +76,19 @@ def given_a_table_having_a_width_of_width_desc(context, width_desc): context.column = document.tables[0].columns[col_idx] +@given('a table having {alignment} alignment') +def given_a_table_having_alignment_alignment(context, alignment): + table_idx = { + 'inherited': 3, + 'left': 4, + 'right': 5, + 'center': 6, + }[alignment] + docx_path = test_docx('tbl-props') + document = Document(docx_path) + context.table_ = document.tables[table_idx] + + @given('a table having an applied style') def given_a_table_having_an_applied_style(context): docx_path = test_docx('tbl-having-applied-style') @@ -129,6 +143,18 @@ def when_apply_style_to_table(context): table.style = 'LightShading-Accent1' +@when('I assign {value_str} to table.alignment') +def when_I_assign_value_to_table_alignment(context, value_str): + value = { + 'None': None, + 'WD_TABLE_ALIGNMENT.LEFT': WD_TABLE_ALIGNMENT.LEFT, + 'WD_TABLE_ALIGNMENT.RIGHT': WD_TABLE_ALIGNMENT.RIGHT, + 'WD_TABLE_ALIGNMENT.CENTER': WD_TABLE_ALIGNMENT.CENTER, + }[value_str] + table = context.table_ + table.alignment = value + + @when('I merge from cell {origin} to cell {other}') def when_I_merge_from_cell_origin_to_cell_other(context, origin, other): def cell(table, idx): @@ -218,6 +244,18 @@ def then_can_iterate_over_row_collection(context): assert actual_count == 2 +@then('table.alignment is {value_str}') +def then_table_alignment_is_value(context, value_str): + value = { + 'None': None, + 'WD_TABLE_ALIGNMENT.LEFT': WD_TABLE_ALIGNMENT.LEFT, + 'WD_TABLE_ALIGNMENT.RIGHT': WD_TABLE_ALIGNMENT.RIGHT, + 'WD_TABLE_ALIGNMENT.CENTER': WD_TABLE_ALIGNMENT.CENTER, + }[value_str] + table = context.table_ + assert table.alignment == value, 'got %s' % table.alignment + + @then('table.cell({row}, {col}).text is {expected_text}') def then_table_cell_row_col_text_is_text(context, row, col, expected_text): table = context.table_ diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx index fab223b3d035598b00d42b8a313bff7bbf7cb6b2..3ca129cea7fddc267ddd7240340874488e95ec66 100644 GIT binary patch delta 12452 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 alignment + Then table.alignment is + + Examples: table alignment settings + | alignment | value | + | inherited | None | + | left | WD_TABLE_ALIGNMENT.LEFT | + | right | WD_TABLE_ALIGNMENT.RIGHT | + | center | WD_TABLE_ALIGNMENT.CENTER | + + + @wip + Scenario Outline: Set table alignment + Given a table having alignment + When I assign to table.alignment + Then table.alignment is + + Examples: results of assignment to table.alignment + | alignment | value | + | inherited | WD_TABLE_ALIGNMENT.LEFT | + | left | WD_TABLE_ALIGNMENT.RIGHT | + | right | WD_TABLE_ALIGNMENT.CENTER | + | center | None | + + Scenario Outline: Get autofit layout setting Given a table having an autofit layout of Then the reported autofit setting is From a18b01326d39e9b8ada09eb543a1c79d9ff95c2e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 18:46:47 -0500 Subject: [PATCH 039/615] tbl: reorder tests --- tests/test_table.py | 54 ++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/test_table.py b/tests/test_table.py index 0aeb46529..35bae77ad 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -23,6 +23,33 @@ class DescribeTable(object): + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): + table, expected_value = autofit_get_fixture + assert table.autofit is expected_value + + def it_can_change_its_autofit_setting(self, autofit_set_fixture): + table, new_value, expected_xml = autofit_set_fixture + table.autofit = new_value + assert table._tbl.xml == expected_xml + + def it_knows_its_table_style(self, table_style_get_fixture): + table, style = table_style_get_fixture + assert table.style == style + + def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): + table, style_name, expected_xml = table_style_set_fixture + table.style = style_name + assert table._tbl.xml == expected_xml + + def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): + table = table_fixture + assert table.table is table + + def it_knows_its_column_count_to_help(self, column_count_fixture): + table, expected_value = column_count_fixture + column_count = table._column_count + assert column_count == expected_value + def it_provides_access_to_the_table_rows(self, table): rows = table.rows assert isinstance(rows, _Rows) @@ -64,33 +91,6 @@ def it_can_add_a_column(self, add_column_fixture): assert isinstance(column, _Column) assert column._gridCol is table._tbl.tblGrid.gridCol_lst[1] - def it_knows_its_table_style(self, table_style_get_fixture): - table, style = table_style_get_fixture - assert table.style == style - - def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): - table, style_name, expected_xml = table_style_set_fixture - table.style = style_name - assert table._tbl.xml == expected_xml - - def it_knows_whether_it_should_autofit(self, autofit_get_fixture): - table, expected_value = autofit_get_fixture - assert table.autofit is expected_value - - def it_can_change_its_autofit_setting(self, autofit_set_fixture): - table, new_value, expected_xml = autofit_set_fixture - table.autofit = new_value - assert table._tbl.xml == expected_xml - - def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): - table = table_fixture - assert table.table is table - - def it_knows_its_column_count_to_help(self, column_count_fixture): - table, expected_value = column_count_fixture - column_count = table._column_count - assert column_count == expected_value - def it_provides_access_to_its_cells_to_help(self, cells_fixture): table, cell_count, unique_count, matches = cells_fixture cells = table._cells From 572526bf55948c0bab51f6eff33b5dad0e190e9e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 19:08:54 -0500 Subject: [PATCH 040/615] tbl: add Table.alignment getter --- docx/oxml/table.py | 29 +++++++++++++++++++++-------- docx/table.py | 10 ++++++++++ features/tbl-props.feature | 1 - tests/test_table.py | 16 ++++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 9fde25de8..390a3316b 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -134,16 +134,29 @@ class CT_TblPr(BaseOxmlElement): ```` element, child of ````, holds child elements that define table properties such as style and borders. """ - tblStyle = ZeroOrOne('w:tblStyle', successors=( - 'w:tblpPr', 'w:tblOverlap', 'w:bidiVisual', 'w:tblStyleRowBandSize', - 'w:tblStyleColBandSize', 'w:tblW', 'w:jc', 'w:tblCellSpacing', - 'w:tblInd', 'w:tblBorders', 'w:shd', 'w:tblLayout', 'w:tblCellMar', - 'w:tblLook', 'w:tblCaption', 'w:tblDescription', 'w:tblPrChange' - )) - tblLayout = ZeroOrOne('w:tblLayout', successors=( + _tag_seq = ( + 'w:tblStyle', 'w:tblpPr', 'w:tblOverlap', 'w:bidiVisual', + 'w:tblStyleRowBandSize', 'w:tblStyleColBandSize', 'w:tblW', 'w:jc', + 'w:tblCellSpacing', 'w:tblInd', 'w:tblBorders', 'w:shd', 'w:tblLayout', 'w:tblCellMar', 'w:tblLook', 'w:tblCaption', 'w:tblDescription', 'w:tblPrChange' - )) + ) + tblStyle = ZeroOrOne('w:tblStyle', successors=_tag_seq[1:]) + jc = ZeroOrOne('w:jc', successors=_tag_seq[8:]) + tblLayout = ZeroOrOne('w:tblLayout', successors=_tag_seq[13:]) + del _tag_seq + + @property + def alignment(self): + """ + Member of :ref:`WdRowAlignment` enumeration or |None|, based on the + contents of the `w:val` attribute of `./w:jc`. |None| if no `w:jc` + element is present. + """ + jc = self.jc + if jc is None: + return None + return jc.val @property def autofit(self): diff --git a/docx/table.py b/docx/table.py index 5123457b1..4d02f0860 100644 --- a/docx/table.py +++ b/docx/table.py @@ -39,6 +39,16 @@ def add_row(self): tr.add_tc() return _Row(tr, self) + @property + def alignment(self): + """ + Read/write. A member of :ref:`WdRowAlignment` or None, specifying the + positioning of this table between the page margins. |None| if no + setting is specified, causing the effective value to be inherited + from the style hierarchy. + """ + return self._tblPr.alignment + @property def autofit(self): """ diff --git a/features/tbl-props.feature b/features/tbl-props.feature index 4f9374a7e..6ffd72fbe 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -4,7 +4,6 @@ Feature: Get and set table properties I need a way to get and set a table's properties - @wip Scenario Outline: Determine table alignment Given a table having alignment Then table.alignment is diff --git a/tests/test_table.py b/tests/test_table.py index 35bae77ad..d1c05b9e2 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -8,6 +8,7 @@ import pytest +from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml import parse_xml from docx.oxml.table import CT_Tc from docx.shared import Inches @@ -23,6 +24,10 @@ class DescribeTable(object): + def it_knows_its_alignment_setting(self, alignment_get_fixture): + table, expected_value = alignment_get_fixture + assert table.alignment == expected_value + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): table, expected_value = autofit_get_fixture assert table.autofit is expected_value @@ -117,6 +122,17 @@ def add_row_fixture(self): expected_xml = _tbl_bldr(rows=2, cols=2).xml() return table, expected_xml + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', None), + ('w:tbl/w:tblPr/w:jc{w:val=center}', WD_TABLE_ALIGNMENT.CENTER), + ('w:tbl/w:tblPr/w:jc{w:val=right}', WD_TABLE_ALIGNMENT.RIGHT), + ('w:tbl/w:tblPr/w:jc{w:val=left}', WD_TABLE_ALIGNMENT.LEFT), + ]) + def alignment_get_fixture(self, request): + tbl_cxml, expected_value = request.param + table = Table(element(tbl_cxml), None) + return table, expected_value + @pytest.fixture(params=[ ('w:tbl/w:tblPr', True), ('w:tbl/w:tblPr/w:tblLayout', True), From f9e033e2dc41fe294899a1f682fca8e0c8d39d96 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 19:17:36 -0500 Subject: [PATCH 041/615] tbl: add Table.alignment.setter --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 4 ++++ features/tbl-props.feature | 1 - tests/test_table.py | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 390a3316b..c932b2f26 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -158,6 +158,14 @@ def alignment(self): return None return jc.val + @alignment.setter + def alignment(self, value): + self._remove_jc() + if value is None: + return + jc = self.get_or_add_jc() + jc.val = value + @property def autofit(self): """ diff --git a/docx/table.py b/docx/table.py index 4d02f0860..424bee541 100644 --- a/docx/table.py +++ b/docx/table.py @@ -49,6 +49,10 @@ def alignment(self): """ return self._tblPr.alignment + @alignment.setter + def alignment(self, value): + self._tblPr.alignment = value + @property def autofit(self): """ diff --git a/features/tbl-props.feature b/features/tbl-props.feature index 6ffd72fbe..6d4fb4da4 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -16,7 +16,6 @@ Feature: Get and set table properties | center | WD_TABLE_ALIGNMENT.CENTER | - @wip Scenario Outline: Set table alignment Given a table having alignment When I assign to table.alignment diff --git a/tests/test_table.py b/tests/test_table.py index d1c05b9e2..107574688 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -28,6 +28,11 @@ def it_knows_its_alignment_setting(self, alignment_get_fixture): table, expected_value = alignment_get_fixture assert table.alignment == expected_value + def it_can_change_its_alignment_setting(self, alignment_set_fixture): + table, new_value, expected_xml = alignment_set_fixture + table.alignment = new_value + assert table._tbl.xml == expected_xml + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): table, expected_value = autofit_get_fixture assert table.autofit is expected_value @@ -133,6 +138,20 @@ def alignment_get_fixture(self, request): table = Table(element(tbl_cxml), None) return table, expected_value + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', WD_TABLE_ALIGNMENT.LEFT, + 'w:tbl/w:tblPr/w:jc{w:val=left}'), + ('w:tbl/w:tblPr/w:jc{w:val=left}', WD_TABLE_ALIGNMENT.RIGHT, + 'w:tbl/w:tblPr/w:jc{w:val=right}'), + ('w:tbl/w:tblPr/w:jc{w:val=right}', None, + 'w:tbl/w:tblPr'), + ]) + def alignment_set_fixture(self, request): + tbl_cxml, new_value, expected_tbl_cxml = request.param + table = Table(element(tbl_cxml), None) + expected_xml = xml(expected_tbl_cxml) + return table, new_value, expected_xml + @pytest.fixture(params=[ ('w:tbl/w:tblPr', True), ('w:tbl/w:tblPr/w:tblLayout', True), From 215ecebacc4086e9215483ca7aa0115225d73d16 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 12:28:15 -0800 Subject: [PATCH 042/615] tmpl: change core props author to python-docx --- docx/templates/default.docx | Bin 58399 -> 38024 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docx/templates/default.docx b/docx/templates/default.docx index 62c580eb5dc01fc7372a5f51b681b18a29883c6e..85201dd1d12caa32be32f1f10f50f75417c8e42d 100644 GIT binary patch literal 38024 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∓}W$t8_CN|y7 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 M5`n2mZ~}-203NHJE&u=k literal 58399 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

sI0%$yZVQxW5kY+gYKw=4A)#>!?*%s=>rX^? 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

|nqvFv(O|e!^&qg?1Quy*n)XR=Y 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 Date: Sat, 13 Dec 2014 13:47:45 -0800 Subject: [PATCH 043/615] docs: document CoreProperties analysis --- docs/dev/analysis/features/coreprops.rst | 199 +++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 200 insertions(+) create mode 100644 docs/dev/analysis/features/coreprops.rst diff --git a/docs/dev/analysis/features/coreprops.rst b/docs/dev/analysis/features/coreprops.rst new file mode 100644 index 000000000..a5b2c47d5 --- /dev/null +++ b/docs/dev/analysis/features/coreprops.rst @@ -0,0 +1,199 @@ + +Core Document Properties +======================== + +The Open XML format provides for a set of descriptive properties to be +maintained with each document. One of these is the *core file properties*. +The core properties are common to all Open XML formats and appear in +document, presentation, and spreadsheet files. The 'Core' in core document +properties refers to `Dublin Core`_, a metadata standard that defines a core +set of elements to describe resources. + +The core properties are described in Part 2 of the ISO/IEC 29500 spec, in +Section 11. The names of some core properties in |docx| are changed from +those in the spec to conform to the MS API. + +Other properties such as company name are custom properties, held in +``app.xml``. + + +Candidate Protocol +------------------ + +:: + + >>> document = Document() + >>> core_properties = document.core_properties + >>> core_properties.author + 'python-docx' + >>> core_properties.author = 'Brian' + >>> core_properties.author + 'Brian' + + +Properties +---------- + +15 properties are supported. All unicode values are limited to 255 characters +(not bytes). + +author *(unicode)* + Note: named 'creator' in spec. An entity primarily responsible for making + the content of the resource. (Dublin Core) + +category *(unicode)* + A categorization of the content of this package. Example values for this + property might include: Resume, Letter, Financial Forecast, Proposal, + Technical Presentation, and so on. (Open Packaging Conventions) + +comments *(unicode)* + Note: named 'description' in spec. An explanation of the content of the + resource. Values might include an abstract, table of contents, reference + to a graphical representation of content, and a free-text account of the + content. (Dublin Core) + +content_status *(unicode)* + The status of the content. Values might include “Draft”, “Reviewed”, and + “Final”. (Open Packaging Conventions) + +created *(datetime)* + Date of creation of the resource. (Dublin Core) + +identifier *(unicode)* + An unambiguous reference to the resource within a given context. + (Dublin Core) + +keywords *(unicode)* + A delimited set of keywords to support searching and indexing. This is + typically a list of terms that are not available elsewhere in the + properties. (Open Packaging Conventions) + +language *(unicode)* + The language of the intellectual content of the resource. (Dublin Core) + +last_modified_by *(unicode)* + The user who performed the last modification. The identification is + environment-specific. Examples include a name, email address, or employee + ID. It is recommended that this value be as concise as possible. + (Open Packaging Conventions) + +last_printed *(datetime)* + The date and time of the last printing. (Open Packaging Conventions) + +modified *(datetime)* + Date on which the resource was changed. (Dublin Core) + +revision *(int)* + The revision number. This value might indicate the number of saves or + revisions, provided the application updates it after each revision. + (Open Packaging Conventions) + +subject *(unicode)* + The topic of the content of the resource. (Dublin Core) + +title *(unicode)* + The name given to the resource. (Dublin Core) + +version *(unicode)* + The version designator. This value is set by the user or by the + application. (Open Packaging Conventions) + + +Specimen XML +------------ + +.. highlight:: xml + +core.xml produced by Microsoft Word:: + + + + Core Document Properties Exploration + PowerPoint core document properties + Steve Canny + powerpoint; open xml; dublin core; microsoft office + + One thing I'd like to discover is just how line wrapping is handled + in the comments. This paragraph is all on a single + line._x000d__x000d_This is a second paragraph separated from the + first by two line feeds. + + Steve Canny + 2 + 2013-04-06T06:03:36Z + 2013-06-15T06:09:18Z + analysis + + + +Schema +====== + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +.. _Dublin Core: + http://en.wikipedia.org/wiki/Dublin_Core diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 49cdeda8e..b17d7524e 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/coreprops features/cell-merge features/table features/table-props From 783cf9ff1068854026fd490f4eb48ad2bfbb3b5f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 14:28:40 -0800 Subject: [PATCH 044/615] acpt: add scenario for reading CoreProperties --- docx/opc/coreprops.py | 17 ++++++ features/doc-coreprops.feature | 10 +++ features/steps/coreprops.py | 61 +++++++++++++++++++ features/steps/test_files/doc-coreprops.docx | Bin 0 -> 11992 bytes 4 files changed, 88 insertions(+) create mode 100644 docx/opc/coreprops.py create mode 100644 features/doc-coreprops.feature create mode 100644 features/steps/coreprops.py create mode 100644 features/steps/test_files/doc-coreprops.docx diff --git a/docx/opc/coreprops.py b/docx/opc/coreprops.py new file mode 100644 index 000000000..cc8091ef3 --- /dev/null +++ b/docx/opc/coreprops.py @@ -0,0 +1,17 @@ +# encoding: utf-8 + +""" +The :mod:`pptx.packaging` module coheres around the concerns of reading and +writing presentations to and from a .pptx file. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + + +class CoreProperties(object): + """ + Corresponds to part named ``/docProps/core.xml``, containing the core + document properties for this document package. + """ diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature new file mode 100644 index 000000000..0d84b9862 --- /dev/null +++ b/features/doc-coreprops.feature @@ -0,0 +1,10 @@ +Feature: Read and write core document properties + In order to find documents and make them manageable by digital means + As a developer using python-docx + I need to access and modify the Dublin Core metadata for a document + + @wip + Scenario: read the core properties of a document + Given a document having known core properties + Then I can access the core properties object + And the core property values match the known values diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py new file mode 100644 index 000000000..41767b8b6 --- /dev/null +++ b/features/steps/coreprops.py @@ -0,0 +1,61 @@ +# encoding: utf-8 + +""" +Gherkin step implementations for core properties-related features. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime + +from behave import given, then + +from docx import Document +from docx.opc.coreprops import CoreProperties + +from helpers import test_docx + + +# given =================================================== + +@given('a document having known core properties') +def given_a_document_having_known_core_properties(context): + context.document = Document(test_docx('doc-coreprops')) + + +# then ==================================================== + +@then('I can access the core properties object') +def then_I_can_access_the_core_properties_object(context): + document = context.document + core_properties = document.core_properties + assert isinstance(core_properties, CoreProperties) + + +@then('the core property values match the known values') +def then_the_core_property_values_match_the_known_values(context): + known_propvals = ( + ('author', 'Steve Canny'), + ('category', 'Category'), + ('comments', 'Description'), + ('content_status', 'Content Status'), + ('created', datetime(2014, 12, 13, 22, 2, 0)), + ('identifier', 'Identifier'), + ('keywords', 'key; word; keyword'), + ('language', 'Language'), + ('last_modified_by', 'Steve Canny'), + ('last_printed', datetime(2014, 12, 13, 22, 2, 42)), + ('modified', datetime(2014, 12, 13, 22, 6, 0)), + ('revision', 2), + ('subject', 'Subject'), + ('title', 'Title'), + ('version', '0.7.1a3'), + ) + core_properties = context.document.core_properties + for name, expected_value in known_propvals: + value = getattr(core_properties, name) + assert value == expected_value, ( + "got '%s' for core property '%s'" % (value, name) + ) diff --git a/features/steps/test_files/doc-coreprops.docx b/features/steps/test_files/doc-coreprops.docx new file mode 100644 index 0000000000000000000000000000000000000000..78ef3f56a4cd6be850caba3cc269d326e48744c8 GIT binary patch literal 11992 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 R5^JDuMNlQro&NW${{yvinSTHP literal 0 HcmV?d00001 From 652fc43011a0b45f4eb1ba3c0952541e0a6d279c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 14:55:07 -0800 Subject: [PATCH 045/615] doc: add Document.core_properties --- docs/conf.py | 2 ++ docx/api.py | 8 ++++++++ docx/opc/package.py | 8 ++++++++ tests/test_api.py | 15 +++++++++++++++ 4 files changed, 33 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 8dac74384..46f243600 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -77,6 +77,8 @@ .. |_Columns| replace:: :class:`_Columns` +.. |CoreProperties| replace:: :class:`.CoreProperties` + .. |Document| replace:: :class:`.Document` .. |docx| replace:: ``python-docx`` diff --git a/docx/api.py b/docx/api.py index c1ac093b7..74eaea557 100644 --- a/docx/api.py +++ b/docx/api.py @@ -108,6 +108,14 @@ def add_table(self, rows, cols, style='LightShading-Accent1'): table.style = style return table + @property + def core_properties(self): + """ + A |CoreProperties| object providing read/write access to the core + properties of this document. + """ + return self._package.core_properties + @property def inline_shapes(self): """ diff --git a/docx/opc/package.py b/docx/opc/package.py index 6c44453ce..a50e90d9e 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -35,6 +35,14 @@ def after_unmarshal(self): # subclass pass + @property + def core_properties(self): + """ + |CoreProperties| object providing read/write access to the Dublin + Core properties for this document. + """ + raise NotImplementedError + def iter_rels(self): """ Generate exactly one reference to each relationship in the package by diff --git a/tests/test_api.py b/tests/test_api.py index 9d7fcfc51..ecf084a9b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,6 +13,7 @@ from docx.api import Document from docx.enum.text import WD_BREAK from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT +from docx.opc.coreprops import CoreProperties from docx.package import Package from docx.parts.document import DocumentPart, InlineShapes from docx.parts.numbering import NumberingPart @@ -131,6 +132,11 @@ def it_can_save_the_package(self, save_fixture): document.save(file_) package_.save.assert_called_once_with(file_) + def it_provides_access_to_the_core_properties(self, core_props_fixture): + document, core_properties_ = core_props_fixture + core_properties = document.core_properties + assert core_properties is core_properties_ + def it_provides_access_to_the_numbering_part(self, num_part_get_fixture): document, document_part_, numbering_part_ = num_part_get_fixture numbering_part = document.numbering_part @@ -214,6 +220,11 @@ def add_table_fixture(self, request, document, document_part_, table_): table_ ) + @pytest.fixture + def core_props_fixture(self, document, core_properties_): + document._package.core_properties = core_properties_ + return document, core_properties_ + @pytest.fixture def init_fixture(self, docx_, open_): return docx_, open_ @@ -249,6 +260,10 @@ def add_paragraph_(self, request, paragraph_): request, Document, 'add_paragraph', return_value=paragraph_ ) + @pytest.fixture + def core_properties_(self, request): + return instance_mock(request, CoreProperties) + @pytest.fixture def default_docx_(self, request): return var_mock(request, 'docx.api._default_docx_path') From da0587c6a9c12c61849e984c3d445498fa556eae Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 15:22:26 -0800 Subject: [PATCH 046/615] opc: add OpcPackage.core_properties Also, organize fixture components separate from fixtures. --- docx/opc/package.py | 10 +++++- docx/opc/parts/__init__.py | 0 docx/opc/parts/coreprops.py | 25 ++++++++++++++ tests/opc/test_package.py | 67 +++++++++++++++++++++++++++---------- 4 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 docx/opc/parts/__init__.py create mode 100644 docx/opc/parts/coreprops.py diff --git a/docx/opc/package.py b/docx/opc/package.py index a50e90d9e..bcfeb6a15 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -41,7 +41,7 @@ def core_properties(self): |CoreProperties| object providing read/write access to the Dublin Core properties for this document. """ - raise NotImplementedError + return self._core_properties_part.core_properties def iter_rels(self): """ @@ -159,6 +159,14 @@ def save(self, pkg_file): part.before_marshal() PackageWriter.write(pkg_file, self.rels, self.parts) + @property + def _core_properties_part(self): + """ + |CorePropertiesPart| object related to this package. Creates + a default core properties part if one is not present (not common). + """ + raise NotImplementedError + class Part(object): """ diff --git a/docx/opc/parts/__init__.py b/docx/opc/parts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py new file mode 100644 index 000000000..748e49cde --- /dev/null +++ b/docx/opc/parts/coreprops.py @@ -0,0 +1,25 @@ +# encoding: utf-8 + +""" +Core properties part, corresponds to ``/docProps/core.xml`` part in package. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from ..package import XmlPart + + +class CorePropertiesPart(XmlPart): + """ + Corresponds to part named ``/docProps/core.xml``, containing the core + document properties for this document package. + """ + @property + def core_properties(self): + """ + A |CoreProperties| object providing read/write access to the core + properties contained in this core properties part. + """ + raise NotImplementedError diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index aa0eff574..71d181e58 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -8,19 +8,22 @@ import pytest +from docx.opc.coreprops import CoreProperties from docx.opc.oxml import CT_Relationships from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.package import ( OpcPackage, Part, PartFactory, _Relationship, Relationships, Unmarshaller, XmlPart ) +from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader from docx.oxml.xmlchemy import BaseOxmlElement from ..unitutil.cxml import element from ..unitutil.mock import ( call, class_mock, cls_attr_mock, function_mock, initializer_mock, - instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock + instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock, + property_mock ) @@ -113,8 +116,53 @@ def it_can_save_to_a_pkg_file( pkg_file_, pkg._rels, parts_ ) + def it_provides_access_to_the_core_properties(self, core_props_fixture): + opc_package, core_properties_ = core_props_fixture + core_properties = opc_package.core_properties + assert core_properties is core_properties_ + # fixtures --------------------------------------------- + @pytest.fixture + def core_props_fixture( + self, _core_properties_part_prop_, core_properties_part_, + core_properties_): + opc_package = OpcPackage() + _core_properties_part_prop_.return_value = core_properties_part_ + core_properties_part_.core_properties = core_properties_ + return opc_package, core_properties_ + + @pytest.fixture + def relate_to_part_fixture_(self, request, pkg, rels_, reltype): + rId = 'rId99' + rel_ = instance_mock(request, _Relationship, name='rel_', rId=rId) + rels_.get_or_add.return_value = rel_ + pkg._rels = rels_ + part_ = instance_mock(request, Part, name='part_') + return pkg, part_, reltype, rId + + @pytest.fixture + def related_part_fixture_(self, request, rels_, reltype): + related_part_ = instance_mock(request, Part, name='related_part_') + rels_.part_with_reltype.return_value = related_part_ + pkg = OpcPackage() + pkg._rels = rels_ + return pkg, reltype, related_part_ + + # fixture components ----------------------------------- + + @pytest.fixture + def core_properties_(self, request): + return instance_mock(request, CoreProperties) + + @pytest.fixture + def core_properties_part_(self, request): + return instance_mock(request, CorePropertiesPart) + + @pytest.fixture + def _core_properties_part_prop_(self, request): + return property_mock(request, OpcPackage, '_core_properties_part') + @pytest.fixture def PackageReader_(self, request): return class_mock(request, 'docx.opc.package.PackageReader') @@ -171,23 +219,6 @@ def rel_attrs_(self, request): rId = 'rId99' return reltype, target_, rId - @pytest.fixture - def relate_to_part_fixture_(self, request, pkg, rels_, reltype): - rId = 'rId99' - rel_ = instance_mock(request, _Relationship, name='rel_', rId=rId) - rels_.get_or_add.return_value = rel_ - pkg._rels = rels_ - part_ = instance_mock(request, Part, name='part_') - return pkg, part_, reltype, rId - - @pytest.fixture - def related_part_fixture_(self, request, rels_, reltype): - related_part_ = instance_mock(request, Part, name='related_part_') - rels_.part_with_reltype.return_value = related_part_ - pkg = OpcPackage() - pkg._rels = rels_ - return pkg, reltype, related_part_ - @pytest.fixture def rels_(self, request): return instance_mock(request, Relationships) From 1d949827abe15f69c09a98c02daf5afe3ab5f21b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 17:27:34 -0800 Subject: [PATCH 047/615] rfctr: extract opc.rel module from opc.package --- docx/opc/package.py | 162 +--------------------------------- docx/opc/rel.py | 170 ++++++++++++++++++++++++++++++++++++ tests/opc/test_package.py | 165 +---------------------------------- tests/opc/test_rel.py | 177 ++++++++++++++++++++++++++++++++++++++ tests/opc/test_rels.py | 136 ++--------------------------- 5 files changed, 358 insertions(+), 452 deletions(-) create mode 100644 docx/opc/rel.py create mode 100644 tests/opc/test_rel.py diff --git a/docx/opc/package.py b/docx/opc/package.py index bcfeb6a15..00ff30d57 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -9,11 +9,12 @@ from .compat import cls_method_fn from .constants import RELATIONSHIP_TYPE as RT -from .oxml import CT_Relationships, serialize_part_xml +from .oxml import serialize_part_xml from ..oxml import parse_xml from .packuri import PACKAGE_URI, PackURI from .pkgreader import PackageReader from .pkgwriter import PackageWriter +from .rel import Relationships from .shared import lazyproperty @@ -386,126 +387,6 @@ def _part_cls_for(cls, content_type): return cls.default_part_type -class Relationships(dict): - """ - Collection object for |_Relationship| instances, having list semantics. - """ - def __init__(self, baseURI): - super(Relationships, self).__init__() - self._baseURI = baseURI - self._target_parts_by_rId = {} - - def add_relationship(self, reltype, target, rId, is_external=False): - """ - Return a newly added |_Relationship| instance. - """ - rel = _Relationship(rId, reltype, target, self._baseURI, is_external) - self[rId] = rel - if not is_external: - self._target_parts_by_rId[rId] = target - return rel - - def get_or_add(self, reltype, target_part): - """ - Return relationship of *reltype* to *target_part*, newly added if not - already present in collection. - """ - rel = self._get_matching(reltype, target_part) - if rel is None: - rId = self._next_rId - rel = self.add_relationship(reltype, target_part, rId) - return rel - - def get_or_add_ext_rel(self, reltype, target_ref): - """ - Return rId of external relationship of *reltype* to *target_ref*, - newly added if not already present in collection. - """ - rel = self._get_matching(reltype, target_ref, is_external=True) - if rel is None: - rId = self._next_rId - rel = self.add_relationship( - reltype, target_ref, rId, is_external=True - ) - return rel.rId - - def part_with_reltype(self, reltype): - """ - Return target part of rel with matching *reltype*, raising |KeyError| - if not found and |ValueError| if more than one matching relationship - is found. - """ - rel = self._get_rel_of_type(reltype) - return rel.target_part - - @property - def related_parts(self): - """ - dict mapping rIds to target parts for all the internal relationships - in the collection. - """ - return self._target_parts_by_rId - - @property - def xml(self): - """ - Serialize this relationship collection into XML suitable for storage - as a .rels file in an OPC package. - """ - rels_elm = CT_Relationships.new() - for rel in self.values(): - rels_elm.add_rel( - rel.rId, rel.reltype, rel.target_ref, rel.is_external - ) - return rels_elm.xml - - def _get_matching(self, reltype, target, is_external=False): - """ - Return relationship of matching *reltype*, *target*, and - *is_external* from collection, or None if not found. - """ - def matches(rel, reltype, target, is_external): - if rel.reltype != reltype: - return False - if rel.is_external != is_external: - return False - rel_target = rel.target_ref if rel.is_external else rel.target_part - if rel_target != target: - return False - return True - - for rel in self.values(): - if matches(rel, reltype, target, is_external): - return rel - return None - - def _get_rel_of_type(self, reltype): - """ - Return single relationship of type *reltype* from the collection. - Raises |KeyError| if no matching relationship is found. Raises - |ValueError| if more than one matching relationship is found. - """ - matching = [rel for rel in self.values() if rel.reltype == reltype] - if len(matching) == 0: - tmpl = "no relationship of type '%s' in collection" - raise KeyError(tmpl % reltype) - if len(matching) > 1: - tmpl = "multiple relationships of type '%s' in collection" - raise ValueError(tmpl % reltype) - return matching[0] - - @property - def _next_rId(self): - """ - Next available rId in collection, starting from 'rId1' and making use - of any gaps in numbering, e.g. 'rId2' for rIds ['rId1', 'rId3']. - """ - for n in range(1, len(self)+2): - rId_candidate = 'rId%d' % n # like 'rId19' - if rId_candidate not in self: - return rId_candidate - - class Unmarshaller(object): """ Hosts static methods for unmarshalling a package from a |PackageReader| @@ -552,42 +433,3 @@ def _unmarshal_relationships(pkg_reader, package, parts): target = (srel.target_ref if srel.is_external else parts[srel.target_partname]) source.load_rel(srel.reltype, target, srel.rId, srel.is_external) - - -class _Relationship(object): - """ - Value object for relationship to part. - """ - def __init__(self, rId, reltype, target, baseURI, external=False): - super(_Relationship, self).__init__() - self._rId = rId - self._reltype = reltype - self._target = target - self._baseURI = baseURI - self._is_external = bool(external) - - @property - def is_external(self): - return self._is_external - - @property - def reltype(self): - return self._reltype - - @property - def rId(self): - return self._rId - - @property - def target_part(self): - if self._is_external: - raise ValueError("target_part property on _Relationship is undef" - "ined when target mode is External") - return self._target - - @property - def target_ref(self): - if self._is_external: - return self._target - else: - return self._target.partname.relative_ref(self._baseURI) diff --git a/docx/opc/rel.py b/docx/opc/rel.py new file mode 100644 index 000000000..7dba2af8e --- /dev/null +++ b/docx/opc/rel.py @@ -0,0 +1,170 @@ +# encoding: utf-8 + +""" +Relationship-related objects. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from .oxml import CT_Relationships + + +class Relationships(dict): + """ + Collection object for |_Relationship| instances, having list semantics. + """ + def __init__(self, baseURI): + super(Relationships, self).__init__() + self._baseURI = baseURI + self._target_parts_by_rId = {} + + def add_relationship(self, reltype, target, rId, is_external=False): + """ + Return a newly added |_Relationship| instance. + """ + rel = _Relationship(rId, reltype, target, self._baseURI, is_external) + self[rId] = rel + if not is_external: + self._target_parts_by_rId[rId] = target + return rel + + def get_or_add(self, reltype, target_part): + """ + Return relationship of *reltype* to *target_part*, newly added if not + already present in collection. + """ + rel = self._get_matching(reltype, target_part) + if rel is None: + rId = self._next_rId + rel = self.add_relationship(reltype, target_part, rId) + return rel + + def get_or_add_ext_rel(self, reltype, target_ref): + """ + Return rId of external relationship of *reltype* to *target_ref*, + newly added if not already present in collection. + """ + rel = self._get_matching(reltype, target_ref, is_external=True) + if rel is None: + rId = self._next_rId + rel = self.add_relationship( + reltype, target_ref, rId, is_external=True + ) + return rel.rId + + def part_with_reltype(self, reltype): + """ + Return target part of rel with matching *reltype*, raising |KeyError| + if not found and |ValueError| if more than one matching relationship + is found. + """ + rel = self._get_rel_of_type(reltype) + return rel.target_part + + @property + def related_parts(self): + """ + dict mapping rIds to target parts for all the internal relationships + in the collection. + """ + return self._target_parts_by_rId + + @property + def xml(self): + """ + Serialize this relationship collection into XML suitable for storage + as a .rels file in an OPC package. + """ + rels_elm = CT_Relationships.new() + for rel in self.values(): + rels_elm.add_rel( + rel.rId, rel.reltype, rel.target_ref, rel.is_external + ) + return rels_elm.xml + + def _get_matching(self, reltype, target, is_external=False): + """ + Return relationship of matching *reltype*, *target*, and + *is_external* from collection, or None if not found. + """ + def matches(rel, reltype, target, is_external): + if rel.reltype != reltype: + return False + if rel.is_external != is_external: + return False + rel_target = rel.target_ref if rel.is_external else rel.target_part + if rel_target != target: + return False + return True + + for rel in self.values(): + if matches(rel, reltype, target, is_external): + return rel + return None + + def _get_rel_of_type(self, reltype): + """ + Return single relationship of type *reltype* from the collection. + Raises |KeyError| if no matching relationship is found. Raises + |ValueError| if more than one matching relationship is found. + """ + matching = [rel for rel in self.values() if rel.reltype == reltype] + if len(matching) == 0: + tmpl = "no relationship of type '%s' in collection" + raise KeyError(tmpl % reltype) + if len(matching) > 1: + tmpl = "multiple relationships of type '%s' in collection" + raise ValueError(tmpl % reltype) + return matching[0] + + @property + def _next_rId(self): + """ + Next available rId in collection, starting from 'rId1' and making use + of any gaps in numbering, e.g. 'rId2' for rIds ['rId1', 'rId3']. + """ + for n in range(1, len(self)+2): + rId_candidate = 'rId%d' % n # like 'rId19' + if rId_candidate not in self: + return rId_candidate + + +class _Relationship(object): + """ + Value object for relationship to part. + """ + def __init__(self, rId, reltype, target, baseURI, external=False): + super(_Relationship, self).__init__() + self._rId = rId + self._reltype = reltype + self._target = target + self._baseURI = baseURI + self._is_external = bool(external) + + @property + def is_external(self): + return self._is_external + + @property + def reltype(self): + return self._reltype + + @property + def rId(self): + return self._rId + + @property + def target_part(self): + if self._is_external: + raise ValueError("target_part property on _Relationship is undef" + "ined when target mode is External") + return self._target + + @property + def target_ref(self): + if self._is_external: + return self._target + else: + return self._target.partname.relative_ref(self._baseURI) diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 71d181e58..610c3244d 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -9,14 +9,13 @@ import pytest from docx.opc.coreprops import CoreProperties -from docx.opc.oxml import CT_Relationships from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.package import ( - OpcPackage, Part, PartFactory, _Relationship, Relationships, - Unmarshaller, XmlPart + OpcPackage, Part, PartFactory, Unmarshaller, XmlPart ) from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader +from docx.opc.rel import _Relationship, Relationships from docx.oxml.xmlchemy import BaseOxmlElement from ..unitutil.cxml import element @@ -644,166 +643,6 @@ def reltype_2_(self, request): return instance_mock(request, str) -class Describe_Relationship(object): - - def it_remembers_construction_values(self): - # test data -------------------- - rId = 'rId9' - reltype = 'reltype' - target = Mock(name='target_part') - external = False - # exercise --------------------- - rel = _Relationship(rId, reltype, target, None, external) - # verify ----------------------- - assert rel.rId == rId - assert rel.reltype == reltype - assert rel.target_part == target - assert rel.is_external == external - - def it_should_raise_on_target_part_access_on_external_rel(self): - rel = _Relationship(None, None, None, None, external=True) - with pytest.raises(ValueError): - rel.target_part - - def it_should_have_target_ref_for_external_rel(self): - rel = _Relationship(None, None, 'target', None, external=True) - assert rel.target_ref == 'target' - - def it_should_have_relative_ref_for_internal_rel(self): - """ - Internal relationships (TargetMode == 'Internal' in the XML) should - have a relative ref, e.g. '../slideLayouts/slideLayout1.xml', for - the target_ref attribute. - """ - part = Mock(name='part', partname=PackURI('/ppt/media/image1.png')) - baseURI = '/ppt/slides' - rel = _Relationship(None, None, part, baseURI) # external=False - assert rel.target_ref == '../media/image1.png' - - -class DescribeRelationships(object): - - def it_has_a_len(self): - rels = Relationships(None) - assert len(rels) == 0 - - def it_has_dict_style_lookup_of_rel_by_rId(self): - rel = Mock(name='rel', rId='foobar') - rels = Relationships(None) - rels['foobar'] = rel - assert rels['foobar'] == rel - - def it_should_raise_on_failed_lookup_by_rId(self): - rels = Relationships(None) - with pytest.raises(KeyError): - rels['barfoo'] - - def it_can_add_a_relationship(self, _Relationship_): - baseURI, rId, reltype, target, external = ( - 'baseURI', 'rId9', 'reltype', 'target', False - ) - rels = Relationships(baseURI) - rel = rels.add_relationship(reltype, target, rId, external) - _Relationship_.assert_called_once_with( - rId, reltype, target, baseURI, external - ) - assert rels[rId] == rel - assert rel == _Relationship_.return_value - - def it_can_add_an_external_relationship(self, add_ext_rel_fixture_): - rels, reltype, url = add_ext_rel_fixture_ - rId = rels.get_or_add_ext_rel(reltype, url) - rel = rels[rId] - assert rel.is_external - assert rel.target_ref == url - assert rel.reltype == reltype - - def it_should_return_an_existing_one_if_it_matches( - self, add_matching_ext_rel_fixture_): - rels, reltype, url, rId = add_matching_ext_rel_fixture_ - _rId = rels.get_or_add_ext_rel(reltype, url) - assert _rId == rId - assert len(rels) == 1 - - def it_can_compose_rels_xml(self, rels, rels_elm): - # exercise --------------------- - rels.xml - # verify ----------------------- - rels_elm.assert_has_calls( - [ - call.add_rel( - 'rId1', 'http://rt-hyperlink', 'http://some/link', True - ), - call.add_rel( - 'rId2', 'http://rt-image', '../media/image1.png', False - ), - call.xml() - ], - any_order=True - ) - - # fixtures --------------------------------------------- - - @pytest.fixture - def add_ext_rel_fixture_(self, reltype, url): - rels = Relationships(None) - return rels, reltype, url - - @pytest.fixture - def add_matching_ext_rel_fixture_(self, request, reltype, url): - rId = 'rId369' - rels = Relationships(None) - rels.add_relationship(reltype, url, rId, is_external=True) - return rels, reltype, url, rId - - @pytest.fixture - def _Relationship_(self, request): - return class_mock(request, 'docx.opc.package._Relationship') - - @pytest.fixture - def rels(self): - """ - Populated Relationships instance that will exercise the rels.xml - property. - """ - rels = Relationships('/baseURI') - rels.add_relationship( - reltype='http://rt-hyperlink', target='http://some/link', - rId='rId1', is_external=True - ) - part = Mock(name='part') - part.partname.relative_ref.return_value = '../media/image1.png' - rels.add_relationship(reltype='http://rt-image', target=part, - rId='rId2') - return rels - - @pytest.fixture - def rels_elm(self, request): - """ - Return a rels_elm mock that will be returned from - CT_Relationships.new() - """ - # create rels_elm mock with a .xml property - rels_elm = Mock(name='rels_elm') - xml = PropertyMock(name='xml') - type(rels_elm).xml = xml - rels_elm.attach_mock(xml, 'xml') - rels_elm.reset_mock() # to clear attach_mock call - # patch CT_Relationships to return that rels_elm - patch_ = patch.object(CT_Relationships, 'new', return_value=rels_elm) - patch_.start() - request.addfinalizer(patch_.stop) - return rels_elm - - @pytest.fixture - def reltype(self): - return 'http://rel/type' - - @pytest.fixture - def url(self): - return 'https://github.com/scanny/python-docx' - - class DescribeUnmarshaller(object): def it_can_unmarshal_from_a_pkg_reader( diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py new file mode 100644 index 000000000..d0710fc7c --- /dev/null +++ b/tests/opc/test_rel.py @@ -0,0 +1,177 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.rel module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.oxml import CT_Relationships +from docx.opc.packuri import PackURI +from docx.opc.rel import _Relationship, Relationships + +from ..unitutil.mock import call, class_mock, Mock, patch, PropertyMock + + +class Describe_Relationship(object): + + def it_remembers_construction_values(self): + # test data -------------------- + rId = 'rId9' + reltype = 'reltype' + target = Mock(name='target_part') + external = False + # exercise --------------------- + rel = _Relationship(rId, reltype, target, None, external) + # verify ----------------------- + assert rel.rId == rId + assert rel.reltype == reltype + assert rel.target_part == target + assert rel.is_external == external + + def it_should_raise_on_target_part_access_on_external_rel(self): + rel = _Relationship(None, None, None, None, external=True) + with pytest.raises(ValueError): + rel.target_part + + def it_should_have_target_ref_for_external_rel(self): + rel = _Relationship(None, None, 'target', None, external=True) + assert rel.target_ref == 'target' + + def it_should_have_relative_ref_for_internal_rel(self): + """ + Internal relationships (TargetMode == 'Internal' in the XML) should + have a relative ref, e.g. '../slideLayouts/slideLayout1.xml', for + the target_ref attribute. + """ + part = Mock(name='part', partname=PackURI('/ppt/media/image1.png')) + baseURI = '/ppt/slides' + rel = _Relationship(None, None, part, baseURI) # external=False + assert rel.target_ref == '../media/image1.png' + + +class DescribeRelationships(object): + + def it_has_a_len(self): + rels = Relationships(None) + assert len(rels) == 0 + + def it_has_dict_style_lookup_of_rel_by_rId(self): + rel = Mock(name='rel', rId='foobar') + rels = Relationships(None) + rels['foobar'] = rel + assert rels['foobar'] == rel + + def it_should_raise_on_failed_lookup_by_rId(self): + rels = Relationships(None) + with pytest.raises(KeyError): + rels['barfoo'] + + def it_can_add_a_relationship(self, _Relationship_): + baseURI, rId, reltype, target, external = ( + 'baseURI', 'rId9', 'reltype', 'target', False + ) + rels = Relationships(baseURI) + rel = rels.add_relationship(reltype, target, rId, external) + _Relationship_.assert_called_once_with( + rId, reltype, target, baseURI, external + ) + assert rels[rId] == rel + assert rel == _Relationship_.return_value + + def it_can_add_an_external_relationship(self, add_ext_rel_fixture_): + rels, reltype, url = add_ext_rel_fixture_ + rId = rels.get_or_add_ext_rel(reltype, url) + rel = rels[rId] + assert rel.is_external + assert rel.target_ref == url + assert rel.reltype == reltype + + def it_should_return_an_existing_one_if_it_matches( + self, add_matching_ext_rel_fixture_): + rels, reltype, url, rId = add_matching_ext_rel_fixture_ + _rId = rels.get_or_add_ext_rel(reltype, url) + assert _rId == rId + assert len(rels) == 1 + + def it_can_compose_rels_xml(self, rels, rels_elm): + # exercise --------------------- + rels.xml + # verify ----------------------- + rels_elm.assert_has_calls( + [ + call.add_rel( + 'rId1', 'http://rt-hyperlink', 'http://some/link', True + ), + call.add_rel( + 'rId2', 'http://rt-image', '../media/image1.png', False + ), + call.xml() + ], + any_order=True + ) + + # fixtures --------------------------------------------- + + @pytest.fixture + def add_ext_rel_fixture_(self, reltype, url): + rels = Relationships(None) + return rels, reltype, url + + @pytest.fixture + def add_matching_ext_rel_fixture_(self, request, reltype, url): + rId = 'rId369' + rels = Relationships(None) + rels.add_relationship(reltype, url, rId, is_external=True) + return rels, reltype, url, rId + + @pytest.fixture + def _Relationship_(self, request): + return class_mock(request, 'docx.opc.rel._Relationship') + + @pytest.fixture + def rels(self): + """ + Populated Relationships instance that will exercise the rels.xml + property. + """ + rels = Relationships('/baseURI') + rels.add_relationship( + reltype='http://rt-hyperlink', target='http://some/link', + rId='rId1', is_external=True + ) + part = Mock(name='part') + part.partname.relative_ref.return_value = '../media/image1.png' + rels.add_relationship(reltype='http://rt-image', target=part, + rId='rId2') + return rels + + @pytest.fixture + def rels_elm(self, request): + """ + Return a rels_elm mock that will be returned from + CT_Relationships.new() + """ + # create rels_elm mock with a .xml property + rels_elm = Mock(name='rels_elm') + xml = PropertyMock(name='xml') + type(rels_elm).xml = xml + rels_elm.attach_mock(xml, 'xml') + rels_elm.reset_mock() # to clear attach_mock call + # patch CT_Relationships to return that rels_elm + patch_ = patch.object(CT_Relationships, 'new', return_value=rels_elm) + patch_.start() + request.addfinalizer(patch_.stop) + return rels_elm + + @pytest.fixture + def reltype(self): + return 'http://rel/type' + + @pytest.fixture + def url(self): + return 'https://github.com/scanny/python-docx' diff --git a/tests/opc/test_rels.py b/tests/opc/test_rels.py index 61036410c..d46566d80 100644 --- a/tests/opc/test_rels.py +++ b/tests/opc/test_rels.py @@ -9,77 +9,14 @@ import pytest from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.opc.oxml import CT_Relationships -from docx.opc.package import Part, _Relationship, Relationships -from docx.opc.packuri import PackURI +from docx.opc.package import Part +from docx.opc.rel import _Relationship, Relationships -from ..unitutil.mock import ( - call, class_mock, instance_mock, loose_mock, Mock, patch, PropertyMock -) - - -class Describe_Relationship(object): - - def it_remembers_construction_values(self): - # test data -------------------- - rId = 'rId9' - reltype = 'reltype' - target = Mock(name='target_part') - external = False - # exercise --------------------- - rel = _Relationship(rId, reltype, target, None, external) - # verify ----------------------- - assert rel.rId == rId - assert rel.reltype == reltype - assert rel.target_part == target - assert rel.is_external == external - - def it_should_raise_on_target_part_access_on_external_rel(self): - rel = _Relationship(None, None, None, None, external=True) - with pytest.raises(ValueError): - rel.target_part - - def it_should_have_target_ref_for_external_rel(self): - rel = _Relationship(None, None, 'target', None, external=True) - assert rel.target_ref == 'target' - - def it_should_have_relative_ref_for_internal_rel(self): - """ - Internal relationships (TargetMode == 'Internal' in the XML) should - have a relative ref, e.g. '../slideLayouts/slideLayout1.xml', for - the target_ref attribute. - """ - part = Mock(name='part', partname=PackURI('/ppt/media/image1.png')) - baseURI = '/ppt/slides' - rel = _Relationship(None, None, part, baseURI) # external=False - assert rel.target_ref == '../media/image1.png' +from ..unitutil.mock import class_mock, instance_mock, loose_mock class DescribeRelationships(object): - def it_also_has_dict_style_get_rel_by_rId(self, rels_with_known_rel): - rels, rId, known_rel = rels_with_known_rel - assert rels[rId] == known_rel - - def it_should_raise_on_failed_lookup_by_rId(self, rels): - with pytest.raises(KeyError): - rels['rId666'] - - def it_has_a_len(self, rels): - assert len(rels) == 0 - - def it_can_add_a_relationship(self, _Relationship_): - baseURI, rId, reltype, target, is_external = ( - 'baseURI', 'rId9', 'reltype', 'target', False - ) - rels = Relationships(baseURI) - rel = rels.add_relationship(reltype, target, rId, is_external) - _Relationship_.assert_called_once_with( - rId, reltype, target, baseURI, is_external - ) - assert rels[rId] == rel - assert rel == _Relationship_.return_value - def it_can_add_a_relationship_if_not_found( self, rels_with_matching_rel_, rels_with_missing_rel_): @@ -109,21 +46,6 @@ def it_raises_KeyError_on_part_with_rId_not_found(self, rels): with pytest.raises(KeyError): rels.related_parts['rId666'] - def it_can_compose_rels_xml(self, rels_with_known_rels, rels_elm): - rels_with_known_rels.xml - rels_elm.assert_has_calls( - [ - call.add_rel( - 'rId1', 'http://rt-hyperlink', 'http://some/link', True - ), - call.add_rel( - 'rId2', 'http://rt-image', '../media/image1.png', False - ), - call.xml() - ], - any_order=True - ) - # def it_raises_on_add_rel_with_duplicate_rId(self, rels, rel): # with pytest.raises(ValueError): # rels.add_rel(rel) @@ -131,57 +53,17 @@ def it_can_compose_rels_xml(self, rels_with_known_rels, rels_elm): # fixtures --------------------------------------------- @pytest.fixture - def _Relationship_(self, request): - return class_mock(request, 'docx.opc.package._Relationship') + def _baseURI(self): + return '/baseURI' @pytest.fixture - def rel(self, _rId, _reltype, _target_part, _baseURI): - return _Relationship(_rId, _reltype, _target_part, _baseURI) + def _Relationship_(self, request): + return class_mock(request, 'docx.opc.rel._Relationship') @pytest.fixture def rels(self, _baseURI): return Relationships(_baseURI) - @pytest.fixture - def rels_elm(self, request): - """ - Return a rels_elm mock that will be returned from - CT_Relationships.new() - """ - # create rels_elm mock with a .xml property - rels_elm = Mock(name='rels_elm') - xml = PropertyMock(name='xml') - type(rels_elm).xml = xml - rels_elm.attach_mock(xml, 'xml') - rels_elm.reset_mock() # to clear attach_mock call - # patch CT_Relationships to return that rels_elm - patch_ = patch.object(CT_Relationships, 'new', return_value=rels_elm) - patch_.start() - request.addfinalizer(patch_.stop) - return rels_elm - - @pytest.fixture - def rels_with_known_rel(self, rels, _rId, rel): - rels[_rId] = rel - return rels, _rId, rel - - @pytest.fixture - def rels_with_known_rels(self): - """ - Populated Relationships instance that will exercise the rels.xml - property. - """ - rels = Relationships('/baseURI') - rels.add_relationship( - reltype='http://rt-hyperlink', target='http://some/link', - rId='rId1', is_external=True - ) - part = Mock(name='part') - part.partname.relative_ref.return_value = '../media/image1.png' - rels.add_relationship(reltype='http://rt-image', target=part, - rId='rId2') - return rels - @pytest.fixture def rels_with_known_target_part(self, rels, _rel_with_known_target_part): rel, rId, target_part = _rel_with_known_target_part @@ -239,10 +121,6 @@ def rels_with_target_known_by_reltype( rels[1] = rel return rels, reltype, target_part - @pytest.fixture - def _baseURI(self): - return '/baseURI' - @pytest.fixture def _rel_with_known_target_part( self, _rId, _reltype, _target_part, _baseURI): From 734ce6fe192446180119b7edb5be8045dbb5fe5a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 18:00:50 -0800 Subject: [PATCH 048/615] opc: consolidate tests for docx.opc.rel Somehow tests for Relationships became fragmented between test_package and test_rels. Move additional tests from test_rels into new test_rel module. --- tests/opc/test_rel.py | 141 ++++++++++++++++++++++++++++++++++----- tests/opc/test_rels.py | 146 ----------------------------------------- 2 files changed, 124 insertions(+), 163 deletions(-) delete mode 100644 tests/opc/test_rels.py diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py index d0710fc7c..db39aa145 100644 --- a/tests/opc/test_rel.py +++ b/tests/opc/test_rel.py @@ -11,10 +11,13 @@ import pytest from docx.opc.oxml import CT_Relationships +from docx.opc.package import Part from docx.opc.packuri import PackURI from docx.opc.rel import _Relationship, Relationships -from ..unitutil.mock import call, class_mock, Mock, patch, PropertyMock +from ..unitutil.mock import ( + call, class_mock, instance_mock, Mock, patch, PropertyMock +) class Describe_Relationship(object): @@ -56,21 +59,6 @@ def it_should_have_relative_ref_for_internal_rel(self): class DescribeRelationships(object): - def it_has_a_len(self): - rels = Relationships(None) - assert len(rels) == 0 - - def it_has_dict_style_lookup_of_rel_by_rId(self): - rel = Mock(name='rel', rId='foobar') - rels = Relationships(None) - rels['foobar'] = rel - assert rels['foobar'] == rel - - def it_should_raise_on_failed_lookup_by_rId(self): - rels = Relationships(None) - with pytest.raises(KeyError): - rels['barfoo'] - def it_can_add_a_relationship(self, _Relationship_): baseURI, rId, reltype, target, external = ( 'baseURI', 'rId9', 'reltype', 'target', False @@ -91,13 +79,43 @@ def it_can_add_an_external_relationship(self, add_ext_rel_fixture_): assert rel.target_ref == url assert rel.reltype == reltype - def it_should_return_an_existing_one_if_it_matches( + def it_can_find_a_relationship_by_rId(self): + rel = Mock(name='rel', rId='foobar') + rels = Relationships(None) + rels['foobar'] = rel + assert rels['foobar'] == rel + + def it_can_find_or_add_a_relationship( + self, rels_with_matching_rel_, rels_with_missing_rel_): + + rels, reltype, part, matching_rel = rels_with_matching_rel_ + assert rels.get_or_add(reltype, part) == matching_rel + + rels, reltype, part, new_rel = rels_with_missing_rel_ + assert rels.get_or_add(reltype, part) == new_rel + + def it_can_find_or_add_an_external_relationship( self, add_matching_ext_rel_fixture_): rels, reltype, url, rId = add_matching_ext_rel_fixture_ _rId = rels.get_or_add_ext_rel(reltype, url) assert _rId == rId assert len(rels) == 1 + def it_can_find_a_related_part_by_rId(self, rels_with_known_target_part): + rels, rId, known_target_part = rels_with_known_target_part + part = rels.related_parts[rId] + assert part is known_target_part + + def it_raises_on_related_part_not_found(self, rels): + with pytest.raises(KeyError): + rels.related_parts['rId666'] + + def it_can_find_a_related_part_by_reltype( + self, rels_with_target_known_by_reltype): + rels, reltype, known_target_part = rels_with_target_known_by_reltype + part = rels.part_with_reltype(reltype) + assert part is known_target_part + def it_can_compose_rels_xml(self, rels, rels_elm): # exercise --------------------- rels.xml @@ -115,6 +133,11 @@ def it_can_compose_rels_xml(self, rels, rels_elm): any_order=True ) + def it_knows_the_next_available_rId_to_help(self, rels_with_rId_gap): + rels, expected_next_rId = rels_with_rId_gap + next_rId = rels._next_rId + assert next_rId == expected_next_rId + # fixtures --------------------------------------------- @pytest.fixture @@ -129,10 +152,22 @@ def add_matching_ext_rel_fixture_(self, request, reltype, url): rels.add_relationship(reltype, url, rId, is_external=True) return rels, reltype, url, rId + # fixture components ----------------------------------- + + @pytest.fixture + def _baseURI(self): + return '/baseURI' + @pytest.fixture def _Relationship_(self, request): return class_mock(request, 'docx.opc.rel._Relationship') + @pytest.fixture + def _rel_with_target_known_by_reltype( + self, _rId, reltype, _target_part, _baseURI): + rel = _Relationship(_rId, reltype, _target_part, _baseURI) + return rel, reltype, _target_part + @pytest.fixture def rels(self): """ @@ -168,10 +203,82 @@ def rels_elm(self, request): request.addfinalizer(patch_.stop) return rels_elm + @pytest.fixture + def _rel_with_known_target_part( + self, _rId, reltype, _target_part, _baseURI): + rel = _Relationship(_rId, reltype, _target_part, _baseURI) + return rel, _rId, _target_part + + @pytest.fixture + def rels_with_known_target_part(self, rels, _rel_with_known_target_part): + rel, rId, target_part = _rel_with_known_target_part + rels.add_relationship(None, target_part, rId) + return rels, rId, target_part + + @pytest.fixture + def rels_with_matching_rel_(self, request, rels): + matching_reltype_ = instance_mock( + request, str, name='matching_reltype_' + ) + matching_part_ = instance_mock( + request, Part, name='matching_part_' + ) + matching_rel_ = instance_mock( + request, _Relationship, name='matching_rel_', + reltype=matching_reltype_, target_part=matching_part_, + is_external=False + ) + rels[1] = matching_rel_ + return rels, matching_reltype_, matching_part_, matching_rel_ + + @pytest.fixture + def rels_with_missing_rel_(self, request, rels, _Relationship_): + missing_reltype_ = instance_mock( + request, str, name='missing_reltype_' + ) + missing_part_ = instance_mock( + request, Part, name='missing_part_' + ) + new_rel_ = instance_mock( + request, _Relationship, name='new_rel_', + reltype=missing_reltype_, target_part=missing_part_, + is_external=False + ) + _Relationship_.return_value = new_rel_ + return rels, missing_reltype_, missing_part_, new_rel_ + + @pytest.fixture + def rels_with_rId_gap(self, request): + rels = Relationships(None) + rel_with_rId1 = instance_mock( + request, _Relationship, name='rel_with_rId1', rId='rId1' + ) + rel_with_rId3 = instance_mock( + request, _Relationship, name='rel_with_rId3', rId='rId3' + ) + rels['rId1'] = rel_with_rId1 + rels['rId3'] = rel_with_rId3 + return rels, 'rId2' + + @pytest.fixture + def rels_with_target_known_by_reltype( + self, rels, _rel_with_target_known_by_reltype): + rel, reltype, target_part = _rel_with_target_known_by_reltype + rels[1] = rel + return rels, reltype, target_part + @pytest.fixture def reltype(self): return 'http://rel/type' + @pytest.fixture + def _rId(self): + return 'rId6' + + @pytest.fixture + def _target_part(self, request): + return instance_mock(request, Part) + @pytest.fixture def url(self): return 'https://github.com/scanny/python-docx' diff --git a/tests/opc/test_rels.py b/tests/opc/test_rels.py deleted file mode 100644 index d46566d80..000000000 --- a/tests/opc/test_rels.py +++ /dev/null @@ -1,146 +0,0 @@ -# encoding: utf-8 - -""" -Test suite for docx.opc relationships -""" - -from __future__ import absolute_import - -import pytest - -from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.opc.package import Part -from docx.opc.rel import _Relationship, Relationships - -from ..unitutil.mock import class_mock, instance_mock, loose_mock - - -class DescribeRelationships(object): - - def it_can_add_a_relationship_if_not_found( - self, rels_with_matching_rel_, rels_with_missing_rel_): - - rels, reltype, part, matching_rel = rels_with_matching_rel_ - assert rels.get_or_add(reltype, part) == matching_rel - - rels, reltype, part, new_rel = rels_with_missing_rel_ - assert rels.get_or_add(reltype, part) == new_rel - - def it_knows_the_next_available_rId(self, rels_with_rId_gap): - rels, expected_next_rId = rels_with_rId_gap - next_rId = rels._next_rId - assert next_rId == expected_next_rId - - def it_can_find_a_related_part_by_reltype( - self, rels_with_target_known_by_reltype): - rels, reltype, known_target_part = rels_with_target_known_by_reltype - part = rels.part_with_reltype(reltype) - assert part is known_target_part - - def it_can_find_a_related_part_by_rId(self, rels_with_known_target_part): - rels, rId, known_target_part = rels_with_known_target_part - part = rels.related_parts[rId] - assert part is known_target_part - - def it_raises_KeyError_on_part_with_rId_not_found(self, rels): - with pytest.raises(KeyError): - rels.related_parts['rId666'] - - # def it_raises_on_add_rel_with_duplicate_rId(self, rels, rel): - # with pytest.raises(ValueError): - # rels.add_rel(rel) - - # fixtures --------------------------------------------- - - @pytest.fixture - def _baseURI(self): - return '/baseURI' - - @pytest.fixture - def _Relationship_(self, request): - return class_mock(request, 'docx.opc.rel._Relationship') - - @pytest.fixture - def rels(self, _baseURI): - return Relationships(_baseURI) - - @pytest.fixture - def rels_with_known_target_part(self, rels, _rel_with_known_target_part): - rel, rId, target_part = _rel_with_known_target_part - rels.add_relationship(None, target_part, rId) - return rels, rId, target_part - - @pytest.fixture - def rels_with_matching_rel_(self, request, rels): - matching_reltype_ = instance_mock( - request, str, name='matching_reltype_' - ) - matching_part_ = instance_mock( - request, Part, name='matching_part_' - ) - matching_rel_ = instance_mock( - request, _Relationship, name='matching_rel_', - reltype=matching_reltype_, target_part=matching_part_, - is_external=False - ) - rels[1] = matching_rel_ - return rels, matching_reltype_, matching_part_, matching_rel_ - - @pytest.fixture - def rels_with_missing_rel_(self, request, rels, _Relationship_): - missing_reltype_ = instance_mock( - request, str, name='missing_reltype_' - ) - missing_part_ = instance_mock( - request, Part, name='missing_part_' - ) - new_rel_ = instance_mock( - request, _Relationship, name='new_rel_', - reltype=missing_reltype_, target_part=missing_part_, - is_external=False - ) - _Relationship_.return_value = new_rel_ - return rels, missing_reltype_, missing_part_, new_rel_ - - @pytest.fixture - def rels_with_rId_gap(self, request, rels): - rel_with_rId1 = instance_mock( - request, _Relationship, name='rel_with_rId1', rId='rId1' - ) - rel_with_rId3 = instance_mock( - request, _Relationship, name='rel_with_rId3', rId='rId3' - ) - rels['rId1'] = rel_with_rId1 - rels['rId3'] = rel_with_rId3 - return rels, 'rId2' - - @pytest.fixture - def rels_with_target_known_by_reltype( - self, rels, _rel_with_target_known_by_reltype): - rel, reltype, target_part = _rel_with_target_known_by_reltype - rels[1] = rel - return rels, reltype, target_part - - @pytest.fixture - def _rel_with_known_target_part( - self, _rId, _reltype, _target_part, _baseURI): - rel = _Relationship(_rId, _reltype, _target_part, _baseURI) - return rel, _rId, _target_part - - @pytest.fixture - def _rel_with_target_known_by_reltype( - self, _rId, _reltype, _target_part, _baseURI): - rel = _Relationship(_rId, _reltype, _target_part, _baseURI) - return rel, _reltype, _target_part - - @pytest.fixture - def _reltype(self): - return RT.SLIDE - - @pytest.fixture - def _rId(self): - return 'rId6' - - @pytest.fixture - def _target_part(self, request): - return loose_mock(request) From 5f820f8950bca6b9f5e54956a260734f32057da8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 19:20:35 -0800 Subject: [PATCH 049/615] rfctr: extract opc.part module from opc.package --- docx/__init__.py | 2 +- docx/opc/package.py | 224 +--------------- docx/opc/part.py | 234 ++++++++++++++++ docx/opc/parts/coreprops.py | 2 +- docx/parts/document.py | 2 +- docx/parts/image.py | 2 +- docx/parts/numbering.py | 2 +- docx/parts/styles.py | 2 +- tests/opc/test_package.py | 509 +---------------------------------- tests/opc/test_part.py | 518 ++++++++++++++++++++++++++++++++++++ tests/opc/test_pkgwriter.py | 2 +- tests/opc/test_rel.py | 2 +- tests/opc/unitdata/rels.py | 2 +- tests/parts/test_image.py | 2 +- 14 files changed, 769 insertions(+), 736 deletions(-) create mode 100644 docx/opc/part.py create mode 100644 tests/opc/test_part.py diff --git a/docx/__init__.py b/docx/__init__.py index a738c0781..f0f3ca482 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -8,7 +8,7 @@ # register custom Part classes with opc package reader from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory +from docx.opc.part import PartFactory from docx.parts.document import DocumentPart from docx.parts.image import ImagePart diff --git a/docx/opc/package.py b/docx/opc/package.py index 00ff30d57..024a8e54e 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -7,11 +7,9 @@ from __future__ import absolute_import, print_function, unicode_literals -from .compat import cls_method_fn from .constants import RELATIONSHIP_TYPE as RT -from .oxml import serialize_part_xml -from ..oxml import parse_xml -from .packuri import PACKAGE_URI, PackURI +from .packuri import PACKAGE_URI +from .part import PartFactory from .pkgreader import PackageReader from .pkgwriter import PackageWriter from .rel import Relationships @@ -169,224 +167,6 @@ def _core_properties_part(self): raise NotImplementedError -class Part(object): - """ - Base class for package parts. Provides common properties and methods, but - intended to be subclassed in client code to implement specific part - behaviors. - """ - def __init__(self, partname, content_type, blob=None, package=None): - super(Part, self).__init__() - self._partname = partname - self._content_type = content_type - self._blob = blob - self._package = package - - def after_unmarshal(self): - """ - Entry point for post-unmarshaling processing, for example to parse - the part XML. May be overridden by subclasses without forwarding call - to super. - """ - # don't place any code here, just catch call if not overridden by - # subclass - pass - - def before_marshal(self): - """ - Entry point for pre-serialization processing, for example to finalize - part naming if necessary. May be overridden by subclasses without - forwarding call to super. - """ - # don't place any code here, just catch call if not overridden by - # subclass - pass - - @property - def blob(self): - """ - Contents of this package part as a sequence of bytes. May be text or - binary. Intended to be overridden by subclasses. Default behavior is - to return load blob. - """ - return self._blob - - @property - def content_type(self): - """ - Content type of this part. - """ - return self._content_type - - def drop_rel(self, rId): - """ - Remove the relationship identified by *rId* if its reference count - is less than 2. Relationships with a reference count of 0 are - implicit relationships. - """ - if self._rel_ref_count(rId) < 2: - del self.rels[rId] - - @classmethod - def load(cls, partname, content_type, blob, package): - return cls(partname, content_type, blob, package) - - def load_rel(self, reltype, target, rId, is_external=False): - """ - Return newly added |_Relationship| instance of *reltype* between this - part and *target* with key *rId*. Target mode is set to - ``RTM.EXTERNAL`` if *is_external* is |True|. Intended for use during - load from a serialized package, where the rId is well-known. Other - methods exist for adding a new relationship to a part when - manipulating a part. - """ - return self.rels.add_relationship(reltype, target, rId, is_external) - - @property - def partname(self): - """ - |PackURI| instance holding partname of this part, e.g. - '/ppt/slides/slide1.xml' - """ - return self._partname - - @partname.setter - def partname(self, partname): - if not isinstance(partname, PackURI): - tmpl = "partname must be instance of PackURI, got '%s'" - raise TypeError(tmpl % type(partname).__name__) - self._partname = partname - - @property - def package(self): - """ - |OpcPackage| instance this part belongs to. - """ - return self._package - - def part_related_by(self, reltype): - """ - Return part to which this part has a relationship of *reltype*. - Raises |KeyError| if no such relationship is found and |ValueError| - if more than one such relationship is found. Provides ability to - resolve implicitly related part, such as Slide -> SlideLayout. - """ - return self.rels.part_with_reltype(reltype) - - def relate_to(self, target, reltype, is_external=False): - """ - Return rId key of relationship of *reltype* to *target*, from an - existing relationship if there is one, otherwise a newly created one. - """ - if is_external: - return self.rels.get_or_add_ext_rel(reltype, target) - else: - rel = self.rels.get_or_add(reltype, target) - return rel.rId - - @property - def related_parts(self): - """ - Dictionary mapping related parts by rId, so child objects can resolve - explicit relationships present in the part XML, e.g. sldIdLst to a - specific |Slide| instance. - """ - return self.rels.related_parts - - @lazyproperty - def rels(self): - """ - |Relationships| instance holding the relationships for this part. - """ - return Relationships(self._partname.baseURI) - - def target_ref(self, rId): - """ - Return URL contained in target ref of relationship identified by - *rId*. - """ - rel = self.rels[rId] - return rel.target_ref - - def _rel_ref_count(self, rId): - """ - Return the count of references in this part's XML to the relationship - identified by *rId*. - """ - rIds = self._element.xpath('//@r:id') - return len([_rId for _rId in rIds if _rId == rId]) - - -class XmlPart(Part): - """ - Base class for package parts containing an XML payload, which is most of - them. Provides additional methods to the |Part| base class that take care - of parsing and reserializing the XML payload and managing relationships - to other parts. - """ - def __init__(self, partname, content_type, element, package): - super(XmlPart, self).__init__( - partname, content_type, package=package - ) - self._element = element - - @property - def blob(self): - return serialize_part_xml(self._element) - - @classmethod - def load(cls, partname, content_type, blob, package): - element = parse_xml(blob) - return cls(partname, content_type, element, package) - - @property - def part(self): - """ - Part of the parent protocol, "children" of the document will not know - the part that contains them so must ask their parent object. That - chain of delegation ends here for child objects. - """ - return self - - -class PartFactory(object): - """ - Provides a way for client code to specify a subclass of |Part| to be - constructed by |Unmarshaller| based on its content type and/or a custom - callable. Setting ``PartFactory.part_class_selector`` to a callable - object will cause that object to be called with the parameters - ``content_type, reltype``, once for each part in the package. If the - callable returns an object, it is used as the class for that part. If it - returns |None|, part class selection falls back to the content type map - defined in ``PartFactory.part_type_for``. If no class is returned from - either of these, the class contained in ``PartFactory.default_part_type`` - is used to construct the part, which is by default ``opc.package.Part``. - """ - part_class_selector = None - part_type_for = {} - default_part_type = Part - - def __new__(cls, partname, content_type, reltype, blob, package): - PartClass = None - if cls.part_class_selector is not None: - part_class_selector = cls_method_fn(cls, 'part_class_selector') - PartClass = part_class_selector(content_type, reltype) - if PartClass is None: - PartClass = cls._part_cls_for(content_type) - return PartClass.load(partname, content_type, blob, package) - - @classmethod - def _part_cls_for(cls, content_type): - """ - Return the custom part class registered for *content_type*, or the - default part class if no custom class is registered for - *content_type*. - """ - if content_type in cls.part_type_for: - return cls.part_type_for[content_type] - return cls.default_part_type - - class Unmarshaller(object): """ Hosts static methods for unmarshalling a package from a |PackageReader| diff --git a/docx/opc/part.py b/docx/opc/part.py new file mode 100644 index 000000000..ed1362110 --- /dev/null +++ b/docx/opc/part.py @@ -0,0 +1,234 @@ +# encoding: utf-8 + +""" +Open Packaging Convention (OPC) objects related to package parts. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from .compat import cls_method_fn +from .oxml import serialize_part_xml +from ..oxml import parse_xml +from .packuri import PackURI +from .rel import Relationships +from .shared import lazyproperty + + +class Part(object): + """ + Base class for package parts. Provides common properties and methods, but + intended to be subclassed in client code to implement specific part + behaviors. + """ + def __init__(self, partname, content_type, blob=None, package=None): + super(Part, self).__init__() + self._partname = partname + self._content_type = content_type + self._blob = blob + self._package = package + + def after_unmarshal(self): + """ + Entry point for post-unmarshaling processing, for example to parse + the part XML. May be overridden by subclasses without forwarding call + to super. + """ + # don't place any code here, just catch call if not overridden by + # subclass + pass + + def before_marshal(self): + """ + Entry point for pre-serialization processing, for example to finalize + part naming if necessary. May be overridden by subclasses without + forwarding call to super. + """ + # don't place any code here, just catch call if not overridden by + # subclass + pass + + @property + def blob(self): + """ + Contents of this package part as a sequence of bytes. May be text or + binary. Intended to be overridden by subclasses. Default behavior is + to return load blob. + """ + return self._blob + + @property + def content_type(self): + """ + Content type of this part. + """ + return self._content_type + + def drop_rel(self, rId): + """ + Remove the relationship identified by *rId* if its reference count + is less than 2. Relationships with a reference count of 0 are + implicit relationships. + """ + if self._rel_ref_count(rId) < 2: + del self.rels[rId] + + @classmethod + def load(cls, partname, content_type, blob, package): + return cls(partname, content_type, blob, package) + + def load_rel(self, reltype, target, rId, is_external=False): + """ + Return newly added |_Relationship| instance of *reltype* between this + part and *target* with key *rId*. Target mode is set to + ``RTM.EXTERNAL`` if *is_external* is |True|. Intended for use during + load from a serialized package, where the rId is well-known. Other + methods exist for adding a new relationship to a part when + manipulating a part. + """ + return self.rels.add_relationship(reltype, target, rId, is_external) + + @property + def partname(self): + """ + |PackURI| instance holding partname of this part, e.g. + '/ppt/slides/slide1.xml' + """ + return self._partname + + @partname.setter + def partname(self, partname): + if not isinstance(partname, PackURI): + tmpl = "partname must be instance of PackURI, got '%s'" + raise TypeError(tmpl % type(partname).__name__) + self._partname = partname + + @property + def package(self): + """ + |OpcPackage| instance this part belongs to. + """ + return self._package + + def part_related_by(self, reltype): + """ + Return part to which this part has a relationship of *reltype*. + Raises |KeyError| if no such relationship is found and |ValueError| + if more than one such relationship is found. Provides ability to + resolve implicitly related part, such as Slide -> SlideLayout. + """ + return self.rels.part_with_reltype(reltype) + + def relate_to(self, target, reltype, is_external=False): + """ + Return rId key of relationship of *reltype* to *target*, from an + existing relationship if there is one, otherwise a newly created one. + """ + if is_external: + return self.rels.get_or_add_ext_rel(reltype, target) + else: + rel = self.rels.get_or_add(reltype, target) + return rel.rId + + @property + def related_parts(self): + """ + Dictionary mapping related parts by rId, so child objects can resolve + explicit relationships present in the part XML, e.g. sldIdLst to a + specific |Slide| instance. + """ + return self.rels.related_parts + + @lazyproperty + def rels(self): + """ + |Relationships| instance holding the relationships for this part. + """ + return Relationships(self._partname.baseURI) + + def target_ref(self, rId): + """ + Return URL contained in target ref of relationship identified by + *rId*. + """ + rel = self.rels[rId] + return rel.target_ref + + def _rel_ref_count(self, rId): + """ + Return the count of references in this part's XML to the relationship + identified by *rId*. + """ + rIds = self._element.xpath('//@r:id') + return len([_rId for _rId in rIds if _rId == rId]) + + +class PartFactory(object): + """ + Provides a way for client code to specify a subclass of |Part| to be + constructed by |Unmarshaller| based on its content type and/or a custom + callable. Setting ``PartFactory.part_class_selector`` to a callable + object will cause that object to be called with the parameters + ``content_type, reltype``, once for each part in the package. If the + callable returns an object, it is used as the class for that part. If it + returns |None|, part class selection falls back to the content type map + defined in ``PartFactory.part_type_for``. If no class is returned from + either of these, the class contained in ``PartFactory.default_part_type`` + is used to construct the part, which is by default ``opc.package.Part``. + """ + part_class_selector = None + part_type_for = {} + default_part_type = Part + + def __new__(cls, partname, content_type, reltype, blob, package): + PartClass = None + if cls.part_class_selector is not None: + part_class_selector = cls_method_fn(cls, 'part_class_selector') + PartClass = part_class_selector(content_type, reltype) + if PartClass is None: + PartClass = cls._part_cls_for(content_type) + return PartClass.load(partname, content_type, blob, package) + + @classmethod + def _part_cls_for(cls, content_type): + """ + Return the custom part class registered for *content_type*, or the + default part class if no custom class is registered for + *content_type*. + """ + if content_type in cls.part_type_for: + return cls.part_type_for[content_type] + return cls.default_part_type + + +class XmlPart(Part): + """ + Base class for package parts containing an XML payload, which is most of + them. Provides additional methods to the |Part| base class that take care + of parsing and reserializing the XML payload and managing relationships + to other parts. + """ + def __init__(self, partname, content_type, element, package): + super(XmlPart, self).__init__( + partname, content_type, package=package + ) + self._element = element + + @property + def blob(self): + return serialize_part_xml(self._element) + + @classmethod + def load(cls, partname, content_type, blob, package): + element = parse_xml(blob) + return cls(partname, content_type, element, package) + + @property + def part(self): + """ + Part of the parent protocol, "children" of the document will not know + the part that contains them so must ask their parent object. That + chain of delegation ends here for child objects. + """ + return self diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index 748e49cde..c45f4b6a5 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from ..package import XmlPart +from ..part import XmlPart class CorePropertiesPart(XmlPart): diff --git a/docx/parts/document.py b/docx/parts/document.py index e7ff08e8b..abf08b2d4 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -13,7 +13,7 @@ from ..blkcntnr import BlockItemContainer from ..enum.section import WD_SECTION from ..opc.constants import RELATIONSHIP_TYPE as RT -from ..opc.package import XmlPart +from ..opc.part import XmlPart from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented diff --git a/docx/parts/image.py b/docx/parts/image.py index 9cc698697..6ece20d80 100644 --- a/docx/parts/image.py +++ b/docx/parts/image.py @@ -11,7 +11,7 @@ import hashlib from docx.image.image import Image -from docx.opc.package import Part +from docx.opc.part import Part from docx.shared import Emu, Inches diff --git a/docx/parts/numbering.py b/docx/parts/numbering.py index e9c8f713d..e324c5aac 100644 --- a/docx/parts/numbering.py +++ b/docx/parts/numbering.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from ..opc.package import XmlPart +from ..opc.part import XmlPart from ..shared import lazyproperty diff --git a/docx/parts/styles.py b/docx/parts/styles.py index d9f4cfda9..d400ce50f 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from ..opc.package import XmlPart +from ..opc.part import XmlPart from ..shared import lazyproperty diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 610c3244d..b746270a3 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -9,20 +9,16 @@ import pytest from docx.opc.coreprops import CoreProperties -from docx.opc.packuri import PACKAGE_URI, PackURI -from docx.opc.package import ( - OpcPackage, Part, PartFactory, Unmarshaller, XmlPart -) +from docx.opc.package import OpcPackage, Unmarshaller +from docx.opc.packuri import PACKAGE_URI +from docx.opc.part import Part from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader from docx.opc.rel import _Relationship, Relationships -from docx.oxml.xmlchemy import BaseOxmlElement -from ..unitutil.cxml import element from ..unitutil.mock import ( - call, class_mock, cls_attr_mock, function_mock, initializer_mock, - instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock, - property_mock + call, class_mock, instance_mock, loose_mock, method_mock, Mock, patch, + PropertyMock, property_mock ) @@ -231,418 +227,6 @@ def Unmarshaller_(self, request): return class_mock(request, 'docx.opc.package.Unmarshaller') -class DescribePart(object): - - def it_can_be_constructed_by_PartFactory(self, load_fixture): - partname_, content_type_, blob_, package_, __init_ = load_fixture - part = Part.load(partname_, content_type_, blob_, package_) - __init_.assert_called_once_with( - partname_, content_type_, blob_, package_ - ) - assert isinstance(part, Part) - - def it_knows_its_partname(self, partname_get_fixture): - part, expected_partname = partname_get_fixture - assert part.partname == expected_partname - - def it_can_change_its_partname(self, partname_set_fixture): - part, new_partname = partname_set_fixture - part.partname = new_partname - assert part.partname == new_partname - - def it_knows_its_content_type(self, content_type_fixture): - part, expected_content_type = content_type_fixture - assert part.content_type == expected_content_type - - def it_knows_the_package_it_belongs_to(self, package_get_fixture): - part, expected_package = package_get_fixture - assert part.package == expected_package - - def it_can_be_notified_after_unmarshalling_is_complete(self, part): - part.after_unmarshal() - - def it_can_be_notified_before_marshalling_is_started(self, part): - part.before_marshal() - - def it_uses_the_load_blob_as_its_blob(self, blob_fixture): - part, load_blob = blob_fixture - assert part.blob is load_blob - - # fixtures --------------------------------------------- - - @pytest.fixture - def blob_fixture(self, blob_): - part = Part(None, None, blob_, None) - return part, blob_ - - @pytest.fixture - def content_type_fixture(self): - content_type = 'content/type' - part = Part(None, content_type, None, None) - return part, content_type - - @pytest.fixture - def load_fixture( - self, request, partname_, content_type_, blob_, package_, - __init_): - return (partname_, content_type_, blob_, package_, __init_) - - @pytest.fixture - def package_get_fixture(self, package_): - part = Part(None, None, None, package_) - return part, package_ - - @pytest.fixture - def part(self): - part = Part(None, None) - return part - - @pytest.fixture - def partname_get_fixture(self): - partname = PackURI('/part/name') - part = Part(partname, None, None, None) - return part, partname - - @pytest.fixture - def partname_set_fixture(self): - old_partname = PackURI('/old/part/name') - new_partname = PackURI('/new/part/name') - part = Part(old_partname, None, None, None) - return part, new_partname - - # fixture components --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def __init_(self, request): - return initializer_mock(request, Part) - - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - -class DescribePartRelationshipManagementInterface(object): - - def it_provides_access_to_its_relationships(self, rels_fixture): - part, Relationships_, partname_, rels_ = rels_fixture - rels = part.rels - Relationships_.assert_called_once_with(partname_.baseURI) - assert rels is rels_ - - def it_can_load_a_relationship(self, load_rel_fixture): - part, rels_, reltype_, target_, rId_ = load_rel_fixture - part.load_rel(reltype_, target_, rId_) - rels_.add_relationship.assert_called_once_with( - reltype_, target_, rId_, False - ) - - def it_can_establish_a_relationship_to_another_part( - self, relate_to_part_fixture): - part, target_, reltype_, rId_ = relate_to_part_fixture - rId = part.relate_to(target_, reltype_) - part.rels.get_or_add.assert_called_once_with(reltype_, target_) - assert rId is rId_ - - def it_can_establish_an_external_relationship( - self, relate_to_url_fixture): - part, url_, reltype_, rId_ = relate_to_url_fixture - rId = part.relate_to(url_, reltype_, is_external=True) - part.rels.get_or_add_ext_rel.assert_called_once_with(reltype_, url_) - assert rId is rId_ - - def it_can_drop_a_relationship(self, drop_rel_fixture): - part, rId, rel_should_be_gone = drop_rel_fixture - part.drop_rel(rId) - if rel_should_be_gone: - assert rId not in part.rels - else: - assert rId in part.rels - - def it_can_find_a_related_part_by_reltype(self, related_part_fixture): - part, reltype_, related_part_ = related_part_fixture - related_part = part.part_related_by(reltype_) - part.rels.part_with_reltype.assert_called_once_with(reltype_) - assert related_part is related_part_ - - def it_can_find_a_related_part_by_rId(self, related_parts_fixture): - part, related_parts_ = related_parts_fixture - assert part.related_parts is related_parts_ - - def it_can_find_the_uri_of_an_external_relationship( - self, target_ref_fixture): - part, rId_, url_ = target_ref_fixture - url = part.target_ref(rId_) - assert url == url_ - - # fixtures --------------------------------------------- - - @pytest.fixture(params=[ - ('w:p', True), - ('w:p/r:a{r:id=rId42}', True), - ('w:p/r:a{r:id=rId42}/r:b{r:id=rId42}', False), - ]) - def drop_rel_fixture(self, request, part): - part_cxml, rel_should_be_dropped = request.param - rId = 'rId42' - part._element = element(part_cxml) - part._rels = {rId: None} - return part, rId, rel_should_be_dropped - - @pytest.fixture - def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): - part._rels = rels_ - return part, rels_, reltype_, part_, rId_ - - @pytest.fixture - def relate_to_part_fixture( - self, request, part, reltype_, part_, rels_, rId_): - part._rels = rels_ - target_ = part_ - return part, target_, reltype_, rId_ - - @pytest.fixture - def relate_to_url_fixture( - self, request, part, rels_, url_, reltype_, rId_): - part._rels = rels_ - return part, url_, reltype_, rId_ - - @pytest.fixture - def related_part_fixture(self, request, part, rels_, reltype_, part_): - part._rels = rels_ - return part, reltype_, part_ - - @pytest.fixture - def related_parts_fixture(self, request, part, rels_, related_parts_): - part._rels = rels_ - return part, related_parts_ - - @pytest.fixture - def rels_fixture(self, Relationships_, partname_, rels_): - part = Part(partname_, None) - return part, Relationships_, partname_, rels_ - - @pytest.fixture - def target_ref_fixture(self, request, part, rId_, rel_, url_): - part._rels = {rId_: rel_} - return part, rId_, url_ - - # fixture components --------------------------------------------- - - @pytest.fixture - def part(self): - return Part(None, None) - - @pytest.fixture - def part_(self, request): - return instance_mock(request, Part) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def Relationships_(self, request, rels_): - return class_mock( - request, 'docx.opc.package.Relationships', return_value=rels_ - ) - - @pytest.fixture - def rel_(self, request, rId_, url_): - return instance_mock( - request, _Relationship, rId=rId_, target_ref=url_ - ) - - @pytest.fixture - def rels_(self, request, part_, rel_, rId_, related_parts_): - rels_ = instance_mock(request, Relationships) - rels_.part_with_reltype.return_value = part_ - rels_.get_or_add.return_value = rel_ - rels_.get_or_add_ext_rel.return_value = rId_ - rels_.related_parts = related_parts_ - return rels_ - - @pytest.fixture - def related_parts_(self, request): - return instance_mock(request, dict) - - @pytest.fixture - def reltype_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def rId_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def url_(self, request): - return instance_mock(request, str) - - -class DescribePartFactory(object): - - def it_constructs_part_from_selector_if_defined( - self, cls_selector_fixture): - # fixture ---------------------- - (cls_selector_fn_, part_load_params, CustomPartClass_, - part_of_custom_type_) = cls_selector_fixture - partname, content_type, reltype, blob, package = part_load_params - # exercise --------------------- - PartFactory.part_class_selector = cls_selector_fn_ - part = PartFactory(partname, content_type, reltype, blob, package) - # verify ----------------------- - cls_selector_fn_.assert_called_once_with(content_type, reltype) - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) - assert part is part_of_custom_type_ - - def it_constructs_custom_part_type_for_registered_content_types( - self, part_args_, CustomPartClass_, part_of_custom_type_): - # fixture ---------------------- - partname, content_type, reltype, package, blob = part_args_ - # exercise --------------------- - PartFactory.part_type_for[content_type] = CustomPartClass_ - part = PartFactory(partname, content_type, reltype, blob, package) - # verify ----------------------- - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) - assert part is part_of_custom_type_ - - def it_constructs_part_using_default_class_when_no_custom_registered( - self, part_args_2_, DefaultPartClass_, part_of_default_type_): - partname, content_type, reltype, blob, package = part_args_2_ - part = PartFactory(partname, content_type, reltype, blob, package) - DefaultPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) - assert part is part_of_default_type_ - - # fixtures --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def blob_2_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def cls_method_fn_(self, request, cls_selector_fn_): - return function_mock( - request, 'docx.opc.package.cls_method_fn', - return_value=cls_selector_fn_ - ) - - @pytest.fixture - def cls_selector_fixture( - self, request, cls_selector_fn_, cls_method_fn_, part_load_params, - CustomPartClass_, part_of_custom_type_): - def reset_part_class_selector(): - PartFactory.part_class_selector = original_part_class_selector - original_part_class_selector = PartFactory.part_class_selector - request.addfinalizer(reset_part_class_selector) - return ( - cls_selector_fn_, part_load_params, CustomPartClass_, - part_of_custom_type_ - ) - - @pytest.fixture - def cls_selector_fn_(self, request, CustomPartClass_): - cls_selector_fn_ = loose_mock(request) - # Python 3 version - cls_selector_fn_.return_value = CustomPartClass_ - # Python 2 version - cls_selector_fn_.__func__ = loose_mock( - request, name='__func__', return_value=cls_selector_fn_ - ) - return cls_selector_fn_ - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def content_type_2_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def CustomPartClass_(self, request, part_of_custom_type_): - CustomPartClass_ = Mock(name='CustomPartClass', spec=Part) - CustomPartClass_.load.return_value = part_of_custom_type_ - return CustomPartClass_ - - @pytest.fixture - def DefaultPartClass_(self, request, part_of_default_type_): - DefaultPartClass_ = cls_attr_mock( - request, PartFactory, 'default_part_type' - ) - DefaultPartClass_.load.return_value = part_of_default_type_ - return DefaultPartClass_ - - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def package_2_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def part_load_params( - self, partname_, content_type_, reltype_, blob_, package_): - return partname_, content_type_, reltype_, blob_, package_ - - @pytest.fixture - def part_of_custom_type_(self, request): - return instance_mock(request, Part) - - @pytest.fixture - def part_of_default_type_(self, request): - return instance_mock(request, Part) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def partname_2_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def part_args_( - self, request, partname_, content_type_, reltype_, package_, - blob_): - return partname_, content_type_, reltype_, blob_, package_ - - @pytest.fixture - def part_args_2_( - self, request, partname_2_, content_type_2_, reltype_2_, - package_2_, blob_2_): - return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ - - @pytest.fixture - def reltype_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def reltype_2_(self, request): - return instance_mock(request, str) - - class DescribeUnmarshaller(object): def it_can_unmarshal_from_a_pkg_reader( @@ -788,86 +372,3 @@ def _unmarshal_parts(self, request, parts_dict_): @pytest.fixture def _unmarshal_relationships(self, request): return method_mock(request, Unmarshaller, '_unmarshal_relationships') - - -class DescribeXmlPart(object): - - def it_can_be_constructed_by_PartFactory(self, load_fixture): - partname_, content_type_, blob_, package_ = load_fixture[:4] - element_, parse_xml_, __init_ = load_fixture[4:] - # exercise --------------------- - part = XmlPart.load(partname_, content_type_, blob_, package_) - # verify ----------------------- - parse_xml_.assert_called_once_with(blob_) - __init_.assert_called_once_with( - partname_, content_type_, element_, package_ - ) - assert isinstance(part, XmlPart) - - def it_can_serialize_to_xml(self, blob_fixture): - xml_part, element_, serialize_part_xml_ = blob_fixture - blob = xml_part.blob - serialize_part_xml_.assert_called_once_with(element_) - assert blob is serialize_part_xml_.return_value - - def it_knows_its_the_part_for_its_child_objects(self, part_fixture): - xml_part = part_fixture - assert xml_part.part is xml_part - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def blob_fixture(self, request, element_, serialize_part_xml_): - xml_part = XmlPart(None, None, element_, None) - return xml_part, element_, serialize_part_xml_ - - @pytest.fixture - def load_fixture( - self, request, partname_, content_type_, blob_, package_, - element_, parse_xml_, __init_): - return ( - partname_, content_type_, blob_, package_, element_, parse_xml_, - __init_ - ) - - @pytest.fixture - def part_fixture(self): - return XmlPart(None, None, None, None) - - # fixture components --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def element_(self, request): - return instance_mock(request, BaseOxmlElement) - - @pytest.fixture - def __init_(self, request): - return initializer_mock(request, XmlPart) - - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def parse_xml_(self, request, element_): - return function_mock( - request, 'docx.opc.package.parse_xml', return_value=element_ - ) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def serialize_part_xml_(self, request): - return function_mock( - request, 'docx.opc.package.serialize_part_xml' - ) diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py new file mode 100644 index 000000000..4721e5be4 --- /dev/null +++ b/tests/opc/test_part.py @@ -0,0 +1,518 @@ +# encoding: utf-8 + +""" +Test suite for docx.opc.part module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.package import OpcPackage +from docx.opc.packuri import PackURI +from docx.opc.part import Part, PartFactory, XmlPart +from docx.opc.rel import _Relationship, Relationships +from docx.oxml.xmlchemy import BaseOxmlElement + +from ..unitutil.cxml import element +from ..unitutil.mock import ( + class_mock, cls_attr_mock, function_mock, initializer_mock, + instance_mock, loose_mock, Mock +) + + +class DescribePart(object): + + def it_can_be_constructed_by_PartFactory(self, load_fixture): + partname_, content_type_, blob_, package_, __init_ = load_fixture + part = Part.load(partname_, content_type_, blob_, package_) + __init_.assert_called_once_with( + partname_, content_type_, blob_, package_ + ) + assert isinstance(part, Part) + + def it_knows_its_partname(self, partname_get_fixture): + part, expected_partname = partname_get_fixture + assert part.partname == expected_partname + + def it_can_change_its_partname(self, partname_set_fixture): + part, new_partname = partname_set_fixture + part.partname = new_partname + assert part.partname == new_partname + + def it_knows_its_content_type(self, content_type_fixture): + part, expected_content_type = content_type_fixture + assert part.content_type == expected_content_type + + def it_knows_the_package_it_belongs_to(self, package_get_fixture): + part, expected_package = package_get_fixture + assert part.package == expected_package + + def it_can_be_notified_after_unmarshalling_is_complete(self, part): + part.after_unmarshal() + + def it_can_be_notified_before_marshalling_is_started(self, part): + part.before_marshal() + + def it_uses_the_load_blob_as_its_blob(self, blob_fixture): + part, load_blob = blob_fixture + assert part.blob is load_blob + + # fixtures --------------------------------------------- + + @pytest.fixture + def blob_fixture(self, blob_): + part = Part(None, None, blob_, None) + return part, blob_ + + @pytest.fixture + def content_type_fixture(self): + content_type = 'content/type' + part = Part(None, content_type, None, None) + return part, content_type + + @pytest.fixture + def load_fixture( + self, request, partname_, content_type_, blob_, package_, + __init_): + return (partname_, content_type_, blob_, package_, __init_) + + @pytest.fixture + def package_get_fixture(self, package_): + part = Part(None, None, None, package_) + return part, package_ + + @pytest.fixture + def part(self): + part = Part(None, None) + return part + + @pytest.fixture + def partname_get_fixture(self): + partname = PackURI('/part/name') + part = Part(partname, None, None, None) + return part, partname + + @pytest.fixture + def partname_set_fixture(self): + old_partname = PackURI('/old/part/name') + new_partname = PackURI('/new/part/name') + part = Part(old_partname, None, None, None) + return part, new_partname + + # fixture components --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, bytes) + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def __init_(self, request): + return initializer_mock(request, Part) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + +class DescribePartRelationshipManagementInterface(object): + + def it_provides_access_to_its_relationships(self, rels_fixture): + part, Relationships_, partname_, rels_ = rels_fixture + rels = part.rels + Relationships_.assert_called_once_with(partname_.baseURI) + assert rels is rels_ + + def it_can_load_a_relationship(self, load_rel_fixture): + part, rels_, reltype_, target_, rId_ = load_rel_fixture + part.load_rel(reltype_, target_, rId_) + rels_.add_relationship.assert_called_once_with( + reltype_, target_, rId_, False + ) + + def it_can_establish_a_relationship_to_another_part( + self, relate_to_part_fixture): + part, target_, reltype_, rId_ = relate_to_part_fixture + rId = part.relate_to(target_, reltype_) + part.rels.get_or_add.assert_called_once_with(reltype_, target_) + assert rId is rId_ + + def it_can_establish_an_external_relationship( + self, relate_to_url_fixture): + part, url_, reltype_, rId_ = relate_to_url_fixture + rId = part.relate_to(url_, reltype_, is_external=True) + part.rels.get_or_add_ext_rel.assert_called_once_with(reltype_, url_) + assert rId is rId_ + + def it_can_drop_a_relationship(self, drop_rel_fixture): + part, rId, rel_should_be_gone = drop_rel_fixture + part.drop_rel(rId) + if rel_should_be_gone: + assert rId not in part.rels + else: + assert rId in part.rels + + def it_can_find_a_related_part_by_reltype(self, related_part_fixture): + part, reltype_, related_part_ = related_part_fixture + related_part = part.part_related_by(reltype_) + part.rels.part_with_reltype.assert_called_once_with(reltype_) + assert related_part is related_part_ + + def it_can_find_a_related_part_by_rId(self, related_parts_fixture): + part, related_parts_ = related_parts_fixture + assert part.related_parts is related_parts_ + + def it_can_find_the_uri_of_an_external_relationship( + self, target_ref_fixture): + part, rId_, url_ = target_ref_fixture + url = part.target_ref(rId_) + assert url == url_ + + # fixtures --------------------------------------------- + + @pytest.fixture(params=[ + ('w:p', True), + ('w:p/r:a{r:id=rId42}', True), + ('w:p/r:a{r:id=rId42}/r:b{r:id=rId42}', False), + ]) + def drop_rel_fixture(self, request, part): + part_cxml, rel_should_be_dropped = request.param + rId = 'rId42' + part._element = element(part_cxml) + part._rels = {rId: None} + return part, rId, rel_should_be_dropped + + @pytest.fixture + def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): + part._rels = rels_ + return part, rels_, reltype_, part_, rId_ + + @pytest.fixture + def relate_to_part_fixture( + self, request, part, reltype_, part_, rels_, rId_): + part._rels = rels_ + target_ = part_ + return part, target_, reltype_, rId_ + + @pytest.fixture + def relate_to_url_fixture( + self, request, part, rels_, url_, reltype_, rId_): + part._rels = rels_ + return part, url_, reltype_, rId_ + + @pytest.fixture + def related_part_fixture(self, request, part, rels_, reltype_, part_): + part._rels = rels_ + return part, reltype_, part_ + + @pytest.fixture + def related_parts_fixture(self, request, part, rels_, related_parts_): + part._rels = rels_ + return part, related_parts_ + + @pytest.fixture + def rels_fixture(self, Relationships_, partname_, rels_): + part = Part(partname_, None) + return part, Relationships_, partname_, rels_ + + @pytest.fixture + def target_ref_fixture(self, request, part, rId_, rel_, url_): + part._rels = {rId_: rel_} + return part, rId_, url_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def part(self): + return Part(None, None) + + @pytest.fixture + def part_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def Relationships_(self, request, rels_): + return class_mock( + request, 'docx.opc.part.Relationships', return_value=rels_ + ) + + @pytest.fixture + def rel_(self, request, rId_, url_): + return instance_mock( + request, _Relationship, rId=rId_, target_ref=url_ + ) + + @pytest.fixture + def rels_(self, request, part_, rel_, rId_, related_parts_): + rels_ = instance_mock(request, Relationships) + rels_.part_with_reltype.return_value = part_ + rels_.get_or_add.return_value = rel_ + rels_.get_or_add_ext_rel.return_value = rId_ + rels_.related_parts = related_parts_ + return rels_ + + @pytest.fixture + def related_parts_(self, request): + return instance_mock(request, dict) + + @pytest.fixture + def reltype_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def rId_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def url_(self, request): + return instance_mock(request, str) + + +class DescribePartFactory(object): + + def it_constructs_part_from_selector_if_defined( + self, cls_selector_fixture): + # fixture ---------------------- + (cls_selector_fn_, part_load_params, CustomPartClass_, + part_of_custom_type_) = cls_selector_fixture + partname, content_type, reltype, blob, package = part_load_params + # exercise --------------------- + PartFactory.part_class_selector = cls_selector_fn_ + part = PartFactory(partname, content_type, reltype, blob, package) + # verify ----------------------- + cls_selector_fn_.assert_called_once_with(content_type, reltype) + CustomPartClass_.load.assert_called_once_with( + partname, content_type, blob, package + ) + assert part is part_of_custom_type_ + + def it_constructs_custom_part_type_for_registered_content_types( + self, part_args_, CustomPartClass_, part_of_custom_type_): + # fixture ---------------------- + partname, content_type, reltype, package, blob = part_args_ + # exercise --------------------- + PartFactory.part_type_for[content_type] = CustomPartClass_ + part = PartFactory(partname, content_type, reltype, blob, package) + # verify ----------------------- + CustomPartClass_.load.assert_called_once_with( + partname, content_type, blob, package + ) + assert part is part_of_custom_type_ + + def it_constructs_part_using_default_class_when_no_custom_registered( + self, part_args_2_, DefaultPartClass_, part_of_default_type_): + partname, content_type, reltype, blob, package = part_args_2_ + part = PartFactory(partname, content_type, reltype, blob, package) + DefaultPartClass_.load.assert_called_once_with( + partname, content_type, blob, package + ) + assert part is part_of_default_type_ + + # fixtures --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def blob_2_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def cls_method_fn_(self, request, cls_selector_fn_): + return function_mock( + request, 'docx.opc.part.cls_method_fn', + return_value=cls_selector_fn_ + ) + + @pytest.fixture + def cls_selector_fixture( + self, request, cls_selector_fn_, cls_method_fn_, part_load_params, + CustomPartClass_, part_of_custom_type_): + def reset_part_class_selector(): + PartFactory.part_class_selector = original_part_class_selector + original_part_class_selector = PartFactory.part_class_selector + request.addfinalizer(reset_part_class_selector) + return ( + cls_selector_fn_, part_load_params, CustomPartClass_, + part_of_custom_type_ + ) + + @pytest.fixture + def cls_selector_fn_(self, request, CustomPartClass_): + cls_selector_fn_ = loose_mock(request) + # Python 3 version + cls_selector_fn_.return_value = CustomPartClass_ + # Python 2 version + cls_selector_fn_.__func__ = loose_mock( + request, name='__func__', return_value=cls_selector_fn_ + ) + return cls_selector_fn_ + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def content_type_2_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def CustomPartClass_(self, request, part_of_custom_type_): + CustomPartClass_ = Mock(name='CustomPartClass', spec=Part) + CustomPartClass_.load.return_value = part_of_custom_type_ + return CustomPartClass_ + + @pytest.fixture + def DefaultPartClass_(self, request, part_of_default_type_): + DefaultPartClass_ = cls_attr_mock( + request, PartFactory, 'default_part_type' + ) + DefaultPartClass_.load.return_value = part_of_default_type_ + return DefaultPartClass_ + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def package_2_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def part_load_params( + self, partname_, content_type_, reltype_, blob_, package_): + return partname_, content_type_, reltype_, blob_, package_ + + @pytest.fixture + def part_of_custom_type_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def part_of_default_type_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def partname_2_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def part_args_( + self, request, partname_, content_type_, reltype_, package_, + blob_): + return partname_, content_type_, reltype_, blob_, package_ + + @pytest.fixture + def part_args_2_( + self, request, partname_2_, content_type_2_, reltype_2_, + package_2_, blob_2_): + return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ + + @pytest.fixture + def reltype_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def reltype_2_(self, request): + return instance_mock(request, str) + + +class DescribeXmlPart(object): + + def it_can_be_constructed_by_PartFactory(self, load_fixture): + partname_, content_type_, blob_, package_ = load_fixture[:4] + element_, parse_xml_, __init_ = load_fixture[4:] + # exercise --------------------- + part = XmlPart.load(partname_, content_type_, blob_, package_) + # verify ----------------------- + parse_xml_.assert_called_once_with(blob_) + __init_.assert_called_once_with( + partname_, content_type_, element_, package_ + ) + assert isinstance(part, XmlPart) + + def it_can_serialize_to_xml(self, blob_fixture): + xml_part, element_, serialize_part_xml_ = blob_fixture + blob = xml_part.blob + serialize_part_xml_.assert_called_once_with(element_) + assert blob is serialize_part_xml_.return_value + + def it_knows_its_the_part_for_its_child_objects(self, part_fixture): + xml_part = part_fixture + assert xml_part.part is xml_part + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def blob_fixture(self, request, element_, serialize_part_xml_): + xml_part = XmlPart(None, None, element_, None) + return xml_part, element_, serialize_part_xml_ + + @pytest.fixture + def load_fixture( + self, request, partname_, content_type_, blob_, package_, + element_, parse_xml_, __init_): + return ( + partname_, content_type_, blob_, package_, element_, parse_xml_, + __init_ + ) + + @pytest.fixture + def part_fixture(self): + return XmlPart(None, None, None, None) + + # fixture components --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def element_(self, request): + return instance_mock(request, BaseOxmlElement) + + @pytest.fixture + def __init_(self, request): + return initializer_mock(request, XmlPart) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def parse_xml_(self, request, element_): + return function_mock( + request, 'docx.opc.part.parse_xml', return_value=element_ + ) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def serialize_part_xml_(self, request): + return function_mock( + request, 'docx.opc.part.serialize_part_xml' + ) diff --git a/tests/opc/test_pkgwriter.py b/tests/opc/test_pkgwriter.py index 9e8806f13..d119748dd 100644 --- a/tests/opc/test_pkgwriter.py +++ b/tests/opc/test_pkgwriter.py @@ -7,8 +7,8 @@ import pytest from docx.opc.constants import CONTENT_TYPE as CT -from docx.opc.package import Part from docx.opc.packuri import PackURI +from docx.opc.part import Part from docx.opc.phys_pkg import _ZipPkgWriter from docx.opc.pkgwriter import _ContentTypesItem, PackageWriter diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py index db39aa145..db9b52b59 100644 --- a/tests/opc/test_rel.py +++ b/tests/opc/test_rel.py @@ -11,8 +11,8 @@ import pytest from docx.opc.oxml import CT_Relationships -from docx.opc.package import Part from docx.opc.packuri import PackURI +from docx.opc.part import Part from docx.opc.rel import _Relationship, Relationships from ..unitutil.mock import ( diff --git a/tests/opc/unitdata/rels.py b/tests/opc/unitdata/rels.py index f55f4c5f9..94e45167e 100644 --- a/tests/opc/unitdata/rels.py +++ b/tests/opc/unitdata/rels.py @@ -7,7 +7,7 @@ from __future__ import absolute_import from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.opc.package import Relationships +from docx.opc.rel import Relationships from docx.opc.constants import NAMESPACE as NS from docx.opc.oxml import parse_xml diff --git a/tests/parts/test_image.py b/tests/parts/test_image.py index 1e1ffc81f..177301345 100644 --- a/tests/parts/test_image.py +++ b/tests/parts/test_image.py @@ -10,8 +10,8 @@ from docx.image.image import Image from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory from docx.opc.packuri import PackURI +from docx.opc.part import PartFactory from docx.package import Package from docx.parts.image import ImagePart From 07127f26dafadff89e42bce5a48a53532cae6f8f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 19:33:31 -0800 Subject: [PATCH 050/615] opc: add OpcPackage._core_properties_part --- docx/__init__.py | 7 +++++- docx/opc/package.py | 8 +++++- docx/opc/parts/coreprops.py | 8 ++++++ tests/opc/test_package.py | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docx/__init__.py b/docx/__init__.py index f0f3ca482..5a24333a1 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -9,6 +9,7 @@ from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT from docx.opc.part import PartFactory +from docx.opc.parts.coreprops import CorePropertiesPart from docx.parts.document import DocumentPart from docx.parts.image import ImagePart @@ -23,8 +24,12 @@ def part_class_selector(content_type, reltype): PartFactory.part_class_selector = part_class_selector +PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_STYLES] = StylesPart -del CT, DocumentPart, PartFactory, part_class_selector +del ( + CT, CorePropertiesPart, DocumentPart, NumberingPart, PartFactory, + StylesPart, part_class_selector +) diff --git a/docx/opc/package.py b/docx/opc/package.py index 024a8e54e..3595f46a6 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -10,6 +10,7 @@ from .constants import RELATIONSHIP_TYPE as RT from .packuri import PACKAGE_URI from .part import PartFactory +from .parts.coreprops import CorePropertiesPart from .pkgreader import PackageReader from .pkgwriter import PackageWriter from .rel import Relationships @@ -164,7 +165,12 @@ def _core_properties_part(self): |CorePropertiesPart| object related to this package. Creates a default core properties part if one is not present (not common). """ - raise NotImplementedError + try: + return self.part_related_by(RT.CORE_PROPERTIES) + except KeyError: + core_properties_part = CorePropertiesPart.default(self) + self.relate_to(core_properties_part, RT.CORE_PROPERTIES) + return core_properties_part class Unmarshaller(object): diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index c45f4b6a5..f4ad480ba 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -16,6 +16,14 @@ class CorePropertiesPart(XmlPart): Corresponds to part named ``/docProps/core.xml``, containing the core document properties for this document package. """ + @classmethod + def default(cls, package): + """ + Return a new |CorePropertiesPart| object initialized with default + values for its base properties. + """ + raise NotImplementedError + @property def core_properties(self): """ diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index b746270a3..f5b7ac3f7 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -8,6 +8,7 @@ import pytest +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties from docx.opc.package import OpcPackage, Unmarshaller from docx.opc.packuri import PACKAGE_URI @@ -116,6 +117,26 @@ def it_provides_access_to_the_core_properties(self, core_props_fixture): core_properties = opc_package.core_properties assert core_properties is core_properties_ + def it_provides_access_to_the_core_properties_part_to_help( + self, core_props_part_fixture): + opc_package, core_properties_part_ = core_props_part_fixture + core_properties_part = opc_package._core_properties_part + assert core_properties_part is core_properties_part_ + + def it_creates_a_default_core_props_part_if_none_present( + self, default_core_props_fixture): + opc_package, CorePropertiesPart_, core_properties_part_ = ( + default_core_props_fixture + ) + + core_properties_part = opc_package._core_properties_part + + CorePropertiesPart_.default.assert_called_once_with(opc_package) + opc_package.relate_to.assert_called_once_with( + core_properties_part_, RT.CORE_PROPERTIES + ) + assert core_properties_part is core_properties_part_ + # fixtures --------------------------------------------- @pytest.fixture @@ -127,6 +148,22 @@ def core_props_fixture( core_properties_part_.core_properties = core_properties_ return opc_package, core_properties_ + @pytest.fixture + def core_props_part_fixture( + self, part_related_by_, core_properties_part_): + opc_package = OpcPackage() + part_related_by_.return_value = core_properties_part_ + return opc_package, core_properties_part_ + + @pytest.fixture + def default_core_props_fixture( + self, part_related_by_, CorePropertiesPart_, relate_to_, + core_properties_part_): + opc_package = OpcPackage() + part_related_by_.side_effect = KeyError + CorePropertiesPart_.default.return_value = core_properties_part_ + return opc_package, CorePropertiesPart_, core_properties_part_ + @pytest.fixture def relate_to_part_fixture_(self, request, pkg, rels_, reltype): rId = 'rId99' @@ -146,6 +183,10 @@ def related_part_fixture_(self, request, rels_, reltype): # fixture components ----------------------------------- + @pytest.fixture + def CorePropertiesPart_(self, request): + return class_mock(request, 'docx.opc.package.CorePropertiesPart') + @pytest.fixture def core_properties_(self, request): return instance_mock(request, CoreProperties) @@ -170,6 +211,10 @@ def PackageWriter_(self, request): def PartFactory_(self, request): return class_mock(request, 'docx.opc.package.PartFactory') + @pytest.fixture + def part_related_by_(self, request): + return method_mock(request, OpcPackage, 'part_related_by') + @pytest.fixture def parts(self, request, parts_): """ @@ -214,6 +259,10 @@ def rel_attrs_(self, request): rId = 'rId99' return reltype, target_, rId + @pytest.fixture + def relate_to_(self, request): + return method_mock(request, OpcPackage, 'relate_to') + @pytest.fixture def rels_(self, request): return instance_mock(request, Relationships) From 7e92b9afcde06c365c5743b1b52f3d5125196f98 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 20:33:47 -0800 Subject: [PATCH 051/615] opc: add CorePropertiesPart.core_properties --- docx/opc/coreprops.py | 2 ++ docx/opc/part.py | 7 +++++ docx/opc/parts/coreprops.py | 3 ++- docx/oxml/parts/coreprops.py | 21 +++++++++++++++ tests/opc/parts/__init__.py | 0 tests/opc/parts/test_coreprops.py | 43 +++++++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 docx/oxml/parts/coreprops.py create mode 100644 tests/opc/parts/__init__.py create mode 100644 tests/opc/parts/test_coreprops.py diff --git a/docx/opc/coreprops.py b/docx/opc/coreprops.py index cc8091ef3..516d3e548 100644 --- a/docx/opc/coreprops.py +++ b/docx/opc/coreprops.py @@ -15,3 +15,5 @@ class CoreProperties(object): Corresponds to part named ``/docProps/core.xml``, containing the core document properties for this document package. """ + def __init__(self, element): + self._element = element diff --git a/docx/opc/part.py b/docx/opc/part.py index ed1362110..1196eee5c 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -219,6 +219,13 @@ def __init__(self, partname, content_type, element, package): def blob(self): return serialize_part_xml(self._element) + @property + def element(self): + """ + The root XML element of this XML part. + """ + return self._element + @classmethod def load(cls, partname, content_type, blob, package): element = parse_xml(blob) diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index f4ad480ba..14dbd3e76 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -8,6 +8,7 @@ absolute_import, division, print_function, unicode_literals ) +from ..coreprops import CoreProperties from ..part import XmlPart @@ -30,4 +31,4 @@ def core_properties(self): A |CoreProperties| object providing read/write access to the core properties contained in this core properties part. """ - raise NotImplementedError + return CoreProperties(self.element) diff --git a/docx/oxml/parts/coreprops.py b/docx/oxml/parts/coreprops.py new file mode 100644 index 000000000..43f3fffd0 --- /dev/null +++ b/docx/oxml/parts/coreprops.py @@ -0,0 +1,21 @@ +# encoding: utf-8 + +""" +lxml custom element classes for core properties-related XML elements. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from ..xmlchemy import BaseOxmlElement + + +class CT_CoreProperties(BaseOxmlElement): + """ + ```` element, the root element of the Core Properties + part stored as ``/docProps/core.xml``. Implements many of the Dublin Core + document metadata elements. String elements resolve to an empty string + ('') if the element is not present in the XML. String elements are + limited in length to 255 unicode characters. + """ diff --git a/tests/opc/parts/__init__.py b/tests/opc/parts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/opc/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py new file mode 100644 index 000000000..d1585a128 --- /dev/null +++ b/tests/opc/parts/test_coreprops.py @@ -0,0 +1,43 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.parts.coreprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.coreprops import CoreProperties +from docx.opc.parts.coreprops import CorePropertiesPart +from docx.oxml.parts.coreprops import CT_CoreProperties + +from ...unitutil.mock import class_mock, instance_mock + + +class DescribeCorePropertiesPart(object): + + def it_provides_access_to_its_core_props_object(self, coreprops_fixture): + core_properties_part, CoreProperties_ = coreprops_fixture + core_properties = core_properties_part.core_properties + CoreProperties_.assert_called_once_with(core_properties_part.element) + assert isinstance(core_properties, CoreProperties) + + # fixtures --------------------------------------------- + + @pytest.fixture + def coreprops_fixture(self, element_, CoreProperties_): + core_properties_part = CorePropertiesPart(None, None, element_, None) + return core_properties_part, CoreProperties_ + + # fixture components ----------------------------------- + + @pytest.fixture + def CoreProperties_(self, request): + return class_mock(request, 'docx.opc.parts.coreprops.CoreProperties') + + @pytest.fixture + def element_(self, request): + return instance_mock(request, CT_CoreProperties) From 358b408068aa77ad120e8f52d5f9189d9dfbd6a4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:15:07 -0800 Subject: [PATCH 052/615] rfctr: use relative imports in oxml.__init__ --- docx/oxml/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index b397a1b46..f8d20904d 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -64,9 +64,9 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): # custom element class mappings # =========================================================================== -from docx.oxml.shared import CT_DecimalNumber, CT_OnOff, CT_String +from .shared import CT_DecimalNumber, CT_OnOff, CT_String -from docx.oxml.shape import ( +from .shape import ( CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, @@ -87,11 +87,11 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('wp:extent', CT_PositiveSize2D) register_element_cls('wp:inline', CT_Inline) -from docx.oxml.parts.document import CT_Body, CT_Document +from .parts.document import CT_Body, CT_Document register_element_cls('w:body', CT_Body) register_element_cls('w:document', CT_Document) -from docx.oxml.parts.numbering import ( +from .parts.numbering import ( CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr ) register_element_cls('w:abstractNumId', CT_DecimalNumber) @@ -103,17 +103,17 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:numbering', CT_Numbering) register_element_cls('w:startOverride', CT_DecimalNumber) -from docx.oxml.parts.styles import CT_Style, CT_Styles +from .parts.styles import CT_Style, CT_Styles register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) -from docx.oxml.section import CT_PageMar, CT_PageSz, CT_SectPr, CT_SectType +from .section import CT_PageMar, CT_PageSz, CT_SectPr, CT_SectType register_element_cls('w:pgMar', CT_PageMar) register_element_cls('w:pgSz', CT_PageSz) register_element_cls('w:sectPr', CT_SectPr) register_element_cls('w:type', CT_SectType) -from docx.oxml.table import ( +from .table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, CT_TblWidth, CT_Tc, CT_TcPr, CT_VMerge ) @@ -130,7 +130,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) register_element_cls('w:vMerge', CT_VMerge) -from docx.oxml.text import ( +from .text import ( CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline ) register_element_cls('w:b', CT_OnOff) From 4d2c4656ba8cb7e7e2ab479663c5916f97e14143 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:13:01 -0800 Subject: [PATCH 053/615] opc: transplant CoreProperties --- docx/opc/coreprops.py | 120 ++++++++++++++ docx/oxml/__init__.py | 4 + docx/oxml/ns.py | 8 +- docx/oxml/parts/coreprops.py | 285 ++++++++++++++++++++++++++++++++- features/doc-coreprops.feature | 2 +- tests/opc/test_coreprops.py | 180 +++++++++++++++++++++ 6 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 tests/opc/test_coreprops.py diff --git a/docx/opc/coreprops.py b/docx/opc/coreprops.py index 516d3e548..2d38dabd3 100644 --- a/docx/opc/coreprops.py +++ b/docx/opc/coreprops.py @@ -17,3 +17,123 @@ class CoreProperties(object): """ def __init__(self, element): self._element = element + + @property + def author(self): + return self._element.author_text + + @author.setter + def author(self, value): + self._element.author_text = value + + @property + def category(self): + return self._element.category_text + + @category.setter + def category(self, value): + self._element.category_text = value + + @property + def comments(self): + return self._element.comments_text + + @comments.setter + def comments(self, value): + self._element.comments_text = value + + @property + def content_status(self): + return self._element.contentStatus_text + + @content_status.setter + def content_status(self, value): + self._element.contentStatus_text = value + + @property + def created(self): + return self._element.created_datetime + + @created.setter + def created(self, value): + self._element.created_datetime = value + + @property + def identifier(self): + return self._element.identifier_text + + @identifier.setter + def identifier(self, value): + self._element.identifier_text = value + + @property + def keywords(self): + return self._element.keywords_text + + @keywords.setter + def keywords(self, value): + self._element.keywords_text = value + + @property + def language(self): + return self._element.language_text + + @language.setter + def language(self, value): + self._element.language_text = value + + @property + def last_modified_by(self): + return self._element.lastModifiedBy_text + + @last_modified_by.setter + def last_modified_by(self, value): + self._element.lastModifiedBy_text = value + + @property + def last_printed(self): + return self._element.lastPrinted_datetime + + @last_printed.setter + def last_printed(self, value): + self._element.lastPrinted_datetime = value + + @property + def modified(self): + return self._element.modified_datetime + + @modified.setter + def modified(self, value): + self._element.modified_datetime = value + + @property + def revision(self): + return self._element.revision_number + + @revision.setter + def revision(self, value): + self._element.revision_number = value + + @property + def subject(self): + return self._element.subject_text + + @subject.setter + def subject(self, value): + self._element.subject_text = value + + @property + def title(self): + return self._element.title_text + + @title.setter + def title(self, value): + self._element.title_text = value + + @property + def version(self): + return self._element.version_text + + @version.setter + def version(self, value): + self._element.version_text = value diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index f8d20904d..73cb5010d 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -87,6 +87,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('wp:extent', CT_PositiveSize2D) register_element_cls('wp:inline', CT_Inline) + +from .parts.coreprops import CT_CoreProperties +register_element_cls('cp:coreProperties', CT_CoreProperties) + from .parts.document import CT_Body, CT_Document register_element_cls('w:body', CT_Body) register_element_cls('w:document', CT_Document) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index d4b3014db..e6f6a4acc 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -10,6 +10,11 @@ nsmap = { 'a': ('http://schemas.openxmlformats.org/drawingml/2006/main'), 'c': ('http://schemas.openxmlformats.org/drawingml/2006/chart'), + 'cp': ('http://schemas.openxmlformats.org/package/2006/metadata/core-pr' + 'operties'), + 'dc': ('http://purl.org/dc/elements/1.1/'), + 'dcmitype': ('http://purl.org/dc/dcmitype/'), + 'dcterms': ('http://purl.org/dc/terms/'), 'dgm': ('http://schemas.openxmlformats.org/drawingml/2006/diagram'), 'pic': ('http://schemas.openxmlformats.org/drawingml/2006/picture'), 'r': ('http://schemas.openxmlformats.org/officeDocument/2006/relations' @@ -17,7 +22,8 @@ 'w': ('http://schemas.openxmlformats.org/wordprocessingml/2006/main'), 'wp': ('http://schemas.openxmlformats.org/drawingml/2006/wordprocessing' 'Drawing'), - 'xml': ('http://www.w3.org/XML/1998/namespace') + 'xml': ('http://www.w3.org/XML/1998/namespace'), + 'xsi': ('http://www.w3.org/2001/XMLSchema-instance'), } pfxmap = dict((value, key) for key, value in nsmap.items()) diff --git a/docx/oxml/parts/coreprops.py b/docx/oxml/parts/coreprops.py index 43f3fffd0..746d1372a 100644 --- a/docx/oxml/parts/coreprops.py +++ b/docx/oxml/parts/coreprops.py @@ -8,7 +8,12 @@ absolute_import, division, print_function, unicode_literals ) -from ..xmlchemy import BaseOxmlElement +import re + +from datetime import datetime, timedelta + +from ..ns import qn +from ..xmlchemy import BaseOxmlElement, ZeroOrOne class CT_CoreProperties(BaseOxmlElement): @@ -19,3 +24,281 @@ class CT_CoreProperties(BaseOxmlElement): ('') if the element is not present in the XML. String elements are limited in length to 255 unicode characters. """ + category = ZeroOrOne('cp:category', successors=()) + contentStatus = ZeroOrOne('cp:contentStatus', successors=()) + created = ZeroOrOne('dcterms:created', successors=()) + creator = ZeroOrOne('dc:creator', successors=()) + description = ZeroOrOne('dc:description', successors=()) + identifier = ZeroOrOne('dc:identifier', successors=()) + keywords = ZeroOrOne('cp:keywords', successors=()) + language = ZeroOrOne('dc:language', successors=()) + lastModifiedBy = ZeroOrOne('cp:lastModifiedBy', successors=()) + lastPrinted = ZeroOrOne('cp:lastPrinted', successors=()) + modified = ZeroOrOne('dcterms:modified', successors=()) + revision = ZeroOrOne('cp:revision', successors=()) + subject = ZeroOrOne('dc:subject', successors=()) + title = ZeroOrOne('dc:title', successors=()) + version = ZeroOrOne('cp:version', successors=()) + + @property + def author_text(self): + """ + The text in the `dc:creator` child element. + """ + return self._text_of_element('creator') + + @author_text.setter + def author_text(self, value): + self._set_element_text('creator', value) + + @property + def category_text(self): + return self._text_of_element('category') + + @category_text.setter + def category_text(self, value): + self._set_element_text('category', value) + + @property + def comments_text(self): + return self._text_of_element('description') + + @comments_text.setter + def comments_text(self, value): + self._set_element_text('description', value) + + @property + def contentStatus_text(self): + return self._text_of_element('contentStatus') + + @contentStatus_text.setter + def contentStatus_text(self, value): + self._set_element_text('contentStatus', value) + + @property + def created_datetime(self): + return self._datetime_of_element('created') + + @created_datetime.setter + def created_datetime(self, value): + self._set_element_datetime('created', value) + + @property + def identifier_text(self): + return self._text_of_element('identifier') + + @identifier_text.setter + def identifier_text(self, value): + self._set_element_text('identifier', value) + + @property + def keywords_text(self): + return self._text_of_element('keywords') + + @keywords_text.setter + def keywords_text(self, value): + self._set_element_text('keywords', value) + + @property + def language_text(self): + return self._text_of_element('language') + + @language_text.setter + def language_text(self, value): + self._set_element_text('language', value) + + @property + def lastModifiedBy_text(self): + return self._text_of_element('lastModifiedBy') + + @lastModifiedBy_text.setter + def lastModifiedBy_text(self, value): + self._set_element_text('lastModifiedBy', value) + + @property + def lastPrinted_datetime(self): + return self._datetime_of_element('lastPrinted') + + @lastPrinted_datetime.setter + def lastPrinted_datetime(self, value): + self._set_element_datetime('lastPrinted', value) + + @property + def modified_datetime(self): + return self._datetime_of_element('modified') + + @modified_datetime.setter + def modified_datetime(self, value): + self._set_element_datetime('modified', value) + + @property + def revision_number(self): + """ + Integer value of revision property. + """ + revision = self.revision + if revision is None: + return 0 + revision_str = revision.text + try: + revision = int(revision_str) + except ValueError: + # non-integer revision strings also resolve to 0 + revision = 0 + # as do negative integers + if revision < 0: + revision = 0 + return revision + + @revision_number.setter + def revision_number(self, value): + """ + Set revision property to string value of integer *value*. + """ + if not isinstance(value, int) or value < 1: + tmpl = "revision property requires positive int, got '%s'" + raise ValueError(tmpl % value) + revision = self.get_or_add_revision() + revision.text = str(value) + + @property + def subject_text(self): + return self._text_of_element('subject') + + @subject_text.setter + def subject_text(self, value): + self._set_element_text('subject', value) + + @property + def title_text(self): + return self._text_of_element('title') + + @title_text.setter + def title_text(self, value): + self._set_element_text('title', value) + + @property + def version_text(self): + return self._text_of_element('version') + + @version_text.setter + def version_text(self, value): + self._set_element_text('version', value) + + def _datetime_of_element(self, property_name): + element = getattr(self, property_name) + if element is None: + return None + datetime_str = element.text + try: + return self._parse_W3CDTF_to_datetime(datetime_str) + except ValueError: + # invalid datetime strings are ignored + return None + + def _get_or_add(self, prop_name): + """ + Return element returned by 'get_or_add_' method for *prop_name*. + """ + get_or_add_method_name = 'get_or_add_%s' % prop_name + get_or_add_method = getattr(self, get_or_add_method_name) + element = get_or_add_method() + return element + + @classmethod + def _offset_dt(cls, dt, offset_str): + """ + Return a |datetime| instance that is offset from datetime *dt* by + the timezone offset specified in *offset_str*, a string like + ``'-07:00'``. + """ + match = cls._offset_pattern.match(offset_str) + if match is None: + raise ValueError( + "'%s' is not a valid offset string" % offset_str + ) + sign, hours_str, minutes_str = match.groups() + sign_factor = -1 if sign == '+' else 1 + hours = int(hours_str) * sign_factor + minutes = int(minutes_str) * sign_factor + td = timedelta(hours=hours, minutes=minutes) + return dt + td + + _offset_pattern = re.compile('([+-])(\d\d):(\d\d)') + + @classmethod + def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): + # valid W3CDTF date cases: + # yyyy e.g. '2003' + # yyyy-mm e.g. '2003-12' + # yyyy-mm-dd e.g. '2003-12-31' + # UTC timezone e.g. '2003-12-31T10:14:55Z' + # numeric timezone e.g. '2003-12-31T10:14:55-08:00' + templates = ( + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%d', + '%Y-%m', + '%Y', + ) + # strptime isn't smart enough to parse literal timezone offsets like + # '-07:30', so we have to do it ourselves + parseable_part = w3cdtf_str[:19] + offset_str = w3cdtf_str[19:] + dt = None + for tmpl in templates: + try: + dt = datetime.strptime(parseable_part, tmpl) + except ValueError: + continue + if dt is None: + tmpl = "could not parse W3CDTF datetime string '%s'" + raise ValueError(tmpl % w3cdtf_str) + if len(offset_str) == 6: + return cls._offset_dt(dt, offset_str) + return dt + + def _set_element_datetime(self, prop_name, value): + """ + Set date/time value of child element having *prop_name* to *value*. + """ + if not isinstance(value, datetime): + tmpl = ( + "property requires object, got %s" + ) + raise ValueError(tmpl % type(value)) + element = self._get_or_add(prop_name) + dt_str = value.strftime('%Y-%m-%dT%H:%M:%SZ') + element.text = dt_str + if prop_name in ('created', 'modified'): + # These two require an explicit 'xsi:type="dcterms:W3CDTF"' + # attribute. The first and last line are a hack required to add + # the xsi namespace to the root element rather than each child + # element in which it is referenced + self.set(qn('xsi:foo'), 'bar') + element.set(qn('xsi:type'), 'dcterms:W3CDTF') + del self.attrib[qn('xsi:foo')] + + def _set_element_text(self, prop_name, value): + """ + Set string value of *name* property to *value*. + """ + value = str(value) + if len(value) > 255: + tmpl = ( + "exceeded 255 char limit for property, got:\n\n'%s'" + ) + raise ValueError(tmpl % value) + element = self._get_or_add(prop_name) + element.text = value + + def _text_of_element(self, property_name): + """ + Return the text in the element matching *property_name*, or an empty + string if the element is not present or contains no text. + """ + element = getattr(self, property_name) + if element is None: + return '' + if element.text is None: + return '' + return element.text diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index 0d84b9862..e255be718 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -3,7 +3,7 @@ Feature: Read and write core document properties As a developer using python-docx I need to access and modify the Dublin Core metadata for a document - @wip + Scenario: read the core properties of a document Given a document having known core properties Then I can access the core properties object diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py new file mode 100644 index 000000000..3f6cfa935 --- /dev/null +++ b/tests/opc/test_coreprops.py @@ -0,0 +1,180 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.coreprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from datetime import datetime + +from docx.opc.coreprops import CoreProperties +from docx.oxml import parse_xml + + +class DescribeCoreProperties(object): + + def it_knows_the_string_property_values(self, text_prop_get_fixture): + core_properties, prop_name, expected_value = text_prop_get_fixture + actual_value = getattr(core_properties, prop_name) + assert actual_value == expected_value + + def it_can_change_the_string_property_values(self, text_prop_set_fixture): + core_properties, prop_name, value, expected_xml = text_prop_set_fixture + setattr(core_properties, prop_name, value) + assert core_properties._element.xml == expected_xml + + def it_knows_the_date_property_values(self, date_prop_get_fixture): + core_properties, prop_name, expected_datetime = date_prop_get_fixture + actual_datetime = getattr(core_properties, prop_name) + assert actual_datetime == expected_datetime + + def it_can_change_the_date_property_values(self, date_prop_set_fixture): + core_properties, prop_name, value, expected_xml = ( + date_prop_set_fixture + ) + setattr(core_properties, prop_name, value) + assert core_properties._element.xml == expected_xml + + def it_knows_the_revision_number(self, revision_get_fixture): + core_properties, expected_revision = revision_get_fixture + assert core_properties.revision == expected_revision + + def it_can_change_the_revision_number(self, revision_set_fixture): + core_properties, revision, expected_xml = revision_set_fixture + core_properties.revision = revision + assert core_properties._element.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ('created', datetime(2012, 11, 17, 16, 37, 40)), + ('last_printed', datetime(2014, 6, 4, 4, 28)), + ('modified', None), + ]) + def date_prop_get_fixture(self, request, core_properties): + prop_name, expected_datetime = request.param + return core_properties, prop_name, expected_datetime + + @pytest.fixture(params=[ + ('created', 'dcterms:created', datetime(2001, 2, 3, 4, 5), + '2001-02-03T04:05:00Z', ' xsi:type="dcterms:W3CDTF"'), + ('last_printed', 'cp:lastPrinted', datetime(2014, 6, 4, 4), + '2014-06-04T04:00:00Z', ''), + ('modified', 'dcterms:modified', datetime(2005, 4, 3, 2, 1), + '2005-04-03T02:01:00Z', ' xsi:type="dcterms:W3CDTF"'), + ]) + def date_prop_set_fixture(self, request): + prop_name, tagname, value, str_val, attrs = request.param + coreProperties = self.coreProperties(None, None) + core_properties = CoreProperties(parse_xml(coreProperties)) + expected_xml = self.coreProperties(tagname, str_val, attrs) + return core_properties, prop_name, value, expected_xml + + @pytest.fixture(params=[ + ('42', 42), (None, 0), ('foobar', 0), ('-17', 0), ('32.7', 0) + ]) + def revision_get_fixture(self, request): + str_val, expected_revision = request.param + tagname = '' if str_val is None else 'cp:revision' + coreProperties = self.coreProperties(tagname, str_val) + core_properties = CoreProperties(parse_xml(coreProperties)) + return core_properties, expected_revision + + @pytest.fixture(params=[ + (42, '42'), + ]) + def revision_set_fixture(self, request): + value, str_val = request.param + coreProperties = self.coreProperties(None, None) + core_properties = CoreProperties(parse_xml(coreProperties)) + expected_xml = self.coreProperties('cp:revision', str_val) + return core_properties, value, expected_xml + + @pytest.fixture(params=[ + ('author', 'python-pptx'), + ('category', ''), + ('comments', ''), + ('content_status', 'DRAFT'), + ('identifier', 'GXS 10.2.1ab'), + ('keywords', 'foo bar baz'), + ('language', 'US-EN'), + ('last_modified_by', 'Steve Canny'), + ('subject', 'Spam'), + ('title', 'Presentation'), + ('version', '1.2.88'), + ]) + def text_prop_get_fixture(self, request, core_properties): + prop_name, expected_value = request.param + return core_properties, prop_name, expected_value + + @pytest.fixture(params=[ + ('author', 'dc:creator', 'scanny'), + ('category', 'cp:category', 'silly stories'), + ('comments', 'dc:description', 'Bar foo to you'), + ('content_status', 'cp:contentStatus', 'FINAL'), + ('identifier', 'dc:identifier', 'GT 5.2.xab'), + ('keywords', 'cp:keywords', 'dog cat moo'), + ('language', 'dc:language', 'GB-EN'), + ('last_modified_by', 'cp:lastModifiedBy', 'Billy Bob'), + ('subject', 'dc:subject', 'Eggs'), + ('title', 'dc:title', 'Dissertation'), + ('version', 'cp:version', '81.2.8'), + ]) + def text_prop_set_fixture(self, request): + prop_name, tagname, value = request.param + coreProperties = self.coreProperties(None, None) + core_properties = CoreProperties(parse_xml(coreProperties)) + expected_xml = self.coreProperties(tagname, value) + return core_properties, prop_name, value, expected_xml + + # fixture components --------------------------------------------- + + def coreProperties(self, tagname, str_val, attrs=''): + tmpl = ( + '%s\n' + ) + if not tagname: + child_element = '' + elif not str_val: + child_element = '\n <%s%s/>\n' % (tagname, attrs) + else: + child_element = ( + '\n <%s%s>%s\n' % (tagname, attrs, str_val, tagname) + ) + return tmpl % child_element + + @pytest.fixture + def core_properties(self): + element = parse_xml( + b'' + b'\n\n' + b' DRAFT\n' + b' python-pptx\n' + b' 2012-11-17T11:07:' + b'40-05:30\n' + b' \n' + b' GXS 10.2.1ab\n' + b' US-EN\n' + b' 2014-06-04T04:28:00Z\n' + b' foo bar baz\n' + b' Steve Canny\n' + b' 4\n' + b' Spam\n' + b' Presentation\n' + b' 1.2.88\n' + b'\n' + ) + return CoreProperties(element) From d49d71f81df5125ec745c4a7de910a2d3dad34f4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:39:15 -0800 Subject: [PATCH 054/615] acpt: add scenario for writing CoreProperties --- features/doc-coreprops.feature | 6 ++++++ features/steps/coreprops.py | 38 +++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index e255be718..b8b9eb312 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -8,3 +8,9 @@ Feature: Read and write core document properties Given a document having known core properties Then I can access the core properties object And the core property values match the known values + + + Scenario: change the core properties of a document + Given a document having known core properties + When I assign new values to the properties + Then the core property values match the new values diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py index 41767b8b6..950f3c5cb 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -10,7 +10,7 @@ from datetime import datetime -from behave import given, then +from behave import given, then, when from docx import Document from docx.opc.coreprops import CoreProperties @@ -25,6 +25,32 @@ def given_a_document_having_known_core_properties(context): context.document = Document(test_docx('doc-coreprops')) +# when ==================================================== + +@when("I assign new values to the properties") +def when_I_assign_new_values_to_the_properties(context): + context.propvals = ( + ('author', 'Creator'), + ('category', 'Category'), + ('comments', 'Description'), + ('content_status', 'Content Status'), + ('created', datetime(2013, 6, 15, 12, 34, 56)), + ('identifier', 'Identifier'), + ('keywords', 'key; word; keyword'), + ('language', 'Language'), + ('last_modified_by', 'Last Modified By'), + ('last_printed', datetime(2013, 6, 15, 12, 34, 56)), + ('modified', datetime(2013, 6, 15, 12, 34, 56)), + ('revision', 9), + ('subject', 'Subject'), + ('title', 'Title'), + ('version', 'Version'), + ) + core_properties = context.document.core_properties + for name, value in context.propvals: + setattr(core_properties, name, value) + + # then ==================================================== @then('I can access the core properties object') @@ -59,3 +85,13 @@ def then_the_core_property_values_match_the_known_values(context): assert value == expected_value, ( "got '%s' for core property '%s'" % (value, name) ) + + +@then('the core property values match the new values') +def then_the_core_property_values_match_the_new_values(context): + core_properties = context.document.core_properties + for name, expected_value in context.propvals: + value = getattr(core_properties, name) + assert value == expected_value, ( + "got '%s' for core property '%s'" % (value, name) + ) From 113c9991833d6448e1c64a8cd9741e46c7f6d950 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:53:46 -0800 Subject: [PATCH 055/615] acpt: add scenario for default CoreProperties part --- features/doc-coreprops.feature | 7 +++++ features/steps/coreprops.py | 25 +++++++++++++++++- .../steps/test_files/doc-no-coreprops.docx | Bin 0 -> 11394 bytes 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 features/steps/test_files/doc-no-coreprops.docx diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index b8b9eb312..7ebdae669 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -14,3 +14,10 @@ Feature: Read and write core document properties Given a document having known core properties When I assign new values to the properties Then the core property values match the new values + + + @wip + Scenario: a default core properties part is added if doc doesn't have one + Given a document having no core properties part + When I access the core properties object + Then a core properties part with default values is added diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py index 950f3c5cb..dc6be2e6c 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from datetime import datetime +from datetime import datetime, timedelta from behave import given, then, when @@ -25,8 +25,18 @@ def given_a_document_having_known_core_properties(context): context.document = Document(test_docx('doc-coreprops')) +@given('a document having no core properties part') +def given_a_document_having_no_core_properties_part(context): + context.document = Document(test_docx('doc-no-coreprops')) + + # when ==================================================== +@when('I access the core properties object') +def when_I_access_the_core_properties_object(context): + context.document.core_properties + + @when("I assign new values to the properties") def when_I_assign_new_values_to_the_properties(context): context.propvals = ( @@ -53,6 +63,19 @@ def when_I_assign_new_values_to_the_properties(context): # then ==================================================== +@then('a core properties part with default values is added') +def then_a_core_properties_part_with_default_values_is_added(context): + core_properties = context.document.core_properties + assert core_properties.title == 'Word Document' + assert core_properties.last_modified_by == 'python-docx' + assert core_properties.revision == 1 + # core_properties.modified only stores time with seconds resolution, so + # comparison needs to be a little loose (within two seconds) + modified_timedelta = datetime.utcnow() - core_properties.modified + max_expected_timedelta = timedelta(seconds=2) + assert modified_timedelta < max_expected_timedelta + + @then('I can access the core properties object') def then_I_can_access_the_core_properties_object(context): document = context.document diff --git a/features/steps/test_files/doc-no-coreprops.docx b/features/steps/test_files/doc-no-coreprops.docx new file mode 100644 index 0000000000000000000000000000000000000000..588bf557f85f99d48a4b632c7217c5b7e22cb267 GIT binary patch literal 11394 zcmbW71yr0#)9(kj;O_3O!QI{6-EDBoKnNCe@Sq_;aDux-f(3`*1a~L6T(Z0GyStq4 z-gD2#93IZ}@UQ9V>8@WrRi&x`1&sv&0N?>rbum)(Sl~D?zK^xSLZwSux@d;Vk&dPxJd)NLqQIp?}NV! z5=Mw1L9i00`!V0PKta>eqj;(#tT3HJbz@?^1+4VEVY(YmM_hK|T^bA)4Q>HhSi3#mEb(tGhyIxD_N((ESa|we9q%G8vPxWG#%9Zpe$nIf%wb6x(~!%5K!vX9Udh z3>*0f&NfY3G4nj8t4PW$D=)0u27{5`eZ&1|7d~1(>g&9znaCov?2cN@j7V+vsd6#1 z0Yf~uLos#NI`18a0WwM+v|$Pw3u^V5lk&+&R}MPF_*rP_@cfCwH?=X45D8?JYJP`{ zq2?$#E;>+i94_7Jsu!{WDXzZY7*9-;d?d(Hn{CIW`7KEXX4E#%;8^gJ%*{%RS}|tH zVdp3Qn`syEfro<>)vSD?Goe!>rBaoHl z_q7~6Di6hu7Jht(WyZ?qX=5NM`QcD1bXjr_Dth3++2cIivgB}qP;BZQj{iy8%5cC< znq?bbqg<=nAde4qsuClNFLCs*w_uf)yS^B#^l`$>5L)WEq|L(8nK&-;)O5l5G^yb0 zvb_YpWk^dQaL;0>Y`f-dJdrzJAq2&q4iUVzlRxtA>rY1v=V@2 zV^`XfMq~am*(!^W9;Rnu!xV)aIkW04K8jFPB}!wc)FU8v9vGt@z2eO`^4|r50+uB> zOxxE)LbX$ovfeoWgY9EW7u7L==U6mcuc=o9Y+?~hQsG=>{vH8pUu>awq!#8+TTRE|$C_b~LhiYsgij%F`qGpU$wX}16T0m` z6Uy_D$U|4C`_f=K50T>&yoj*ldT7%_ zxI=_lxnH+79Ho{&m&^&1_Q^bb`SXB_D;|wOA>0=Xtb)_SUC-VTRcL|r z{xf;?ZD8ypUzAb!k|ucQOPK9qbX>pcU|!9Xxqh=_liUgtQ*;Pde;8M)0U|CR5jN

>9!)DBQj43=}|5!4c+&rZJ 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 Date: Sat, 13 Dec 2014 23:23:17 -0800 Subject: [PATCH 056/615] opc: add CorePropertiesPart.default() --- docx/opc/parts/coreprops.py | 22 +++++++++++++++++++++- docx/oxml/parts/coreprops.py | 16 +++++++++++++++- features/doc-coreprops.feature | 1 - tests/opc/parts/test_coreprops.py | 13 +++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index 14dbd3e76..080e0f81f 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -8,7 +8,12 @@ absolute_import, division, print_function, unicode_literals ) +from datetime import datetime + +from ..constants import CONTENT_TYPE as CT from ..coreprops import CoreProperties +from ...oxml.parts.coreprops import CT_CoreProperties +from ..packuri import PackURI from ..part import XmlPart @@ -23,7 +28,13 @@ def default(cls, package): Return a new |CorePropertiesPart| object initialized with default values for its base properties. """ - raise NotImplementedError + core_properties_part = cls._new(package) + core_properties = core_properties_part.core_properties + core_properties.title = 'Word Document' + core_properties.last_modified_by = 'python-docx' + core_properties.revision = 1 + core_properties.modified = datetime.utcnow() + return core_properties_part @property def core_properties(self): @@ -32,3 +43,12 @@ def core_properties(self): properties contained in this core properties part. """ return CoreProperties(self.element) + + @classmethod + def _new(cls, package): + partname = PackURI('/docProps/core.xml') + content_type = CT.OPC_CORE_PROPERTIES + coreProperties = CT_CoreProperties.new() + return CorePropertiesPart( + partname, content_type, coreProperties, package + ) diff --git a/docx/oxml/parts/coreprops.py b/docx/oxml/parts/coreprops.py index 746d1372a..fbd73cb94 100644 --- a/docx/oxml/parts/coreprops.py +++ b/docx/oxml/parts/coreprops.py @@ -12,7 +12,8 @@ from datetime import datetime, timedelta -from ..ns import qn +from .. import parse_xml +from ..ns import nsdecls, qn from ..xmlchemy import BaseOxmlElement, ZeroOrOne @@ -40,6 +41,19 @@ class CT_CoreProperties(BaseOxmlElement): title = ZeroOrOne('dc:title', successors=()) version = ZeroOrOne('cp:version', successors=()) + _coreProperties_tmpl = ( + '\n' % nsdecls('cp', 'dc', 'dcterms') + ) + + @classmethod + def new(cls): + """ + Return a new ```` element + """ + xml = cls._coreProperties_tmpl + coreProperties = parse_xml(xml) + return coreProperties + @property def author_text(self): """ diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index 7ebdae669..15a5724c3 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -16,7 +16,6 @@ Feature: Read and write core document properties Then the core property values match the new values - @wip Scenario: a default core properties part is added if doc doesn't have one Given a document having no core properties part When I access the core properties object diff --git a/tests/opc/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py index d1585a128..f324f15db 100644 --- a/tests/opc/parts/test_coreprops.py +++ b/tests/opc/parts/test_coreprops.py @@ -8,6 +8,8 @@ absolute_import, division, print_function, unicode_literals ) +from datetime import datetime, timedelta + import pytest from docx.opc.coreprops import CoreProperties @@ -25,6 +27,17 @@ def it_provides_access_to_its_core_props_object(self, coreprops_fixture): CoreProperties_.assert_called_once_with(core_properties_part.element) assert isinstance(core_properties, CoreProperties) + def it_can_create_a_default_core_properties_part(self): + core_properties_part = CorePropertiesPart.default(None) + assert isinstance(core_properties_part, CorePropertiesPart) + core_properties = core_properties_part.core_properties + assert core_properties.title == 'Word Document' + assert core_properties.last_modified_by == 'python-docx' + assert core_properties.revision == 1 + delta = datetime.utcnow() - core_properties.modified + max_expected_delta = timedelta(seconds=2) + assert delta < max_expected_delta + # fixtures --------------------------------------------- @pytest.fixture From b6e20505211af3b9426b1ab1b3dd043cd31aca85 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 00:00:02 -0800 Subject: [PATCH 057/615] docs: add API documentation for CoreProperties --- docs/api/document.rst | 96 +++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 4 ++ 2 files changed, 100 insertions(+) diff --git a/docs/api/document.rst b/docs/api/document.rst index accab05b3..6fae9ea26 100644 --- a/docs/api/document.rst +++ b/docs/api/document.rst @@ -28,3 +28,99 @@ The main Document and related objects. .. autoclass:: Sections :members: + + +|CoreProperties| objects +------------------------- + +Each |Document| object provides access to its |CoreProperties| object via its +:attr:`core_properties` attribute. A |CoreProperties| object provides +read/write access to the so-called *core properties* for the document. The +core properties are author, category, comments, content_status, created, +identifier, keywords, language, last_modified_by, last_printed, modified, +revision, subject, title, and version. + +Each property is one of three types, |str|, |datetime|, or |int|. String +properties are limited in length to 255 characters and return an empty string +('') if not set. Date properties are assigned and returned as |datetime| +objects without timezone, i.e. in UTC. Any timezone conversions are the +responsibility of the client. Date properties return |None| if not set. + +|docx| does not automatically set any of the document core properties other +than to add a core properties part to a presentation that doesn't have one +(very uncommon). If |docx| adds a core properties part, it contains default +values for the title, last_modified_by, revision, and modified properties. +Client code should update properties like revision and last_modified_by +if that behavior is desired. + +.. currentmodule:: docx.opc.coreprops + +.. class:: CoreProperties + + .. attribute:: author + + *string* -- An entity primarily responsible for making the content of the + resource. + + .. attribute:: category + + *string* -- A categorization of the content of this package. Example + values might include: Resume, Letter, Financial Forecast, Proposal, + or Technical Presentation. + + .. attribute:: comments + + *string* -- An account of the content of the resource. + + .. attribute:: content_status + + *string* -- completion status of the document, e.g. 'draft' + + .. attribute:: created + + *datetime* -- time of intial creation of the document + + .. attribute:: identifier + + *string* -- An unambiguous reference to the resource within a given + context, e.g. ISBN. + + .. attribute:: keywords + + *string* -- descriptive words or short phrases likely to be used as + search terms for this document + + .. attribute:: language + + *string* -- language the document is written in + + .. attribute:: last_modified_by + + *string* -- name or other identifier (such as email address) of person + who last modified the document + + .. attribute:: last_printed + + *datetime* -- time the document was last printed + + .. attribute:: modified + + *datetime* -- time the document was last modified + + .. attribute:: revision + + *int* -- number of this revision, incremented by Word each time the + document is saved. Note however |docx| does not automatically increment + the revision number when it saves a document. + + .. attribute:: subject + + *string* -- The topic of the content of the resource. + + .. attribute:: title + + *string* -- The name given to the resource. + + .. attribute:: version + + *string* -- free-form version string diff --git a/docs/conf.py b/docs/conf.py index 46f243600..7549dbd65 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,6 +79,8 @@ .. |CoreProperties| replace:: :class:`.CoreProperties` +.. |datetime| replace:: :class:`datetime.datetime` + .. |Document| replace:: :class:`.Document` .. |docx| replace:: ``python-docx`` @@ -121,6 +123,8 @@ .. |Sections| replace:: :class:`.Sections` +.. |str| replace:: :class:`str` + .. |StylesPart| replace:: :class:`.StylesPart` .. |Table| replace:: :class:`.Table` From 344b370f0d53f9af554f8e8f71a89c4ffd5c4a8b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 00:20:39 -0800 Subject: [PATCH 058/615] release: prepare v0.7.6 release --- HISTORY.rst | 7 +++++++ docx/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 0e0537148..4462daa10 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ Release History --------------- +0.7.6 (2014-12-14) +++++++++++++++++++ + +- Add feature #69: Table.alignment +- Add feature #29: Document.core_properties + + 0.7.5 (2014-11-29) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 5a24333a1..3672e23d1 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.5' +__version__ = '0.7.6' # register custom Part classes with opc package reader From 68e52c8f6e1b72457f6f65cf260905f0c34ee2f5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 13:07:07 -0800 Subject: [PATCH 059/615] opc: change pasted pptx reference to docx --- tests/opc/test_coreprops.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py index 3f6cfa935..47195f597 100644 --- a/tests/opc/test_coreprops.py +++ b/tests/opc/test_coreprops.py @@ -96,7 +96,7 @@ def revision_set_fixture(self, request): return core_properties, value, expected_xml @pytest.fixture(params=[ - ('author', 'python-pptx'), + ('author', 'python-docx'), ('category', ''), ('comments', ''), ('content_status', 'DRAFT'), @@ -105,7 +105,7 @@ def revision_set_fixture(self, request): ('language', 'US-EN'), ('last_modified_by', 'Steve Canny'), ('subject', 'Spam'), - ('title', 'Presentation'), + ('title', 'Word Document'), ('version', '1.2.88'), ]) def text_prop_get_fixture(self, request, core_properties): @@ -162,7 +162,7 @@ def core_properties(self): b'itype/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="h' b'ttp://www.w3.org/2001/XMLSchema-instance">\n' b' DRAFT\n' - b' python-pptx\n' + b' python-docx\n' b' 2012-11-17T11:07:' b'40-05:30\n' b' \n' @@ -173,7 +173,7 @@ def core_properties(self): b' Steve Canny\n' b' 4\n' b' Spam\n' - b' Presentation\n' + b' Word Document\n' b' 1.2.88\n' b'\n' ) From 378d206cd40591a39441e87be31a5cc2752bd931 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 23:24:09 -0800 Subject: [PATCH 060/615] tbl: enable sliced access to _Rows --- docx/table.py | 11 +++-------- tests/test_table.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docx/table.py b/docx/table.py index 424bee541..0038de1a7 100644 --- a/docx/table.py +++ b/docx/table.py @@ -370,8 +370,8 @@ def _index(self): class _Rows(Parented): """ - Sequence of |_Row| instances corresponding to the rows in a table. - Supports ``len()``, iteration and indexed access. + Sequence of |_Row| objects corresponding to the rows in a table. + Supports ``len()``, iteration, indexed access, and slicing. """ def __init__(self, tbl, parent): super(_Rows, self).__init__(parent) @@ -381,12 +381,7 @@ def __getitem__(self, idx): """ Provide indexed access, (e.g. 'rows[0]') """ - try: - tr = self._tbl.tr_lst[idx] - except IndexError: - msg = "row index [%d] out of range" % idx - raise IndexError(msg) - return _Row(tr, self) + return list(self)[idx] def __iter__(self): return (_Row(tr, self) for tr in self._tbl.tr_lst) diff --git a/tests/test_table.py b/tests/test_table.py index 107574688..349b2f51e 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -678,6 +678,15 @@ def it_provides_indexed_access_to_rows(self, rows_fixture): row = rows[idx] assert isinstance(row, _Row) + def it_provides_sliced_access_to_rows(self, slice_fixture): + rows, start, end, expected_count = slice_fixture + slice_of_rows = rows[start:end] + assert len(slice_of_rows) == expected_count + tr_lst = rows._tbl.tr_lst + for idx, row in enumerate(slice_of_rows): + assert tr_lst.index(row._tr) == start + idx + assert isinstance(row, _Row) + def it_raises_on_indexed_access_out_of_range(self, rows_fixture): rows, row_count = rows_fixture with pytest.raises(IndexError): @@ -700,6 +709,16 @@ def rows_fixture(self): rows = _Rows(tbl, None) return rows, row_count + @pytest.fixture(params=[ + (3, 1, 3, 2), + (3, 0, -1, 2), + ]) + def slice_fixture(self, request): + row_count, start, end, expected_count = request.param + tbl = _tbl_bldr(rows=row_count, cols=2).element + rows = _Rows(tbl, None) + return rows, start, end, expected_count + @pytest.fixture def table_fixture(self, table_): rows = _Rows(None, table_) From cd292280ffba5ec13c15f203c99bcb8c67be866c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 14:21:53 -0800 Subject: [PATCH 061/615] reorg: mv docx/text.py => docx/text/__init__.py First step in extracting text module into a sub-package. --- docx/{text.py => text/__init__.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docx/{text.py => text/__init__.py} (99%) diff --git a/docx/text.py b/docx/text/__init__.py similarity index 99% rename from docx/text.py rename to docx/text/__init__.py index 0c551beeb..21131c3d0 100644 --- a/docx/text.py +++ b/docx/text/__init__.py @@ -6,8 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals -from .enum.text import WD_BREAK -from .shared import Parented +from ..enum.text import WD_BREAK +from ..shared import Parented def boolproperty(f): From 67c901b2acdf8fcbee590692fc88347bbc0330c7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 14:43:13 -0800 Subject: [PATCH 062/615] reorg: mv oxml/text.py => oxml/text/__init__.py In preparation to extract text subpackage. --- docx/oxml/{text.py => text/__init__.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename docx/oxml/{text.py => text/__init__.py} (98%) diff --git a/docx/oxml/text.py b/docx/oxml/text/__init__.py similarity index 98% rename from docx/oxml/text.py rename to docx/oxml/text/__init__.py index 9fdd1d64b..ce044cc5c 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text/__init__.py @@ -5,10 +5,10 @@ (CT_R). """ -from ..enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE -from .ns import qn -from .simpletypes import ST_BrClear, ST_BrType -from .xmlchemy import ( +from ...enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE +from ..ns import qn +from ..simpletypes import ST_BrClear, ST_BrType +from ..xmlchemy import ( BaseOxmlElement, OptionalAttribute, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne ) From 78f9107f4ad8ff2eec528475d218ba33f3575b17 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:34:25 -0800 Subject: [PATCH 063/615] reorg: extract tests.oxml.text subpackage --- tests/oxml/text/__init__.py | 0 tests/oxml/{test_text.py => text/test_run.py} | 8 +++++--- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 tests/oxml/text/__init__.py rename tests/oxml/{test_text.py => text/test_run.py} (82%) diff --git a/tests/oxml/text/__init__.py b/tests/oxml/text/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/oxml/test_text.py b/tests/oxml/text/test_run.py similarity index 82% rename from tests/oxml/test_text.py rename to tests/oxml/text/test_run.py index 974e19504..57b8580fe 100644 --- a/tests/oxml/test_text.py +++ b/tests/oxml/text/test_run.py @@ -1,14 +1,16 @@ # encoding: utf-8 """ -Test suite for the docx.oxml.text module. +Test suite for the docx.oxml.text.run module. """ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) import pytest -from ..unitutil.cxml import element, xml +from ...unitutil.cxml import element, xml class DescribeCT_R(object): From afc60c139b40aaacf3b2e090fb5c9b14da2f21f6 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:35:57 -0800 Subject: [PATCH 064/615] reorg: extract docx.oxml.text.run module --- docx/oxml/__init__.py | 14 +- docx/oxml/text/__init__.py | 279 +--------------------------------- docx/oxml/text/run.py | 285 +++++++++++++++++++++++++++++++++++ tests/parts/test_document.py | 2 +- tests/test_text.py | 3 +- 5 files changed, 297 insertions(+), 286 deletions(-) create mode 100644 docx/oxml/text/run.py diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 73cb5010d..1cae55f02 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -134,9 +134,13 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) register_element_cls('w:vMerge', CT_VMerge) -from .text import ( - CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline -) +from .text import CT_Jc, CT_P, CT_PPr +register_element_cls('w:jc', CT_Jc) +register_element_cls('w:p', CT_P) +register_element_cls('w:pPr', CT_PPr) +register_element_cls('w:pStyle', CT_String) + +from .text.run import CT_Br, CT_R, CT_RPr, CT_Text, CT_Underline register_element_cls('w:b', CT_OnOff) register_element_cls('w:bCs', CT_OnOff) register_element_cls('w:br', CT_Br) @@ -147,13 +151,9 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:i', CT_OnOff) register_element_cls('w:iCs', CT_OnOff) register_element_cls('w:imprint', CT_OnOff) -register_element_cls('w:jc', CT_Jc) register_element_cls('w:noProof', CT_OnOff) register_element_cls('w:oMath', CT_OnOff) register_element_cls('w:outline', CT_OnOff) -register_element_cls('w:p', CT_P) -register_element_cls('w:pPr', CT_PPr) -register_element_cls('w:pStyle', CT_String) register_element_cls('w:r', CT_R) register_element_cls('w:rPr', CT_RPr) register_element_cls('w:rStyle', CT_String) diff --git a/docx/oxml/text/__init__.py b/docx/oxml/text/__init__.py index ce044cc5c..f2f3067d6 100644 --- a/docx/oxml/text/__init__.py +++ b/docx/oxml/text/__init__.py @@ -5,23 +5,13 @@ (CT_R). """ -from ...enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE +from ...enum.text import WD_ALIGN_PARAGRAPH from ..ns import qn -from ..simpletypes import ST_BrClear, ST_BrType from ..xmlchemy import ( - BaseOxmlElement, OptionalAttribute, OxmlElement, RequiredAttribute, - ZeroOrMore, ZeroOrOne + BaseOxmlElement, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne ) -class CT_Br(BaseOxmlElement): - """ - ```` element, indicating a line, page, or column break in a run. - """ - type = OptionalAttribute('w:type', ST_BrType) - clear = OptionalAttribute('w:clear', ST_BrClear) - - class CT_Jc(BaseOxmlElement): """ ```` element, specifying paragraph justification. @@ -164,268 +154,3 @@ def style(self, style): return pStyle = self.get_or_add_pStyle() pStyle.val = style - - -class CT_R(BaseOxmlElement): - """ - ```` element, containing the properties and text for a run. - """ - rPr = ZeroOrOne('w:rPr') - t = ZeroOrMore('w:t') - br = ZeroOrMore('w:br') - cr = ZeroOrMore('w:cr') - tab = ZeroOrMore('w:tab') - drawing = ZeroOrMore('w:drawing') - - def _insert_rPr(self, rPr): - self.insert(0, rPr) - return rPr - - def add_t(self, text): - """ - Return a newly added ```` element containing *text*. - """ - t = self._add_t(text=text) - if len(text.strip()) < len(text): - t.set(qn('xml:space'), 'preserve') - return t - - def add_drawing(self, inline_or_anchor): - """ - Return a newly appended ``CT_Drawing`` (````) child - element having *inline_or_anchor* as its child. - """ - drawing = self._add_drawing() - drawing.append(inline_or_anchor) - return drawing - - def clear_content(self): - """ - Remove all child elements except the ```` element if present. - """ - content_child_elms = self[1:] if self.rPr is not None else self[:] - for child in content_child_elms: - self.remove(child) - - @property - def style(self): - """ - String contained in w:val attribute of grandchild, or - |None| if that element is not present. - """ - rPr = self.rPr - if rPr is None: - return None - return rPr.style - - @style.setter - def style(self, style): - """ - Set the character style of this element to *style*. If *style* - is None, remove the style element. - """ - rPr = self.get_or_add_rPr() - rPr.style = style - - @property - def text(self): - """ - A string representing the textual content of this run, with content - child elements like ```` translated to their Python - equivalent. - """ - text = '' - for child in self: - if child.tag == qn('w:t'): - t_text = child.text - text += t_text if t_text is not None else '' - elif child.tag == qn('w:tab'): - text += '\t' - elif child.tag in (qn('w:br'), qn('w:cr')): - text += '\n' - return text - - @text.setter - def text(self, text): - self.clear_content() - _RunContentAppender.append_to_run_from_text(self, text) - - @property - def underline(self): - """ - String contained in w:val attribute of ./w:rPr/w:u grandchild, or - |None| if not present. - """ - rPr = self.rPr - if rPr is None: - return None - return rPr.underline - - @underline.setter - def underline(self, value): - rPr = self.get_or_add_rPr() - rPr.underline = value - - -class CT_RPr(BaseOxmlElement): - """ - ```` element, containing the properties for a run. - """ - rStyle = ZeroOrOne('w:rStyle', successors=('w:rPrChange',)) - b = ZeroOrOne('w:b', successors=('w:rPrChange',)) - bCs = ZeroOrOne('w:bCs', successors=('w:rPrChange',)) - caps = ZeroOrOne('w:caps', successors=('w:rPrChange',)) - cs = ZeroOrOne('w:cs', successors=('w:rPrChange',)) - dstrike = ZeroOrOne('w:dstrike', successors=('w:rPrChange',)) - emboss = ZeroOrOne('w:emboss', successors=('w:rPrChange',)) - i = ZeroOrOne('w:i', successors=('w:rPrChange',)) - iCs = ZeroOrOne('w:iCs', successors=('w:rPrChange',)) - imprint = ZeroOrOne('w:imprint', successors=('w:rPrChange',)) - noProof = ZeroOrOne('w:noProof', successors=('w:rPrChange',)) - oMath = ZeroOrOne('w:oMath', successors=('w:rPrChange',)) - outline = ZeroOrOne('w:outline', successors=('w:rPrChange',)) - rtl = ZeroOrOne('w:rtl', successors=('w:rPrChange',)) - shadow = ZeroOrOne('w:shadow', successors=('w:rPrChange',)) - smallCaps = ZeroOrOne('w:smallCaps', successors=('w:rPrChange',)) - snapToGrid = ZeroOrOne('w:snapToGrid', successors=('w:rPrChange',)) - specVanish = ZeroOrOne('w:specVanish', successors=('w:rPrChange',)) - strike = ZeroOrOne('w:strike', successors=('w:rPrChange',)) - u = ZeroOrOne('w:u', successors=('w:rPrChange',)) - vanish = ZeroOrOne('w:vanish', successors=('w:rPrChange',)) - webHidden = ZeroOrOne('w:webHidden', successors=('w:rPrChange',)) - - @property - def style(self): - """ - String contained in child, or None if that element is not - present. - """ - rStyle = self.rStyle - if rStyle is None: - return None - return rStyle.val - - @style.setter - def style(self, style): - """ - Set val attribute of child element to *style*, adding a - new element if necessary. If *style* is |None|, remove the - element if present. - """ - if style is None: - self._remove_rStyle() - elif self.rStyle is None: - self._add_rStyle(val=style) - else: - self.rStyle.val = style - - @property - def underline(self): - """ - Underline type specified in child, or None if that element is - not present. - """ - u = self.u - if u is None: - return None - return u.val - - @underline.setter - def underline(self, value): - self._remove_u() - if value is not None: - u = self._add_u() - u.val = value - - -class CT_Text(BaseOxmlElement): - """ - ```` element, containing a sequence of characters within a run. - """ - - -class CT_Underline(BaseOxmlElement): - """ - ```` element, specifying the underlining style for a run. - """ - @property - def val(self): - """ - The underline type corresponding to the ``w:val`` attribute value. - """ - val = self.get(qn('w:val')) - underline = WD_UNDERLINE.from_xml(val) - if underline == WD_UNDERLINE.SINGLE: - return True - if underline == WD_UNDERLINE.NONE: - return False - return underline - - @val.setter - def val(self, value): - # works fine without these two mappings, but only because True == 1 - # and False == 0, which happen to match the mapping for WD_UNDERLINE - # .SINGLE and .NONE respectively. - if value is True: - value = WD_UNDERLINE.SINGLE - elif value is False: - value = WD_UNDERLINE.NONE - - val = WD_UNDERLINE.to_xml(value) - self.set(qn('w:val'), val) - - -class _RunContentAppender(object): - """ - Service object that knows how to translate a Python string into run - content elements appended to a specified ```` element. Contiguous - sequences of regular characters are appended in a single ```` - element. Each tab character ('\t') causes a ```` element to be - appended. Likewise a newline or carriage return character ('\n', '\r') - causes a ```` element to be appended. - """ - def __init__(self, r): - self._r = r - self._bfr = [] - - @classmethod - def append_to_run_from_text(cls, r, text): - """ - Create a "one-shot" ``_RunContentAppender`` instance and use it to - append the run content elements corresponding to *text* to the - ```` element *r*. - """ - appender = cls(r) - appender.add_text(text) - - def add_text(self, text): - """ - Append the run content elements corresponding to *text* to the - ```` element of this instance. - """ - for char in text: - self.add_char(char) - self.flush() - - def add_char(self, char): - """ - Process the next character of input through the translation finite - state maching (FSM). There are two possible states, buffer pending - and not pending, but those are hidden behind the ``.flush()`` method - which must be called at the end of text to ensure any pending - ```` element is written. - """ - if char == '\t': - self.flush() - self._r.add_tab() - elif char in '\r\n': - self.flush() - self._r.add_br() - else: - self._bfr.append(char) - - def flush(self): - text = ''.join(self._bfr) - if text: - self._r.add_t(text) - del self._bfr[:] diff --git a/docx/oxml/text/run.py b/docx/oxml/text/run.py new file mode 100644 index 000000000..a7ffeb32c --- /dev/null +++ b/docx/oxml/text/run.py @@ -0,0 +1,285 @@ +# encoding: utf-8 + +""" +Custom element classes related to text runs (CT_R). +""" + +from ...enum.text import WD_UNDERLINE +from ..ns import qn +from ..simpletypes import ST_BrClear, ST_BrType +from ..xmlchemy import ( + BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne +) + + +class CT_Br(BaseOxmlElement): + """ + ```` element, indicating a line, page, or column break in a run. + """ + type = OptionalAttribute('w:type', ST_BrType) + clear = OptionalAttribute('w:clear', ST_BrClear) + + +class CT_R(BaseOxmlElement): + """ + ```` element, containing the properties and text for a run. + """ + rPr = ZeroOrOne('w:rPr') + t = ZeroOrMore('w:t') + br = ZeroOrMore('w:br') + cr = ZeroOrMore('w:cr') + tab = ZeroOrMore('w:tab') + drawing = ZeroOrMore('w:drawing') + + def _insert_rPr(self, rPr): + self.insert(0, rPr) + return rPr + + def add_t(self, text): + """ + Return a newly added ```` element containing *text*. + """ + t = self._add_t(text=text) + if len(text.strip()) < len(text): + t.set(qn('xml:space'), 'preserve') + return t + + def add_drawing(self, inline_or_anchor): + """ + Return a newly appended ``CT_Drawing`` (````) child + element having *inline_or_anchor* as its child. + """ + drawing = self._add_drawing() + drawing.append(inline_or_anchor) + return drawing + + def clear_content(self): + """ + Remove all child elements except the ```` element if present. + """ + content_child_elms = self[1:] if self.rPr is not None else self[:] + for child in content_child_elms: + self.remove(child) + + @property + def style(self): + """ + String contained in w:val attribute of grandchild, or + |None| if that element is not present. + """ + rPr = self.rPr + if rPr is None: + return None + return rPr.style + + @style.setter + def style(self, style): + """ + Set the character style of this element to *style*. If *style* + is None, remove the style element. + """ + rPr = self.get_or_add_rPr() + rPr.style = style + + @property + def text(self): + """ + A string representing the textual content of this run, with content + child elements like ```` translated to their Python + equivalent. + """ + text = '' + for child in self: + if child.tag == qn('w:t'): + t_text = child.text + text += t_text if t_text is not None else '' + elif child.tag == qn('w:tab'): + text += '\t' + elif child.tag in (qn('w:br'), qn('w:cr')): + text += '\n' + return text + + @text.setter + def text(self, text): + self.clear_content() + _RunContentAppender.append_to_run_from_text(self, text) + + @property + def underline(self): + """ + String contained in w:val attribute of ./w:rPr/w:u grandchild, or + |None| if not present. + """ + rPr = self.rPr + if rPr is None: + return None + return rPr.underline + + @underline.setter + def underline(self, value): + rPr = self.get_or_add_rPr() + rPr.underline = value + + +class CT_RPr(BaseOxmlElement): + """ + ```` element, containing the properties for a run. + """ + rStyle = ZeroOrOne('w:rStyle', successors=('w:rPrChange',)) + b = ZeroOrOne('w:b', successors=('w:rPrChange',)) + bCs = ZeroOrOne('w:bCs', successors=('w:rPrChange',)) + caps = ZeroOrOne('w:caps', successors=('w:rPrChange',)) + cs = ZeroOrOne('w:cs', successors=('w:rPrChange',)) + dstrike = ZeroOrOne('w:dstrike', successors=('w:rPrChange',)) + emboss = ZeroOrOne('w:emboss', successors=('w:rPrChange',)) + i = ZeroOrOne('w:i', successors=('w:rPrChange',)) + iCs = ZeroOrOne('w:iCs', successors=('w:rPrChange',)) + imprint = ZeroOrOne('w:imprint', successors=('w:rPrChange',)) + noProof = ZeroOrOne('w:noProof', successors=('w:rPrChange',)) + oMath = ZeroOrOne('w:oMath', successors=('w:rPrChange',)) + outline = ZeroOrOne('w:outline', successors=('w:rPrChange',)) + rtl = ZeroOrOne('w:rtl', successors=('w:rPrChange',)) + shadow = ZeroOrOne('w:shadow', successors=('w:rPrChange',)) + smallCaps = ZeroOrOne('w:smallCaps', successors=('w:rPrChange',)) + snapToGrid = ZeroOrOne('w:snapToGrid', successors=('w:rPrChange',)) + specVanish = ZeroOrOne('w:specVanish', successors=('w:rPrChange',)) + strike = ZeroOrOne('w:strike', successors=('w:rPrChange',)) + u = ZeroOrOne('w:u', successors=('w:rPrChange',)) + vanish = ZeroOrOne('w:vanish', successors=('w:rPrChange',)) + webHidden = ZeroOrOne('w:webHidden', successors=('w:rPrChange',)) + + @property + def style(self): + """ + String contained in child, or None if that element is not + present. + """ + rStyle = self.rStyle + if rStyle is None: + return None + return rStyle.val + + @style.setter + def style(self, style): + """ + Set val attribute of child element to *style*, adding a + new element if necessary. If *style* is |None|, remove the + element if present. + """ + if style is None: + self._remove_rStyle() + elif self.rStyle is None: + self._add_rStyle(val=style) + else: + self.rStyle.val = style + + @property + def underline(self): + """ + Underline type specified in child, or None if that element is + not present. + """ + u = self.u + if u is None: + return None + return u.val + + @underline.setter + def underline(self, value): + self._remove_u() + if value is not None: + u = self._add_u() + u.val = value + + +class CT_Text(BaseOxmlElement): + """ + ```` element, containing a sequence of characters within a run. + """ + + +class CT_Underline(BaseOxmlElement): + """ + ```` element, specifying the underlining style for a run. + """ + @property + def val(self): + """ + The underline type corresponding to the ``w:val`` attribute value. + """ + val = self.get(qn('w:val')) + underline = WD_UNDERLINE.from_xml(val) + if underline == WD_UNDERLINE.SINGLE: + return True + if underline == WD_UNDERLINE.NONE: + return False + return underline + + @val.setter + def val(self, value): + # works fine without these two mappings, but only because True == 1 + # and False == 0, which happen to match the mapping for WD_UNDERLINE + # .SINGLE and .NONE respectively. + if value is True: + value = WD_UNDERLINE.SINGLE + elif value is False: + value = WD_UNDERLINE.NONE + + val = WD_UNDERLINE.to_xml(value) + self.set(qn('w:val'), val) + + +class _RunContentAppender(object): + """ + Service object that knows how to translate a Python string into run + content elements appended to a specified ```` element. Contiguous + sequences of regular characters are appended in a single ```` + element. Each tab character ('\t') causes a ```` element to be + appended. Likewise a newline or carriage return character ('\n', '\r') + causes a ```` element to be appended. + """ + def __init__(self, r): + self._r = r + self._bfr = [] + + @classmethod + def append_to_run_from_text(cls, r, text): + """ + Create a "one-shot" ``_RunContentAppender`` instance and use it to + append the run content elements corresponding to *text* to the + ```` element *r*. + """ + appender = cls(r) + appender.add_text(text) + + def add_text(self, text): + """ + Append the run content elements corresponding to *text* to the + ```` element of this instance. + """ + for char in text: + self.add_char(char) + self.flush() + + def add_char(self, char): + """ + Process the next character of input through the translation finite + state maching (FSM). There are two possible states, buffer pending + and not pending, but those are hidden behind the ``.flush()`` method + which must be called at the end of text to ensure any pending + ```` element is written. + """ + if char == '\t': + self.flush() + self._r.add_tab() + elif char in '\r\n': + self.flush() + self._r.add_br() + else: + self._bfr.append(char) + + def flush(self): + text = ''.join(self._bfr) + if text: + self._r.add_t(text) + del self._bfr[:] diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 26d0ff901..7f94e28bf 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -11,7 +11,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.oxml.parts.document import CT_Body, CT_Document from docx.oxml.section import CT_SectPr -from docx.oxml.text import CT_R +from docx.oxml.text.run import CT_R from docx.package import ImageParts, Package from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections from docx.parts.image import ImagePart diff --git a/tests/test_text.py b/tests/test_text.py index f918c6d2f..eba0cc222 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -10,7 +10,8 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE from docx.oxml.ns import qn -from docx.oxml.text import CT_P, CT_R +from docx.oxml.text import CT_P +from docx.oxml.text.run import CT_R from docx.parts.document import InlineShapes from docx.shape import InlineShape from docx.text import Paragraph, Run From 5b924f4934fbadd33e8c590e909d520ec57dc66f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:54:24 -0800 Subject: [PATCH 065/615] reorg: extract docx.oxml.text.paragraph module --- docx/oxml/__init__.py | 2 +- docx/oxml/text/__init__.py | 156 ------------------------------------ docx/oxml/text/paragraph.py | 155 +++++++++++++++++++++++++++++++++++ tests/test_text.py | 2 +- 4 files changed, 157 insertions(+), 158 deletions(-) create mode 100644 docx/oxml/text/paragraph.py diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 1cae55f02..a61f65943 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -134,7 +134,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) register_element_cls('w:vMerge', CT_VMerge) -from .text import CT_Jc, CT_P, CT_PPr +from .text.paragraph import CT_Jc, CT_P, CT_PPr register_element_cls('w:jc', CT_Jc) register_element_cls('w:p', CT_P) register_element_cls('w:pPr', CT_PPr) diff --git a/docx/oxml/text/__init__.py b/docx/oxml/text/__init__.py index f2f3067d6..e69de29bb 100644 --- a/docx/oxml/text/__init__.py +++ b/docx/oxml/text/__init__.py @@ -1,156 +0,0 @@ -# encoding: utf-8 - -""" -Custom element classes related to text, such as paragraph (CT_P) and runs -(CT_R). -""" - -from ...enum.text import WD_ALIGN_PARAGRAPH -from ..ns import qn -from ..xmlchemy import ( - BaseOxmlElement, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne -) - - -class CT_Jc(BaseOxmlElement): - """ - ```` element, specifying paragraph justification. - """ - val = RequiredAttribute('w:val', WD_ALIGN_PARAGRAPH) - - -class CT_P(BaseOxmlElement): - """ - ```` element, containing the properties and text for a paragraph. - """ - pPr = ZeroOrOne('w:pPr') - r = ZeroOrMore('w:r') - - def _insert_pPr(self, pPr): - self.insert(0, pPr) - return pPr - - def add_p_before(self): - """ - Return a new ```` element inserted directly prior to this one. - """ - new_p = OxmlElement('w:p') - self.addprevious(new_p) - return new_p - - @property - def alignment(self): - """ - The value of the ```` grandchild element or |None| if not - present. - """ - pPr = self.pPr - if pPr is None: - return None - return pPr.alignment - - @alignment.setter - def alignment(self, value): - pPr = self.get_or_add_pPr() - pPr.alignment = value - - def clear_content(self): - """ - Remove all child elements, except the ```` element if present. - """ - for child in self[:]: - if child.tag == qn('w:pPr'): - continue - self.remove(child) - - def set_sectPr(self, sectPr): - """ - Unconditionally replace or add *sectPr* as a grandchild in the - correct sequence. - """ - pPr = self.get_or_add_pPr() - pPr._remove_sectPr() - pPr._insert_sectPr(sectPr) - - @property - def style(self): - """ - String contained in w:val attribute of ./w:pPr/w:pStyle grandchild, - or |None| if not present. - """ - pPr = self.pPr - if pPr is None: - return None - return pPr.style - - @style.setter - def style(self, style): - pPr = self.get_or_add_pPr() - pPr.style = style - - -class CT_PPr(BaseOxmlElement): - """ - ```` element, containing the properties for a paragraph. - """ - __child_sequence__ = ( - 'w:pStyle', 'w:keepNext', 'w:keepLines', 'w:pageBreakBefore', - 'w:framePr', 'w:widowControl', 'w:numPr', 'w:suppressLineNumbers', - 'w:pBdr', 'w:shd', 'w:tabs', 'w:suppressAutoHyphens', 'w:kinsoku', - 'w:wordWrap', 'w:overflowPunct', 'w:topLinePunct', 'w:autoSpaceDE', - 'w:autoSpaceDN', 'w:bidi', 'w:adjustRightInd', 'w:snapToGrid', - 'w:spacing', 'w:ind', 'w:contextualSpacing', 'w:mirrorIndents', - 'w:suppressOverlap', 'w:jc', 'w:textDirection', 'w:textAlignment', - 'w:textboxTightWrap', 'w:outlineLvl', 'w:divId', 'w:cnfStyle', - 'w:rPr', 'w:sectPr', 'w:pPrChange' - ) - pStyle = ZeroOrOne('w:pStyle') - numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) - jc = ZeroOrOne('w:jc', successors=__child_sequence__[27:]) - sectPr = ZeroOrOne('w:sectPr', successors=('w:pPrChange',)) - - def _insert_pStyle(self, pStyle): - self.insert(0, pStyle) - return pStyle - - @property - def alignment(self): - """ - The value of the ```` child element or |None| if not present. - """ - jc = self.jc - if jc is None: - return None - return jc.val - - @alignment.setter - def alignment(self, value): - if value is None: - self._remove_jc() - return - jc = self.get_or_add_jc() - jc.val = value - - @property - def style(self): - """ - String contained in child, or None if that element is not - present. - """ - pStyle = self.pStyle - if pStyle is None: - return None - return pStyle.val - - @style.setter - def style(self, style): - """ - Set val attribute of child element to *style*, adding a - new element if necessary. If *style* is |None|, remove the - element if present. - """ - if style is None: - self._remove_pStyle() - return - pStyle = self.get_or_add_pStyle() - pStyle.val = style diff --git a/docx/oxml/text/paragraph.py b/docx/oxml/text/paragraph.py new file mode 100644 index 000000000..055ed6adf --- /dev/null +++ b/docx/oxml/text/paragraph.py @@ -0,0 +1,155 @@ +# encoding: utf-8 + +""" +Custom element classes related to paragraphs (CT_P). +""" + +from ...enum.text import WD_ALIGN_PARAGRAPH +from ..ns import qn +from ..xmlchemy import ( + BaseOxmlElement, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne +) + + +class CT_Jc(BaseOxmlElement): + """ + ```` element, specifying paragraph justification. + """ + val = RequiredAttribute('w:val', WD_ALIGN_PARAGRAPH) + + +class CT_P(BaseOxmlElement): + """ + ```` element, containing the properties and text for a paragraph. + """ + pPr = ZeroOrOne('w:pPr') + r = ZeroOrMore('w:r') + + def _insert_pPr(self, pPr): + self.insert(0, pPr) + return pPr + + def add_p_before(self): + """ + Return a new ```` element inserted directly prior to this one. + """ + new_p = OxmlElement('w:p') + self.addprevious(new_p) + return new_p + + @property + def alignment(self): + """ + The value of the ```` grandchild element or |None| if not + present. + """ + pPr = self.pPr + if pPr is None: + return None + return pPr.alignment + + @alignment.setter + def alignment(self, value): + pPr = self.get_or_add_pPr() + pPr.alignment = value + + def clear_content(self): + """ + Remove all child elements, except the ```` element if present. + """ + for child in self[:]: + if child.tag == qn('w:pPr'): + continue + self.remove(child) + + def set_sectPr(self, sectPr): + """ + Unconditionally replace or add *sectPr* as a grandchild in the + correct sequence. + """ + pPr = self.get_or_add_pPr() + pPr._remove_sectPr() + pPr._insert_sectPr(sectPr) + + @property + def style(self): + """ + String contained in w:val attribute of ./w:pPr/w:pStyle grandchild, + or |None| if not present. + """ + pPr = self.pPr + if pPr is None: + return None + return pPr.style + + @style.setter + def style(self, style): + pPr = self.get_or_add_pPr() + pPr.style = style + + +class CT_PPr(BaseOxmlElement): + """ + ```` element, containing the properties for a paragraph. + """ + __child_sequence__ = ( + 'w:pStyle', 'w:keepNext', 'w:keepLines', 'w:pageBreakBefore', + 'w:framePr', 'w:widowControl', 'w:numPr', 'w:suppressLineNumbers', + 'w:pBdr', 'w:shd', 'w:tabs', 'w:suppressAutoHyphens', 'w:kinsoku', + 'w:wordWrap', 'w:overflowPunct', 'w:topLinePunct', 'w:autoSpaceDE', + 'w:autoSpaceDN', 'w:bidi', 'w:adjustRightInd', 'w:snapToGrid', + 'w:spacing', 'w:ind', 'w:contextualSpacing', 'w:mirrorIndents', + 'w:suppressOverlap', 'w:jc', 'w:textDirection', 'w:textAlignment', + 'w:textboxTightWrap', 'w:outlineLvl', 'w:divId', 'w:cnfStyle', + 'w:rPr', 'w:sectPr', 'w:pPrChange' + ) + pStyle = ZeroOrOne('w:pStyle') + numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) + jc = ZeroOrOne('w:jc', successors=__child_sequence__[27:]) + sectPr = ZeroOrOne('w:sectPr', successors=('w:pPrChange',)) + + def _insert_pStyle(self, pStyle): + self.insert(0, pStyle) + return pStyle + + @property + def alignment(self): + """ + The value of the ```` child element or |None| if not present. + """ + jc = self.jc + if jc is None: + return None + return jc.val + + @alignment.setter + def alignment(self, value): + if value is None: + self._remove_jc() + return + jc = self.get_or_add_jc() + jc.val = value + + @property + def style(self): + """ + String contained in child, or None if that element is not + present. + """ + pStyle = self.pStyle + if pStyle is None: + return None + return pStyle.val + + @style.setter + def style(self, style): + """ + Set val attribute of child element to *style*, adding a + new element if necessary. If *style* is |None|, remove the + element if present. + """ + if style is None: + self._remove_pStyle() + return + pStyle = self.get_or_add_pStyle() + pStyle.val = style diff --git a/tests/test_text.py b/tests/test_text.py index eba0cc222..f25533c7f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -10,7 +10,7 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE from docx.oxml.ns import qn -from docx.oxml.text import CT_P +from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import InlineShapes from docx.shape import InlineShape From cbd587ecdbd1d52c404875a2e24460b6cd12a2c0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:36:56 -0800 Subject: [PATCH 066/615] reorg: extract tests.text.test_run module --- tests/test_text.py | 352 +-------------------------------------- tests/text/__init__.py | 0 tests/text/test_run.py | 367 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 351 deletions(-) create mode 100644 tests/text/__init__.py create mode 100644 tests/text/test_run.py diff --git a/tests/test_text.py b/tests/test_text.py index f25533c7f..b91cb4382 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,12 +8,10 @@ absolute_import, division, print_function, unicode_literals ) -from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE +from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R -from docx.parts.document import InlineShapes -from docx.shape import InlineShape from docx.text import Paragraph, Run import pytest @@ -230,351 +228,3 @@ def runs_(self, request): run_ = instance_mock(request, Run, name='run_') run_2_ = instance_mock(request, Run, name='run_2_') return run_, run_2_ - - -class DescribeRun(object): - - def it_knows_its_bool_prop_states(self, bool_prop_get_fixture): - run, prop_name, expected_state = bool_prop_get_fixture - assert getattr(run, prop_name) == expected_state - - def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): - run, prop_name, value, expected_xml = bool_prop_set_fixture - setattr(run, prop_name, value) - assert run._r.xml == expected_xml - - def it_knows_its_character_style(self, style_get_fixture): - run, expected_style = style_get_fixture - assert run.style == expected_style - - def it_can_change_its_character_style(self, style_set_fixture): - run, style, expected_xml = style_set_fixture - run.style = style - assert run._r.xml == expected_xml - - def it_knows_its_underline_type(self, underline_get_fixture): - run, expected_value = underline_get_fixture - assert run.underline is expected_value - - def it_can_change_its_underline_type(self, underline_set_fixture): - run, underline, expected_xml = underline_set_fixture - run.underline = underline - assert run._r.xml == expected_xml - - def it_raises_on_assign_invalid_underline_type( - self, underline_raise_fixture): - run, underline = underline_raise_fixture - with pytest.raises(ValueError): - run.underline = underline - - def it_can_add_text(self, add_text_fixture): - run, text_str, expected_xml, Text_ = add_text_fixture - _text = run.add_text(text_str) - assert run._r.xml == expected_xml - assert _text is Text_.return_value - - def it_can_add_a_break(self, add_break_fixture): - run, break_type, expected_xml = add_break_fixture - run.add_break(break_type) - assert run._r.xml == expected_xml - - def it_can_add_a_tab(self, add_tab_fixture): - run, expected_xml = add_tab_fixture - run.add_tab() - assert run._r.xml == expected_xml - - def it_can_add_a_picture(self, add_picture_fixture): - (run, image_descriptor_, width, height, inline_shapes_, - expected_width, expected_height, picture_) = add_picture_fixture - picture = run.add_picture(image_descriptor_, width, height) - inline_shapes_.add_picture.assert_called_once_with( - image_descriptor_, run - ) - assert picture is picture_ - assert picture.width == expected_width - assert picture.height == expected_height - - def it_can_remove_its_content_but_keep_formatting(self, clear_fixture): - run, expected_xml = clear_fixture - _run = run.clear() - assert run._r.xml == expected_xml - assert _run is run - - def it_knows_the_text_it_contains(self, text_get_fixture): - run, expected_text = text_get_fixture - assert run.text == expected_text - - def it_can_replace_the_text_it_contains(self, text_set_fixture): - run, text, expected_xml = text_set_fixture - run.text = text - assert run._r.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture(params=[ - (WD_BREAK.LINE, 'w:r/w:br'), - (WD_BREAK.PAGE, 'w:r/w:br{w:type=page}'), - (WD_BREAK.COLUMN, 'w:r/w:br{w:type=column}'), - (WD_BREAK.LINE_CLEAR_LEFT, - 'w:r/w:br{w:type=textWrapping, w:clear=left}'), - (WD_BREAK.LINE_CLEAR_RIGHT, - 'w:r/w:br{w:type=textWrapping, w:clear=right}'), - (WD_BREAK.LINE_CLEAR_ALL, - 'w:r/w:br{w:type=textWrapping, w:clear=all}'), - ]) - def add_break_fixture(self, request): - break_type, expected_cxml = request.param - run = Run(element('w:r'), None) - expected_xml = xml(expected_cxml) - return run, break_type, expected_xml - - @pytest.fixture(params=[ - (None, None, 200, 100), - (1000, 500, 1000, 500), - (2000, None, 2000, 1000), - (None, 2000, 4000, 2000), - ]) - def add_picture_fixture( - self, request, paragraph_, inline_shapes_, picture_): - width, height, expected_width, expected_height = request.param - paragraph_.part.inline_shapes = inline_shapes_ - run = Run(None, paragraph_) - image_descriptor_ = 'image_descriptor_' - picture_.width, picture_.height = 200, 100 - return ( - run, image_descriptor_, width, height, inline_shapes_, - expected_width, expected_height, picture_ - ) - - @pytest.fixture(params=[ - ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), - ]) - def add_tab_fixture(self, request): - r_cxml, expected_cxml = request.param - run = Run(element(r_cxml), None) - expected_xml = xml(expected_cxml) - return run, expected_xml - - @pytest.fixture(params=[ - ('w:r', 'foo', 'w:r/w:t"foo"'), - ('w:r/w:t"foo"', 'bar', 'w:r/(w:t"foo", w:t"bar")'), - ('w:r', 'fo ', 'w:r/w:t{xml:space=preserve}"fo "'), - ('w:r', 'f o', 'w:r/w:t"f o"'), - ]) - def add_text_fixture(self, request, Text_): - r_cxml, text, expected_cxml = request.param - run = Run(element(r_cxml), None) - expected_xml = xml(expected_cxml) - return run, text, expected_xml, Text_ - - @pytest.fixture(params=[ - ('w:r/w:rPr', 'all_caps', None), - ('w:r/w:rPr/w:caps', 'all_caps', True), - ('w:r/w:rPr/w:caps{w:val=on}', 'all_caps', True), - ('w:r/w:rPr/w:caps{w:val=off}', 'all_caps', False), - ('w:r/w:rPr/w:b{w:val=1}', 'bold', True), - ('w:r/w:rPr/w:i{w:val=0}', 'italic', False), - ('w:r/w:rPr/w:cs{w:val=true}', 'complex_script', True), - ('w:r/w:rPr/w:bCs{w:val=false}', 'cs_bold', False), - ('w:r/w:rPr/w:iCs{w:val=on}', 'cs_italic', True), - ('w:r/w:rPr/w:dstrike{w:val=off}', 'double_strike', False), - ('w:r/w:rPr/w:emboss{w:val=1}', 'emboss', True), - ('w:r/w:rPr/w:vanish{w:val=0}', 'hidden', False), - ('w:r/w:rPr/w:i{w:val=true}', 'italic', True), - ('w:r/w:rPr/w:imprint{w:val=false}', 'imprint', False), - ('w:r/w:rPr/w:oMath{w:val=on}', 'math', True), - ('w:r/w:rPr/w:noProof{w:val=off}', 'no_proof', False), - ('w:r/w:rPr/w:outline{w:val=1}', 'outline', True), - ('w:r/w:rPr/w:rtl{w:val=0}', 'rtl', False), - ('w:r/w:rPr/w:shadow{w:val=true}', 'shadow', True), - ('w:r/w:rPr/w:smallCaps{w:val=false}', 'small_caps', False), - ('w:r/w:rPr/w:snapToGrid{w:val=on}', 'snap_to_grid', True), - ('w:r/w:rPr/w:specVanish{w:val=off}', 'spec_vanish', False), - ('w:r/w:rPr/w:strike{w:val=1}', 'strike', True), - ('w:r/w:rPr/w:webHidden{w:val=0}', 'web_hidden', False), - ]) - def bool_prop_get_fixture(self, request): - r_cxml, bool_prop_name, expected_value = request.param - run = Run(element(r_cxml), None) - return run, bool_prop_name, expected_value - - @pytest.fixture(params=[ - # nothing to True, False, and None --------------------------- - ('w:r', 'all_caps', True, - 'w:r/w:rPr/w:caps'), - ('w:r', 'bold', False, - 'w:r/w:rPr/w:b{w:val=0}'), - ('w:r', 'italic', None, - 'w:r/w:rPr'), - # default to True, False, and None --------------------------- - ('w:r/w:rPr/w:cs', 'complex_script', True, - 'w:r/w:rPr/w:cs'), - ('w:r/w:rPr/w:bCs', 'cs_bold', False, - 'w:r/w:rPr/w:bCs{w:val=0}'), - ('w:r/w:rPr/w:iCs', 'cs_italic', None, - 'w:r/w:rPr'), - # True to True, False, and None ------------------------------ - ('w:r/w:rPr/w:dstrike{w:val=1}', 'double_strike', True, - 'w:r/w:rPr/w:dstrike'), - ('w:r/w:rPr/w:emboss{w:val=on}', 'emboss', False, - 'w:r/w:rPr/w:emboss{w:val=0}'), - ('w:r/w:rPr/w:vanish{w:val=1}', 'hidden', None, - 'w:r/w:rPr'), - # False to True, False, and None ----------------------------- - ('w:r/w:rPr/w:i{w:val=false}', 'italic', True, - 'w:r/w:rPr/w:i'), - ('w:r/w:rPr/w:imprint{w:val=0}', 'imprint', False, - 'w:r/w:rPr/w:imprint{w:val=0}'), - ('w:r/w:rPr/w:oMath{w:val=off}', 'math', None, - 'w:r/w:rPr'), - # random mix ------------------------------------------------- - ('w:r/w:rPr/w:noProof{w:val=1}', 'no_proof', False, - 'w:r/w:rPr/w:noProof{w:val=0}'), - ('w:r/w:rPr', 'outline', True, - 'w:r/w:rPr/w:outline'), - ('w:r/w:rPr/w:rtl{w:val=true}', 'rtl', False, - 'w:r/w:rPr/w:rtl{w:val=0}'), - ('w:r/w:rPr/w:shadow{w:val=on}', 'shadow', True, - 'w:r/w:rPr/w:shadow'), - ('w:r/w:rPr/w:smallCaps', 'small_caps', False, - 'w:r/w:rPr/w:smallCaps{w:val=0}'), - ('w:r/w:rPr/w:snapToGrid', 'snap_to_grid', True, - 'w:r/w:rPr/w:snapToGrid'), - ('w:r/w:rPr/w:specVanish', 'spec_vanish', None, - 'w:r/w:rPr'), - ('w:r/w:rPr/w:strike{w:val=foo}', 'strike', True, - 'w:r/w:rPr/w:strike'), - ('w:r/w:rPr/w:webHidden', 'web_hidden', False, - 'w:r/w:rPr/w:webHidden{w:val=0}'), - ]) - def bool_prop_set_fixture(self, request): - initial_r_cxml, bool_prop_name, value, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, bool_prop_name, value, expected_xml - - @pytest.fixture(params=[ - ('w:r', 'w:r'), - ('w:r/w:t"foo"', 'w:r'), - ('w:r/w:br', 'w:r'), - ('w:r/w:rPr', 'w:r/w:rPr'), - ('w:r/(w:rPr, w:t"foo")', 'w:r/w:rPr'), - ('w:r/(w:rPr/(w:b, w:i), w:t"foo", w:cr, w:t"bar")', - 'w:r/w:rPr/(w:b, w:i)'), - ]) - def clear_fixture(self, request): - initial_r_cxml, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, expected_xml - - @pytest.fixture(params=[ - ('w:r', None), - ('w:r/w:rPr/w:rStyle{w:val=Foobar}', 'Foobar'), - ]) - def style_get_fixture(self, request): - r_cxml, expected_style = request.param - run = Run(element(r_cxml), None) - return run, expected_style - - @pytest.fixture(params=[ - ('w:r', None, - 'w:r/w:rPr'), - ('w:r', 'Foo', - 'w:r/w:rPr/w:rStyle{w:val=Foo}'), - ('w:r/w:rPr/w:rStyle{w:val=Foo}', None, - 'w:r/w:rPr'), - ('w:r/w:rPr/w:rStyle{w:val=Foo}', 'Bar', - 'w:r/w:rPr/w:rStyle{w:val=Bar}'), - ]) - def style_set_fixture(self, request): - initial_r_cxml, new_style, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_style, expected_xml - - @pytest.fixture(params=[ - ('w:r', ''), - ('w:r/w:t"foobar"', 'foobar'), - ('w:r/(w:t"abc", w:tab, w:t"def", w:cr)', 'abc\tdef\n'), - ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', '\nabcdef\t'), - ]) - def text_get_fixture(self, request): - r_cxml, expected_text = request.param - run = Run(element(r_cxml), None) - return run, expected_text - - @pytest.fixture(params=[ - ('abc def', 'w:r/w:t"abc def"'), - ('abc\tdef', 'w:r/(w:t"abc", w:tab, w:t"def")'), - ('abc\ndef', 'w:r/(w:t"abc", w:br, w:t"def")'), - ('abc\rdef', 'w:r/(w:t"abc", w:br, w:t"def")'), - ]) - def text_set_fixture(self, request): - new_text, expected_cxml = request.param - initial_r_cxml = 'w:r/w:t"should get deleted"' - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_text, expected_xml - - @pytest.fixture(params=[ - ('w:r', None), - ('w:r/w:rPr/w:u', None), - ('w:r/w:rPr/w:u{w:val=single}', True), - ('w:r/w:rPr/w:u{w:val=none}', False), - ('w:r/w:rPr/w:u{w:val=double}', WD_UNDERLINE.DOUBLE), - ('w:r/w:rPr/w:u{w:val=wave}', WD_UNDERLINE.WAVY), - ]) - def underline_get_fixture(self, request): - r_cxml, expected_underline = request.param - run = Run(element(r_cxml), None) - return run, expected_underline - - @pytest.fixture(params=[ - ('w:r', True, 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r', False, 'w:r/w:rPr/w:u{w:val=none}'), - ('w:r', None, 'w:r/w:rPr'), - ('w:r', WD_UNDERLINE.SINGLE, 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r', WD_UNDERLINE.THICK, 'w:r/w:rPr/w:u{w:val=thick}'), - ('w:r/w:rPr/w:u{w:val=single}', True, - 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r/w:rPr/w:u{w:val=single}', False, - 'w:r/w:rPr/w:u{w:val=none}'), - ('w:r/w:rPr/w:u{w:val=single}', None, - 'w:r/w:rPr'), - ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.SINGLE, - 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.DOTTED, - 'w:r/w:rPr/w:u{w:val=dotted}'), - ]) - def underline_set_fixture(self, request): - initial_r_cxml, new_underline, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_underline, expected_xml - - @pytest.fixture(params=['foobar', 42, 'single']) - def underline_raise_fixture(self, request): - invalid_underline_setting = request.param - run = Run(element('w:r/w:rPr'), None) - return run, invalid_underline_setting - - # fixture components --------------------------------------------- - - @pytest.fixture - def inline_shapes_(self, request, picture_): - inline_shapes_ = instance_mock(request, InlineShapes) - inline_shapes_.add_picture.return_value = picture_ - return inline_shapes_ - - @pytest.fixture - def paragraph_(self, request): - return instance_mock(request, Paragraph) - - @pytest.fixture - def picture_(self, request): - return instance_mock(request, InlineShape) - - @pytest.fixture - def Text_(self, request): - return class_mock(request, 'docx.text.Text') diff --git a/tests/text/__init__.py b/tests/text/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/text/test_run.py b/tests/text/test_run.py new file mode 100644 index 000000000..5803e7353 --- /dev/null +++ b/tests/text/test_run.py @@ -0,0 +1,367 @@ +# encoding: utf-8 + +""" +Test suite for the docx.text.run module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.parts.document import InlineShapes +from docx.shape import InlineShape +from docx.text import Paragraph, Run + +import pytest + +from ..unitutil.cxml import element, xml +from ..unitutil.mock import class_mock, instance_mock + + +class DescribeRun(object): + + def it_knows_its_bool_prop_states(self, bool_prop_get_fixture): + run, prop_name, expected_state = bool_prop_get_fixture + assert getattr(run, prop_name) == expected_state + + def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): + run, prop_name, value, expected_xml = bool_prop_set_fixture + setattr(run, prop_name, value) + assert run._r.xml == expected_xml + + def it_knows_its_character_style(self, style_get_fixture): + run, expected_style = style_get_fixture + assert run.style == expected_style + + def it_can_change_its_character_style(self, style_set_fixture): + run, style, expected_xml = style_set_fixture + run.style = style + assert run._r.xml == expected_xml + + def it_knows_its_underline_type(self, underline_get_fixture): + run, expected_value = underline_get_fixture + assert run.underline is expected_value + + def it_can_change_its_underline_type(self, underline_set_fixture): + run, underline, expected_xml = underline_set_fixture + run.underline = underline + assert run._r.xml == expected_xml + + def it_raises_on_assign_invalid_underline_type( + self, underline_raise_fixture): + run, underline = underline_raise_fixture + with pytest.raises(ValueError): + run.underline = underline + + def it_can_add_text(self, add_text_fixture): + run, text_str, expected_xml, Text_ = add_text_fixture + _text = run.add_text(text_str) + assert run._r.xml == expected_xml + assert _text is Text_.return_value + + def it_can_add_a_break(self, add_break_fixture): + run, break_type, expected_xml = add_break_fixture + run.add_break(break_type) + assert run._r.xml == expected_xml + + def it_can_add_a_tab(self, add_tab_fixture): + run, expected_xml = add_tab_fixture + run.add_tab() + assert run._r.xml == expected_xml + + def it_can_add_a_picture(self, add_picture_fixture): + (run, image_descriptor_, width, height, inline_shapes_, + expected_width, expected_height, picture_) = add_picture_fixture + picture = run.add_picture(image_descriptor_, width, height) + inline_shapes_.add_picture.assert_called_once_with( + image_descriptor_, run + ) + assert picture is picture_ + assert picture.width == expected_width + assert picture.height == expected_height + + def it_can_remove_its_content_but_keep_formatting(self, clear_fixture): + run, expected_xml = clear_fixture + _run = run.clear() + assert run._r.xml == expected_xml + assert _run is run + + def it_knows_the_text_it_contains(self, text_get_fixture): + run, expected_text = text_get_fixture + assert run.text == expected_text + + def it_can_replace_the_text_it_contains(self, text_set_fixture): + run, text, expected_xml = text_set_fixture + run.text = text + assert run._r.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + (WD_BREAK.LINE, 'w:r/w:br'), + (WD_BREAK.PAGE, 'w:r/w:br{w:type=page}'), + (WD_BREAK.COLUMN, 'w:r/w:br{w:type=column}'), + (WD_BREAK.LINE_CLEAR_LEFT, + 'w:r/w:br{w:type=textWrapping, w:clear=left}'), + (WD_BREAK.LINE_CLEAR_RIGHT, + 'w:r/w:br{w:type=textWrapping, w:clear=right}'), + (WD_BREAK.LINE_CLEAR_ALL, + 'w:r/w:br{w:type=textWrapping, w:clear=all}'), + ]) + def add_break_fixture(self, request): + break_type, expected_cxml = request.param + run = Run(element('w:r'), None) + expected_xml = xml(expected_cxml) + return run, break_type, expected_xml + + @pytest.fixture(params=[ + (None, None, 200, 100), + (1000, 500, 1000, 500), + (2000, None, 2000, 1000), + (None, 2000, 4000, 2000), + ]) + def add_picture_fixture( + self, request, paragraph_, inline_shapes_, picture_): + width, height, expected_width, expected_height = request.param + paragraph_.part.inline_shapes = inline_shapes_ + run = Run(None, paragraph_) + image_descriptor_ = 'image_descriptor_' + picture_.width, picture_.height = 200, 100 + return ( + run, image_descriptor_, width, height, inline_shapes_, + expected_width, expected_height, picture_ + ) + + @pytest.fixture(params=[ + ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), + ]) + def add_tab_fixture(self, request): + r_cxml, expected_cxml = request.param + run = Run(element(r_cxml), None) + expected_xml = xml(expected_cxml) + return run, expected_xml + + @pytest.fixture(params=[ + ('w:r', 'foo', 'w:r/w:t"foo"'), + ('w:r/w:t"foo"', 'bar', 'w:r/(w:t"foo", w:t"bar")'), + ('w:r', 'fo ', 'w:r/w:t{xml:space=preserve}"fo "'), + ('w:r', 'f o', 'w:r/w:t"f o"'), + ]) + def add_text_fixture(self, request, Text_): + r_cxml, text, expected_cxml = request.param + run = Run(element(r_cxml), None) + expected_xml = xml(expected_cxml) + return run, text, expected_xml, Text_ + + @pytest.fixture(params=[ + ('w:r/w:rPr', 'all_caps', None), + ('w:r/w:rPr/w:caps', 'all_caps', True), + ('w:r/w:rPr/w:caps{w:val=on}', 'all_caps', True), + ('w:r/w:rPr/w:caps{w:val=off}', 'all_caps', False), + ('w:r/w:rPr/w:b{w:val=1}', 'bold', True), + ('w:r/w:rPr/w:i{w:val=0}', 'italic', False), + ('w:r/w:rPr/w:cs{w:val=true}', 'complex_script', True), + ('w:r/w:rPr/w:bCs{w:val=false}', 'cs_bold', False), + ('w:r/w:rPr/w:iCs{w:val=on}', 'cs_italic', True), + ('w:r/w:rPr/w:dstrike{w:val=off}', 'double_strike', False), + ('w:r/w:rPr/w:emboss{w:val=1}', 'emboss', True), + ('w:r/w:rPr/w:vanish{w:val=0}', 'hidden', False), + ('w:r/w:rPr/w:i{w:val=true}', 'italic', True), + ('w:r/w:rPr/w:imprint{w:val=false}', 'imprint', False), + ('w:r/w:rPr/w:oMath{w:val=on}', 'math', True), + ('w:r/w:rPr/w:noProof{w:val=off}', 'no_proof', False), + ('w:r/w:rPr/w:outline{w:val=1}', 'outline', True), + ('w:r/w:rPr/w:rtl{w:val=0}', 'rtl', False), + ('w:r/w:rPr/w:shadow{w:val=true}', 'shadow', True), + ('w:r/w:rPr/w:smallCaps{w:val=false}', 'small_caps', False), + ('w:r/w:rPr/w:snapToGrid{w:val=on}', 'snap_to_grid', True), + ('w:r/w:rPr/w:specVanish{w:val=off}', 'spec_vanish', False), + ('w:r/w:rPr/w:strike{w:val=1}', 'strike', True), + ('w:r/w:rPr/w:webHidden{w:val=0}', 'web_hidden', False), + ]) + def bool_prop_get_fixture(self, request): + r_cxml, bool_prop_name, expected_value = request.param + run = Run(element(r_cxml), None) + return run, bool_prop_name, expected_value + + @pytest.fixture(params=[ + # nothing to True, False, and None --------------------------- + ('w:r', 'all_caps', True, + 'w:r/w:rPr/w:caps'), + ('w:r', 'bold', False, + 'w:r/w:rPr/w:b{w:val=0}'), + ('w:r', 'italic', None, + 'w:r/w:rPr'), + # default to True, False, and None --------------------------- + ('w:r/w:rPr/w:cs', 'complex_script', True, + 'w:r/w:rPr/w:cs'), + ('w:r/w:rPr/w:bCs', 'cs_bold', False, + 'w:r/w:rPr/w:bCs{w:val=0}'), + ('w:r/w:rPr/w:iCs', 'cs_italic', None, + 'w:r/w:rPr'), + # True to True, False, and None ------------------------------ + ('w:r/w:rPr/w:dstrike{w:val=1}', 'double_strike', True, + 'w:r/w:rPr/w:dstrike'), + ('w:r/w:rPr/w:emboss{w:val=on}', 'emboss', False, + 'w:r/w:rPr/w:emboss{w:val=0}'), + ('w:r/w:rPr/w:vanish{w:val=1}', 'hidden', None, + 'w:r/w:rPr'), + # False to True, False, and None ----------------------------- + ('w:r/w:rPr/w:i{w:val=false}', 'italic', True, + 'w:r/w:rPr/w:i'), + ('w:r/w:rPr/w:imprint{w:val=0}', 'imprint', False, + 'w:r/w:rPr/w:imprint{w:val=0}'), + ('w:r/w:rPr/w:oMath{w:val=off}', 'math', None, + 'w:r/w:rPr'), + # random mix ------------------------------------------------- + ('w:r/w:rPr/w:noProof{w:val=1}', 'no_proof', False, + 'w:r/w:rPr/w:noProof{w:val=0}'), + ('w:r/w:rPr', 'outline', True, + 'w:r/w:rPr/w:outline'), + ('w:r/w:rPr/w:rtl{w:val=true}', 'rtl', False, + 'w:r/w:rPr/w:rtl{w:val=0}'), + ('w:r/w:rPr/w:shadow{w:val=on}', 'shadow', True, + 'w:r/w:rPr/w:shadow'), + ('w:r/w:rPr/w:smallCaps', 'small_caps', False, + 'w:r/w:rPr/w:smallCaps{w:val=0}'), + ('w:r/w:rPr/w:snapToGrid', 'snap_to_grid', True, + 'w:r/w:rPr/w:snapToGrid'), + ('w:r/w:rPr/w:specVanish', 'spec_vanish', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:strike{w:val=foo}', 'strike', True, + 'w:r/w:rPr/w:strike'), + ('w:r/w:rPr/w:webHidden', 'web_hidden', False, + 'w:r/w:rPr/w:webHidden{w:val=0}'), + ]) + def bool_prop_set_fixture(self, request): + initial_r_cxml, bool_prop_name, value, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, bool_prop_name, value, expected_xml + + @pytest.fixture(params=[ + ('w:r', 'w:r'), + ('w:r/w:t"foo"', 'w:r'), + ('w:r/w:br', 'w:r'), + ('w:r/w:rPr', 'w:r/w:rPr'), + ('w:r/(w:rPr, w:t"foo")', 'w:r/w:rPr'), + ('w:r/(w:rPr/(w:b, w:i), w:t"foo", w:cr, w:t"bar")', + 'w:r/w:rPr/(w:b, w:i)'), + ]) + def clear_fixture(self, request): + initial_r_cxml, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, expected_xml + + @pytest.fixture(params=[ + ('w:r', None), + ('w:r/w:rPr/w:rStyle{w:val=Foobar}', 'Foobar'), + ]) + def style_get_fixture(self, request): + r_cxml, expected_style = request.param + run = Run(element(r_cxml), None) + return run, expected_style + + @pytest.fixture(params=[ + ('w:r', None, + 'w:r/w:rPr'), + ('w:r', 'Foo', + 'w:r/w:rPr/w:rStyle{w:val=Foo}'), + ('w:r/w:rPr/w:rStyle{w:val=Foo}', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:rStyle{w:val=Foo}', 'Bar', + 'w:r/w:rPr/w:rStyle{w:val=Bar}'), + ]) + def style_set_fixture(self, request): + initial_r_cxml, new_style, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, new_style, expected_xml + + @pytest.fixture(params=[ + ('w:r', ''), + ('w:r/w:t"foobar"', 'foobar'), + ('w:r/(w:t"abc", w:tab, w:t"def", w:cr)', 'abc\tdef\n'), + ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', '\nabcdef\t'), + ]) + def text_get_fixture(self, request): + r_cxml, expected_text = request.param + run = Run(element(r_cxml), None) + return run, expected_text + + @pytest.fixture(params=[ + ('abc def', 'w:r/w:t"abc def"'), + ('abc\tdef', 'w:r/(w:t"abc", w:tab, w:t"def")'), + ('abc\ndef', 'w:r/(w:t"abc", w:br, w:t"def")'), + ('abc\rdef', 'w:r/(w:t"abc", w:br, w:t"def")'), + ]) + def text_set_fixture(self, request): + new_text, expected_cxml = request.param + initial_r_cxml = 'w:r/w:t"should get deleted"' + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, new_text, expected_xml + + @pytest.fixture(params=[ + ('w:r', None), + ('w:r/w:rPr/w:u', None), + ('w:r/w:rPr/w:u{w:val=single}', True), + ('w:r/w:rPr/w:u{w:val=none}', False), + ('w:r/w:rPr/w:u{w:val=double}', WD_UNDERLINE.DOUBLE), + ('w:r/w:rPr/w:u{w:val=wave}', WD_UNDERLINE.WAVY), + ]) + def underline_get_fixture(self, request): + r_cxml, expected_underline = request.param + run = Run(element(r_cxml), None) + return run, expected_underline + + @pytest.fixture(params=[ + ('w:r', True, 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r', False, 'w:r/w:rPr/w:u{w:val=none}'), + ('w:r', None, 'w:r/w:rPr'), + ('w:r', WD_UNDERLINE.SINGLE, 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r', WD_UNDERLINE.THICK, 'w:r/w:rPr/w:u{w:val=thick}'), + ('w:r/w:rPr/w:u{w:val=single}', True, + 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r/w:rPr/w:u{w:val=single}', False, + 'w:r/w:rPr/w:u{w:val=none}'), + ('w:r/w:rPr/w:u{w:val=single}', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.SINGLE, + 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.DOTTED, + 'w:r/w:rPr/w:u{w:val=dotted}'), + ]) + def underline_set_fixture(self, request): + initial_r_cxml, new_underline, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, new_underline, expected_xml + + @pytest.fixture(params=['foobar', 42, 'single']) + def underline_raise_fixture(self, request): + invalid_underline_setting = request.param + run = Run(element('w:r/w:rPr'), None) + return run, invalid_underline_setting + + # fixture components --------------------------------------------- + + @pytest.fixture + def inline_shapes_(self, request, picture_): + inline_shapes_ = instance_mock(request, InlineShapes) + inline_shapes_.add_picture.return_value = picture_ + return inline_shapes_ + + @pytest.fixture + def paragraph_(self, request): + return instance_mock(request, Paragraph) + + @pytest.fixture + def picture_(self, request): + return instance_mock(request, InlineShape) + + @pytest.fixture + def Text_(self, request): + return class_mock(request, 'docx.text.Text') From cd918489db429f665f94de92eaabbd2e5cfe3908 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 16:04:06 -0800 Subject: [PATCH 067/615] reorg: extract tests.text.test_paragraph module --- tests/{test_text.py => text/test_paragraph.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename tests/{test_text.py => text/test_paragraph.py} (98%) diff --git a/tests/test_text.py b/tests/text/test_paragraph.py similarity index 98% rename from tests/test_text.py rename to tests/text/test_paragraph.py index b91cb4382..434b900a0 100644 --- a/tests/test_text.py +++ b/tests/text/test_paragraph.py @@ -16,8 +16,8 @@ import pytest -from .unitutil.cxml import element, xml -from .unitutil.mock import call, class_mock, instance_mock +from ..unitutil.cxml import element, xml +from ..unitutil.mock import call, class_mock, instance_mock class DescribeParagraph(object): From c1215cfe5c07b99572dcb352585c34a1efb66f63 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:37:23 -0800 Subject: [PATCH 068/615] reorg: extract docx.text.run module --- docx/text/__init__.py | 373 +--------------------------------------- docx/text/run.py | 381 +++++++++++++++++++++++++++++++++++++++++ tests/text/test_run.py | 2 +- 3 files changed, 383 insertions(+), 373 deletions(-) create mode 100644 docx/text/run.py diff --git a/docx/text/__init__.py b/docx/text/__init__.py index 21131c3d0..1351a76b4 100644 --- a/docx/text/__init__.py +++ b/docx/text/__init__.py @@ -6,55 +6,10 @@ from __future__ import absolute_import, print_function, unicode_literals -from ..enum.text import WD_BREAK +from .run import Run from ..shared import Parented -def boolproperty(f): - """ - @boolproperty decorator. Decorated method must return the XML element - name of the boolean property element occuring under rPr. Causes - a read/write tri-state property to be added to the class having the name - of the decorated function. - """ - def _get_prop_value(parent, attr_name): - return getattr(parent, attr_name) - - def _remove_prop(parent, attr_name): - remove_method_name = '_remove_%s' % attr_name - remove_method = getattr(parent, remove_method_name) - remove_method() - - def _add_prop(parent, attr_name): - add_method_name = '_add_%s' % attr_name - add_method = getattr(parent, add_method_name) - return add_method() - - def getter(obj): - r, attr_name = obj._r, f(obj) - if r.rPr is None: - return None - prop_value = _get_prop_value(r.rPr, attr_name) - if prop_value is None: - return None - return prop_value.val - - def setter(obj, value): - if value not in (True, False, None): - raise ValueError( - "assigned value must be True, False, or None, got '%s'" - % value - ) - r, attr_name = obj._r, f(obj) - rPr = r.get_or_add_rPr() - _remove_prop(rPr, attr_name) - if value is not None: - elm = _add_prop(rPr, attr_name) - elm.val = value - - return property(getter, setter, doc=f.__doc__) - - class Paragraph(Parented): """ Proxy object wrapping ```` element. @@ -161,329 +116,3 @@ def text(self): def text(self, text): self.clear() self.add_run(text) - - -class Run(Parented): - """ - Proxy object wrapping ```` element. Several of the properties on Run - take a tri-state value, |True|, |False|, or |None|. |True| and |False| - correspond to on and off respectively. |None| indicates the property is - not specified directly on the run and its effective value is taken from - the style hierarchy. - """ - def __init__(self, r, parent): - super(Run, self).__init__(parent) - self._r = r - - def add_break(self, break_type=WD_BREAK.LINE): - """ - Add a break element of *break_type* to this run. *break_type* can - take the values `WD_BREAK.LINE`, `WD_BREAK.PAGE`, and - `WD_BREAK.COLUMN` where `WD_BREAK` is imported from `docx.enum.text`. - *break_type* defaults to `WD_BREAK.LINE`. - """ - type_, clear = { - WD_BREAK.LINE: (None, None), - WD_BREAK.PAGE: ('page', None), - WD_BREAK.COLUMN: ('column', None), - WD_BREAK.LINE_CLEAR_LEFT: ('textWrapping', 'left'), - WD_BREAK.LINE_CLEAR_RIGHT: ('textWrapping', 'right'), - WD_BREAK.LINE_CLEAR_ALL: ('textWrapping', 'all'), - }[break_type] - br = self._r.add_br() - if type_ is not None: - br.type = type_ - if clear is not None: - br.clear = clear - - def add_picture(self, image_path_or_stream, width=None, height=None): - """ - Return an |InlineShape| instance containing the image identified by - *image_path_or_stream*, added to the end of this run. - *image_path_or_stream* can be a path (a string) or a file-like object - containing a binary image. If neither width nor height is specified, - the picture appears at its native size. If only one is specified, it - is used to compute a scaling factor that is then applied to the - unspecified dimension, preserving the aspect ratio of the image. The - native size of the picture is calculated using the dots-per-inch - (dpi) value specified in the image file, defaulting to 72 dpi if no - value is specified, as is often the case. - """ - inline_shapes = self.part.inline_shapes - picture = inline_shapes.add_picture(image_path_or_stream, self) - - # scale picture dimensions if width and/or height provided - if width is not None or height is not None: - native_width, native_height = picture.width, picture.height - if width is None: - scaling_factor = float(height) / float(native_height) - width = int(round(native_width * scaling_factor)) - elif height is None: - scaling_factor = float(width) / float(native_width) - height = int(round(native_height * scaling_factor)) - # set picture to scaled dimensions - picture.width = width - picture.height = height - - return picture - - def add_tab(self): - """ - Add a ```` element at the end of the run, which Word - interprets as a tab character. - """ - self._r._add_tab() - - def add_text(self, text): - """ - Returns a newly appended |Text| object (corresponding to a new - ```` child element) to the run, containing *text*. Compare with - the possibly more friendly approach of assigning text to the - :attr:`Run.text` property. - """ - t = self._r.add_t(text) - return Text(t) - - @boolproperty - def all_caps(self): - """ - Read/write. Causes the text of the run to appear in capital letters. - """ - return 'caps' - - @boolproperty - def bold(self): - """ - Read/write. Causes the text of the run to appear in bold. - """ - return 'b' - - def clear(self): - """ - Return reference to this run after removing all its content. All run - formatting is preserved. - """ - self._r.clear_content() - return self - - @boolproperty - def complex_script(self): - """ - Read/write tri-state value. When |True|, causes the characters in the - run to be treated as complex script regardless of their Unicode - values. - """ - return 'cs' - - @boolproperty - def cs_bold(self): - """ - Read/write tri-state value. When |True|, causes the complex script - characters in the run to be displayed in bold typeface. - """ - return 'bCs' - - @boolproperty - def cs_italic(self): - """ - Read/write tri-state value. When |True|, causes the complex script - characters in the run to be displayed in italic typeface. - """ - return 'iCs' - - @boolproperty - def double_strike(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to appear with double strikethrough. - """ - return 'dstrike' - - @boolproperty - def emboss(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to appear as if raised off the page in relief. - """ - return 'emboss' - - @boolproperty - def hidden(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to be hidden from display, unless applications settings force hidden - text to be shown. - """ - return 'vanish' - - @boolproperty - def italic(self): - """ - Read/write tri-state value. When |True|, causes the text of the run - to appear in italics. - """ - return 'i' - - @boolproperty - def imprint(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to appear as if pressed into the page. - """ - return 'imprint' - - @boolproperty - def math(self): - """ - Read/write tri-state value. When |True|, specifies this run contains - WML that should be handled as though it was Office Open XML Math. - """ - return 'oMath' - - @boolproperty - def no_proof(self): - """ - Read/write tri-state value. When |True|, specifies that the contents - of this run should not report any errors when the document is scanned - for spelling and grammar. - """ - return 'noProof' - - @boolproperty - def outline(self): - """ - Read/write tri-state value. When |True| causes the characters in the - run to appear as if they have an outline, by drawing a one pixel wide - border around the inside and outside borders of each character glyph. - """ - return 'outline' - - @boolproperty - def rtl(self): - """ - Read/write tri-state value. When |True| causes the text in the run - to have right-to-left characteristics. - """ - return 'rtl' - - @boolproperty - def shadow(self): - """ - Read/write tri-state value. When |True| causes the text in the run - to appear as if each character has a shadow. - """ - return 'shadow' - - @boolproperty - def small_caps(self): - """ - Read/write tri-state value. When |True| causes the lowercase - characters in the run to appear as capital letters two points smaller - than the font size specified for the run. - """ - return 'smallCaps' - - @boolproperty - def snap_to_grid(self): - """ - Read/write tri-state value. When |True| causes the run to use the - document grid characters per line settings defined in the docGrid - element when laying out the characters in this run. - """ - return 'snapToGrid' - - @boolproperty - def spec_vanish(self): - """ - Read/write tri-state value. When |True|, specifies that the given run - shall always behave as if it is hidden, even when hidden text is - being displayed in the current document. The property has a very - narrow, specialized use related to the table of contents. Consult the - spec (§17.3.2.36) for more details. - """ - return 'specVanish' - - @boolproperty - def strike(self): - """ - Read/write tri-state value. When |True| causes the text in the run - to appear with a single horizontal line through the center of the - line. - """ - return 'strike' - - @property - def style(self): - """ - Read/write. The string style ID of the character style applied to - this run, or |None| if it has no directly-applied character style. - Setting this property to |None| causes any directly-applied character - style to be removed such that the run inherits character formatting - from its containing paragraph. - """ - return self._r.style - - @style.setter - def style(self, char_style): - self._r.style = char_style - - @property - def text(self): - """ - String formed by concatenating the text equivalent of each run - content child element into a Python string. Each ```` element - adds the text characters it contains. A ```` element adds - a ``\\t`` character. A ```` or ```` element each add - a ``\\n`` character. Note that a ```` element can indicate - a page break or column break as well as a line break. All ```` - elements translate to a single ``\\n`` character regardless of their - type. All other content child elements, such as ````, are - ignored. - - Assigning text to this property has the reverse effect, translating - each ``\\t`` character to a ```` element and each ``\\n`` or - ``\\r`` character to a ```` element. Any existing run content - is replaced. Run formatting is preserved. - """ - return self._r.text - - @text.setter - def text(self, text): - self._r.text = text - - @property - def underline(self): - """ - The underline style for this |Run|, one of |None|, |True|, |False|, - or a value from :ref:`WdUnderline`. A value of |None| indicates the - run has no directly-applied underline value and so will inherit the - underline value of its containing paragraph. Assigning |None| to this - property removes any directly-applied underline value. A value of - |False| indicates a directly-applied setting of no underline, - overriding any inherited value. A value of |True| indicates single - underline. The values from :ref:`WdUnderline` are used to specify - other outline styles such as double, wavy, and dotted. - """ - return self._r.underline - - @underline.setter - def underline(self, value): - self._r.underline = value - - @boolproperty - def web_hidden(self): - """ - Read/write tri-state value. When |True|, specifies that the contents - of this run shall be hidden when the document is displayed in web - page view. - """ - return 'webHidden' - - -class Text(object): - """ - Proxy object wrapping ```` element. - """ - def __init__(self, t_elm): - super(Text, self).__init__() - self._t = t_elm diff --git a/docx/text/run.py b/docx/text/run.py new file mode 100644 index 000000000..68c5905f5 --- /dev/null +++ b/docx/text/run.py @@ -0,0 +1,381 @@ +# encoding: utf-8 + +""" +Run-related proxy objects for python-docx, Run in particular. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from ..enum.text import WD_BREAK +from ..shared import Parented + + +def boolproperty(f): + """ + @boolproperty decorator. Decorated method must return the XML element + name of the boolean property element occuring under rPr. Causes + a read/write tri-state property to be added to the class having the name + of the decorated function. + """ + def _get_prop_value(parent, attr_name): + return getattr(parent, attr_name) + + def _remove_prop(parent, attr_name): + remove_method_name = '_remove_%s' % attr_name + remove_method = getattr(parent, remove_method_name) + remove_method() + + def _add_prop(parent, attr_name): + add_method_name = '_add_%s' % attr_name + add_method = getattr(parent, add_method_name) + return add_method() + + def getter(obj): + r, attr_name = obj._r, f(obj) + if r.rPr is None: + return None + prop_value = _get_prop_value(r.rPr, attr_name) + if prop_value is None: + return None + return prop_value.val + + def setter(obj, value): + if value not in (True, False, None): + raise ValueError( + "assigned value must be True, False, or None, got '%s'" + % value + ) + r, attr_name = obj._r, f(obj) + rPr = r.get_or_add_rPr() + _remove_prop(rPr, attr_name) + if value is not None: + elm = _add_prop(rPr, attr_name) + elm.val = value + + return property(getter, setter, doc=f.__doc__) + + +class Run(Parented): + """ + Proxy object wrapping ```` element. Several of the properties on Run + take a tri-state value, |True|, |False|, or |None|. |True| and |False| + correspond to on and off respectively. |None| indicates the property is + not specified directly on the run and its effective value is taken from + the style hierarchy. + """ + def __init__(self, r, parent): + super(Run, self).__init__(parent) + self._r = r + + def add_break(self, break_type=WD_BREAK.LINE): + """ + Add a break element of *break_type* to this run. *break_type* can + take the values `WD_BREAK.LINE`, `WD_BREAK.PAGE`, and + `WD_BREAK.COLUMN` where `WD_BREAK` is imported from `docx.enum.text`. + *break_type* defaults to `WD_BREAK.LINE`. + """ + type_, clear = { + WD_BREAK.LINE: (None, None), + WD_BREAK.PAGE: ('page', None), + WD_BREAK.COLUMN: ('column', None), + WD_BREAK.LINE_CLEAR_LEFT: ('textWrapping', 'left'), + WD_BREAK.LINE_CLEAR_RIGHT: ('textWrapping', 'right'), + WD_BREAK.LINE_CLEAR_ALL: ('textWrapping', 'all'), + }[break_type] + br = self._r.add_br() + if type_ is not None: + br.type = type_ + if clear is not None: + br.clear = clear + + def add_picture(self, image_path_or_stream, width=None, height=None): + """ + Return an |InlineShape| instance containing the image identified by + *image_path_or_stream*, added to the end of this run. + *image_path_or_stream* can be a path (a string) or a file-like object + containing a binary image. If neither width nor height is specified, + the picture appears at its native size. If only one is specified, it + is used to compute a scaling factor that is then applied to the + unspecified dimension, preserving the aspect ratio of the image. The + native size of the picture is calculated using the dots-per-inch + (dpi) value specified in the image file, defaulting to 72 dpi if no + value is specified, as is often the case. + """ + inline_shapes = self.part.inline_shapes + picture = inline_shapes.add_picture(image_path_or_stream, self) + + # scale picture dimensions if width and/or height provided + if width is not None or height is not None: + native_width, native_height = picture.width, picture.height + if width is None: + scaling_factor = float(height) / float(native_height) + width = int(round(native_width * scaling_factor)) + elif height is None: + scaling_factor = float(width) / float(native_width) + height = int(round(native_height * scaling_factor)) + # set picture to scaled dimensions + picture.width = width + picture.height = height + + return picture + + def add_tab(self): + """ + Add a ```` element at the end of the run, which Word + interprets as a tab character. + """ + self._r._add_tab() + + def add_text(self, text): + """ + Returns a newly appended |_Text| object (corresponding to a new + ```` child element) to the run, containing *text*. Compare with + the possibly more friendly approach of assigning text to the + :attr:`Run.text` property. + """ + t = self._r.add_t(text) + return _Text(t) + + @boolproperty + def all_caps(self): + """ + Read/write. Causes the text of the run to appear in capital letters. + """ + return 'caps' + + @boolproperty + def bold(self): + """ + Read/write. Causes the text of the run to appear in bold. + """ + return 'b' + + def clear(self): + """ + Return reference to this run after removing all its content. All run + formatting is preserved. + """ + self._r.clear_content() + return self + + @boolproperty + def complex_script(self): + """ + Read/write tri-state value. When |True|, causes the characters in the + run to be treated as complex script regardless of their Unicode + values. + """ + return 'cs' + + @boolproperty + def cs_bold(self): + """ + Read/write tri-state value. When |True|, causes the complex script + characters in the run to be displayed in bold typeface. + """ + return 'bCs' + + @boolproperty + def cs_italic(self): + """ + Read/write tri-state value. When |True|, causes the complex script + characters in the run to be displayed in italic typeface. + """ + return 'iCs' + + @boolproperty + def double_strike(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to appear with double strikethrough. + """ + return 'dstrike' + + @boolproperty + def emboss(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to appear as if raised off the page in relief. + """ + return 'emboss' + + @boolproperty + def hidden(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to be hidden from display, unless applications settings force hidden + text to be shown. + """ + return 'vanish' + + @boolproperty + def italic(self): + """ + Read/write tri-state value. When |True|, causes the text of the run + to appear in italics. + """ + return 'i' + + @boolproperty + def imprint(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to appear as if pressed into the page. + """ + return 'imprint' + + @boolproperty + def math(self): + """ + Read/write tri-state value. When |True|, specifies this run contains + WML that should be handled as though it was Office Open XML Math. + """ + return 'oMath' + + @boolproperty + def no_proof(self): + """ + Read/write tri-state value. When |True|, specifies that the contents + of this run should not report any errors when the document is scanned + for spelling and grammar. + """ + return 'noProof' + + @boolproperty + def outline(self): + """ + Read/write tri-state value. When |True| causes the characters in the + run to appear as if they have an outline, by drawing a one pixel wide + border around the inside and outside borders of each character glyph. + """ + return 'outline' + + @boolproperty + def rtl(self): + """ + Read/write tri-state value. When |True| causes the text in the run + to have right-to-left characteristics. + """ + return 'rtl' + + @boolproperty + def shadow(self): + """ + Read/write tri-state value. When |True| causes the text in the run + to appear as if each character has a shadow. + """ + return 'shadow' + + @boolproperty + def small_caps(self): + """ + Read/write tri-state value. When |True| causes the lowercase + characters in the run to appear as capital letters two points smaller + than the font size specified for the run. + """ + return 'smallCaps' + + @boolproperty + def snap_to_grid(self): + """ + Read/write tri-state value. When |True| causes the run to use the + document grid characters per line settings defined in the docGrid + element when laying out the characters in this run. + """ + return 'snapToGrid' + + @boolproperty + def spec_vanish(self): + """ + Read/write tri-state value. When |True|, specifies that the given run + shall always behave as if it is hidden, even when hidden text is + being displayed in the current document. The property has a very + narrow, specialized use related to the table of contents. Consult the + spec (§17.3.2.36) for more details. + """ + return 'specVanish' + + @boolproperty + def strike(self): + """ + Read/write tri-state value. When |True| causes the text in the run + to appear with a single horizontal line through the center of the + line. + """ + return 'strike' + + @property + def style(self): + """ + Read/write. The string style ID of the character style applied to + this run, or |None| if it has no directly-applied character style. + Setting this property to |None| causes any directly-applied character + style to be removed such that the run inherits character formatting + from its containing paragraph. + """ + return self._r.style + + @style.setter + def style(self, char_style): + self._r.style = char_style + + @property + def text(self): + """ + String formed by concatenating the text equivalent of each run + content child element into a Python string. Each ```` element + adds the text characters it contains. A ```` element adds + a ``\\t`` character. A ```` or ```` element each add + a ``\\n`` character. Note that a ```` element can indicate + a page break or column break as well as a line break. All ```` + elements translate to a single ``\\n`` character regardless of their + type. All other content child elements, such as ````, are + ignored. + + Assigning text to this property has the reverse effect, translating + each ``\\t`` character to a ```` element and each ``\\n`` or + ``\\r`` character to a ```` element. Any existing run content + is replaced. Run formatting is preserved. + """ + return self._r.text + + @text.setter + def text(self, text): + self._r.text = text + + @property + def underline(self): + """ + The underline style for this |Run|, one of |None|, |True|, |False|, + or a value from :ref:`WdUnderline`. A value of |None| indicates the + run has no directly-applied underline value and so will inherit the + underline value of its containing paragraph. Assigning |None| to this + property removes any directly-applied underline value. A value of + |False| indicates a directly-applied setting of no underline, + overriding any inherited value. A value of |True| indicates single + underline. The values from :ref:`WdUnderline` are used to specify + other outline styles such as double, wavy, and dotted. + """ + return self._r.underline + + @underline.setter + def underline(self, value): + self._r.underline = value + + @boolproperty + def web_hidden(self): + """ + Read/write tri-state value. When |True|, specifies that the contents + of this run shall be hidden when the document is displayed in web + page view. + """ + return 'webHidden' + + +class _Text(object): + """ + Proxy object wrapping ```` element. + """ + def __init__(self, t_elm): + super(_Text, self).__init__() + self._t = t_elm diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 5803e7353..728dc4471 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -364,4 +364,4 @@ def picture_(self, request): @pytest.fixture def Text_(self, request): - return class_mock(request, 'docx.text.Text') + return class_mock(request, 'docx.text.run._Text') From 1a493cc16292fb43991d12ccd37d448c06e9619e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 16:27:56 -0800 Subject: [PATCH 069/615] reorg: extract docx.text.paragraph module --- docs/api/text.rst | 6 +- docs/conf.py | 2 +- docx/blkcntnr.py | 2 +- docx/text/__init__.py | 118 ----------------------------------- docx/text/paragraph.py | 118 +++++++++++++++++++++++++++++++++++ features/steps/paragraph.py | 2 +- features/steps/text.py | 2 +- tests/parts/test_document.py | 3 +- tests/test_api.py | 5 +- tests/test_blkcntnr.py | 2 +- tests/test_table.py | 2 +- tests/text/test_paragraph.py | 5 +- tests/text/test_run.py | 3 +- 13 files changed, 138 insertions(+), 132 deletions(-) create mode 100644 docx/text/paragraph.py diff --git a/docs/api/text.rst b/docs/api/text.rst index cdb55ff61..33481af79 100644 --- a/docs/api/text.rst +++ b/docs/api/text.rst @@ -4,12 +4,12 @@ Text-related objects ==================== -.. currentmodule:: docx.text - |Paragraph| objects ------------------- +.. currentmodule:: docx.text.paragraph + .. autoclass:: Paragraph :members: @@ -17,5 +17,7 @@ Text-related objects |Run| objects ------------- +.. currentmodule:: docx.text.run + .. autoclass:: Run :members: diff --git a/docs/conf.py b/docs/conf.py index 7549dbd65..4fb3f806f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -129,7 +129,7 @@ .. |Table| replace:: :class:`.Table` -.. |Text| replace:: :class:`Text` +.. |_Text| replace:: :class:`._Text` .. |True| replace:: ``True`` diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index b11f3a50d..fde9c5793 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -9,7 +9,7 @@ from __future__ import absolute_import, print_function from .shared import Parented -from .text import Paragraph +from .text.paragraph import Paragraph class BlockItemContainer(Parented): diff --git a/docx/text/__init__.py b/docx/text/__init__.py index 1351a76b4..e69de29bb 100644 --- a/docx/text/__init__.py +++ b/docx/text/__init__.py @@ -1,118 +0,0 @@ -# encoding: utf-8 - -""" -Text-related proxy types for python-docx, such as Paragraph and Run. -""" - -from __future__ import absolute_import, print_function, unicode_literals - -from .run import Run -from ..shared import Parented - - -class Paragraph(Parented): - """ - Proxy object wrapping ```` element. - """ - def __init__(self, p, parent): - super(Paragraph, self).__init__(parent) - self._p = p - - def add_run(self, text=None, style=None): - """ - Append a run to this paragraph containing *text* and having character - style identified by style ID *style*. *text* can contain tab - (``\\t``) characters, which are converted to the appropriate XML form - for a tab. *text* can also include newline (``\\n``) or carriage - return (``\\r``) characters, each of which is converted to a line - break. - """ - r = self._p.add_r() - run = Run(r, self) - if text: - run.text = text - if style: - run.style = style - return run - - @property - def alignment(self): - """ - A member of the :ref:`WdParagraphAlignment` enumeration specifying - the justification setting for this paragraph. A value of |None| - indicates the paragraph has no directly-applied alignment value and - will inherit its alignment value from its style hierarchy. Assigning - |None| to this property removes any directly-applied alignment value. - """ - return self._p.alignment - - @alignment.setter - def alignment(self, value): - self._p.alignment = value - - def clear(self): - """ - Return this same paragraph after removing all its content. - Paragraph-level formatting, such as style, is preserved. - """ - self._p.clear_content() - return self - - def insert_paragraph_before(self, text=None, style=None): - """ - Return a newly created paragraph, inserted directly before this - paragraph. If *text* is supplied, the new paragraph contains that - text in a single run. If *style* is provided, that style is assigned - to the new paragraph. - """ - p = self._p.add_p_before() - paragraph = Paragraph(p, self._parent) - if text: - paragraph.add_run(text) - if style is not None: - paragraph.style = style - return paragraph - - @property - def runs(self): - """ - Sequence of |Run| instances corresponding to the elements in - this paragraph. - """ - return [Run(r, self) for r in self._p.r_lst] - - @property - def style(self): - """ - Paragraph style for this paragraph. Read/Write. - """ - style = self._p.style - return style if style is not None else 'Normal' - - @style.setter - def style(self, style): - self._p.style = None if style == 'Normal' else style - - @property - def text(self): - """ - String formed by concatenating the text of each run in the paragraph. - Tabs and line breaks in the XML are mapped to ``\\t`` and ``\\n`` - characters respectively. - - Assigning text to this property causes all existing paragraph content - to be replaced with a single run containing the assigned text. - A ``\\t`` character in the text is mapped to a ```` element - and each ``\\n`` or ``\\r`` character is mapped to a line break. - Paragraph-level formatting, such as style, is preserved. All - run-level formatting, such as bold or italic, is removed. - """ - text = '' - for run in self.runs: - text += run.text - return text - - @text.setter - def text(self, text): - self.clear() - self.add_run(text) diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py new file mode 100644 index 000000000..5dc2f2e23 --- /dev/null +++ b/docx/text/paragraph.py @@ -0,0 +1,118 @@ +# encoding: utf-8 + +""" +Paragraph-related proxy types. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .run import Run +from ..shared import Parented + + +class Paragraph(Parented): + """ + Proxy object wrapping ```` element. + """ + def __init__(self, p, parent): + super(Paragraph, self).__init__(parent) + self._p = p + + def add_run(self, text=None, style=None): + """ + Append a run to this paragraph containing *text* and having character + style identified by style ID *style*. *text* can contain tab + (``\\t``) characters, which are converted to the appropriate XML form + for a tab. *text* can also include newline (``\\n``) or carriage + return (``\\r``) characters, each of which is converted to a line + break. + """ + r = self._p.add_r() + run = Run(r, self) + if text: + run.text = text + if style: + run.style = style + return run + + @property + def alignment(self): + """ + A member of the :ref:`WdParagraphAlignment` enumeration specifying + the justification setting for this paragraph. A value of |None| + indicates the paragraph has no directly-applied alignment value and + will inherit its alignment value from its style hierarchy. Assigning + |None| to this property removes any directly-applied alignment value. + """ + return self._p.alignment + + @alignment.setter + def alignment(self, value): + self._p.alignment = value + + def clear(self): + """ + Return this same paragraph after removing all its content. + Paragraph-level formatting, such as style, is preserved. + """ + self._p.clear_content() + return self + + def insert_paragraph_before(self, text=None, style=None): + """ + Return a newly created paragraph, inserted directly before this + paragraph. If *text* is supplied, the new paragraph contains that + text in a single run. If *style* is provided, that style is assigned + to the new paragraph. + """ + p = self._p.add_p_before() + paragraph = Paragraph(p, self._parent) + if text: + paragraph.add_run(text) + if style is not None: + paragraph.style = style + return paragraph + + @property + def runs(self): + """ + Sequence of |Run| instances corresponding to the elements in + this paragraph. + """ + return [Run(r, self) for r in self._p.r_lst] + + @property + def style(self): + """ + Paragraph style for this paragraph. Read/Write. + """ + style = self._p.style + return style if style is not None else 'Normal' + + @style.setter + def style(self, style): + self._p.style = None if style == 'Normal' else style + + @property + def text(self): + """ + String formed by concatenating the text of each run in the paragraph. + Tabs and line breaks in the XML are mapped to ``\\t`` and ``\\n`` + characters respectively. + + Assigning text to this property causes all existing paragraph content + to be replaced with a single run containing the assigned text. + A ``\\t`` character in the text is mapped to a ```` element + and each ``\\n`` or ``\\r`` character is mapped to a line break. + Paragraph-level formatting, such as style, is preserved. All + run-level formatting, such as bold or italic, is removed. + """ + text = '' + for run in self.runs: + text += run.text + return text + + @text.setter + def text(self, text): + self.clear() + self.add_run(text) diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index cdd79bde6..59a1ceca9 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -10,7 +10,7 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import parse_xml from docx.oxml.ns import nsdecls -from docx.text import Paragraph +from docx.text.paragraph import Paragraph from helpers import saved_docx_path, test_docx, test_text diff --git a/features/steps/text.py b/features/steps/text.py index 0d9afe97e..a65b89753 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -14,7 +14,7 @@ from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.oxml import parse_xml from docx.oxml.ns import nsdecls, qn -from docx.text import Run +from docx.text.run import Run from helpers import test_docx, test_file, test_text diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 7f94e28bf..c5f9a08cd 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -18,7 +18,8 @@ from docx.section import Section from docx.shape import InlineShape from docx.table import Table -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run from ..oxml.parts.unitdata.document import a_body, a_document from ..oxml.unitdata.text import a_p diff --git a/tests/test_api.py b/tests/test_api.py index ecf084a9b..c22230b55 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,7 +21,8 @@ from docx.section import Section from docx.shape import InlineShape from docx.table import Table -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run from .unitutil.mock import ( instance_mock, class_mock, method_mock, property_mock, var_mock @@ -203,7 +204,7 @@ def add_picture_fixture(self, request, run_, picture_): document = Document() image_path_ = instance_mock(request, str, name='image_path_') width, height = 100, 200 - class_mock(request, 'docx.text.Run', return_value=run_) + class_mock(request, 'docx.text.paragraph.Run', return_value=run_) run_.add_picture.return_value = picture_ return (document, image_path_, width, height, run_, picture_) diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index b8de5e400..02cd8ddab 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -10,7 +10,7 @@ from docx.blkcntnr import BlockItemContainer from docx.table import Table -from docx.text import Paragraph +from docx.text.paragraph import Paragraph from .unitutil.cxml import element, xml diff --git a/tests/test_table.py b/tests/test_table.py index 349b2f51e..741d08da0 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -13,7 +13,7 @@ from docx.oxml.table import CT_Tc from docx.shared import Inches from docx.table import _Cell, _Column, _Columns, _Row, _Rows, Table -from docx.text import Paragraph +from docx.text.paragraph import Paragraph from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr from .oxml.unitdata.text import a_p diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 434b900a0..a08d8f0e2 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -12,7 +12,8 @@ from docx.oxml.ns import qn from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run import pytest @@ -212,7 +213,7 @@ def p_(self, request, r_, r_2_): def Run_(self, request, runs_): run_, run_2_ = runs_ return class_mock( - request, 'docx.text.Run', side_effect=[run_, run_2_] + request, 'docx.text.paragraph.Run', side_effect=[run_, run_2_] ) @pytest.fixture diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 728dc4471..53381c4d2 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -11,7 +11,8 @@ from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.parts.document import InlineShapes from docx.shape import InlineShape -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run import pytest From 013f32fce3de834beb4b7b4c696d07d5639fb88e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Dec 2014 22:06:58 -0800 Subject: [PATCH 070/615] reorg: resequence oxml.shape imports --- docx/oxml/__init__.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index a61f65943..8b2d4a76e 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -66,27 +66,6 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from .shared import CT_DecimalNumber, CT_OnOff, CT_String -from .shape import ( - CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, - CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, - CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, - CT_Transform2D -) -register_element_cls('a:blip', CT_Blip) -register_element_cls('a:ext', CT_PositiveSize2D) -register_element_cls('a:graphic', CT_GraphicalObject) -register_element_cls('a:graphicData', CT_GraphicalObjectData) -register_element_cls('a:off', CT_Point2D) -register_element_cls('a:xfrm', CT_Transform2D) -register_element_cls('pic:blipFill', CT_BlipFillProperties) -register_element_cls('pic:cNvPr', CT_NonVisualDrawingProps) -register_element_cls('pic:nvPicPr', CT_PictureNonVisual) -register_element_cls('pic:pic', CT_Picture) -register_element_cls('pic:spPr', CT_ShapeProperties) -register_element_cls('wp:docPr', CT_NonVisualDrawingProps) -register_element_cls('wp:extent', CT_PositiveSize2D) -register_element_cls('wp:inline', CT_Inline) - from .parts.coreprops import CT_CoreProperties register_element_cls('cp:coreProperties', CT_CoreProperties) @@ -117,6 +96,27 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:sectPr', CT_SectPr) register_element_cls('w:type', CT_SectType) +from .shape import ( + CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, + CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, + CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, + CT_Transform2D +) +register_element_cls('a:blip', CT_Blip) +register_element_cls('a:ext', CT_PositiveSize2D) +register_element_cls('a:graphic', CT_GraphicalObject) +register_element_cls('a:graphicData', CT_GraphicalObjectData) +register_element_cls('a:off', CT_Point2D) +register_element_cls('a:xfrm', CT_Transform2D) +register_element_cls('pic:blipFill', CT_BlipFillProperties) +register_element_cls('pic:cNvPr', CT_NonVisualDrawingProps) +register_element_cls('pic:nvPicPr', CT_PictureNonVisual) +register_element_cls('pic:pic', CT_Picture) +register_element_cls('pic:spPr', CT_ShapeProperties) +register_element_cls('wp:docPr', CT_NonVisualDrawingProps) +register_element_cls('wp:extent', CT_PositiveSize2D) +register_element_cls('wp:inline', CT_Inline) + from .table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, CT_TblWidth, CT_Tc, CT_TcPr, CT_VMerge From 71f4d031f80ff1de78cfdaa246f01a3afa53b6e1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:26:06 -0800 Subject: [PATCH 071/615] shr: add ElementProxy.__eq__() --- docx/shared.py | 31 +++++++++++++++++++++++++++++++ tests/test_shared.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/test_shared.py diff --git a/docx/shared.py b/docx/shared.py index f7cd4e147..d8e501247 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -164,6 +164,37 @@ def write_only_property(f): return property(fset=f, doc=docstring) +class ElementProxy(object): + """ + Base class for lxml element proxy classes. An element proxy class is one + whose primary responsibilities are fulfilled by manipulating the + attributes and child elements of an XML element. They are the most common + type of class in python-docx other than custom element (oxml) classes. + """ + + __slots__ = ('_element',) + + def __init__(self, element): + self._element = element + + def __eq__(self, other): + """ + Return |True| if this proxy object refers to the same oxml element as + does *other*. ElementProxy objects are value objects and should + maintain no mutable local state. Equality for proxy objects is + defined as referring to the same XML element, whether or not they are + the same proxy object instance. + """ + if not isinstance(other, ElementProxy): + return False + return self._element is other._element + + def __ne__(self, other): + if not isinstance(other, ElementProxy): + return True + return self._element is not other._element + + class Parented(object): """ Provides common services for document elements that occur below a part diff --git a/tests/test_shared.py b/tests/test_shared.py new file mode 100644 index 000000000..cf281ae59 --- /dev/null +++ b/tests/test_shared.py @@ -0,0 +1,40 @@ +# encoding: utf-8 + +""" +Test suite for the docx.shared module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.shared import ElementProxy + +from .unitutil.cxml import element + + +class DescribeElementProxy(object): + + def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): + proxy, proxy_2, proxy_3, not_a_proxy = eq_fixture + + assert (proxy == proxy_2) is True + assert (proxy == proxy_3) is False + assert (proxy == not_a_proxy) is False + + assert (proxy != proxy_2) is False + assert (proxy != proxy_3) is True + assert (proxy != not_a_proxy) is True + + # fixture -------------------------------------------------------- + + @pytest.fixture + def eq_fixture(self): + p, q = element('w:p'), element('w:p') + proxy = ElementProxy(p) + proxy_2 = ElementProxy(p) + proxy_3 = ElementProxy(q) + not_a_proxy = 'Foobar' + return proxy, proxy_2, proxy_3, not_a_proxy From 576c047fd04b3866c56a4df51704b102a7c08b96 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:46:35 -0800 Subject: [PATCH 072/615] shr: add ElementProxy.element --- docx/shared.py | 7 +++++++ tests/test_shared.py | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/docx/shared.py b/docx/shared.py index d8e501247..31593a662 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -194,6 +194,13 @@ def __ne__(self, other): return True return self._element is not other._element + @property + def element(self): + """ + The lxml element proxied by this object. + """ + return self._element + class Parented(object): """ diff --git a/tests/test_shared.py b/tests/test_shared.py index cf281ae59..939f0966f 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -28,8 +28,18 @@ def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): assert (proxy != proxy_3) is True assert (proxy != not_a_proxy) is True + def it_knows_its_element(self, element_fixture): + proxy, element = element_fixture + assert proxy.element is element + # fixture -------------------------------------------------------- + @pytest.fixture + def element_fixture(self): + p = element('w:p') + proxy = ElementProxy(p) + return proxy, p + @pytest.fixture def eq_fixture(self): p, q = element('w:p'), element('w:p') From 08152f9b58dfbe27fbb06ebc30bc2332bf48d091 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 00:28:52 -0800 Subject: [PATCH 073/615] shr: add ElementProxy.part --- docx/shared.py | 12 ++++++++++-- tests/test_shared.py | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docx/shared.py b/docx/shared.py index 31593a662..79e7c8c24 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -172,10 +172,11 @@ class ElementProxy(object): type of class in python-docx other than custom element (oxml) classes. """ - __slots__ = ('_element',) + __slots__ = ('_element', '_parent') - def __init__(self, element): + def __init__(self, element, parent=None): self._element = element + self._parent = parent def __eq__(self, other): """ @@ -201,6 +202,13 @@ def element(self): """ return self._element + @property + def part(self): + """ + The package part containing this object + """ + return self._parent.part + class Parented(object): """ diff --git a/tests/test_shared.py b/tests/test_shared.py index 939f0966f..7b8492f7d 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -10,9 +10,11 @@ import pytest +from docx.opc.part import XmlPart from docx.shared import ElementProxy from .unitutil.cxml import element +from .unitutil.mock import instance_mock class DescribeElementProxy(object): @@ -32,6 +34,10 @@ def it_knows_its_element(self, element_fixture): proxy, element = element_fixture assert proxy.element is element + def it_knows_its_part(self, part_fixture): + proxy, part_ = part_fixture + assert proxy.part is part_ + # fixture -------------------------------------------------------- @pytest.fixture @@ -48,3 +54,19 @@ def eq_fixture(self): proxy_3 = ElementProxy(q) not_a_proxy = 'Foobar' return proxy, proxy_2, proxy_3, not_a_proxy + + @pytest.fixture + def part_fixture(self, other_proxy_, part_): + other_proxy_.part = part_ + proxy = ElementProxy(None, other_proxy_) + return proxy, part_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def other_proxy_(self, request): + return instance_mock(request, ElementProxy) + + @pytest.fixture + def part_(self, request): + return instance_mock(request, XmlPart) From 226b69956f8341c4ea10ef67e771097abd8392bc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 00:45:42 -0800 Subject: [PATCH 074/615] shr: add DescribeLength --- docx/shared.py | 30 ++++++++++------------------- tests/test_shared.py | 45 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docx/shared.py b/docx/shared.py index 79e7c8c24..e7ade75c2 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -17,7 +17,7 @@ class Length(int): _EMUS_PER_INCH = 914400 _EMUS_PER_CM = 360000 _EMUS_PER_MM = 36000 - _EMUS_PER_PX = 12700 + _EMUS_PER_PT = 12700 _EMUS_PER_TWIP = 635 def __new__(cls, emu): @@ -52,10 +52,11 @@ def mm(self): return self / float(self._EMUS_PER_MM) @property - def px(self): - # round can somtimes return values like x.999999 which are truncated - # to x by int(); adding the 0.1 prevents this - return int(round(self / float(self._EMUS_PER_PX)) + 0.1) + def pt(self): + """ + Floating point length in points + """ + return self / float(self._EMUS_PER_PT) @property def twips(self): @@ -104,23 +105,12 @@ def __new__(cls, mm): return Length.__new__(cls, emu) -class Pt(int): - """ - Convenience class for setting font sizes in points - """ - _UNITS_PER_POINT = 100 - - def __new__(cls, pts): - units = int(pts * Pt._UNITS_PER_POINT) - return int.__new__(cls, units) - - -class Px(Length): +class Pt(Length): """ - Convenience constructor for length in pixels. + Convenience value class for specifying a length in points """ - def __new__(cls, px): - emu = int(px * Length._EMUS_PER_PX) + def __new__(cls, points): + emu = int(points * Length._EMUS_PER_PT) return Length.__new__(cls, emu) diff --git a/tests/test_shared.py b/tests/test_shared.py index 7b8492f7d..1dfb64e21 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -11,7 +11,7 @@ import pytest from docx.opc.part import XmlPart -from docx.shared import ElementProxy +from docx.shared import ElementProxy, Length, Cm, Emu, Inches, Mm, Pt, Twips from .unitutil.cxml import element from .unitutil.mock import instance_mock @@ -70,3 +70,46 @@ def other_proxy_(self, request): @pytest.fixture def part_(self, request): return instance_mock(request, XmlPart) + + +class DescribeLength(object): + + def it_can_construct_from_convenient_units(self, construct_fixture): + UnitCls, units_val, emu = construct_fixture + length = UnitCls(units_val) + assert isinstance(length, Length) + assert length == emu + + def it_can_self_convert_to_convenient_units(self, units_fixture): + emu, units_prop_name, expected_length_in_units, type_ = units_fixture + length = Length(emu) + length_in_units = getattr(length, units_prop_name) + assert length_in_units == expected_length_in_units + assert isinstance(length_in_units, type_) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + (Length, 914400, 914400), + (Inches, 1.1, 1005840), + (Cm, 2.53, 910799), + (Emu, 9144.9, 9144), + (Mm, 13.8, 496800), + (Pt, 24.5, 311150), + (Twips, 360, 228600), + ]) + def construct_fixture(self, request): + UnitCls, units_val, emu = request.param + return UnitCls, units_val, emu + + @pytest.fixture(params=[ + (914400, 'inches', 1.0, float), + (914400, 'cm', 2.54, float), + (914400, 'emu', 914400, int), + (914400, 'mm', 25.4, float), + (914400, 'pt', 72.0, float), + (914400, 'twips', 1440, int), + ]) + def units_fixture(self, request): + emu, units_prop_name, expected_length_in_units, type_ = request.param + return emu, units_prop_name, expected_length_in_units, type_ From bdef31d3101b63854fa7fb8cccaa2e4d276f870a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 23 Dec 2014 21:16:18 -0800 Subject: [PATCH 075/615] docs: add Working with Styles page * Add some prerequisite content to Understanding Styles --- docs/conf.py | 10 +- docs/index.rst | 3 +- docs/user/quickstart.rst | 4 +- .../{styles.rst => styles-understanding.rst} | 147 +++++++++++++----- docs/user/styles-using.rst | 128 +++++++++++++++ 5 files changed, 249 insertions(+), 43 deletions(-) rename docs/user/{styles.rst => styles-understanding.rst} (66%) create mode 100644 docs/user/styles-using.rst diff --git a/docs/conf.py b/docs/conf.py index 4fb3f806f..e379d9664 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,8 @@ .. |False| replace:: ``False`` +.. |Font| replace:: :class:`.Font` + .. |InlineShape| replace:: :class:`.InlineShape` .. |InlineShapes| replace:: :class:`.InlineShapes` @@ -97,6 +99,8 @@ .. |int| replace:: :class:`int` +.. |LatentStyles| replace:: :class:`.LatentStyles` + .. |Length| replace:: :class:`.Length` .. |OpcPackage| replace:: :class:`OpcPackage` @@ -117,7 +121,7 @@ .. |_Rows| replace:: :class:`_Rows` -.. |Run| replace:: :class:`Run` +.. |Run| replace:: :class:`.Run` .. |Section| replace:: :class:`.Section` @@ -125,6 +129,10 @@ .. |str| replace:: :class:`str` +.. |Style| replace:: :class:`.Style` + +.. |Styles| replace:: :class:`.Styles` + .. |StylesPart| replace:: :class:`.StylesPart` .. |Table| replace:: :class:`.Table` diff --git a/docs/index.rst b/docs/index.rst index a79fa9644..8de6a1b48 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -70,7 +70,8 @@ User Guide user/documents user/sections user/api-concepts - user/styles + user/styles-understanding + user/styles-using user/shapes user/text diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index 01c5f2729..6987fba44 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -308,7 +308,9 @@ settings, Word has *character styles* which specify a group of run-level settings. In general you can think of a character style as specifying a font, including its typeface, size, color, bold, italic, etc. -Like paragraph styles, a character style must already be defined in the document you open with the ``Document()`` call (*see* :doc:`styles`). +Like paragraph styles, a character style must already be defined in the +document you open with the ``Document()`` call (*see* +:ref:`understandingstyles`). A character style can be specified when adding a new run:: diff --git a/docs/user/styles.rst b/docs/user/styles-understanding.rst similarity index 66% rename from docs/user/styles.rst rename to docs/user/styles-understanding.rst index 87e34272d..2a8bff21f 100644 --- a/docs/user/styles.rst +++ b/docs/user/styles-understanding.rst @@ -1,3 +1,4 @@ +.. _understandingstyles: Understanding Styles ==================== @@ -27,9 +28,9 @@ text, a table, and a list, respectively. Experienced programmers will recognize styles as a level of indirection. The great thing about those is it allows you to define something once, then apply -that definition many times. This saves the work of defining the same thing over -an over; but more importantly it allows you to change it the definition and -have that change reflected in all the places you originally applied it. +that definition many times. This saves the work of defining the same thing +over an over; but more importantly it allows you to change the definition and +have that change reflected in all the places you have applied it. Why doesn't the style I applied show up? @@ -49,11 +50,11 @@ work around it, so here it is up top. The file would get a little bloated if it contained all the style definitions you could use but haven't. -#. If you apply a style that's not defined in your file (in the styles.xml part - if you're curious), Word just ignores it. It doesn't complain, it just - doesn't change how things are formatted. I'm sure there's a good reason for - this. But it can present as a bit of a puzzle if you don't understand how - Word works that way. +#. If you apply a style using |docx| that's not defined in your file (in the + styles.xml part if you're curious), Word just ignores it. It doesn't + complain, it just doesn't change how things are formatted. I'm sure + there's a good reason for this. But it can present as a bit of a puzzle if + you don't understand how Word works that way. #. When you use a style, Word adds it to the file. Once there, it stays. I imagine there's a way to get rid of it, but you have to work at it. If @@ -75,8 +76,74 @@ then deleting the paragraph works fine. That's how I got the ones below into the default template :). +Glossary +-------- + +style definition + A ```` element in the styles part of a document that explicitly + defines the attributes of a style. + +defined style + A style that is explicitly defined in a document. Contrast with *latent + style*. + +built-in style + One of the set of 276 pre-set styles built into Word, such as "Heading + 1". A built-in style can be either defined or latent. A built-in style + that is not yet defined is known as a *latent style*. Both defined and + latent built-in styles may appear as options in Word's style panel and + style gallery. + +custom style + Also known as a *user defined style*, any style defined in a Word + document that is not a built-in style. Note that a custom style cannot be + a latent style. + +latent style + A built-in style having no definition in a particular document is known + as a *latent style* in that document. A latent style can appear as an + option in the Word UI depending on the settings in the |LatentStyles| + object for the document. + +recommended style list + A list of styles that appears in the styles toolbox or panel when + "Recommended" is selected from the "List:" dropdown box. + +Style Gallery + The selection of example styles that appear in the ribbon of the Word UI + and which may be applied by clicking on one of them. + + +Identifying a style +------------------- + +A style has three identifying properties, `name`, `style_id`, and `type`. + +Each style's :attr:`name` property is its stable, unique identifier for +access purposes. + +A style's :attr:`style_id` is used internally to key a content object such as +a paragraph to its style. However this value is generated automatically by +Word and is not guaranteed to be stable across saves. In general, the style +id is formed simply by removing spaces from the *localized* style name, +however there are exceptions. Users of |docx| should generally avoid using +the style id unless they are confident with the internals involved. + +A style's :attr:`type` is set at creation time and cannot be changed. + + +Style inheritance +----------------- + +A style can inherit properties from another style, somewhat similarly to how +Cascading Style Sheets (CSS) works. Inheritance is specified using the +:attr:`~.BaseStyle.base_style` attribute. By basing one style on another, an +inheritance hierarchy of arbitrary depth can be formed. A style having no +base style inherits properties from the document defaults. + + Paragraph styles in default template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------------------ * Normal * BodyText @@ -114,8 +181,38 @@ Paragraph styles in default template * Title +Character styles in default template +------------------------------------ + +* BodyTextChar +* BodyText2Char +* BodyText3Char +* BookTitle +* DefaultParagraphFont +* Emphasis +* Heading1Char +* Heading2Char +* Heading3Char +* Heading4Char +* Heading5Char +* Heading6Char +* Heading7Char +* Heading8Char +* Heading9Char +* IntenseEmphasis +* IntenseQuoteChar +* IntenseReference +* MacroTextChar +* QuoteChar +* Strong +* SubtitleChar +* SubtleEmphasis +* SubtleReference +* TitleChar + + Table styles in default template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------------- * TableNormal * ColorfulGrid @@ -217,33 +314,3 @@ Table styles in default template * MediumShading2-Accent5 * MediumShading2-Accent6 * TableGrid - - -Character styles in default template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* BodyText2Char -* BodyText3Char -* BodyTextChar -* BookTitle -* DefaultParagraphFont -* Emphasis -* Heading1Char -* Heading2Char -* Heading3Char -* Heading4Char -* Heading5Char -* Heading6Char -* Heading7Char -* Heading8Char -* Heading9Char -* IntenseEmphasis -* IntenseQuoteChar -* IntenseReference -* MacroTextChar -* QuoteChar -* Strong -* SubtitleChar -* SubtleEmphasis -* SubtleReference -* TitleChar diff --git a/docs/user/styles-using.rst b/docs/user/styles-using.rst new file mode 100644 index 000000000..9bd964b36 --- /dev/null +++ b/docs/user/styles-using.rst @@ -0,0 +1,128 @@ + +Working with Styles +=================== + +This page uses concepts developed in the prior page without introduction. If +a term is unfamiliar, consult the prior page :ref:`understandingstyles` for +a definition. + + +Accessing a style +----------------- + +Styles are accessed using the :attr:`.Document.styles` attribute:: + + >>> document = Document() + >>> styles = document.styles + >>> styles + + +The |Styles| object provides dictionary-style access to defined styles by +name:: + + >>> styles['Normal'] + + +.. note:: Built-in styles are stored in a WordprocessingML file using their + English name, e.g. 'Heading 1', even though users working on a localized + version of Word will see native language names in the UI, e.g. 'Kop 1'. + Because |docx| operates on the WordprocessingML file, style lookups must + use the English name. A document available on this external site allows + you to create a mapping between local language names and English style + names: + http://www.thedoctools.com/index.php?show=mt_create_style_name_list + + User-defined styles, also known as *custom styles*, are not localized and + are accessed with the name exactly as it appears in the Word UI. + +The |Styles| object is also iterable. By using the identification properties +on |Style|, various subsets of the defined styles can be generated. For +example, this code will produce a list of the defined paragraph styles:: + + >>> from docx.enum.style import WD_STYLE_TYPE + >>> styles = document.styles + >>> paragraph_styles = [ + ... s for s in styles if s.type == WD_STYLE_TYPE.PARAGRAPH + ... ] + >>> for style in paragraph_styles: + ... print(style.name) + ... + Normal + Body Text + List Bullet + + +Applying a style +---------------- + +The |Paragraph|, |Run|, and |Table| objects each have a :attr:`style` +attribute. Assigning a |Style| object of the appropriate type to this +attribute applies that style:: + + >>> document = Document() + >>> paragraph = document.add_paragraph() + >>> paragraph.style + None # inherits the default style, usually 'Normal' for a paragraph + >>> paragraph.style = document.styles['Heading 1'] + >>> paragraph.style.name + 'Heading 1' + +A style name can also be assigned directly, in which case |docx| will do the +lookup for you:: + + >>> paragraph.style = 'List Bullet' + >>> paragraph.style + + >>> paragraph.style.name + 'List Bullet' + +A style can also be applied at creation time using either the |Style| object +or its name:: + + >>> paragraph = document.add_paragraph(style='Heading 1') + >>> paragraph.style.name + 'Heading 1' + >>> heading_1_style = document.styles['Heading 1'] + >>> paragraph = document.add_paragraph(style=heading_1_style) + >>> paragraph.style.name + 'Heading 1' + + +Controlling how a style appears in the Word UI +---------------------------------------------- + +The properties of a style fall into two categories, *behavioral properties* +and *formatting properties*. Its behavioral properties control when and where +the style appears in the Word UI. Its formatting properties determine the +formatting of content to which the style is applied, such as the size of the +font and its paragraph indentation. + +There are five behavioral properties of a style: + +* :attr:`~.Style.hidden` +* :attr:`~.Style.unhide_when_used` +* :attr:`~.Style.priority` +* :attr:`~.Style.quick_style` +* :attr:`~.Style.locked` + +The key notion to understanding the behavioral properties is the *recommended +list*. In the style pane in Word, the user can select which list of styles +they want to see. One of those is named *Recommended*. All five behavior +properties affect some aspect of the style's appearance in this list and in +the style gallery. + +In brief, a style appears in the recommended list if its `hidden` property is +|False|. If a style is not hidden and its `quick_style` property is |True|, +it also appears in the style gallery. The style's `priority` value (|int|) +determines its position in the sequence of styles. If a styles's `locked` +property is |True| and formatting restrictions are turned on for the +document, the style will not appear in any list or the style gallery and +cannot be applied to content. + + +Working with Latent Styles +-------------------------- + +... describe latent styles in Understanding page ... + +Let's illustrate these behaviors with a few examples. From 1fff811afca47397f47be2119cea24ee934a1669 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 00:13:57 -0800 Subject: [PATCH 076/615] docs: document Styles feature analysis --- docs/api/enum/WdBuiltinStyle.rst | 415 +++++++++++++++++++ docs/api/enum/WdStyleType.rst | 29 ++ docs/api/enum/index.rst | 2 + docs/dev/analysis/features/coreprops.rst | 4 +- docs/dev/analysis/features/doc-styles.rst | 469 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 2 +- docs/dev/analysis/schema/ct_styles.rst | 120 ------ docx/enum/style.py | 466 +++++++++++++++++++++ 8 files changed, 1384 insertions(+), 123 deletions(-) create mode 100644 docs/api/enum/WdBuiltinStyle.rst create mode 100644 docs/api/enum/WdStyleType.rst create mode 100644 docs/dev/analysis/features/doc-styles.rst delete mode 100644 docs/dev/analysis/schema/ct_styles.rst create mode 100644 docx/enum/style.py diff --git a/docs/api/enum/WdBuiltinStyle.rst b/docs/api/enum/WdBuiltinStyle.rst new file mode 100644 index 000000000..b7aa682d4 --- /dev/null +++ b/docs/api/enum/WdBuiltinStyle.rst @@ -0,0 +1,415 @@ +.. _WdBuiltinStyle: + +``WD_BUILTIN_STYLE`` +==================== + +alias: **WD_STYLE** + +Specifies a built-in Microsoft Word style. + +Example:: + + from docx import Document + from docx.enum.style import WD_STYLE + + document = Document() + styles = document.styles + style = styles[WD_STYLE.BODY_TEXT] + +---- + +BLOCK_QUOTATION + Block Text. + +BODY_TEXT + Body Text. + +BODY_TEXT_2 + Body Text 2. + +BODY_TEXT_3 + Body Text 3. + +BODY_TEXT_FIRST_INDENT + Body Text First Indent. + +BODY_TEXT_FIRST_INDENT_2 + Body Text First Indent 2. + +BODY_TEXT_INDENT + Body Text Indent. + +BODY_TEXT_INDENT_2 + Body Text Indent 2. + +BODY_TEXT_INDENT_3 + Body Text Indent 3. + +BOOK_TITLE + Book Title. + +CAPTION + Caption. + +CLOSING + Closing. + +COMMENT_REFERENCE + Comment Reference. + +COMMENT_TEXT + Comment Text. + +DATE + Date. + +DEFAULT_PARAGRAPH_FONT + Default Paragraph Font. + +EMPHASIS + Emphasis. + +ENDNOTE_REFERENCE + Endnote Reference. + +ENDNOTE_TEXT + Endnote Text. + +ENVELOPE_ADDRESS + Envelope Address. + +ENVELOPE_RETURN + Envelope Return. + +FOOTER + Footer. + +FOOTNOTE_REFERENCE + Footnote Reference. + +FOOTNOTE_TEXT + Footnote Text. + +HEADER + Header. + +HEADING_1 + Heading 1. + +HEADING_2 + Heading 2. + +HEADING_3 + Heading 3. + +HEADING_4 + Heading 4. + +HEADING_5 + Heading 5. + +HEADING_6 + Heading 6. + +HEADING_7 + Heading 7. + +HEADING_8 + Heading 8. + +HEADING_9 + Heading 9. + +HTML_ACRONYM + HTML Acronym. + +HTML_ADDRESS + HTML Address. + +HTML_CITE + HTML Cite. + +HTML_CODE + HTML Code. + +HTML_DFN + HTML Definition. + +HTML_KBD + HTML Keyboard. + +HTML_NORMAL + Normal (Web). + +HTML_PRE + HTML Preformatted. + +HTML_SAMP + HTML Sample. + +HTML_TT + HTML Typewriter. + +HTML_VAR + HTML Variable. + +HYPERLINK + Hyperlink. + +HYPERLINK_FOLLOWED + Followed Hyperlink. + +INDEX_1 + Index 1. + +INDEX_2 + Index 2. + +INDEX_3 + Index 3. + +INDEX_4 + Index 4. + +INDEX_5 + Index 5. + +INDEX_6 + Index 6. + +INDEX_7 + Index 7. + +INDEX_8 + Index 8. + +INDEX_9 + Index 9. + +INDEX_HEADING + Index Heading + +INTENSE_EMPHASIS + Intense Emphasis. + +INTENSE_QUOTE + Intense Quote. + +INTENSE_REFERENCE + Intense Reference. + +LINE_NUMBER + Line Number. + +LIST + List. + +LIST_2 + List 2. + +LIST_3 + List 3. + +LIST_4 + List 4. + +LIST_5 + List 5. + +LIST_BULLET + List Bullet. + +LIST_BULLET_2 + List Bullet 2. + +LIST_BULLET_3 + List Bullet 3. + +LIST_BULLET_4 + List Bullet 4. + +LIST_BULLET_5 + List Bullet 5. + +LIST_CONTINUE + List Continue. + +LIST_CONTINUE_2 + List Continue 2. + +LIST_CONTINUE_3 + List Continue 3. + +LIST_CONTINUE_4 + List Continue 4. + +LIST_CONTINUE_5 + List Continue 5. + +LIST_NUMBER + List Number. + +LIST_NUMBER_2 + List Number 2. + +LIST_NUMBER_3 + List Number 3. + +LIST_NUMBER_4 + List Number 4. + +LIST_NUMBER_5 + List Number 5. + +LIST_PARAGRAPH + List Paragraph. + +MACRO_TEXT + Macro Text. + +MESSAGE_HEADER + Message Header. + +NAV_PANE + Document Map. + +NORMAL + Normal. + +NORMAL_INDENT + Normal Indent. + +NORMAL_OBJECT + Normal (applied to an object). + +NORMAL_TABLE + Normal (applied within a table). + +NOTE_HEADING + Note Heading. + +PAGE_NUMBER + Page Number. + +PLAIN_TEXT + Plain Text. + +QUOTE + Quote. + +SALUTATION + Salutation. + +SIGNATURE + Signature. + +STRONG + Strong. + +SUBTITLE + Subtitle. + +SUBTLE_EMPHASIS + Subtle Emphasis. + +SUBTLE_REFERENCE + Subtle Reference. + +TABLE_COLORFUL_GRID + Colorful Grid. + +TABLE_COLORFUL_LIST + Colorful List. + +TABLE_COLORFUL_SHADING + Colorful Shading. + +TABLE_DARK_LIST + Dark List. + +TABLE_LIGHT_GRID + Light Grid. + +TABLE_LIGHT_GRID_ACCENT_1 + Light Grid Accent 1. + +TABLE_LIGHT_LIST + Light List. + +TABLE_LIGHT_LIST_ACCENT_1 + Light List Accent 1. + +TABLE_LIGHT_SHADING + Light Shading. + +TABLE_LIGHT_SHADING_ACCENT_1 + Light Shading Accent 1. + +TABLE_MEDIUM_GRID_1 + Medium Grid 1. + +TABLE_MEDIUM_GRID_2 + Medium Grid 2. + +TABLE_MEDIUM_GRID_3 + Medium Grid 3. + +TABLE_MEDIUM_LIST_1 + Medium List 1. + +TABLE_MEDIUM_LIST_1_ACCENT_1 + Medium List 1 Accent 1. + +TABLE_MEDIUM_LIST_2 + Medium List 2. + +TABLE_MEDIUM_SHADING_1 + Medium Shading 1. + +TABLE_MEDIUM_SHADING_1_ACCENT_1 + Medium Shading 1 Accent 1. + +TABLE_MEDIUM_SHADING_2 + Medium Shading 2. + +TABLE_MEDIUM_SHADING_2_ACCENT_1 + Medium Shading 2 Accent 1. + +TABLE_OF_AUTHORITIES + Table of Authorities. + +TABLE_OF_FIGURES + Table of Figures. + +TITLE + Title. + +TOAHEADING + TOA Heading. + +TOC_1 + TOC 1. + +TOC_2 + TOC 2. + +TOC_3 + TOC 3. + +TOC_4 + TOC 4. + +TOC_5 + TOC 5. + +TOC_6 + TOC 6. + +TOC_7 + TOC 7. + +TOC_8 + TOC 8. + +TOC_9 + TOC 9. diff --git a/docs/api/enum/WdStyleType.rst b/docs/api/enum/WdStyleType.rst new file mode 100644 index 000000000..4a4a3213b --- /dev/null +++ b/docs/api/enum/WdStyleType.rst @@ -0,0 +1,29 @@ +.. _WdStyleType: + +``WD_STYLE_TYPE`` +================= + +Specifies one of the four style types: paragraph, character, list, or +table. + +Example:: + + from docx import Document + from docx.enum.style import WD_STYLE_TYPE + + styles = Document().styles + assert styles[0].type == WD_STYLE_TYPE.PARAGRAPH + +---- + +CHARACTER + Character style. + +LIST + List style. + +PARAGRAPH + Paragraph style. + +TABLE + Table style. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst index 826994660..af3204f23 100644 --- a/docs/api/enum/index.rst +++ b/docs/api/enum/index.rst @@ -9,7 +9,9 @@ can be found here: :titlesonly: WdAlignParagraph + WdBuiltinStyle WdOrientation WdSectionStart + WdStyleType WdRowAlignment WdUnderline diff --git a/docs/dev/analysis/features/coreprops.rst b/docs/dev/analysis/features/coreprops.rst index a5b2c47d5..d4100864d 100644 --- a/docs/dev/analysis/features/coreprops.rst +++ b/docs/dev/analysis/features/coreprops.rst @@ -131,8 +131,8 @@ core.xml produced by Microsoft Word:: -Schema -====== +Schema Excerpt +-------------- :: diff --git a/docs/dev/analysis/features/doc-styles.rst b/docs/dev/analysis/features/doc-styles.rst new file mode 100644 index 000000000..18d0128d6 --- /dev/null +++ b/docs/dev/analysis/features/doc-styles.rst @@ -0,0 +1,469 @@ + +Styles +====== + +Word supports the definition of *styles* to allow a group of formatting +properties to be easily and consistently applied to a paragraph, run, table, +or numbering scheme; all at once. The mechanism is similar to how Cascading +Style Sheets (CSS) works with HTML. + +Styles are defined in the ``styles.xml`` package part and are keyed to +a paragraph, run, or table using the `styleId` string. + + +Latent style candidate protocol +------------------------------- + +:: + + >>> latent_styles = document.styles.latent_styles + >>> latent_styles + + + >>> latent_styles.default_locked_state + False + >>> latent_styles.default_locked_state = True + >>> latent_styles.default_locked_state + True + + >>> latent_styles.default_hidden + False + >>> latent_styles.default_hidden = True + >>> latent_styles.default_hidden + True + + >>> exception = latent_styles.exceptions[0] + + >>> exception.name + 'Normal' + + >>> exception.priority + None + >>> exception.priority = 10 + >>> exception.priority + True + + >>> exception.locked + None + >>> exception.locked = True + >>> exception.locked + True + + >>> exception.quick_style + None + >>> exception.quick_style = True + >>> exception.quick_style + True + + +Latent style behavior +--------------------- + +* A style has two categories of attribute, *behavioral* and *formatting*. + Behavioral attributes specify where and when the style should appear in the + user interface. Behavioral attributes can be specified for latent styles + using the ```` element and its ```` child + elements. The 5 behavioral attributes are: + + + locked + + uiPriority + + semiHidden + + unhideWhenUsed + + qFormat + +* **locked**. The `locked` attribute specifies that the style should not + appear in any list or the gallery and may not be applied to content. This + behavior is only active when restricted formatting is turned on. + + Locking is turned on via the menu: Developer Tab > Protect Document > + Formatting Restrictions (Windows only). + +* **uiPriority**. The `uiPriority` attribute acts as a sort key for + sequencing style names in the user interface. Both the lists in the styles + panel and the Style Gallery are sensitive to this setting. Its effective + value is 0 if not specified. + +* **semiHidden**. The `semiHidden` attribute causes the style to be excluded + from the recommended list. The notion of *semi* in this context is that + while the style is hidden from the recommended list, it still appears in + the "All Styles" list. This attribute is removed on first application of + the style if an `unhideWhenUsed` attribute set |True| is also present. + +* **unhideWhenUsed**. The `unhideWhenUsed` attribute causes any `semiHidden` + attribute to be removed when the style is first applied to content. Word + does *not* remove the `semiHidden` attribute just because there exists an + object in the document having that style. The `unhideWhenUsed` attribute is + not removed along with the `semiHidden` attribute when the style is + applied. + + The `semiHidden` and `unhideWhenUsed` attributes operate in combination to + produce *hide-until-used* behavior. + + *Hypothesis.* The persistance of the `unhideWhenUsed` attribute after + removing the `semiHidden` attribute on first application of the style is + necessary to produce appropriate behavior in style inheritance situations. + In that case, the `semiHidden` attribute may be explictly set to |False| to + override an inherited value. Or it could allow the `semiHidden` attribute + to be re-set to |True| later while preserving the hide-until-used behavior. + +* **qFormat**. The `qFormat` attribute specifies whether the style should + appear in the Style Gallery when it appears in the recommended list. + A style will never appear in the gallery unless it also appears in the + recommended list. + +* Latent style attributes are only operative for latent styles. Once a style + is defined, the attributes of the definition exclusively determine style + behavior; no attributes are inherited from its corresponding latent style + definition. + + +Style visual behavior +--------------------- + +* **Sort order.** Built-in styles appear in order of the effective value of + their `uiPriority` attribute. By default, a custom style will not receive + a `uiPriority` attribute, causing its effective value to default to 0. This + will generlly place custom styles at the top of the sort order. A set of + styles having the same `uiPriority` value will be sub-sorted in + alphabetical order. + + If a `uiPriority` attribute is defined for a custom style, that style is + interleaved with the built-in styles, according to their `uiPriority` + value. The `uiPriority` attribute takes a signed integer, and accepts + negative numbers. Note that Word does not allow the use of negative + integers via its UI; rather it allows the `uiPriority` number of built-in + types to be increased to produce the desired sorting behavior. + +* **Identification.** A style is identified by its name, not its styleId + attribute. The styleId is used only for internal linking of an object like + a paragraph to a style. The styleId may be changed by the application, and + in fact is routinely changed by Word on each save to be a transformation of + the name. + + *Hypothesis.* Word calculates the `styleId` by removing all spaces from the + style name. + +* **List membership.** There are four style list options in the styles panel: + + + *Recommended.* The recommended list contains all latent and defined + styles that have `semiHidden` == |False|. + + + *Styles in Use.* The styles-in-use list contains all styles that have + been applied to content in the document (implying they are defined) that + also have `semiHidden` == |False|. + + + *In Current Document.* The in-current-document list contains all defined + styles in the document having `semiHidden` == |False|. + + + *All Styles.* The all-styles list contains all latent and defined + styles in the document. + +* **Definition of built-in style.** When a built-in style is added to + a document (upon first use), the value of each of the `locked`, + `uiPriority` and `qFormat` attributes from its latent style definition (the + `latentStyles` attributes overridden by those of any `lsdException` + element) is used to override the corresponding value in the inserted style + definition from their built-in defaults. + +* Each built-in style has default attributes that can be revealed by setting + the `latentStyles/@count` attribute to 0 and inspecting the style in the + style manager. This may include default behavioral properties. + +* Anomaly. Style "No Spacing" does not appear in the recommended list even + though its behavioral attributes indicate it should. (Google indicates it + may be a legacy style from Word 2003). + +* Word has 267 built-in styles, listed here: + http://www.thedoctools.com/downloads/DocTools_List_Of_Built-in_Style_English_Danish_German_French.pdf + + Note that at least one other sources has the number at 276 rather than 267. + +* **Appearance in the Style Gallery.** A style appears in the style gallery + when: `semiHidden` == |False| and `qFormat` == |True| + + +Glossary +-------- + +built-in style + One of a set of standard styles known to Word, such as "Heading 1". + Built-in styles are presented in Word's style panel whether or not they + are actually defined in the styles part. + +latent style + A built-in style having no definition in a particular document is known + as a *latent style* in that document. + +style definition + A ```` element in the styles part that explicitly defines the + attributes of a style. + +recommended style list + A list of styles that appears in the styles toolbox or panel when + "Recommended" is selected from the "List:" dropdown box. + + +Word behavior +------------- + +If no style having an assigned style id is defined in the styles part, the +style application has no effect. + +Word does not add a formatting definition (```` element) for a +built-in style until it is used. + +Once present in the styles part, Word does not remove a built-in style +definition if it is no longer applied to any content. The definition of each +of the styles ever used in a document are accumulated in its ``styles.xml``. + + +Candidate protocol +------------------ + +:: + + >>> styles = document.styles # default styles part added if not present + >>> list_styles = [s for s in styles if s.type == WD_STYLE_TYPE.LIST] + >>> list_styles[0].type + WD_STYLE_TYPE.LIST (4) + + >>> style = styles.add_style('Citation', WD_STYLE_TYPE.PARAGRAPH) + + >>> document.add_paragraph(style='undefined-style') + KeyError: no style with id 'undefined-style' + + +Feature Notes +------------- + +* could add a default builtin style from known specs on first access via + WD_BUILTIN_STYLE enumeration:: + + >>> style = document.styles['Heading1'] + KeyError: no style with id or name 'Heading1' + >>> style = document.styles[WD_STYLE.HEADING_1] + >>> assert style == document.styles['Heading1'] + + +Related MS API *(partial)* +-------------------------- + +* Document.Styles +* Styles.Add, .Item, .Count, access by name, e.g. Styles("Foobar") +* Style.BaseStyle +* Style.Builtin +* Style.Delete() +* Style.Description +* Style.Font +* Style.Linked +* Style.LinkStyle +* Style.LinkToListTemplate() +* Style.ListLevelNumber +* Style.ListTemplate +* Style.Locked +* Style.NameLocal +* Style.NameParagraphStyle +* Style.NoSpaceBetweenParagraphsOfSameStyle +* Style.ParagraphFormat +* Style.Priority +* Style.QuickStyle +* Style.Shading +* Style.Table(Style) +* Style.Type +* Style.UnhideWhenUsed +* Style.Visibility + + +Enumerations +------------ + +* WdBuiltinStyle + + +Spec text +--------- + + This element specifies all of the style information stored in the + WordprocessingML document: style definitions as well as latent style + information. + + Example: The Normal paragraph style in a word processing document can have + any number of formatting properties, e.g. font face = Times New Roman; font + size = 12pt; paragraph justification = left. All paragraphs which reference + this paragraph style would automatically inherit these properties. + + +Example XML +----------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Schema excerpt +-------------- + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index b17d7524e..10633d672 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/doc-styles features/coreprops features/cell-merge features/table @@ -42,4 +43,3 @@ ISO/IEC 29500 spec. schema/ct_body schema/ct_p schema/ct_ppr - schema/ct_styles diff --git a/docs/dev/analysis/schema/ct_styles.rst b/docs/dev/analysis/schema/ct_styles.rst deleted file mode 100644 index 1977acc8a..000000000 --- a/docs/dev/analysis/schema/ct_styles.rst +++ /dev/null @@ -1,120 +0,0 @@ - -``CT_Styles`` -============= - -.. highlight:: xml - -.. csv-table:: - :header-rows: 0 - :stub-columns: 1 - :widths: 15, 50 - - Schema Name, CT_Styles - Spec Name, Styles - Tag(s), w:styles - Namespace, wordprocessingml (wml.xsd) - Spec Section, 17.7.4.18 - - -Analysis --------- - -Only styles with an explicit ```` definition affect the formatting -of paragraphs that are assigned that style. - -Word includes behavior definitions (```` elements) for the -"latent" styles that are built in to the Word client. These are present in a -new document created from install defaults. - -Word does not add a formatting definition (```` element) for a -built-in style until it is used. - -Once present in ``styles.xml``, Word does not remove a style element when it -is no longer used by any paragraphs. The definition of each of the styles -ever used in a document are accumulated in ``styles.xml``. - - -Spec text ---------- - - This element specifies all of the style information stored in the - WordprocessingML document: style definitions as well as latent style - information. - - Example: The Normal paragraph style in a word processing document can have - any number of formatting properties, e.g. font face = Times New Roman; font - size = 12pt; paragraph justification = left. All paragraphs which reference - this paragraph style would automatically inherit these properties. - - -Schema excerpt --------------- - -:: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docx/enum/style.py b/docx/enum/style.py new file mode 100644 index 000000000..515c594ce --- /dev/null +++ b/docx/enum/style.py @@ -0,0 +1,466 @@ +# encoding: utf-8 + +""" +Enumerations related to styles +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .base import alias, EnumMember, XmlEnumeration, XmlMappedEnumMember + + +@alias('WD_STYLE') +class WD_BUILTIN_STYLE(XmlEnumeration): + """ + alias: **WD_STYLE** + + Specifies a built-in Microsoft Word style. + + Example:: + + from docx import Document + from docx.enum.style import WD_STYLE + + document = Document() + styles = document.styles + style = styles[WD_STYLE.BODY_TEXT] + """ + + __ms_name__ = 'WdBuiltinStyle' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff835210.aspx' + + __members__ = ( + EnumMember( + 'BLOCK_QUOTATION', -85, 'Block Text.' + ), + EnumMember( + 'BODY_TEXT', -67, 'Body Text.' + ), + EnumMember( + 'BODY_TEXT_2', -81, 'Body Text 2.' + ), + EnumMember( + 'BODY_TEXT_3', -82, 'Body Text 3.' + ), + EnumMember( + 'BODY_TEXT_FIRST_INDENT', -78, 'Body Text First Indent.' + ), + EnumMember( + 'BODY_TEXT_FIRST_INDENT_2', -79, 'Body Text First Indent 2.' + ), + EnumMember( + 'BODY_TEXT_INDENT', -68, 'Body Text Indent.' + ), + EnumMember( + 'BODY_TEXT_INDENT_2', -83, 'Body Text Indent 2.' + ), + EnumMember( + 'BODY_TEXT_INDENT_3', -84, 'Body Text Indent 3.' + ), + EnumMember( + 'BOOK_TITLE', -265, 'Book Title.' + ), + EnumMember( + 'CAPTION', -35, 'Caption.' + ), + EnumMember( + 'CLOSING', -64, 'Closing.' + ), + EnumMember( + 'COMMENT_REFERENCE', -40, 'Comment Reference.' + ), + EnumMember( + 'COMMENT_TEXT', -31, 'Comment Text.' + ), + EnumMember( + 'DATE', -77, 'Date.' + ), + EnumMember( + 'DEFAULT_PARAGRAPH_FONT', -66, 'Default Paragraph Font.' + ), + EnumMember( + 'EMPHASIS', -89, 'Emphasis.' + ), + EnumMember( + 'ENDNOTE_REFERENCE', -43, 'Endnote Reference.' + ), + EnumMember( + 'ENDNOTE_TEXT', -44, 'Endnote Text.' + ), + EnumMember( + 'ENVELOPE_ADDRESS', -37, 'Envelope Address.' + ), + EnumMember( + 'ENVELOPE_RETURN', -38, 'Envelope Return.' + ), + EnumMember( + 'FOOTER', -33, 'Footer.' + ), + EnumMember( + 'FOOTNOTE_REFERENCE', -39, 'Footnote Reference.' + ), + EnumMember( + 'FOOTNOTE_TEXT', -30, 'Footnote Text.' + ), + EnumMember( + 'HEADER', -32, 'Header.' + ), + EnumMember( + 'HEADING_1', -2, 'Heading 1.' + ), + EnumMember( + 'HEADING_2', -3, 'Heading 2.' + ), + EnumMember( + 'HEADING_3', -4, 'Heading 3.' + ), + EnumMember( + 'HEADING_4', -5, 'Heading 4.' + ), + EnumMember( + 'HEADING_5', -6, 'Heading 5.' + ), + EnumMember( + 'HEADING_6', -7, 'Heading 6.' + ), + EnumMember( + 'HEADING_7', -8, 'Heading 7.' + ), + EnumMember( + 'HEADING_8', -9, 'Heading 8.' + ), + EnumMember( + 'HEADING_9', -10, 'Heading 9.' + ), + EnumMember( + 'HTML_ACRONYM', -96, 'HTML Acronym.' + ), + EnumMember( + 'HTML_ADDRESS', -97, 'HTML Address.' + ), + EnumMember( + 'HTML_CITE', -98, 'HTML Cite.' + ), + EnumMember( + 'HTML_CODE', -99, 'HTML Code.' + ), + EnumMember( + 'HTML_DFN', -100, 'HTML Definition.' + ), + EnumMember( + 'HTML_KBD', -101, 'HTML Keyboard.' + ), + EnumMember( + 'HTML_NORMAL', -95, 'Normal (Web).' + ), + EnumMember( + 'HTML_PRE', -102, 'HTML Preformatted.' + ), + EnumMember( + 'HTML_SAMP', -103, 'HTML Sample.' + ), + EnumMember( + 'HTML_TT', -104, 'HTML Typewriter.' + ), + EnumMember( + 'HTML_VAR', -105, 'HTML Variable.' + ), + EnumMember( + 'HYPERLINK', -86, 'Hyperlink.' + ), + EnumMember( + 'HYPERLINK_FOLLOWED', -87, 'Followed Hyperlink.' + ), + EnumMember( + 'INDEX_1', -11, 'Index 1.' + ), + EnumMember( + 'INDEX_2', -12, 'Index 2.' + ), + EnumMember( + 'INDEX_3', -13, 'Index 3.' + ), + EnumMember( + 'INDEX_4', -14, 'Index 4.' + ), + EnumMember( + 'INDEX_5', -15, 'Index 5.' + ), + EnumMember( + 'INDEX_6', -16, 'Index 6.' + ), + EnumMember( + 'INDEX_7', -17, 'Index 7.' + ), + EnumMember( + 'INDEX_8', -18, 'Index 8.' + ), + EnumMember( + 'INDEX_9', -19, 'Index 9.' + ), + EnumMember( + 'INDEX_HEADING', -34, 'Index Heading' + ), + EnumMember( + 'INTENSE_EMPHASIS', -262, 'Intense Emphasis.' + ), + EnumMember( + 'INTENSE_QUOTE', -182, 'Intense Quote.' + ), + EnumMember( + 'INTENSE_REFERENCE', -264, 'Intense Reference.' + ), + EnumMember( + 'LINE_NUMBER', -41, 'Line Number.' + ), + EnumMember( + 'LIST', -48, 'List.' + ), + EnumMember( + 'LIST_2', -51, 'List 2.' + ), + EnumMember( + 'LIST_3', -52, 'List 3.' + ), + EnumMember( + 'LIST_4', -53, 'List 4.' + ), + EnumMember( + 'LIST_5', -54, 'List 5.' + ), + EnumMember( + 'LIST_BULLET', -49, 'List Bullet.' + ), + EnumMember( + 'LIST_BULLET_2', -55, 'List Bullet 2.' + ), + EnumMember( + 'LIST_BULLET_3', -56, 'List Bullet 3.' + ), + EnumMember( + 'LIST_BULLET_4', -57, 'List Bullet 4.' + ), + EnumMember( + 'LIST_BULLET_5', -58, 'List Bullet 5.' + ), + EnumMember( + 'LIST_CONTINUE', -69, 'List Continue.' + ), + EnumMember( + 'LIST_CONTINUE_2', -70, 'List Continue 2.' + ), + EnumMember( + 'LIST_CONTINUE_3', -71, 'List Continue 3.' + ), + EnumMember( + 'LIST_CONTINUE_4', -72, 'List Continue 4.' + ), + EnumMember( + 'LIST_CONTINUE_5', -73, 'List Continue 5.' + ), + EnumMember( + 'LIST_NUMBER', -50, 'List Number.' + ), + EnumMember( + 'LIST_NUMBER_2', -59, 'List Number 2.' + ), + EnumMember( + 'LIST_NUMBER_3', -60, 'List Number 3.' + ), + EnumMember( + 'LIST_NUMBER_4', -61, 'List Number 4.' + ), + EnumMember( + 'LIST_NUMBER_5', -62, 'List Number 5.' + ), + EnumMember( + 'LIST_PARAGRAPH', -180, 'List Paragraph.' + ), + EnumMember( + 'MACRO_TEXT', -46, 'Macro Text.' + ), + EnumMember( + 'MESSAGE_HEADER', -74, 'Message Header.' + ), + EnumMember( + 'NAV_PANE', -90, 'Document Map.' + ), + EnumMember( + 'NORMAL', -1, 'Normal.' + ), + EnumMember( + 'NORMAL_INDENT', -29, 'Normal Indent.' + ), + EnumMember( + 'NORMAL_OBJECT', -158, 'Normal (applied to an object).' + ), + EnumMember( + 'NORMAL_TABLE', -106, 'Normal (applied within a table).' + ), + EnumMember( + 'NOTE_HEADING', -80, 'Note Heading.' + ), + EnumMember( + 'PAGE_NUMBER', -42, 'Page Number.' + ), + EnumMember( + 'PLAIN_TEXT', -91, 'Plain Text.' + ), + EnumMember( + 'QUOTE', -181, 'Quote.' + ), + EnumMember( + 'SALUTATION', -76, 'Salutation.' + ), + EnumMember( + 'SIGNATURE', -65, 'Signature.' + ), + EnumMember( + 'STRONG', -88, 'Strong.' + ), + EnumMember( + 'SUBTITLE', -75, 'Subtitle.' + ), + EnumMember( + 'SUBTLE_EMPHASIS', -261, 'Subtle Emphasis.' + ), + EnumMember( + 'SUBTLE_REFERENCE', -263, 'Subtle Reference.' + ), + EnumMember( + 'TABLE_COLORFUL_GRID', -172, 'Colorful Grid.' + ), + EnumMember( + 'TABLE_COLORFUL_LIST', -171, 'Colorful List.' + ), + EnumMember( + 'TABLE_COLORFUL_SHADING', -170, 'Colorful Shading.' + ), + EnumMember( + 'TABLE_DARK_LIST', -169, 'Dark List.' + ), + EnumMember( + 'TABLE_LIGHT_GRID', -161, 'Light Grid.' + ), + EnumMember( + 'TABLE_LIGHT_GRID_ACCENT_1', -175, 'Light Grid Accent 1.' + ), + EnumMember( + 'TABLE_LIGHT_LIST', -160, 'Light List.' + ), + EnumMember( + 'TABLE_LIGHT_LIST_ACCENT_1', -174, 'Light List Accent 1.' + ), + EnumMember( + 'TABLE_LIGHT_SHADING', -159, 'Light Shading.' + ), + EnumMember( + 'TABLE_LIGHT_SHADING_ACCENT_1', -173, 'Light Shading Accent 1.' + ), + EnumMember( + 'TABLE_MEDIUM_GRID_1', -166, 'Medium Grid 1.' + ), + EnumMember( + 'TABLE_MEDIUM_GRID_2', -167, 'Medium Grid 2.' + ), + EnumMember( + 'TABLE_MEDIUM_GRID_3', -168, 'Medium Grid 3.' + ), + EnumMember( + 'TABLE_MEDIUM_LIST_1', -164, 'Medium List 1.' + ), + EnumMember( + 'TABLE_MEDIUM_LIST_1_ACCENT_1', -178, 'Medium List 1 Accent 1.' + ), + EnumMember( + 'TABLE_MEDIUM_LIST_2', -165, 'Medium List 2.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_1', -162, 'Medium Shading 1.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_1_ACCENT_1', -176, + 'Medium Shading 1 Accent 1.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_2', -163, 'Medium Shading 2.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_2_ACCENT_1', -177, + 'Medium Shading 2 Accent 1.' + ), + EnumMember( + 'TABLE_OF_AUTHORITIES', -45, 'Table of Authorities.' + ), + EnumMember( + 'TABLE_OF_FIGURES', -36, 'Table of Figures.' + ), + EnumMember( + 'TITLE', -63, 'Title.' + ), + EnumMember( + 'TOAHEADING', -47, 'TOA Heading.' + ), + EnumMember( + 'TOC_1', -20, 'TOC 1.' + ), + EnumMember( + 'TOC_2', -21, 'TOC 2.' + ), + EnumMember( + 'TOC_3', -22, 'TOC 3.' + ), + EnumMember( + 'TOC_4', -23, 'TOC 4.' + ), + EnumMember( + 'TOC_5', -24, 'TOC 5.' + ), + EnumMember( + 'TOC_6', -25, 'TOC 6.' + ), + EnumMember( + 'TOC_7', -26, 'TOC 7.' + ), + EnumMember( + 'TOC_8', -27, 'TOC 8.' + ), + EnumMember( + 'TOC_9', -28, 'TOC 9.' + ), + ) + + +class WD_STYLE_TYPE(XmlEnumeration): + """ + Specifies one of the four style types: paragraph, character, list, or + table. + + Example:: + + from docx import Document + from docx.enum.style import WD_STYLE_TYPE + + styles = Document().styles + assert styles[0].type == WD_STYLE_TYPE.PARAGRAPH + """ + + __ms_name__ = 'WdStyleType' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff196870.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'CHARACTER', 2, 'character', 'Character style.' + ), + XmlMappedEnumMember( + 'LIST', 4, 'numbering', 'List style.' + ), + XmlMappedEnumMember( + 'PARAGRAPH', 1, 'paragraph', 'Paragraph style.' + ), + XmlMappedEnumMember( + 'TABLE', 3, 'table', 'Table style.' + ), + ) From 2c7321365f8344ff4392daf65df655a23abddf78 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 01:47:02 -0800 Subject: [PATCH 077/615] acpt: add scenarios for style access --- docx/styles/__init__.py | 0 docx/styles/style.py | 20 ++++++++ docx/styles/styles.py | 17 +++++++ features/doc-styles.feature | 24 ++++++++++ features/steps/styles.py | 43 +++++++++++++++++- .../test_files/sty-having-no-styles-part.docx | Bin 0 -> 8358 bytes .../test_files/sty-having-styles-part.docx | Bin 21362 -> 21573 bytes 7 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 docx/styles/__init__.py create mode 100644 docx/styles/style.py create mode 100644 docx/styles/styles.py create mode 100644 features/doc-styles.feature create mode 100644 features/steps/test_files/sty-having-no-styles-part.docx diff --git a/docx/styles/__init__.py b/docx/styles/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docx/styles/style.py b/docx/styles/style.py new file mode 100644 index 000000000..fe84c8f35 --- /dev/null +++ b/docx/styles/style.py @@ -0,0 +1,20 @@ +# encoding: utf-8 + +""" +Style object hierarchy. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from ..shared import ElementProxy + + +class BaseStyle(ElementProxy): + """ + Base class for the various types of style object, paragraph, character, + table, and numbering. + """ + + __slots__ = () diff --git a/docx/styles/styles.py b/docx/styles/styles.py new file mode 100644 index 000000000..e6b24d892 --- /dev/null +++ b/docx/styles/styles.py @@ -0,0 +1,17 @@ +# encoding: utf-8 + +""" +Styles object, container for all objects in the styles part. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + + +class Styles(object): + """ + A collection of |Style| objects defined in a document. Supports + ``len()``, iteration, and dictionary-style access by style id and style + UI name. + """ diff --git a/features/doc-styles.feature b/features/doc-styles.feature new file mode 100644 index 000000000..f72f33fa6 --- /dev/null +++ b/features/doc-styles.feature @@ -0,0 +1,24 @@ +Feature: Access document styles + In order to discover and manipulate document styles + As a developer using python-docx + I need a way to access document styles + + + @wip + Scenario Outline: Access document styles collection + Given a document having + Then I can access the document styles collection + And len(styles) is + + Examples: having styles or not + | styles-state | style-count | + | a styles part | 6 | + | no styles part | 4 | + + + @wip + Scenario: Access style in style collection + Given a document having a styles part + Then I can iterate over its styles + And I can access a style by style id + And I can access a style by its UI name diff --git a/features/steps/styles.py b/features/steps/styles.py index dc31dd80e..dda452b82 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -7,6 +7,8 @@ from behave import given, then, when from docx import Document +from docx.styles.styles import Styles +from docx.styles.style import BaseStyle from helpers import test_docx @@ -19,6 +21,12 @@ def given_a_document_having_a_styles_part(context): context.document = Document(docx_path) +@given('a document having no styles part') +def given_a_document_having_no_styles_part(context): + docx_path = test_docx('sty-having-no-styles-part') + context.document = Document(docx_path) + + # when ==================================================== @when('I get the styles part from the document') @@ -29,7 +37,40 @@ def when_get_styles_part_from_document(context): # then ===================================================== +@then('I can access a style by its UI name') +def then_I_can_access_a_style_by_its_UI_name(context): + styles = context.document.styles + style = styles['Default Paragraph Font'] + assert isinstance(style, BaseStyle) + + +@then('I can access a style by style id') +def then_I_can_access_a_style_by_style_id(context): + styles = context.document.styles + style = styles['DefaultParagraphFont'] + assert isinstance(style, BaseStyle) + + +@then('I can access the document styles collection') +def then_I_can_access_the_document_styles_collection(context): + document = context.document + styles = document.styles + assert isinstance(styles, Styles) + + +@then('I can iterate over its styles') +def then_I_can_iterate_over_its_styles(context): + styles = [s for s in context.document.styles] + assert len(styles) > 0 + assert all(isinstance(s, BaseStyle) for s in styles) + + +@then('len(styles) is {style_count_str}') +def then_len_styles_is_style_count(context, style_count_str): + assert len(context.document.styles) == int(style_count_str) + + @then('the styles part has the expected number of style definitions') def then_styles_part_has_expected_number_of_style_definitions(context): styles_part = context.styles_part - assert len(styles_part.styles) == 4 + assert len(styles_part.styles) == 6 diff --git a/features/steps/test_files/sty-having-no-styles-part.docx b/features/steps/test_files/sty-having-no-styles-part.docx new file mode 100644 index 0000000000000000000000000000000000000000..ce4d0b5ef1f8c8b1e0dc3efeec340a9752bae457 GIT binary patch literal 8358 zcmaJ`1yGyowhivV-QAtGK(Rt`cP$>A;4M-p4n>LwcPrlF?i46cq`%<{MAs(3G5C6 zyPIfvIfLBZaCtg9)Fmpac1sY1T--*;zj%}Kwljn`v|G}pgu&b>=BFXaz6hV2*PV|O z240{DS%5OW>!Hxr%fhC`d&y)6WHA=|>c)h63uKvjqf{57j+m^3n-nAhCgQ?pv3-iD zE*&$wpCCYZ`mbpE0H1E; zTSu}d_KLQMvAQde>6zkdAg+&5g^sO0Lk4>ift-c0@)d1K7$3#>52f~=l=3SLjp>(W zc}9&wWWP5|Tk-SUr@m5^TUMP~wS5o33h5`#wGA7s81({gXeDroF1cctaH2DsT-epW9aWBUo%vYMV`mX%!U`seKGa6T!^P25seA7) z1e*i1opcfA_?)^m)K2BzL!7{sV^P8{PII-J20zSn*$X+eCsF&cE z?sw)3Urjkl4&K2aYMBKTzeSJV7-#Po@1dyuBT&GDGLO5*0Kwn^0F1|{i7Uv#jhpL_ zvm$;(0f86C|IjxgR#9cqp)7pB$_F?1Mcg&Og0F>q@itbe&etbDyjiWnH0I>un3sRV zZcFAoiWT}RNl&ZJblZbx`a)sB3A~vc2!eMq#iUA_wIC{cJH4=is%7=t)WQmBwPqDT zy)~Y14~}x3?e%HlM98|oLW(!(EAdw`uKF{?e7@F4Z@p6Y~fiCJ&-$gCVR`mJH6}828 z%Um5=nDXA7jz`X)nL2k3VDl{mbPRm{9PFMVMp3ViBiyv;1wVQe`C47aW)x%;1%$P~ zKnDkUyZ-w4!69vw!Wgv`*_TweD^mJ+H}zgu3q^agCkz~+uL>ivf9X7~0DoL&R9^>N zH)zjulc%wLxh4VQuVD&lPCzgXNY0Unp|vGY9(w%Hug+A$(xQK)tTRx>YmmUHBfEDIv-zr8&M(6moAXGCTq?C4DwXJ1F?$l8Jp?ULK<# z7QJ04ZF5D=!;NE<=!Yj8#P;Q9Fd|U=3$^i-S2n-b!43JL1u;0n%evy?myZ+`R&+57 z;VozcIeTjeal3Uxcp07G+Q)CyQZc>!7s^-_=mF1qh#FGGd7iz?ls)%L*CBad|3l{N z9o#>Ic1YiRWbqiZ>BrQ@{d>?XoLoVFL@hdg1l-Mo6MQHl-OE_q3;a+@hpJH`NLYfW zRpITnk7R_`Urj8z)UHCqic8^bGz!}~D=$8AgLpIZ=xo|;%F>>Rs)-P5>|5R6Sdofc zWl0wyD!L)@cOdSKCM+fHrEDQDDOGmmAnpmpR`br(+*7Id2kW(mMa_)~CE_*4bfJcV04EY_6Y$A zqgG`nNQq2DWklBY3sJfju1=(01*E@BJMUGj^w(~`a&kH!KTtej&l=0J;%-;QksAN9gNoQdq z?;pMrXt`&&sEr2-Ye-ATqTpZ^iAY(4S*xK zcY29%?$%?*V;o^|dxx*HXw3r$MiOVrT2p=tCvj^FJX2wJO^fW>ck_yEpv~4vwyaWJ zwF7=1U?B_)+Mbjpe$yG?`swtSXXem{(uCf%fPUbhk`DPz1lJpBrj#em-sb29zY6yzhX@ z>HJOLW-*@72YD?n`#0J-`ZH>vh~(GkbBrqzlL8K+Z1&`|QpaP#3NiaavM_wz=I@jP zHE~t=Qa{+9SyyVJCJIkoE-Zb4g>Q78p&%p?+9{diFcix4aoyN`pKP*(L~*NUDnfkj z7|&DMIVsf6={9BRBXF%9mE6V{=M|;|*8_3Nqx;rny^Z>68~9^KgZiQ{5K&)IiY?*< z51GOmELB$&iEdzT($9pw<(c#Qf!9$~6CMvMr@oGA9yRyBtuOuBQL@`^ z2ZIB}yRv)Bh$DC!{NvT>m;4y9Sy_YD$(oNLT_K}llOgf$x)s}4m7^-2Hq60wNy%)v zz6RZPVs`g)3l>WbOLq6iXJZq`g6j>sN}H zAiAB!JFDcKVAe-^W5n&WjZfjqU-`4m+5xexS6;hS?ig9h#d+hAp2j;b;Ek4WqVE?;qm7pW zk5%Dez}Ght^>vqR01V;@O==B)p1IDSbDpCXGT29WOeomS-}U|L{AhT6Ko0xWvrdF_ zz~_~wS=+x>GqelF@l6XKsKEAPG!5iI9}kc%&?8x0qj2@;dq`8asYoK5ewWhRn}PH` z9SQq>kDU}CfRUhc^tf=WcxIW}4kK)%n6HOL4{=UlnXFI5m?XtDSoLfsqCxf-=`+H9 zC=XZRL&FNJtfDEBL~Va3X0#OjX!V(f1$O(C_tnQw@cDRoDDST_UV1N5br~L>R3)y< zX}S&VPKQzXt&i93QTe=ID=NEs_+e$Ws^iV|U5xlco(FFyR-%~An_0wA z#$bAH1LB_NngMY~YpxzW5kxz0%0gpC4HM$c=Dv9OT*RVX{cxk-?UgD?#kW3J7FJc% zWNWGGo`28;A%rmQ3Ilg2z=HJF!B@^33PATS*hPWJWHvbg|Mhs_4?ph~|7Q=b<)&fZ zdc5g79(ypwN2c2MP|PYE=l*`)@^YZF1-(dh!%ahWh*Chen_R5yasFo&t7B#0mi6Wz1R9Gri@^ zH|(z*zJAS@<*~yqc&@Fp)MFzwq9%@rhZbmQIZ6SpWt!=+WW9jn@i3rf9x92-1Wt?2 z0Z+Zu%RMsqEtAk(f(fDD1{xi?3O--J-3MJbz7%XcJMe;EH>tOQ-*hKuEQ+T55*Nmx z%2D!A+h{bgO86FA{I%5zT5=iV8-o}l=pyPelgT(*%vn5DhLtstdc(R=*e3CMd%qM$ z`A-MYe3F#ld~{AT^1n#RpAP!FYYMfMW9A>JEZuw%DWe2-p!^<9kbruL4Qz9|bl?JOB$dXBPH@VUrVbyyJ4|3B z@rrS~HyhbZ%}@aW^v$$M^)=N;7eisVhOcOnFWtxxF;1IcLibvT5%QQtIY?i(cQJ9j zKd4!(YxVx7|G(y@|`z*y#&V!80<}XmY`+wP6IG!(m*4LTZ>aPdH?koN*@)faORoKw=Kc*KRU+ zp)YrB4O&?JDI4dyrVl{c=pkJ>>cQ1a9wdF#{W2$|&ybQ~(S{Kd)SJYjcEXz26NgOt z`irX8&#tllnOo!sPz1Wi+$u)}0Py}(d{&QxxR#l@!=DUzBS{<#9n-*h z-5zs$df7uiD=wN0Ka%>@sEi{DD)?GSU3A4RE#y$PCm$SMPH!g}tiyqkJRDjar1N2X zsLqwB%N67kZ`C#2lt(NL`H{jxJJr&MG}#*nu0o=c26e47P_OTDDT(0zq&jNI^uBgfDBiS?Or~WY%Y$SD#f8y|x`P zPvK#v!*CANd>{4lsOS>0od=lb{X7%bBDKtVEfj)3*yqX~H?b3{=PYY&=%_-2Bv+Gb zD27y5YmtFe#anABi1|ED5Mu%=XCNC76?ZZq+*ycGJ6sDpJ~1QwNbYtUb2EeZ#4zANCl$Xz9cUb*VWQbin_ zJ1e`xsHPxVF>+#}QTl1a`^=*4x~aWUud4m-s)zL~IQ7Gxdlqij3e~Kxlhg+?-@?00 z`(HbIj?kK*=_eTleOF`vd7OcV(f)Q@xq;l>ZNb)Ve_Z5sI#4iF5XW~^7uEY%ky5_B z(7u;-pNwq6UTo5^s4VI@8x+bnI8q>H#~J5CL|7IS)L%(7sNARO>p*cAox?7;f$?FXX}bI z*|@=XogbSL(Gdg5ON73%>^k;-^kbdkNjAb&^i!%N) zYK^LZ%OKKi!VOtrh$5;BV|AEWPMX;B_cwL&8$62_vgJm(UU<4DZ#F|S6XdW&CzSAg zVb)zIS9dyc%6!O?I5lWaT!Db}-LOOv!C}H}3icM%1VLavGP|jif>pLW#GoX$jya`o z>a)fA%RG7TtKx`t&r?Iza>%eI##KXY!>l(M`%f~`>M&+3AM`NhG^mEuUgfR^vzEE7 z>Dfjc1;{pEN8fG2n}{cthC!Uc4DOS;VRkI zPuIFJ<1Ru(MI(Lk4|29`+7#Rma$;OJGwUpr6fw*-mgig@l{{ni@a+E$(INVGiI;icUd^^j=0ay+?%2!GgS{?F*v%6@X6#m1k?t>B5B zBI6hP?)+8hLW{sHJ$^Q0XUJ{wPbUV8-+(?7%6pk9N5>ZhLlX)ep6QJC^LH)u=m)!t z2gOR$^Hyu=rs83`@3C@no?}MkAu(4SxR7{QOr?CW6VE1@431dEoGNJu)SEDQKK_wj z+`}opd?`O;RiHpN%fo3Hw()QB&>50Eb6YCeaiEUDE)L-BJVp)G>)eB!l>YG!d;Z^&J8v+ys&7N zi-70-`*l7+_kGi3zu>Qd43o0^b*!uQGgWUI(W_92y-?*EN2@yPH_&m7Sd$t#!)QBR zBdiZK;ex0Le#b>C0)tTEr|Aprnie>`aBhf>a>M4Q7spYdsF(zW9{cbBw|lBM>h@oD z929kE(7K+-x@1qo))n+gy3)KHE^bvMnGGv&9K56S=8*{NhDUUzWuDg&x~);*m6fND zBY4e`r!;ofBc93i3P(Y2YG>Ay})9A*0wZF0O`oMYGz-1qJ?>7tiDj1{fgks zuWUkIJ{G=D04d&mDB5*VE+MH1qFS@G<4(56lx7fGl%@KU?ZqCsHomf`)K8W#%GUW% z$21!<(ljeE<%i$iHQ)kUCL5!`ySDdugmpU-KWXrI^46_*6&0&%HO8y*C*?BUy3l?4 z%uQsOheE9}oxE4Oh(<7rVosKWTgzC9fkwsmp5av^dRK4!WX8zJR-&Fi6cZ)S(U(F( z-~7{byYJN+GCFc=;by5rl!hfQp-qI&6tZo zyvpF-L+!RLg~Szj7F^bKphO!W?GK!wbj}_$Hw?Ow^#j9t{)QGxZzD^9;q>r zTRsbDNFC8cJ`AUo8$uhj1KAZ3?ZIzT|v!q5N z8eJMOxbvgG@mHNOdieoImIX=ax5dNL6#XHXMZ9zbrxd+jw3l4zc5I9BbuOk@~#NmX!m=R(<$1X=aP^^u$WHzIa1Eh1{=(H^;>3RZA}R zSjU+GxtOzDq3qDodnV%G!%55HxW}lW zJCxi^@@-#Y4%EoW-ze?d&d^-`xQ)nqBzIl_dxzb8VwB!T3VsqGyi6anUSkUvW`PnR zdtFb8eRXd>O^(r0Hm5I}$>-DN?K=Vp1W5es^g>pcn!s@KH)S*XyaO5II)m8Zukv8a%x9&pgUV zS0Wjsu2H(K8C1BCzOB#y%u|v)c2S#G(aJL49q9CVF=F}!k^5>;P<`Ff z=jR-zJvoli>y)y7XYftbx)r$bUzh70x+cc$U`YKJao`&dx4dG63I_v-{dq+lJz_?Ugm6d9s(9@E$(zU zP>)Om9tQk+ghG#9bz$L@Aq0Jo`%cS%l2JZw?xRh3h~J^pXSWqeIe6k9=ql_TORwGM zqQW;+eY$dfk2`;tDXV_7PdU$3T%7?uICXqcMmZq+jM)O!&{%~8u~e{@^Lwx&s0G8w z;Ucy5XNp#$4xl=P6#*c5cO))#Q}RtHnR#{8L2+GM3HFQERPVXj3SV%2-|*4{ct&B$ zGY1u8)*(SDWO!aupM@vII{X}$^t1Vr7P#8~rIX5BbJ>a;{PSn!Gt(=L4;^iI)5{wr zYMQxz@nOHb$F=*z0mp7Y1s4Ig{!c) zKMlXcG12xfXCgMNnV`Z37DgNBS4=M1MP3}z*{OZo&a1hC(Baw(T`W74X9m-;hu4MD zPGVm9w#sQ*6#3yAtF-kF$vum`0;LGUVNolfy%*sWyVv72*DRxWCFA)>C|eR=)nhwTF!I)MS~QJZU-0WcCuuolqTQh_S^7XW!u3l& z*Rh(9n-5uMH3v3nNqrc$Xz`5&^(AUFQ5pT#9rJlnjsVr86(#tCruwG3pZ< zB#p|Upq$mC7$?Uy-(>~|i)SvX)@3v zF6YSi@tFFBJkD*DEp>SH*8Xj$QB=@})Zh2imWiiwf}tW*HIB6!F0CBX1KWwD`fVyi zZQIWTY=bABn{oK5*+dUPk3*cU{ADpS8$0nmT(GcE3TcV#@3)|izaJO&aPR~O|F@9) z=(ay!{*ONVud?n_15X9Uf58C&f4GuI6MvH%p8}t1ZhrwsA7|R93fxolQ}O37bTH!I z(EpKwp2D9h6@S5r9+}hs{`o(3i>F4OF2DXVf{OY7ufm>Mdb%b0%MvTrzb*ZJXY>^Q zbP@0uJOumS@PDiYp5mX5eShI+9)-0(%joYz;8XZh!uc0G5KpX literal 0 HcmV?d00001 diff --git a/features/steps/test_files/sty-having-styles-part.docx b/features/steps/test_files/sty-having-styles-part.docx index 65b40d9baa5561f7f891250948efde4ffcb992ca..06feeedd5e9a2e69ffe9d47e1af8e3e782aae7a3 100644 GIT binary patch delta 6904 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 delta 6665 zcmZ`;byQW~*1dqza4#+0(uj0Phkzg@0wN)eq;wvnyDr^%X{Ea*MEZinrBgs!E+z4y z-}t`YdvA<){#kRJz1Ljxtg+7+bFAH0f^q^wsdC1`TAVif`~npOa)tU}Q2<5`89Wdm zr|!`II@=h|AFU2me%nw2hL#n1XH%JWVV~(W5Ka4DWzj<#T(l4pC>^cKIkmnEOE=oD zTp-g*<2VUXY~ev$aY}DFzn&LADG&+Iv=lWZv8tvU>xpmO8~``ErlTx#oDdfpDl#PO1ctVvHj;x2#r_ zdl!-NkLq`t=Z}6wbYVt&Iaft^3ZcI-4qn_J!6c#B+SJj3OBP6o{xreIsc95_)OE3J)x6F-yETTxdYE{=Lnb}t)L4-A5EZ8D!8tZ^ znvN^GN|CHW#0rVeSg`(^*^H&6_(NIm@m3m+7XhzX3u?E@z?IFfQ8Gs&R0pHz;@5U$ z^SW#7f+MQdHH72MfbdCcoH=KY^b$3v7V~A&Aoxc5(u+f-#yUcg`PY6BYYr1Ki=KYI zpHE*|q(W@?wZ5L1;+;nwJ{KRpsi>78LVJ1=Qbn{NiHNc9ayCF1i&wzQcR14oT2!kq zgC{6LkIi`PJXCx-v)8?4ts_2jN_lm)CcKy@=7=PWPY|(VS8P~$R-)BJcuA*^os5+! zDg=Ejf^if$_UyrgTjvw=7DQz2iJXS2-Bnr0%G!W}6+6nx+Jk4R~HK&RKoz0wj zbsCFNPV#!};8$G+ik?D$&l(J|S~At09HC=N!lH@56|PEF$ZV-|@RsM+%P%8%-llhIjk094Rz>0X zcKs-hjVrEBjRcy|h*fphOU~y0@Th|3ro^KjMUICnYrSXnTRZp!8gCb(Q#$#*npjHUJ5HF&1aCFqjs*%vs}eoU?X3je5J-O zjNeJYxgU|&{?$F&$=59uD5?Xh@$?!l81I_4Ggik@24S_4*9->Nj|RtThvt5q$8}To z9~PYSIQ12DbYQd0d?NsxI?8$!v5{9&WvoaY<-^BvO2a%JeuW)Tu+6gvtUG~lS)#a{ zm>E{igUIj%c1fp~xZV)7lV6`vT)k!cw0zT@@jJ92n?kwtvdGVoKx{Pcd#SD;j~B$2El3~x_%=SAup+@w~v&!1_3jlyX}hV z!51k9Hy=qfum?fNGF6F}j}~q14u(1X1TOF|V{Q0$s>274hD>e)r?~Z|4!~;kR2t=y zxHZ;^su<)ow&NLiHNOB>F|o=h}ZK3wEbYJNa*Ros&iTmFc$ z&Z;u1Zl*T#n3?xYJ_w`RiQWDqXIn0d9v_0_C}0l0_Crm!GHcTfGm+uJOvy&oVLaCb5=(#l-^FjoGtXG}&vibCK`fJ{T9rm3{Jv)_@f z1P6uQ@@mW4;LzBZ<>b)N;2J12=umjBt1>@ujbEJafcM5I{pTGD^caU2+Nueur59#F z!GHGBp|BbQ1oC+X0#SgVz2Rfoz$a7m)_egxA?xvS>p)Grl%Osu%Ep%LCO1yiu(piBSgI*@+4+HTMf;Fd%Y&Nh~LN!UG?pgjdrCL^6ad0Qepm^-=uzbFKem ztMCbbmF@%hguLpQDwt_!CSXtd0B7~BKA*cWuZ$RL%%1pm03t=>`bOfZF7`XEFWJxQjt0p3 zpd93G*k9Wx?uz%PXSS@Td58b4!%JbMtLz zZr?nQ*|Vk9o>R}K%(lFiWH-xSawDbq8TJk3Q7fwv%c81*i_kHw73;fI>u6s^D^~r; z&TepJwC1ZGX!>y%sZsOe$A>YA6*L;<`O^gT`PS4L|^RLjIzptFS z=yLOQBbTbo75d%CGlw_EoLcA;O1WGz)CWpCCw~(kKIxt^td-x5=IYRYVS(dR6}+gx zluJnytnJ}`I%h%r^cLehnGI{$qOGqv#`>n81`tduZg6bbY#8mWGl6GV_8{*13)2D% z7JU#9NhN#TSh+3t8+2#4E;`Xs8oPce58cwg>F!12`b_S%Z+ILQi4F*+}-HAq06u z+7lFvbuHDT^g$s9Pp*1TbHb`ey3`T-0&h}&6`G)Ht^^~?ybys?>d*Ow?aKux{f0abf^uJn|5MhHc}9jWTCBRgX?s*zTj znmg`W%emo(>K}IXQAnzU)&ni*FA!^+m%j=WlEN85ADH58Ly_4c8UB0)sFmP4_(Rh1 zlHD$sqar#iE2!=^8!$Z3w8yc^zEI`{jH(f}q3CasykOR@quK@O-*i#ZO@Z&z{~b^- z?K9kT-yb83KvNs?B@jJ7O{V&v_zOD(4`bC%=f)vaNa|r$u4o$y`}Ur;|E^#uh|47a zVAscA0&)GqhCTYgrL~Znd#{x)-){vEvWu|<%KHoO58dK#SMbJVYP+A-|4~Z;{y)|C z%%to8$t;X9F2hoizj>d+i-i9b@vT%Q^-n+kh5F#go@@sU+!yDkq|zEXLdVQIUJ3g4 zC(b!SmMo&L-Tt);4@>^&bP2fE>k6v;ql-6q?ti(lWGqNb;D4GzfJAffS9kYlj6B6~ z@Fz{jizzj~@Bg@mqs-rEJO0D=yQjv{@sx%vfz%i|aWO#A6|f${B>nIg2$&Ec*&U^~ zlca`gRE0tEX4PrNA(BqF+uitXFDbAeaQ5jCtZzL(j}$4=Y6;^>qi$AmJU>r5|8D4C z8qLRyyxNFvG1cziHap{zb}0|59&n{hY4RkrJ0WQ9Im6c7!^=4Fq0zKOB4NY3QE(9XW#$i5_8)0v>rl$H~- ze3-ZA{dv@i1}Qel(j9wYs}@0<#W>dXu2$b&{p~0(cfDtcWC87YBv>a=33s@yKTdm#n#$in5a^954|b@ga`|al=%!wSK59y0%DSR<-0V zUHVU^xqc4zll84==2EfH=J2x?rhUDc5KaHl)1}5WvCCE9k4=Aw!+z=KQR^+ugaSlq zsyg)W;I_X+03{!uC|#z)%80FBWQOo#!Bkt#!#_p|Mia{JbQ&RK@(}o~eSTwOXU++8 zY-0h8y2aSfm?!+>L3ON!@Od`RH&1#L357TGPb$WFm`Q|rRSpjcP2OqfdejG_L}DAP zB-KR*rzKG5hqJl9!w_*t8ylxtAO-|~Y|FM5g+xAEMy2LGlX8~g7Wtr$69Us>vQ%Od zRdTM+&Dgo1r zOUA2eA3cHc5ak&|EIMlj`6z6@=R{t3~oljhQw$tsz$*;%Z$qMS%I*F(CM)^*^LGHeZ(0Ws808Viv9Bs)%g@2@CCK_Kw`s=LDnvnS3jp0?)B+#Yte zbr~AY8GpQ%!&V2{7w_F!we|NfdluFC_ZA34eHQ1jM`i_Vbohg+@RtA_n-~?v4b3+K z#TwAx-|d`@H|%UzAv$E?U?Q7C?N0C&|AIG(mtDvKFeCE6nhiVa>9zLhwvxyvk};TR z+7wUrB2>__MfiR}lka0qkk4xyz+Khd^GE>;gzYEi?M5}kl_63M)jg4!L9Lx!VboJBTLSO zw&phWCRP`P%A41pzhzJ+pS%>0NL4y0Ga>yIc0*aar%@OPw(?xeGx@lI?9zY{lk@P+t*Tc|c>$RcMw~)rkU2j`1yVP1@i$P(LQksa zpNy>0VSZ0P?0z2Mh&XreE|8v5$?)OISs_^)By>x9Kp!bjSl!cV;VzgTbZZpcI2fq@ zy1Tm@h{pYC*%6!67@oI*-=ik>xZV44#m$&O57j9*Y!d%W+rpLJj53oh3l2Ie^4sk?S@#Zp!#2GM3y7eKt>vhQ>hR6f=GiU~FRY@O6 z8`1tApZkaT0PZ$nkXPuf8^q><+G`@uQ}>%W4B(C*{xXqF%>h>7_%%2!CKEM4A_1UU z89Ze2?@y9JEn$>wS@twg?Un5(Q$&sA9QqU?jmS&#l-IGq$WQVQ-+jDxQ*MfM`ytb6 z&%n9?s{i>oKqf}5Ai23E+*nmdh@c{-Y=XL=1*VzFFf$_hwH=F2JYAuUAlJH=66G&A zUVDa{rq)KlStBT{S{W&X`al(6H9}ICa4%s(^e8J3NuA`cT)`EmKy#K<8v*eLf5cjJ zZd}16^AaX0f1a64;ASCy_rG)gcXGL842-hBMOYC>g&tqZ?)Lw%p-quGJP4-B3dwCr z(S86#Qstr#g$o5dRAn7huc2#ZQ&VMJ!irvT^b~ z_@g}-|LFJTu$m<$u@DfsjL5A%N`=J{=l^wT&!oAOj7 zG(HE&wOc91yW|z)s=DCahqeRQz6=p~hxp`J72-?~F*w@KH8FZWkEZCwp_m8K1s8&= zs0}X5U%0an(m|VLKbX3al4MIP-XPd_u;%XZD5YGa?|dj(@yDojsSsZgN8|kUfw%-aM{Aq z!5Lk*+xg=r)7Rp-8>`P11cpDjUw;uxv^%$owK!}_n!{S+vzTRD5>uq%uhy^wtJ97K z(Bnm`huUTskXQ(wh7NJUX?BTxkBs;zhqt+_V5h%ul9`N9=k+%hRf!t3&UUUJu^Wqh znbKiph9BHMw1tGmiM8&zn5R{MD#;*fJxUyP65oXR?yGhtM9)_5*E>P=v&$wbve zvX6z89G{FVmpr&! zZ+Vn#U;cO@%`>S$i1FEiM){ot*Ds!U?_T7Va=ld&#dn?B+k||Zw>eXwBlX930>IF4wESW?E5WB<3+|(+BhOWM}SI`ZV zm^IGj!v0eoy%x$|1gmrbKw+ScOA=3KNGKwZc_F2i*uWVu8xcml`iSL%_g;d(WLqLws27$wL%-_`mGBNYoUggyL?5^XB$9P<@CvGHAel3ax| zu~LT75A_Iqj?5EJ&sv$!-fFrHv7IX4H7#nAq7-zZ6>6rRyrJXDFZNILq}f}QGL$BY z9OVu_e%}=b^51fwdM2wia~;bw6>8CXS-*I_WN73dBq^FV)w+;%yR!ZKndw&NGqDso z$EJ5;&AlFbW6E>vgU1fDoeP}ESML>T>C}?Y{^fF{Lqi>91q#-bLE(pLYO+)P|6Cgj z1R}V%Y5zR?e9%MK Date: Fri, 19 Dec 2014 02:07:01 -0800 Subject: [PATCH 078/615] style: add Document.styles --- docx/api.py | 7 +++++++ docx/parts/document.py | 8 ++++++++ tests/test_api.py | 15 +++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/docx/api.py b/docx/api.py index 74eaea557..d3541bab9 100644 --- a/docx/api.py +++ b/docx/api.py @@ -159,6 +159,13 @@ def sections(self): """ return self._document_part.sections + @property + def styles(self): + """ + A |Styles| object providing access to the styles for this document. + """ + return self._document_part.styles + @lazyproperty def styles_part(self): """ diff --git a/docx/parts/document.py b/docx/parts/document.py index abf08b2d4..d8bc5c154 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -102,6 +102,14 @@ def sections(self): """ return Sections(self._element) + @property + def styles(self): + """ + A |Styles| object providing access to the styles in the styles part + of this document. + """ + raise NotImplementedError + @property def tables(self): """ diff --git a/tests/test_api.py b/tests/test_api.py index c22230b55..c03d63af3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,6 +20,7 @@ from docx.parts.styles import StylesPart from docx.section import Section from docx.shape import InlineShape +from docx.styles.styles import Styles from docx.table import Table from docx.text.paragraph import Paragraph from docx.text.run import Run @@ -138,6 +139,11 @@ def it_provides_access_to_the_core_properties(self, core_props_fixture): core_properties = document.core_properties assert core_properties is core_properties_ + def it_provides_access_to_its_styles(self, styles_fixture): + document, styles_ = styles_fixture + styles = document.styles + assert styles is styles_ + def it_provides_access_to_the_numbering_part(self, num_part_get_fixture): document, document_part_, numbering_part_ = num_part_get_fixture numbering_part = document.numbering_part @@ -249,6 +255,11 @@ def save_fixture(self, request, open_, package_): document = Document() return document, package_, file_ + @pytest.fixture + def styles_fixture(self, document, styles_): + document._document_part.styles = styles_ + return document, styles_ + @pytest.fixture def tables_fixture(self, document, tables_): return document, tables_ @@ -362,6 +373,10 @@ def section_(self, request): def start_type_(self, request): return instance_mock(request, int) + @pytest.fixture + def styles_(self, request): + return instance_mock(request, Styles) + @pytest.fixture def StylesPart_(self, request, styles_part_): StylesPart_ = class_mock(request, 'docx.api.StylesPart') From 5b25f14411aa521673210c2d13ecd033fd702f25 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 17:25:40 -0800 Subject: [PATCH 079/615] style: add DocumentPart.styles --- docx/parts/document.py | 10 +++++++++- tests/parts/test_document.py | 28 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index d8bc5c154..1e39435bd 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -108,7 +108,7 @@ def styles(self): A |Styles| object providing access to the styles in the styles part of this document. """ - raise NotImplementedError + return self._styles_part.styles @property def tables(self): @@ -119,6 +119,14 @@ def tables(self): """ return self.body.tables + @property + def _styles_part(self): + """ + Instance of |StylesPart| for this document. Creates an empty styles + part if one is not present. + """ + raise NotImplementedError + class _Body(BlockItemContainer): """ diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c5f9a08cd..ceeb1f635 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -15,8 +15,10 @@ from docx.package import ImageParts, Package from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections from docx.parts.image import ImagePart +from docx.parts.styles import StylesPart from docx.section import Section from docx.shape import InlineShape +from docx.styles.styles import Styles from docx.table import Table from docx.text.paragraph import Paragraph from docx.text.run import Run @@ -54,6 +56,11 @@ def it_provides_access_to_the_document_tables(self, tables_fixture): tables = document_part.tables assert tables is tables_ + def it_provides_access_to_the_document_styles(self, styles_fixture): + document_part, styles_ = styles_fixture + styles = document_part.styles + assert styles is styles_ + def it_provides_access_to_the_inline_shapes_in_the_document( self, inline_shapes_fixture): document, InlineShapes_, body_elm = inline_shapes_fixture @@ -162,11 +169,18 @@ def paragraphs_fixture(self, document_part_body_, body_, paragraphs_): return document_part, paragraphs_ @pytest.fixture - def sections_fixture(self, request, Sections_): + def sections_fixture(self, Sections_): document_elm = a_document().with_nsdecls().element document = DocumentPart(None, None, document_elm, None) return document, document_elm, Sections_ + @pytest.fixture + def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): + document_part = DocumentPart(None, None, None, None) + _styles_part_prop_.return_value = styles_part_ + styles_part_.styles = styles_ + return document_part, styles_ + @pytest.fixture def tables_fixture(self, document_part_body_, body_, tables_): document_part = DocumentPart(None, None, None, None) @@ -275,6 +289,18 @@ def sectPr_(self, request): def start_type_(self, request): return instance_mock(request, int) + @pytest.fixture + def styles_(self, request): + return instance_mock(request, Styles) + + @pytest.fixture + def styles_part_(self, request): + return instance_mock(request, StylesPart) + + @pytest.fixture + def _styles_part_prop_(self, request): + return property_mock(request, DocumentPart, '_styles_part') + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From adf878eafbc9b568dd23d02b45cadd47fdf1c003 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 18:07:22 -0800 Subject: [PATCH 080/615] style: add DocumentPart._styles_part * resequence Part.package property --- docx/opc/part.py | 14 ++++++------- docx/parts/document.py | 8 +++++++- docx/parts/styles.py | 8 ++++++++ tests/parts/test_document.py | 40 ++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docx/opc/part.py b/docx/opc/part.py index 1196eee5c..928d3c183 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -89,6 +89,13 @@ def load_rel(self, reltype, target, rId, is_external=False): """ return self.rels.add_relationship(reltype, target, rId, is_external) + @property + def package(self): + """ + |OpcPackage| instance this part belongs to. + """ + return self._package + @property def partname(self): """ @@ -104,13 +111,6 @@ def partname(self, partname): raise TypeError(tmpl % type(partname).__name__) self._partname = partname - @property - def package(self): - """ - |OpcPackage| instance this part belongs to. - """ - return self._package - def part_related_by(self, reltype): """ Return part to which this part has a relationship of *reltype*. diff --git a/docx/parts/document.py b/docx/parts/document.py index 1e39435bd..f8d7c0b7a 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -17,6 +17,7 @@ from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented +from .styles import StylesPart class DocumentPart(XmlPart): @@ -125,7 +126,12 @@ def _styles_part(self): Instance of |StylesPart| for this document. Creates an empty styles part if one is not present. """ - raise NotImplementedError + try: + return self.part_related_by(RT.STYLES) + except KeyError: + styles_part = StylesPart.default(self.package) + self.relate_to(styles_part, RT.STYLES) + return styles_part class _Body(BlockItemContainer): diff --git a/docx/parts/styles.py b/docx/parts/styles.py index d400ce50f..c4443ecdd 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -17,6 +17,14 @@ class StylesPart(XmlPart): Proxy for the styles.xml part containing style definitions for a document or glossary. """ + @classmethod + def default(cls, package): + """ + Return a newly created styles part, containing a default set of + elements. + """ + raise NotImplementedError + @classmethod def new(cls): """ diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index ceeb1f635..9a509d088 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -105,6 +105,23 @@ def it_knows_the_next_available_xml_id(self, next_id_fixture): document, expected_id = next_id_fixture assert document.next_id == expected_id + def it_provides_access_to_its_styles_part_to_help( + self, styles_part_get_fixture): + document_part, styles_part_ = styles_part_get_fixture + styles_part = document_part._styles_part + document_part.part_related_by.assert_called_once_with(RT.STYLES) + assert styles_part is styles_part_ + + def it_creates_default_styles_part_if_not_present_to_help( + self, styles_part_create_fixture): + document_part, StylesPart_, styles_part_ = styles_part_create_fixture + styles_part = document_part._styles_part + StylesPart_.default.assert_called_once_with(document_part.package) + document_part.relate_to.assert_called_once_with( + styles_part_, RT.STYLES + ) + assert styles_part is styles_part_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -181,6 +198,21 @@ def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): styles_part_.styles = styles_ return document_part, styles_ + @pytest.fixture + def styles_part_create_fixture( + self, package_, part_related_by_, StylesPart_, styles_part_, + relate_to_): + document_part = DocumentPart(None, None, None, package_) + part_related_by_.side_effect = KeyError + StylesPart_.default.return_value = styles_part_ + return document_part, StylesPart_, styles_part_ + + @pytest.fixture + def styles_part_get_fixture(self, part_related_by_, styles_part_): + document_part = DocumentPart(None, None, None, None) + part_related_by_.return_value = styles_part_ + return document_part, styles_part_ + @pytest.fixture def tables_fixture(self, document_part_body_, body_, tables_): document_part = DocumentPart(None, None, None, None) @@ -257,6 +289,10 @@ def package_(self, request): def paragraphs_(self, request): return instance_mock(request, list) + @pytest.fixture + def part_related_by_(self, request): + return method_mock(request, DocumentPart, 'part_related_by') + @pytest.fixture def relate_to_(self, request, rId_): relate_to_ = method_mock(request, DocumentPart, 'relate_to') @@ -293,6 +329,10 @@ def start_type_(self, request): def styles_(self, request): return instance_mock(request, Styles) + @pytest.fixture + def StylesPart_(self, request): + return class_mock(request, 'docx.parts.document.StylesPart') + @pytest.fixture def styles_part_(self, request): return instance_mock(request, StylesPart) From f1ad6483fbeea7cd1addcae93c795b1e673577d9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 18:55:51 -0800 Subject: [PATCH 081/615] style: add StylesPart.styles Remove legacy _Styles class and tests --- docx/parts/styles.py | 19 +++----------- docx/styles/styles.py | 6 ++++- features/sty-get-styles-part.feature | 1 + tests/parts/test_styles.py | 39 +++++++--------------------- 4 files changed, 19 insertions(+), 46 deletions(-) diff --git a/docx/parts/styles.py b/docx/parts/styles.py index c4443ecdd..a0678c4b6 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -9,7 +9,7 @@ ) from ..opc.part import XmlPart -from ..shared import lazyproperty +from ..styles.styles import Styles class StylesPart(XmlPart): @@ -33,23 +33,10 @@ def new(cls): """ raise NotImplementedError - @lazyproperty + @property def styles(self): """ The |_Styles| instance containing the styles ( element proxies) for this styles part. """ - return _Styles(self._element) - - -class _Styles(object): - """ - Collection of |_Style| instances corresponding to the ```` - elements in a styles part. - """ - def __init__(self, styles_elm): - super(_Styles, self).__init__() - self._styles_elm = styles_elm - - def __len__(self): - return len(self._styles_elm.style_lst) + return Styles(self.element) diff --git a/docx/styles/styles.py b/docx/styles/styles.py index e6b24d892..fe7850096 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -8,10 +8,14 @@ absolute_import, division, print_function, unicode_literals ) +from ..shared import ElementProxy -class Styles(object): + +class Styles(ElementProxy): """ A collection of |Style| objects defined in a document. Supports ``len()``, iteration, and dictionary-style access by style id and style UI name. """ + + __slots__ = () diff --git a/features/sty-get-styles-part.feature b/features/sty-get-styles-part.feature index 27618568a..0b5ccbea4 100644 --- a/features/sty-get-styles-part.feature +++ b/features/sty-get-styles-part.feature @@ -3,6 +3,7 @@ Feature: Get the document styles part As a programmer using the advanced python-docx API I need access to the styles part of the document + @wip Scenario: Get an existing styles part from document Given a document having a styles part When I get the styles part from the document diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index 3294546c7..857a294b4 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -9,58 +9,39 @@ import pytest from docx.oxml.parts.styles import CT_Styles -from docx.parts.styles import StylesPart, _Styles +from docx.parts.styles import StylesPart +from docx.styles.styles import Styles -from ..oxml.unitdata.styles import a_style, a_styles from ..unitutil.mock import class_mock, instance_mock class DescribeStylesPart(object): - def it_provides_access_to_the_styles(self, styles_fixture): - styles_part, _Styles_, styles_elm_, styles_ = styles_fixture + def it_provides_access_to_its_styles(self, styles_fixture): + styles_part, Styles_, styles_ = styles_fixture styles = styles_part.styles - _Styles_.assert_called_once_with(styles_elm_) + Styles_.assert_called_once_with(styles_part.element) assert styles is styles_ # fixtures ------------------------------------------------------- @pytest.fixture - def styles_fixture(self, _Styles_, styles_elm_, styles_): + def styles_fixture(self, Styles_, styles_elm_, styles_): styles_part = StylesPart(None, None, styles_elm_, None) - return styles_part, _Styles_, styles_elm_, styles_ + return styles_part, Styles_, styles_ # fixture components --------------------------------------------- @pytest.fixture - def _Styles_(self, request, styles_): + def Styles_(self, request, styles_): return class_mock( - request, 'docx.parts.styles._Styles', return_value=styles_ + request, 'docx.parts.styles.Styles', return_value=styles_ ) @pytest.fixture def styles_(self, request): - return instance_mock(request, _Styles) + return instance_mock(request, Styles) @pytest.fixture def styles_elm_(self, request): return instance_mock(request, CT_Styles) - - -class Describe_Styles(object): - - def it_knows_how_many_styles_it_contains(self, len_fixture): - styles, style_count = len_fixture - assert len(styles) == style_count - - # fixtures ------------------------------------------------------- - - @pytest.fixture(params=[0, 1, 2, 3]) - def len_fixture(self, request): - style_count = request.param - styles_bldr = a_styles().with_nsdecls() - for idx in range(style_count): - styles_bldr.with_child(a_style()) - styles_elm = styles_bldr.element - styles = _Styles(styles_elm) - return styles, style_count From 587a8a319afd840f198ed34c4b66048e0f6a0ee2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 19:08:27 -0800 Subject: [PATCH 082/615] style: remove dead Document.styles_part and tests --- docx/api.py | 14 ---------- docx/parts/styles.py | 8 ------ features/steps/styles.py | 16 +---------- features/sty-get-styles-part.feature | 10 ------- tests/test_api.py | 42 +--------------------------- 5 files changed, 2 insertions(+), 88 deletions(-) delete mode 100644 features/sty-get-styles-part.feature diff --git a/docx/api.py b/docx/api.py index d3541bab9..3adc3622e 100644 --- a/docx/api.py +++ b/docx/api.py @@ -15,7 +15,6 @@ from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT from docx.package import Package from docx.parts.numbering import NumberingPart -from docx.parts.styles import StylesPart from docx.shared import lazyproperty @@ -166,19 +165,6 @@ def styles(self): """ return self._document_part.styles - @lazyproperty - def styles_part(self): - """ - Instance of |StylesPart| for this document. Creates an empty styles - part if one is not present. - """ - try: - return self._document_part.part_related_by(RT.STYLES) - except KeyError: - styles_part = StylesPart.new() - self._document_part.relate_to(styles_part, RT.STYLES) - return styles_part - @property def tables(self): """ diff --git a/docx/parts/styles.py b/docx/parts/styles.py index a0678c4b6..19d45e8f0 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -25,14 +25,6 @@ def default(cls, package): """ raise NotImplementedError - @classmethod - def new(cls): - """ - Return newly created empty styles part, containing only the root - ```` element. - """ - raise NotImplementedError - @property def styles(self): """ diff --git a/features/steps/styles.py b/features/steps/styles.py index dda452b82..652ae132b 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -4,7 +4,7 @@ Step implementations for styles-related features """ -from behave import given, then, when +from behave import given, then from docx import Document from docx.styles.styles import Styles @@ -27,14 +27,6 @@ def given_a_document_having_no_styles_part(context): context.document = Document(docx_path) -# when ==================================================== - -@when('I get the styles part from the document') -def when_get_styles_part_from_document(context): - document = context.document - context.styles_part = document.styles_part - - # then ===================================================== @then('I can access a style by its UI name') @@ -68,9 +60,3 @@ def then_I_can_iterate_over_its_styles(context): @then('len(styles) is {style_count_str}') def then_len_styles_is_style_count(context, style_count_str): assert len(context.document.styles) == int(style_count_str) - - -@then('the styles part has the expected number of style definitions') -def then_styles_part_has_expected_number_of_style_definitions(context): - styles_part = context.styles_part - assert len(styles_part.styles) == 6 diff --git a/features/sty-get-styles-part.feature b/features/sty-get-styles-part.feature deleted file mode 100644 index 0b5ccbea4..000000000 --- a/features/sty-get-styles-part.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Get the document styles part - In order to query and modify styles - As a programmer using the advanced python-docx API - I need access to the styles part of the document - - @wip - Scenario: Get an existing styles part from document - Given a document having a styles part - When I get the styles part from the document - Then the styles part has the expected number of style definitions diff --git a/tests/test_api.py b/tests/test_api.py index c03d63af3..fd375001c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -17,7 +17,6 @@ from docx.package import Package from docx.parts.document import DocumentPart, InlineShapes from docx.parts.numbering import NumberingPart -from docx.parts.styles import StylesPart from docx.section import Section from docx.shape import InlineShape from docx.styles.styles import Styles @@ -134,7 +133,7 @@ def it_can_save_the_package(self, save_fixture): document.save(file_) package_.save.assert_called_once_with(file_) - def it_provides_access_to_the_core_properties(self, core_props_fixture): + def it_provides_access_to_its_core_properties(self, core_props_fixture): document, core_properties_ = core_props_fixture core_properties = document.core_properties assert core_properties is core_properties_ @@ -162,24 +161,6 @@ def it_creates_numbering_part_on_first_access_if_not_present( ) assert numbering_part is numbering_part_ - def it_provides_access_to_the_styles_part(self, styles_part_get_fixture): - document, document_part_, styles_part_ = styles_part_get_fixture - styles_part = document.styles_part - document_part_.part_related_by.assert_called_once_with(RT.STYLES) - assert styles_part is styles_part_ - - def it_creates_styles_part_on_first_access_if_not_present( - self, styles_part_create_fixture): - document, StylesPart_, document_part_, styles_part_ = ( - styles_part_create_fixture - ) - styles_part = document.styles_part - StylesPart_.new.assert_called_once_with() - document_part_.relate_to.assert_called_once_with( - styles_part_, RT.STYLES - ) - assert styles_part is styles_part_ - # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -377,27 +358,6 @@ def start_type_(self, request): def styles_(self, request): return instance_mock(request, Styles) - @pytest.fixture - def StylesPart_(self, request, styles_part_): - StylesPart_ = class_mock(request, 'docx.api.StylesPart') - StylesPart_.new.return_value = styles_part_ - return StylesPart_ - - @pytest.fixture - def styles_part_(self, request): - return instance_mock(request, StylesPart) - - @pytest.fixture - def styles_part_create_fixture( - self, document, StylesPart_, document_part_, styles_part_): - document_part_.part_related_by.side_effect = KeyError - return document, StylesPart_, document_part_, styles_part_ - - @pytest.fixture - def styles_part_get_fixture(self, document, document_part_, styles_part_): - document_part_.part_related_by.return_value = styles_part_ - return document, document_part_, styles_part_ - @pytest.fixture def table_(self, request): return instance_mock(request, Table, style=None) From bb948db06f0d2a43bff03dc60bbc08c36bb115ae Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 22:03:15 -0800 Subject: [PATCH 083/615] style: add Styles.__len__() --- docx/styles/styles.py | 3 +++ tests/styles/__init__.py | 0 tests/styles/test_styles.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/styles/__init__.py create mode 100644 tests/styles/test_styles.py diff --git a/docx/styles/styles.py b/docx/styles/styles.py index fe7850096..78bf55bfe 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -19,3 +19,6 @@ class Styles(ElementProxy): """ __slots__ = () + + def __len__(self): + return len(self._element.style_lst) diff --git a/tests/styles/__init__.py b/tests/styles/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py new file mode 100644 index 000000000..a211c758f --- /dev/null +++ b/tests/styles/test_styles.py @@ -0,0 +1,35 @@ +# encoding: utf-8 + +""" +Test suite for the docx.styles module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.styles.styles import Styles + +from ..unitutil.cxml import element + + +class DescribeStyles(object): + + def it_knows_its_length(self, len_fixture): + styles, expected_value = len_fixture + assert len(styles) == expected_value + + # fixture -------------------------------------------------------- + + @pytest.fixture(params=[ + ('w:styles', 0), + ('w:styles/w:style', 1), + ('w:styles/(w:style,w:style)', 2), + ('w:styles/(w:style,w:style,w:style)', 3), + ]) + def len_fixture(self, request): + styles_cxml, expected_value = request.param + styles = Styles(element(styles_cxml)) + return styles, expected_value From 371c94f092e186349749f63a543018740c9894e4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 22:39:47 -0800 Subject: [PATCH 084/615] style: add StylesPart.default() --- docx/parts/styles.py | 23 +++- docx/templates/default-styles.xml | 190 ++++++++++++++++++++++++++++++ features/doc-styles.feature | 1 - tests/parts/test_styles.py | 11 ++ 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 docx/templates/default-styles.xml diff --git a/docx/parts/styles.py b/docx/parts/styles.py index 19d45e8f0..00c7cb3c3 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -8,7 +8,12 @@ absolute_import, division, print_function, unicode_literals ) +import os + +from ..opc.constants import CONTENT_TYPE as CT +from ..opc.packuri import PackURI from ..opc.part import XmlPart +from ..oxml import parse_xml from ..styles.styles import Styles @@ -23,7 +28,10 @@ def default(cls, package): Return a newly created styles part, containing a default set of elements. """ - raise NotImplementedError + partname = PackURI('/word/styles.xml') + content_type = CT.WML_STYLES + element = parse_xml(cls._default_styles_xml()) + return cls(partname, content_type, element, package) @property def styles(self): @@ -32,3 +40,16 @@ def styles(self): proxies) for this styles part. """ return Styles(self.element) + + @classmethod + def _default_styles_xml(cls): + """ + Return a bytestream containing XML for a default styles part. + """ + path = os.path.join( + os.path.split(__file__)[0], '..', 'templates', + 'default-styles.xml' + ) + with open(path, 'rb') as f: + xml_bytes = f.read() + return xml_bytes diff --git a/docx/templates/default-styles.xml b/docx/templates/default-styles.xml new file mode 100644 index 000000000..b8b97bc70 --- /dev/null +++ b/docx/templates/default-styles.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/doc-styles.feature b/features/doc-styles.feature index f72f33fa6..47c232e48 100644 --- a/features/doc-styles.feature +++ b/features/doc-styles.feature @@ -4,7 +4,6 @@ Feature: Access document styles I need a way to access document styles - @wip Scenario Outline: Access document styles collection Given a document having Then I can access the document styles collection diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index 857a294b4..5e5f202be 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -8,6 +8,8 @@ import pytest +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.package import OpcPackage from docx.oxml.parts.styles import CT_Styles from docx.parts.styles import StylesPart from docx.styles.styles import Styles @@ -23,6 +25,15 @@ def it_provides_access_to_its_styles(self, styles_fixture): Styles_.assert_called_once_with(styles_part.element) assert styles is styles_ + def it_can_construct_a_default_styles_part_to_help(self): + package = OpcPackage() + styles_part = StylesPart.default(package) + assert isinstance(styles_part, StylesPart) + assert styles_part.partname == '/word/styles.xml' + assert styles_part.content_type == CT.WML_STYLES + assert styles_part.package is package + assert len(styles_part.element) == 6 + # fixtures ------------------------------------------------------- @pytest.fixture From d76d47c4255849a5b4115f998cfb36958c9caa1f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 23:56:08 -0800 Subject: [PATCH 085/615] style: add Styles.__iter__() --- docx/styles/style.py | 8 ++++++++ docx/styles/styles.py | 4 ++++ tests/styles/test_styles.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/docx/styles/style.py b/docx/styles/style.py index fe84c8f35..f96289317 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -11,6 +11,14 @@ from ..shared import ElementProxy +def StyleFactory(style_elm): + """ + Return a style object of the appropriate |_BaseStyle| subclass, according + to it style type. + """ + raise NotImplementedError + + class BaseStyle(ElementProxy): """ Base class for the various types of style object, paragraph, character, diff --git a/docx/styles/styles.py b/docx/styles/styles.py index 78bf55bfe..5f452902a 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -9,6 +9,7 @@ ) from ..shared import ElementProxy +from .style import StyleFactory class Styles(ElementProxy): @@ -20,5 +21,8 @@ class Styles(ElementProxy): __slots__ = () + def __iter__(self): + return (StyleFactory(style) for style in self._element.style_lst) + def __len__(self): return len(self._element.style_lst) diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index a211c758f..cc429ef71 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -10,9 +10,11 @@ import pytest +from docx.styles.style import BaseStyle from docx.styles.styles import Styles from ..unitutil.cxml import element +from ..unitutil.mock import call, function_mock, instance_mock class DescribeStyles(object): @@ -21,8 +23,33 @@ def it_knows_its_length(self, len_fixture): styles, expected_value = len_fixture assert len(styles) == expected_value + def it_can_iterate_over_its_styles(self, iter_fixture): + styles, expected_count, style_, StyleFactory_, expected_calls = ( + iter_fixture + ) + count = 0 + for style in styles: + assert style is style_ + count += 1 + assert count == expected_count + assert StyleFactory_.call_args_list == expected_calls + # fixture -------------------------------------------------------- + @pytest.fixture(params=[ + ('w:styles', 0), + ('w:styles/w:style', 1), + ('w:styles/(w:style,w:style)', 2), + ('w:styles/(w:style,w:style,w:style)', 3), + ]) + def iter_fixture(self, request, StyleFactory_, style_): + styles_cxml, expected_count = request.param + styles_elm = element(styles_cxml) + styles = Styles(styles_elm) + expected_calls = [call(style_elm) for style_elm in styles_elm] + StyleFactory_.return_value = style_ + return styles, expected_count, style_, StyleFactory_, expected_calls + @pytest.fixture(params=[ ('w:styles', 0), ('w:styles/w:style', 1), @@ -33,3 +60,13 @@ def len_fixture(self, request): styles_cxml, expected_value = request.param styles = Styles(element(styles_cxml)) return styles, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def style_(self, request): + return instance_mock(request, BaseStyle) + + @pytest.fixture + def StyleFactory_(self, request): + return function_mock(request, 'docx.styles.styles.StyleFactory') From 11f5f839c6c870a6e1442f848dee7ae7455ab205 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 02:40:19 -0800 Subject: [PATCH 086/615] style: add StyleFactory() * refactor xmlchemy declarations in CT_Style --- docx/oxml/parts/styles.py | 18 ++++++-- docx/styles/style.py | 46 +++++++++++++++++-- tests/styles/test_style.py | 92 +++++++++++++++++++++++++++++++++++++ tests/styles/test_styles.py | 2 +- 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 tests/styles/test_style.py diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 7fea25a01..6abbee45b 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,16 +4,26 @@ Custom element classes related to the styles part """ -from ..xmlchemy import BaseOxmlElement, ZeroOrMore, ZeroOrOne +from ...enum.style import WD_STYLE_TYPE +from ..xmlchemy import ( + BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne +) class CT_Style(BaseOxmlElement): """ A ```` element, representing a style definition """ - pPr = ZeroOrOne('w:pPr', successors=( - 'w:rPr', 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' - )) + _tag_seq = ( + 'w:name', 'w:aliases', 'w:basedOn', 'w:next', 'w:link', + 'w:autoRedefine', 'w:hidden', 'w:uiPriority', 'w:semiHidden', + 'w:unhideWhenUsed', 'w:qFormat', 'w:locked', 'w:personal', + 'w:personalCompose', 'w:personalReply', 'w:rsid', 'w:pPr', 'w:rPr', + 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' + ) + pPr = ZeroOrOne('w:pPr', successors=_tag_seq[17:]) + type = OptionalAttribute('w:type', WD_STYLE_TYPE) + del _tag_seq class CT_Styles(BaseOxmlElement): diff --git a/docx/styles/style.py b/docx/styles/style.py index f96289317..bcfee051b 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -8,15 +8,23 @@ absolute_import, division, print_function, unicode_literals ) +from ..enum.style import WD_STYLE_TYPE from ..shared import ElementProxy def StyleFactory(style_elm): """ - Return a style object of the appropriate |_BaseStyle| subclass, according - to it style type. + Return a style object of the appropriate |BaseStyle| subclass, according + to the type of *style_elm*. """ - raise NotImplementedError + style_cls = { + WD_STYLE_TYPE.PARAGRAPH: _ParagraphStyle, + WD_STYLE_TYPE.CHARACTER: _CharacterStyle, + WD_STYLE_TYPE.TABLE: _TableStyle, + WD_STYLE_TYPE.LIST: _NumberingStyle + }[style_elm.type] + + return style_cls(style_elm) class BaseStyle(ElementProxy): @@ -26,3 +34,35 @@ class BaseStyle(ElementProxy): """ __slots__ = () + + +class _CharacterStyle(BaseStyle): + """ + A character style. + """ + + __slots__ = () + + +class _ParagraphStyle(_CharacterStyle): + """ + A paragraph style. + """ + + __slots__ = () + + +class _TableStyle(_ParagraphStyle): + """ + A table style. + """ + + __slots__ = () + + +class _NumberingStyle(BaseStyle): + """ + A numbering style. + """ + + __slots__ = () diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py new file mode 100644 index 000000000..76e5ed95f --- /dev/null +++ b/tests/styles/test_style.py @@ -0,0 +1,92 @@ +# encoding: utf-8 + +""" +Test suite for the docx.styles.style module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.styles.style import ( + _CharacterStyle, _ParagraphStyle, _NumberingStyle, StyleFactory, + _TableStyle +) + +from ..unitutil.cxml import element +from ..unitutil.mock import class_mock, instance_mock + + +class DescribeStyleFactory(object): + + def it_constructs_the_right_type_of_style(self, factory_fixture): + style_elm, StyleCls_, style_ = factory_fixture + style = StyleFactory(style_elm) + StyleCls_.assert_called_once_with(style_elm) + assert style is style_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=['paragraph', 'character', 'table', 'numbering']) + def factory_fixture( + self, request, paragraph_style_, _ParagraphStyle_, + character_style_, _CharacterStyle_, table_style_, _TableStyle_, + numbering_style_, _NumberingStyle_): + type_attr_val = request.param + StyleCls_, style_mock = { + 'paragraph': (_ParagraphStyle_, paragraph_style_), + 'character': (_CharacterStyle_, character_style_), + 'table': (_TableStyle_, table_style_), + 'numbering': (_NumberingStyle_, numbering_style_), + }[request.param] + style_cxml = 'w:style{w:type=%s}' % type_attr_val + style_elm = element(style_cxml) + return style_elm, StyleCls_, style_mock + + # fixture components ----------------------------------- + + @pytest.fixture + def _ParagraphStyle_(self, request, paragraph_style_): + return class_mock( + request, 'docx.styles.style._ParagraphStyle', + return_value=paragraph_style_ + ) + + @pytest.fixture + def paragraph_style_(self, request): + return instance_mock(request, _ParagraphStyle) + + @pytest.fixture + def _CharacterStyle_(self, request, character_style_): + return class_mock( + request, 'docx.styles.style._CharacterStyle', + return_value=character_style_ + ) + + @pytest.fixture + def character_style_(self, request): + return instance_mock(request, _CharacterStyle) + + @pytest.fixture + def _TableStyle_(self, request, table_style_): + return class_mock( + request, 'docx.styles.style._TableStyle', + return_value=table_style_ + ) + + @pytest.fixture + def table_style_(self, request): + return instance_mock(request, _TableStyle) + + @pytest.fixture + def _NumberingStyle_(self, request, numbering_style_): + return class_mock( + request, 'docx.styles.style._NumberingStyle', + return_value=numbering_style_ + ) + + @pytest.fixture + def numbering_style_(self, request): + return instance_mock(request, _NumberingStyle) diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index cc429ef71..3ba3e256f 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -1,7 +1,7 @@ # encoding: utf-8 """ -Test suite for the docx.styles module +Test suite for the docx.styles.styles module """ from __future__ import ( From 7fc1c2cf08932c723adb9a21eb038395c186d77b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 23:58:01 -0800 Subject: [PATCH 087/615] style: add Styles.__getitem__() --- docx/oxml/parts/styles.py | 19 +++++++++++---- docx/styles/styles.py | 10 ++++++++ features/doc-styles.feature | 1 - tests/styles/test_styles.py | 48 +++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 6abbee45b..80a8a2921 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -33,13 +33,24 @@ class CT_Styles(BaseOxmlElement): """ style = ZeroOrMore('w:style', successors=()) - def style_having_styleId(self, styleId): + def get_by_id(self, styleId): """ Return the ```` child element having ``styleId`` attribute - matching *styleId*. + matching *styleId*, or |None| if not found. """ - xpath = './w:style[@w:styleId="%s"]' % styleId + xpath = 'w:style[@w:styleId="%s"]' % styleId try: return self.xpath(xpath)[0] except IndexError: - raise KeyError('no element with styleId %s' % styleId) + return None + + def get_by_name(self, name): + """ + Return the ```` child element having ```` child + element with value *name*, or |None| if not found. + """ + xpath = 'w:style[w:name/@w:val="%s"]' % name + try: + return self.xpath(xpath)[0] + except IndexError: + return None diff --git a/docx/styles/styles.py b/docx/styles/styles.py index 5f452902a..1f5805756 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -21,6 +21,16 @@ class Styles(ElementProxy): __slots__ = () + def __getitem__(self, key): + """ + Enables dictionary-style access by style id or UI name. + """ + for get in (self._element.get_by_id, self._element.get_by_name): + style_elm = get(key) + if style_elm is not None: + return StyleFactory(style_elm) + raise KeyError("no style with id or name '%s'" % key) + def __iter__(self): return (StyleFactory(style) for style in self._element.style_lst) diff --git a/features/doc-styles.feature b/features/doc-styles.feature index 47c232e48..dafe3bce7 100644 --- a/features/doc-styles.feature +++ b/features/doc-styles.feature @@ -15,7 +15,6 @@ Feature: Access document styles | no styles part | 4 | - @wip Scenario: Access style in style collection Given a document having a styles part Then I can iterate over its styles diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index 3ba3e256f..2d8c8f977 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -34,8 +34,56 @@ def it_can_iterate_over_its_styles(self, iter_fixture): assert count == expected_count assert StyleFactory_.call_args_list == expected_calls + def it_can_get_a_style_by_id(self, get_by_id_fixture): + styles, key, expected_element = get_by_id_fixture + style = styles[key] + assert style._element is expected_element + + def it_can_get_a_style_by_name(self, get_by_name_fixture): + styles, key, expected_element = get_by_name_fixture + style = styles[key] + assert style._element is expected_element + + def it_raises_on_style_not_found(self, get_raises_fixture): + styles, key = get_raises_fixture + with pytest.raises(KeyError): + styles[key] + # fixture -------------------------------------------------------- + @pytest.fixture(params=[ + ('w:styles/(w:style{%s,w:styleId=Foobar},w:style,w:style)', 0), + ('w:styles/(w:style,w:style{%s,w:styleId=Foobar},w:style)', 1), + ('w:styles/(w:style,w:style,w:style{%s,w:styleId=Foobar})', 2), + ]) + def get_by_id_fixture(self, request): + styles_cxml_tmpl, style_idx = request.param + styles_cxml = styles_cxml_tmpl % 'w:type=paragraph' + styles = Styles(element(styles_cxml)) + expected_element = styles._element[style_idx] + return styles, 'Foobar', expected_element + + @pytest.fixture(params=[ + ('w:styles/(w:style%s/w:name{w:val=foo},w:style,w:style)', 0), + ('w:styles/(w:style,w:style%s/w:name{w:val=foo},w:style)', 1), + ('w:styles/(w:style,w:style,w:style%s/w:name{w:val=foo})', 2), + ]) + def get_by_name_fixture(self, request): + styles_cxml_tmpl, style_idx = request.param + styles_cxml = styles_cxml_tmpl % '{w:type=character}' + styles = Styles(element(styles_cxml)) + expected_element = styles._element[style_idx] + return styles, 'foo', expected_element + + @pytest.fixture(params=[ + ('w:styles/(w:style,w:style/w:name{w:val=foo},w:style)'), + ('w:styles/(w:style{w:styleId=foo},w:style,w:style)'), + ]) + def get_raises_fixture(self, request): + styles_cxml = request.param + styles = Styles(element(styles_cxml)) + return styles, 'bar' + @pytest.fixture(params=[ ('w:styles', 0), ('w:styles/w:style', 1), From 6f0bc1ff4964ecd8e31f5a226ccb772b7f9abdd9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:27:58 -0800 Subject: [PATCH 088/615] acpt: add scenarios for Style.style_id --- features/steps/styles.py | 26 +++++++++++++++++++++++++- features/sty-style-props.feature | 17 +++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 features/sty-style-props.feature diff --git a/features/steps/styles.py b/features/steps/styles.py index 652ae132b..9d653bc3e 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -4,7 +4,7 @@ Step implementations for styles-related features """ -from behave import given, then +from behave import given, then, when from docx import Document from docx.styles.styles import Styles @@ -27,6 +27,20 @@ def given_a_document_having_no_styles_part(context): context.document = Document(docx_path) +@given('a style having a known style id') +def given_a_style_having_a_known_style_id(context): + docx_path = test_docx('sty-having-styles-part') + document = Document(docx_path) + context.style = document.styles['Normal'] + + +# when ===================================================== + +@when('I assign a new value to style.style_id') +def when_I_assign_a_new_value_to_style_style_id(context): + context.style.style_id = 'Foo42' + + # then ===================================================== @then('I can access a style by its UI name') @@ -60,3 +74,13 @@ def then_I_can_iterate_over_its_styles(context): @then('len(styles) is {style_count_str}') def then_len_styles_is_style_count(context, style_count_str): assert len(context.document.styles) == int(style_count_str) + + +@then('style.style_id is the {which} style id') +def then_style_style_id_is_the_which_style_id(context, which): + expected_style_id = { + 'known': 'Normal', + 'new': 'Foo42', + }[which] + style = context.style + assert style.style_id == expected_style_id diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature new file mode 100644 index 000000000..76d142fb6 --- /dev/null +++ b/features/sty-style-props.feature @@ -0,0 +1,17 @@ +Feature: Get and set style properties + In order to adjust styles to suit my needs + As a developer using python-docx + I need a set of read/write style properties + + + @wip + Scenario: Get style id + Given a style having a known style id + Then style.style_id is the known style id + + + @wip + Scenario: Set style id + Given a style having a known style id + When I assign a new value to style.style_id + Then style.style_id is the new style id From 4d196dd68644c748d6f96824aaec4ef2d76829ec Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:48:41 -0800 Subject: [PATCH 089/615] style: add _Style.style_id getter --- docx/oxml/parts/styles.py | 2 ++ docx/styles/style.py | 7 +++++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 22 ++++++++++++++++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 80a8a2921..3c6fee6ed 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -5,6 +5,7 @@ """ from ...enum.style import WD_STYLE_TYPE +from ..simpletypes import ST_String from ..xmlchemy import ( BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne ) @@ -23,6 +24,7 @@ class CT_Style(BaseOxmlElement): ) pPr = ZeroOrOne('w:pPr', successors=_tag_seq[17:]) type = OptionalAttribute('w:type', WD_STYLE_TYPE) + styleId = OptionalAttribute('w:styleId', ST_String) del _tag_seq diff --git a/docx/styles/style.py b/docx/styles/style.py index bcfee051b..d51241ce0 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -35,6 +35,13 @@ class BaseStyle(ElementProxy): __slots__ = () + @property + def style_id(self): + """ + The unique key name (string) for this style. + """ + return self._element.styleId + class _CharacterStyle(BaseStyle): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index 76d142fb6..e3e087b3c 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -4,7 +4,6 @@ Feature: Get and set style properties I need a set of read/write style properties - @wip Scenario: Get style id Given a style having a known style id Then style.style_id is the known style id diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 76e5ed95f..8f5b01c0a 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -11,8 +11,8 @@ import pytest from docx.styles.style import ( - _CharacterStyle, _ParagraphStyle, _NumberingStyle, StyleFactory, - _TableStyle + BaseStyle, _CharacterStyle, _ParagraphStyle, _NumberingStyle, + StyleFactory, _TableStyle ) from ..unitutil.cxml import element @@ -90,3 +90,21 @@ def _NumberingStyle_(self, request, numbering_style_): @pytest.fixture def numbering_style_(self, request): return instance_mock(request, _NumberingStyle) + + +class DescribeBaseStyle(object): + + def it_knows_its_style_id(self, id_get_fixture): + style, expected_value = id_get_fixture + assert style.style_id == expected_value + + # fixture -------------------------------------------------------- + + @pytest.fixture(params=[ + ('w:style', None), + ('w:style{w:styleId=Foobar}', 'Foobar'), + ]) + def id_get_fixture(self, request): + style_cxml, expected_value = request.param + style = BaseStyle(element(style_cxml)) + return style, expected_value From fbc93af1f0b5d1d9911809d85a7296c47e322543 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:57:36 -0800 Subject: [PATCH 090/615] style: add _Style.style_id setter --- docx/styles/style.py | 4 ++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docx/styles/style.py b/docx/styles/style.py index d51241ce0..8a721fa0b 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -42,6 +42,10 @@ def style_id(self): """ return self._element.styleId + @style_id.setter + def style_id(self, value): + self._element.styleId = value + class _CharacterStyle(BaseStyle): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index e3e087b3c..eb7498556 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -9,7 +9,6 @@ Feature: Get and set style properties Then style.style_id is the known style id - @wip Scenario: Set style id Given a style having a known style id When I assign a new value to style.style_id diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 8f5b01c0a..6beb672ce 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -15,7 +15,7 @@ StyleFactory, _TableStyle ) -from ..unitutil.cxml import element +from ..unitutil.cxml import element, xml from ..unitutil.mock import class_mock, instance_mock @@ -98,6 +98,11 @@ def it_knows_its_style_id(self, id_get_fixture): style, expected_value = id_get_fixture assert style.style_id == expected_value + def it_can_change_its_style_id(self, id_set_fixture): + style, new_value, expected_xml = id_set_fixture + style.style_id = new_value + assert style._element.xml == expected_xml + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -108,3 +113,15 @@ def id_get_fixture(self, request): style_cxml, expected_value = request.param style = BaseStyle(element(style_cxml)) return style, expected_value + + @pytest.fixture(params=[ + ('w:style', 'Foo', 'w:style{w:styleId=Foo}'), + ('w:style{w:styleId=Foo}', 'Bar', 'w:style{w:styleId=Bar}'), + ('w:style{w:styleId=Bar}', None, 'w:style'), + ('w:style', None, 'w:style'), + ]) + def id_set_fixture(self, request): + style_cxml, new_value, expected_style_cxml = request.param + style = BaseStyle(element(style_cxml)) + expected_xml = xml(expected_style_cxml) + return style, new_value, expected_xml From c4c978aa08fe12a98b4eac576adb8e0ec754f0c9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 02:13:18 -0800 Subject: [PATCH 091/615] acpt: add scenario for _Style.type --- features/steps/styles.py | 14 ++++++++++++++ features/sty-style-props.feature | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/features/steps/styles.py b/features/steps/styles.py index 9d653bc3e..adb18ea7e 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -7,6 +7,7 @@ from behave import given, then, when from docx import Document +from docx.enum.style import WD_STYLE_TYPE from docx.styles.styles import Styles from docx.styles.style import BaseStyle @@ -34,6 +35,13 @@ def given_a_style_having_a_known_style_id(context): context.style = document.styles['Normal'] +@given('a style having a known type') +def given_a_style_having_a_known_type(context): + docx_path = test_docx('sty-having-styles-part') + document = Document(docx_path) + context.style = document.styles['Normal'] + + # when ===================================================== @when('I assign a new value to style.style_id') @@ -84,3 +92,9 @@ def then_style_style_id_is_the_which_style_id(context, which): }[which] style = context.style assert style.style_id == expected_style_id + + +@then('style.type is the known type') +def then_style_type_is_the_known_type(context): + style = context.style + assert style.type == WD_STYLE_TYPE.PARAGRAPH diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index eb7498556..b4da5c054 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -13,3 +13,9 @@ Feature: Get and set style properties Given a style having a known style id When I assign a new value to style.style_id Then style.style_id is the new style id + + + @wip + Scenario: Get style type + Given a style having a known type + Then style.type is the known type From ba9c2a29eee2ad06ec8925d69c8cc7c504db7f2b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 02:32:01 -0800 Subject: [PATCH 092/615] style: add _Style.type getter --- docx/styles/style.py | 11 +++++++++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docx/styles/style.py b/docx/styles/style.py index 8a721fa0b..bb857e05d 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -46,6 +46,17 @@ def style_id(self): def style_id(self, value): self._element.styleId = value + @property + def type(self): + """ + Member of :ref:`WdStyleType` corresponding to the type of this style, + e.g. ``WD_STYLE_TYPE.PARAGRAPH`. + """ + type = self._element.type + if type is None: + return WD_STYLE_TYPE.PARAGRAPH + return type + class _CharacterStyle(BaseStyle): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index b4da5c054..84952efef 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -15,7 +15,6 @@ Feature: Get and set style properties Then style.style_id is the new style id - @wip Scenario: Get style type Given a style having a known type Then style.type is the known type diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 6beb672ce..3e9ef350d 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -10,6 +10,7 @@ import pytest +from docx.enum.style import WD_STYLE_TYPE from docx.styles.style import ( BaseStyle, _CharacterStyle, _ParagraphStyle, _NumberingStyle, StyleFactory, _TableStyle @@ -103,6 +104,10 @@ def it_can_change_its_style_id(self, id_set_fixture): style.style_id = new_value assert style._element.xml == expected_xml + def it_knows_its_type(self, type_get_fixture): + style, expected_value = type_get_fixture + assert style.type == expected_value + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -125,3 +130,14 @@ def id_set_fixture(self, request): style = BaseStyle(element(style_cxml)) expected_xml = xml(expected_style_cxml) return style, new_value, expected_xml + + @pytest.fixture(params=[ + ('w:style', WD_STYLE_TYPE.PARAGRAPH), + ('w:style{w:type=paragraph}', WD_STYLE_TYPE.PARAGRAPH), + ('w:style{w:type=character}', WD_STYLE_TYPE.CHARACTER), + ('w:style{w:type=numbering}', WD_STYLE_TYPE.LIST), + ]) + def type_get_fixture(self, request): + style_cxml, expected_value = request.param + style = BaseStyle(element(style_cxml)) + return style, expected_value From 21cd98ccb119ba363e56fce11d55c1f9315ceacc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 02:37:56 -0800 Subject: [PATCH 093/615] acpt: add scenarios for _Style.name --- features/steps/styles.py | 24 ++++++++++++++++-------- features/sty-style-props.feature | 13 +++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/features/steps/styles.py b/features/steps/styles.py index adb18ea7e..4730777ff 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -28,21 +28,19 @@ def given_a_document_having_no_styles_part(context): context.document = Document(docx_path) -@given('a style having a known style id') -def given_a_style_having_a_known_style_id(context): +@given('a style having a known {attr_name}') +def given_a_style_having_a_known_attr_name(context, attr_name): docx_path = test_docx('sty-having-styles-part') document = Document(docx_path) context.style = document.styles['Normal'] -@given('a style having a known type') -def given_a_style_having_a_known_type(context): - docx_path = test_docx('sty-having-styles-part') - document = Document(docx_path) - context.style = document.styles['Normal'] +# when ===================================================== +@when('I assign a new name to the style') +def when_I_assign_a_new_name_to_the_style(context): + context.style.name = 'Foobar' -# when ===================================================== @when('I assign a new value to style.style_id') def when_I_assign_a_new_value_to_style_style_id(context): @@ -84,6 +82,16 @@ def then_len_styles_is_style_count(context, style_count_str): assert len(context.document.styles) == int(style_count_str) +@then('style.name is the {which} name') +def then_style_name_is_the_which_name(context, which): + expected_name = { + 'known': 'Normal', + 'new': 'Foobar', + }[which] + style = context.style + assert style.name == expected_name + + @then('style.style_id is the {which} style id') def then_style_style_id_is_the_which_style_id(context, which): expected_style_id = { diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index 84952efef..2d7da456c 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -4,6 +4,19 @@ Feature: Get and set style properties I need a set of read/write style properties + @wip + Scenario: Get name + Given a style having a known name + Then style.name is the known name + + + @wip + Scenario: Set name + Given a style having a known name + When I assign a new name to the style + Then style.name is the new name + + Scenario: Get style id Given a style having a known style id Then style.style_id is the known style id From 50af0f6d8f2ca90af27ac37f903af506407798ad Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 03:02:40 -0800 Subject: [PATCH 094/615] style: add _Style.name getter --- docx/oxml/__init__.py | 1 + docx/oxml/parts/styles.py | 11 +++++++++++ docx/styles/style.py | 7 +++++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 13 +++++++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 8b2d4a76e..284557969 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -87,6 +87,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:startOverride', CT_DecimalNumber) from .parts.styles import CT_Style, CT_Styles +register_element_cls('w:name', CT_String) register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 3c6fee6ed..8c483fa5f 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -22,11 +22,22 @@ class CT_Style(BaseOxmlElement): 'w:personalCompose', 'w:personalReply', 'w:rsid', 'w:pPr', 'w:rPr', 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' ) + name = ZeroOrOne('w:name', successors=_tag_seq[1:]) pPr = ZeroOrOne('w:pPr', successors=_tag_seq[17:]) type = OptionalAttribute('w:type', WD_STYLE_TYPE) styleId = OptionalAttribute('w:styleId', ST_String) del _tag_seq + @property + def name_val(self): + """ + Value of ```` child or |None| if not present. + """ + name = self.name + if name is None: + return None + return name.val + class CT_Styles(BaseOxmlElement): """ diff --git a/docx/styles/style.py b/docx/styles/style.py index bb857e05d..ba046b6da 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -35,6 +35,13 @@ class BaseStyle(ElementProxy): __slots__ = () + @property + def name(self): + """ + The UI name of this style. + """ + return self._element.name_val + @property def style_id(self): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index 2d7da456c..c8bf6a392 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -4,7 +4,6 @@ Feature: Get and set style properties I need a set of read/write style properties - @wip Scenario: Get name Given a style having a known name Then style.name is the known name diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 3e9ef350d..082b51004 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -108,6 +108,10 @@ def it_knows_its_type(self, type_get_fixture): style, expected_value = type_get_fixture assert style.type == expected_value + def it_knows_its_name(self, name_get_fixture): + style, expected_value = name_get_fixture + assert style.name == expected_value + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -131,6 +135,15 @@ def id_set_fixture(self, request): expected_xml = xml(expected_style_cxml) return style, new_value, expected_xml + @pytest.fixture(params=[ + ('w:style{w:type=table}', None), + ('w:style{w:type=table}/w:name{w:val=Boofar}', 'Boofar'), + ]) + def name_get_fixture(self, request): + style_cxml, expected_value = request.param + style = BaseStyle(element(style_cxml)) + return style, expected_value + @pytest.fixture(params=[ ('w:style', WD_STYLE_TYPE.PARAGRAPH), ('w:style{w:type=paragraph}', WD_STYLE_TYPE.PARAGRAPH), From f945031c4de9440fabe417d1cfe4a563437d9f4e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 03:17:08 -0800 Subject: [PATCH 095/615] style: add _Style.name setter --- docx/oxml/parts/styles.py | 7 +++++++ docx/styles/style.py | 4 ++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 8c483fa5f..9da7e15b0 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -38,6 +38,13 @@ def name_val(self): return None return name.val + @name_val.setter + def name_val(self, value): + self._remove_name() + if value is not None: + name = self._add_name() + name.val = value + class CT_Styles(BaseOxmlElement): """ diff --git a/docx/styles/style.py b/docx/styles/style.py index ba046b6da..96ac14267 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -42,6 +42,10 @@ def name(self): """ return self._element.name_val + @name.setter + def name(self, value): + self._element.name_val = value + @property def style_id(self): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index c8bf6a392..29b8b6a70 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -9,7 +9,6 @@ Feature: Get and set style properties Then style.name is the known name - @wip Scenario: Set name Given a style having a known name When I assign a new name to the style diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 082b51004..a6c9ae7ce 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -112,6 +112,11 @@ def it_knows_its_name(self, name_get_fixture): style, expected_value = name_get_fixture assert style.name == expected_value + def it_can_change_its_name(self, name_set_fixture): + style, new_value, expected_xml = name_set_fixture + style.name = new_value + assert style._element.xml == expected_xml + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -144,6 +149,17 @@ def name_get_fixture(self, request): style = BaseStyle(element(style_cxml)) return style, expected_value + @pytest.fixture(params=[ + ('w:style', 'Foo', 'w:style/w:name{w:val=Foo}'), + ('w:style/w:name{w:val=Foo}', 'Bar', 'w:style/w:name{w:val=Bar}'), + ('w:style/w:name{w:val=Bar}', None, 'w:style'), + ]) + def name_set_fixture(self, request): + style_cxml, new_value, expected_style_cxml = request.param + style = BaseStyle(element(style_cxml)) + expected_xml = xml(expected_style_cxml) + return style, new_value, expected_xml + @pytest.fixture(params=[ ('w:style', WD_STYLE_TYPE.PARAGRAPH), ('w:style{w:type=paragraph}', WD_STYLE_TYPE.PARAGRAPH), From d0968211f12b39eb255707868ea2f50a158c3c07 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 17:32:18 -0800 Subject: [PATCH 096/615] acpt: elaborate Document.add_paragraph() scenario Add case for specifying style as style object. --- features/api-add-paragraph.feature | 13 +++++++++++-- features/steps/api.py | 21 ++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/features/api-add-paragraph.feature b/features/api-add-paragraph.feature index 5ca5e8367..f99542f0b 100644 --- a/features/api-add-paragraph.feature +++ b/features/api-add-paragraph.feature @@ -3,17 +3,26 @@ Feature: Add a paragraph with optional text and style As a programmer using the basic python-docx API I want to add a styled paragraph of text in a single step + Scenario: Add an empty paragraph Given a document When I add a paragraph without specifying text or style Then the last paragraph is the empty paragraph I added + Scenario: Add a paragraph specifying its text Given a document When I add a paragraph specifying its text Then the last paragraph contains the text I specified - Scenario: Add a paragraph specifying its style + + @wip + Scenario Outline: Add a paragraph specifying its style Given a document - When I add a paragraph specifying its style + When I add a paragraph specifying its style as a Then the last paragraph has the style I specified + + Examples: ways of specifying a style + | style-spec | + | style object | + | style name | diff --git a/features/steps/api.py b/features/steps/api.py index 96363b0a4..3f370d0fa 100644 --- a/features/steps/api.py +++ b/features/steps/api.py @@ -46,11 +46,15 @@ def when_add_page_break_to_document(context): document.add_page_break() -@when('I add a paragraph specifying its style') -def when_add_paragraph_specifying_style(context): +@when('I add a paragraph specifying its style as a {kind}') +def when_I_add_a_paragraph_specifying_its_style_as_a(context, kind): document = context.document - context.paragraph_style = 'barfoo' - document.add_paragraph(style=context.paragraph_style) + style = context.style = document.styles['Heading 1'] + style_spec = { + 'style object': style, + 'style name': 'Heading 1', + }[kind] + document.add_paragraph(style=style_spec) @when('I add a paragraph specifying its text') @@ -135,11 +139,10 @@ def then_last_p_contains_specified_text(context): @then('the last paragraph has the style I specified') -def then_last_p_has_specified_style(context): - document = context.document - style = context.paragraph_style - p = document.paragraphs[-1] - assert p.style == style +def then_the_last_paragraph_has_the_style_I_specified(context): + document, expected_style = context.document, context.style + paragraph = document.paragraphs[-1] + assert paragraph.style == expected_style @then('the last paragraph is the empty paragraph I added') From 884e4f17916b7c6e3ca6469f656da83956b319df Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 18:01:20 -0800 Subject: [PATCH 097/615] style: add _Styles._translate_special_case_names() * elaborate unit test to exercise new helper method * elaborate cxml grammar to allow spaces in attribute values --- docx/styles/styles.py | 22 ++++++++++++++++++++++ tests/styles/test_styles.py | 10 +++++----- tests/unitutil/cxml.py | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docx/styles/styles.py b/docx/styles/styles.py index 1f5805756..c7b45c567 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -25,6 +25,7 @@ def __getitem__(self, key): """ Enables dictionary-style access by style id or UI name. """ + key = self._translate_special_case_names(key) for get in (self._element.get_by_id, self._element.get_by_name): style_elm = get(key) if style_elm is not None: @@ -36,3 +37,24 @@ def __iter__(self): def __len__(self): return len(self._element.style_lst) + + @staticmethod + def _translate_special_case_names(name): + """ + Translate special-case style names from their English UI + counterparts. Some style names are stored differently than they + appear in the UI, with a leading lowercase letter, perhaps for legacy + reasons. + """ + return { + 'Caption': 'caption', + 'Heading 1': 'heading 1', + 'Heading 2': 'heading 2', + 'Heading 3': 'heading 3', + 'Heading 4': 'heading 4', + 'Heading 5': 'heading 5', + 'Heading 6': 'heading 6', + 'Heading 7': 'heading 7', + 'Heading 8': 'heading 8', + 'Heading 9': 'heading 9', + }.get(name, name) diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index 2d8c8f977..27c5adef5 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -64,16 +64,16 @@ def get_by_id_fixture(self, request): return styles, 'Foobar', expected_element @pytest.fixture(params=[ - ('w:styles/(w:style%s/w:name{w:val=foo},w:style,w:style)', 0), - ('w:styles/(w:style,w:style%s/w:name{w:val=foo},w:style)', 1), - ('w:styles/(w:style,w:style,w:style%s/w:name{w:val=foo})', 2), + ('w:styles/(w:style%s/w:name{w:val=foo},w:style)', 'foo', 0), + ('w:styles/(w:style,w:style%s/w:name{w:val=foo})', 'foo', 1), + ('w:styles/w:style%s/w:name{w:val=heading 1}', 'Heading 1', 0), ]) def get_by_name_fixture(self, request): - styles_cxml_tmpl, style_idx = request.param + styles_cxml_tmpl, key, style_idx = request.param styles_cxml = styles_cxml_tmpl % '{w:type=character}' styles = Styles(element(styles_cxml)) expected_element = styles._element[style_idx] - return styles, 'foo', expected_element + return styles, key, expected_element @pytest.fixture(params=[ ('w:styles/(w:style,w:style/w:name{w:val=foo},w:style)'), diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py index c66bc0091..6bf0ce3f3 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -228,7 +228,7 @@ def grammar(): # np:attr_name=attr_val ---------------------- attr_name = Word(alphas + ':') - attr_val = Word(alphanums + '-.%') + attr_val = Word(alphanums + ' -.%') attr_def = Group(attr_name + equal + attr_val) attr_list = open_brace + delimitedList(attr_def) + close_brace From 06ead3c947ae74f81f582844a97e18245360aa07 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 20:54:23 -0800 Subject: [PATCH 098/615] acpt: update scenario for Paragraph.style --- features/par-set-style.feature | 10 ---- features/par-style-prop.feature | 29 +++++++++++ features/steps/paragraph.py | 45 ++++++++++++++---- .../steps/test_files/par-known-styles.docx | Bin 0 -> 20901 bytes 4 files changed, 66 insertions(+), 18 deletions(-) delete mode 100644 features/par-set-style.feature create mode 100644 features/par-style-prop.feature create mode 100644 features/steps/test_files/par-known-styles.docx diff --git a/features/par-set-style.feature b/features/par-set-style.feature deleted file mode 100644 index 9b9f0e90c..000000000 --- a/features/par-set-style.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Each paragraph has a read/write style - In order to use the stylesheet capability built into Word - As a developer using python-docx - I need the ability to get and set the style of a paragraph - - Scenario: Set the style of a paragraph - Given a paragraph - When I set the paragraph style - And I save the document - Then the paragraph has the style I set diff --git a/features/par-style-prop.feature b/features/par-style-prop.feature new file mode 100644 index 000000000..919bd6989 --- /dev/null +++ b/features/par-style-prop.feature @@ -0,0 +1,29 @@ +Feature: Each paragraph has a read/write style + In order to use the stylesheet capability built into Word + As a developer using python-docx + I need the ability to get and set the style of a paragraph + + + @wip + Scenario Outline: Get the style of a paragraph + Given a paragraph having