diff --git a/packtools/sps/formats/pdf/pipeline/docx.py b/packtools/sps/formats/pdf/pipeline/docx.py index c7a9824a1..bed734613 100644 --- a/packtools/sps/formats/pdf/pipeline/docx.py +++ b/packtools/sps/formats/pdf/pipeline/docx.py @@ -1,10 +1,23 @@ -from docx.shared import Pt +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt +from docx.text.paragraph import Paragraph from packtools.sps.formats.pdf import enum as pdf_enum from packtools.sps.formats.pdf.pipeline import xml as xml_pipe from packtools.sps.formats.pdf.renderer import docx as docx_renderer from packtools.sps.formats.pdf.utils import xml_utils +# Share of the first-page header's width given to the journal title column +# (the rest goes to the DOI). Skewed well past an even 50/50: a DOI URL is +# short and fairly constant in length (max ~55 chars across the real test +# corpus), while the journal title uses a much larger masthead font and +# genuinely needs the room - an even split pushed some real journal titles +# (e.g. "Urbe. Revista Brasileira de Gestão Urbana") from 2 to 3 lines for +# no benefit to the DOI, which fits comfortably either way. +_JOURNAL_TITLE_DOI_SPLIT = 0.65 + def pipeline_docx(xml_tree, data): """ @@ -109,18 +122,28 @@ def pipeline_docx(xml_tree, data): def docx_journal_title_pipe(docx, journal_title_text, style_name='SCL Journal Title Char'): """ - Adds the journal title text to the first page header of the DOCX document, with each word on a new line. - + Adds the journal title text to the first page header of the DOCX document, + capped at two lines. + + Written into the left cell of a borderless 2-column table shared with + the DOI (see docx_doi_pipe): giving the DOI its own column, rather than + tab-appending it after this text in the same paragraph, means a long + second line here never leaves the DOI without room to reach the right + margin on that line. + Args: docx (python-docx.Document): The DOCX document object. journal_title_text (str): The text of the journal title to be added. style_name (str, optional): The name of the style to apply to the journal title text. Defaults to 'SCL Journal Title Char'. - + Returns: python-docx.Paragraph: The paragraph object containing the journal title text. """ first_page_header = docx_renderer.section.get_first_page_header(docx) - para = docx_renderer.text.get_first_paragraph(first_page_header) + journal_cell, _doi_cell = _add_two_column_header_table( + first_page_header, left_ratio=_JOURNAL_TITLE_DOI_SPLIT + ) + para = journal_cell.paragraphs[0] left_run = para.add_run(_format_journal_title_two_lines(journal_title_text)) left_run.style = docx.styles[style_name] @@ -129,14 +152,23 @@ def docx_journal_title_pipe(docx, journal_title_text, style_name='SCL Journal Ti def docx_doi_pipe(docx, doi_code, paragraph=None, style_name='SCL Header Paragraph Char'): """ - Adds the DOI (Digital Object Identifier) code to the first page header of the DOCX document, with the DOI URL formatted as a tab-indented string. - + Adds the DOI (Digital Object Identifier) code to the first page header of the DOCX document. + + Written into the right cell of the borderless 2-column table docx_journal_title_pipe + creates (or, if that hasn't run yet, a fresh one of its own), right-aligned within + that cell. Previously the DOI was tab-appended after the journal title in one shared + paragraph; a tab stop only sets where a run *starts*, not where it wraps, so a long + second line of the journal title (see _format_journal_title_two_lines) left no room + on that line for the DOI to reach the right margin, forcing it onto a line of its own + instead of sitting flush right. A separate column has its own width regardless of how + much text is in the journal title's cell. + Args: docx (python-docx.Document): The DOCX document object. doi_code (str): The DOI code to be added. - paragraph (python-docx.Paragraph, optional): The paragraph object to add the DOI URL to. If not provided, the first paragraph in the first page header will be used. + paragraph (python-docx.Paragraph, optional): The paragraph object to add the DOI URL to. If not provided, the DOI cell of the first page header's title table is used (creating that table if docx_journal_title_pipe hasn't run yet). style_name (str, optional): The name of the style to apply to the DOI URL. Defaults to 'SCL Header Paragraph Char'. - + Returns: None """ @@ -146,9 +178,19 @@ def docx_doi_pipe(docx, doi_code, paragraph=None, style_name='SCL Header Paragra para = paragraph else: first_page_header = docx_renderer.section.get_first_page_header(docx) - para = docx_renderer.text.get_first_paragraph(first_page_header) - - r = para.add_run(f'\t{doi_url}') + if first_page_header.tables: + para = first_page_header.tables[-1].rows[0].cells[1].paragraphs[0] + else: + _journal_cell, doi_cell = _add_two_column_header_table( + first_page_header, left_ratio=_JOURNAL_TITLE_DOI_SPLIT + ) + para = doi_cell.paragraphs[0] + + # Right-aligned regardless of which branch supplied `para`: a caller + # passing its own `paragraph` wants the DOI right-aligned in it too, + # not just when this function creates the cell itself. + para.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r = para.add_run(doi_url) r.style = docx.styles[style_name] def docx_article_type_and_category_pipe(docx, category, article_type='Original Article', style_name='SCL Article Category'): @@ -335,12 +377,12 @@ def docx_cite_as_pipe( docx_renderer.style.add_run_with_style(para, f'{cite_as_part_two}.', p2_style) def docx_second_header_pipe( - docx, - journal_title, - article_title, + docx, + journal_title, + article_title, paragraph_header_style_name='SCL Header Paragraph', character_header_style_name='SCL Header Paragraph Char', - paragraph_title_style_name='SCL Journal Title Char' + paragraph_title_style_name='SCL Header Paragraph Char' ): """ Adds the journal title and article title to the second page header of the DOCX document. @@ -352,6 +394,22 @@ def docx_second_header_pipe( the previous section, and the second section needs a continuous break to let body content start on the same page as the front matter. + Rendered as a borderless 2-column table rather than tab-separated runs + in one paragraph: a tab stop only positions where a run *starts*, it + doesn't constrain where a long run *wraps* (a wrapped line returns to + the paragraph's own left margin, not back to the tab position). A long + article title would otherwise overflow past the journal title's column + instead of wrapping under it. Each column gets half of the content + width; the article title is right-aligned within its own column. + + The journal title is written as plain, unbroken text in the small + 'SCL Header Paragraph Char' style, not the masthead's + _format_journal_title_two_lines()/'SCL Journal Title Char' treatment: + that treatment is sized for the large first-page masthead, and forcing + it into this running header's column (already narrower, and further + split with the article title) pushed titles that fit the masthead in + two lines into three lines here instead. + Args: docx (python-docx.Document): The DOCX document object. journal_title (str): The title of the journal to be added. @@ -363,13 +421,17 @@ def docx_second_header_pipe( None """ header = docx_renderer.section.get_default_header(docx) - para = header.add_paragraph() - para.style = docx.styles[paragraph_header_style_name] + journal_cell, title_cell = _add_two_column_header_table(header) - r1 = para.add_run(_format_journal_title_two_lines(journal_title)) + journal_para = journal_cell.paragraphs[0] + journal_para.style = docx.styles[paragraph_header_style_name] + r1 = journal_para.add_run(journal_title) r1.style = docx.styles[paragraph_title_style_name] - r2 = para.add_run(f'\t{article_title}') + title_para = title_cell.paragraphs[0] + title_para.style = docx.styles[paragraph_header_style_name] + title_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT + r2 = title_para.add_run(article_title) r2.style = docx.styles[character_header_style_name] def docx_second_footer_pipe(docx, footer_data, paragraph_style_name='SCL Footer'): @@ -530,6 +592,92 @@ def _format_journal_title_two_lines(journal_title_text): return f"{words[0]}\n{' '.join(words[1:])}" +def _content_width(): + """Content width (page width minus left/right margins), from PAGE_ATTRIBUTES.""" + attrs = pdf_enum.PAGE_ATTRIBUTES + page_width = attrs.get('page_width', Cm(21.0)) + left_margin = attrs.get('left_margin', Cm(2.0)) + right_margin = attrs.get('right_margin', Cm(2.0)) + return page_width - left_margin - right_margin + + +def _add_two_column_header_table(container, left_ratio=0.5): + """ + Add a borderless 1x2 table to a header (or footer) container, its two + columns splitting the content width by left_ratio (right column gets + the remainder). No style is assigned, so it keeps python-docx's + default 'Normal Table' style, which has no borders. + + Returns: + tuple: (left_cell, right_cell) + """ + content_width = int(_content_width()) + left_width = int(content_width * left_ratio) + right_width = content_width - left_width + + table = container.add_table(rows=1, cols=2, width=content_width) + table.autofit = False + table.allow_autofit = False + _remove_leading_empty_placeholder_paragraph(table) + _zero_table_cell_margins(table) + + left_cell, right_cell = table.rows[0].cells + for column, width in zip(table.columns, (left_width, right_width)): + column.width = width + for cell, width in ((left_cell, left_width), (right_cell, right_width)): + cell.width = width + + return left_cell, right_cell + + +def _remove_leading_empty_placeholder_paragraph(table): + """ + Removes the empty paragraph that precedes `table` in its container, if + and only if that paragraph is truly empty (no text, no runs, so no + graphic content either, since a drawing lives inside a run). + + python-docx auto-creates one empty paragraph the first time a header or + footer's body is accessed, before any content is added to it. Because + `container.add_table()` appends the table after whatever is already + there, that placeholder paragraph ends up immediately before the table, + and being a paragraph (even an empty one) it still reserves a line's + worth of vertical space above it, pushing the table down. + """ + previous = table._tbl.getprevious() + if previous is None or previous.tag != qn('w:p'): + return + placeholder = Paragraph(previous, table._parent) + if placeholder.text or placeholder.runs: + return + previous.getparent().remove(previous) + + +def _zero_table_cell_margins(table, sides=('left', 'right')): + """ + Zero the given cell-margin sides on a table's default cell margins + (w:tblCellMar in w:tblPr). Without this, a python-docx table keeps the + OOXML default of 108 twips (5.4pt) on every side, which offsets a + header/footer table's content from the flush-left/flush-right text used + everywhere else in the document (a plain paragraph has no such margin). + """ + tblPr = table._tbl.tblPr + tblCellMar = OxmlElement('w:tblCellMar') + for side in sides: + margin = OxmlElement(f'w:{side}') + margin.set(qn('w:w'), '0') + margin.set(qn('w:type'), 'dxa') + tblCellMar.append(margin) + + # w:tblCellMar must come after w:tblLayout/w:tblBorders/w:shd (none of + # which this table sets) and before w:tblLook in the CT_TblPr schema + # sequence; anchor on tblLook, which python-docx always adds. + tbl_look = tblPr.find(qn('w:tblLook')) + if tbl_look is not None: + tbl_look.addprevious(tblCellMar) + else: + tblPr.append(tblCellMar) + + def _body_column_count(): """Number of columns configured for the body, from PAGE_ATTRIBUTES.""" return max(1, pdf_enum.PAGE_ATTRIBUTES.get('default_column_count', 2)) diff --git a/tests/sps/formats/pdf/pipeline/test_docx.py b/tests/sps/formats/pdf/pipeline/test_docx.py index 3dc799d66..c1cd7dd80 100644 --- a/tests/sps/formats/pdf/pipeline/test_docx.py +++ b/tests/sps/formats/pdf/pipeline/test_docx.py @@ -4,6 +4,7 @@ from docx import Document from docx.enum.style import WD_STYLE_TYPE +from docx.enum.text import WD_ALIGN_PARAGRAPH from packtools.sps.formats.pdf.pipeline import docx as docx_pipe from packtools.sps.formats.pdf.renderer import docx as docx_renderer @@ -92,10 +93,83 @@ def test_run_uses_the_given_style(self): para = docx_pipe.docx_journal_title_pipe(self.docx, 'Acta Amazonica') self.assertEqual(para.runs[0].style.name, 'SCL Journal Title Char') + def test_paragraph_lives_in_the_left_cell_of_a_header_table(self): + returned_para = docx_pipe.docx_journal_title_pipe(self.docx, 'Acta Amazonica') + header = docx_renderer.section.get_first_page_header(self.docx) + table = header.tables[-1] + cell_para = table.rows[0].cells[0].paragraphs[0] + # python-docx builds a fresh Paragraph wrapper on each access, so + # compare the underlying XML element (identity) rather than the + # wrapper objects themselves. + self.assertIs(returned_para._p, cell_para._p) + + def test_left_column_gets_the_configured_share_of_content_width(self): + docx_pipe.docx_journal_title_pipe(self.docx, 'Acta Amazonica') + header = docx_renderer.section.get_first_page_header(self.docx) + table = header.tables[-1] + content_width = int(docx_pipe._content_width()) + expected_left = int(content_width * docx_pipe._JOURNAL_TITLE_DOI_SPLIT) + # Column widths round-trip through python-docx's internal Length + # representation with a little rounding noise. + self.assertAlmostEqual(table.columns[0].width, expected_left, delta=500) + class TestDocxDoiPipe(unittest.TestCase): - # TODO - ... + """ + Regression: the DOI used to be tab-appended after the journal title in + the same paragraph, relying on a tab stop to reach the right margin. A + tab stop only sets where a run *starts*, not where it wraps - when the + journal title's own second line (see _format_journal_title_two_lines) + was already long, there was no room left on that line for the DOI, and + it wrapped onto a line of its own instead of landing flush right. The + DOI now gets its own column in a 2-column header table, right-aligned + within it, independent of how much text is in the journal title's cell. + """ + + def setUp(self): + self.docx = Document() + self.docx.styles.add_style('SCL Journal Title Char', WD_STYLE_TYPE.CHARACTER) + self.docx.styles.add_style('SCL Header Paragraph Char', WD_STYLE_TYPE.CHARACTER) + + def _doi_cell_paragraph(self): + header = docx_renderer.section.get_first_page_header(self.docx) + return header.tables[-1].rows[0].cells[1].paragraphs[0] + + def test_doi_run_has_no_leading_tab(self): + docx_pipe.docx_doi_pipe(self.docx, '10.1590/example') + para = self._doi_cell_paragraph() + self.assertEqual(para.runs[-1].text, 'http://dx.doi.org/10.1590/example') + + def test_doi_paragraph_is_right_aligned(self): + docx_pipe.docx_doi_pipe(self.docx, '10.1590/example') + para = self._doi_cell_paragraph() + self.assertEqual(para.alignment, WD_ALIGN_PARAGRAPH.RIGHT) + + def test_reuses_the_table_journal_title_pipe_already_created(self): + docx_pipe.docx_journal_title_pipe(self.docx, 'Urbe. Revista Brasileira de Gestão Urbana') + docx_pipe.docx_doi_pipe(self.docx, '10.1590/example') + header = docx_renderer.section.get_first_page_header(self.docx) + self.assertEqual(len(header.tables), 1) + journal_para = header.tables[0].rows[0].cells[0].paragraphs[0] + self.assertEqual(journal_para.runs[0].text, 'Urbe.\nRevista Brasileira de Gestão Urbana') + doi_para = header.tables[0].rows[0].cells[1].paragraphs[0] + self.assertEqual(doi_para.runs[-1].text, 'http://dx.doi.org/10.1590/example') + + def test_works_standalone_without_journal_title_pipe(self): + # docx_doi_pipe creates its own table when none exists yet, rather + # than assuming docx_journal_title_pipe always runs first. + docx_pipe.docx_doi_pipe(self.docx, '10.1590/example') + header = docx_renderer.section.get_first_page_header(self.docx) + self.assertEqual(len(header.tables), 1) + + def test_right_aligns_a_caller_supplied_paragraph_too(self): + # Regression: the right-alignment used to be set only on the branch + # that creates its own cell, so a caller passing an existing + # `paragraph` got the DOI added without any alignment at all. + para = self.docx.add_paragraph() + docx_pipe.docx_doi_pipe(self.docx, '10.1590/example', paragraph=para) + self.assertEqual(para.alignment, WD_ALIGN_PARAGRAPH.RIGHT) + self.assertEqual(para.runs[-1].text, 'http://dx.doi.org/10.1590/example') class TestDocxArticleTypeAndCategoryPipe(unittest.TestCase): @@ -178,6 +252,15 @@ def test_uses_elocation_id_when_fpage_is_absent(self): class TestDocxSecondHeaderPipe(unittest.TestCase): + """ + Regression: the running header used to be a single paragraph with the + article title appended after a tab character. A tab stop only + positions where a run starts, not where it wraps, so a long article + title overflowed past the journal title instead of wrapping under + itself. It's now a borderless 2-column table, each column getting half + of the content width, with the article title right-aligned in its own + column - independently bounded wrapping. + """ def setUp(self): self.docx = Document() @@ -185,24 +268,48 @@ def setUp(self): self.docx.styles.add_style('SCL Header Paragraph Char', WD_STYLE_TYPE.CHARACTER) self.docx.styles.add_style('SCL Journal Title Char', WD_STYLE_TYPE.CHARACTER) - def _second_header_paragraph(self): + def _second_header_table(self): header = docx_pipe.docx_renderer.section.get_default_header(self.docx) - return header.paragraphs[-1] + return header.tables[-1] - def test_two_word_title_keeps_one_word_per_line(self): - docx_pipe.docx_second_header_pipe(self.docx, 'Acta Amazonica', 'Some Article Title') - para = self._second_header_paragraph() - self.assertEqual(para.runs[0].text, 'Acta\nAmazonica') + def _journal_paragraph(self): + return self._second_header_table().rows[0].cells[0].paragraphs[0] - def test_multi_word_title_is_capped_at_two_lines(self): + def _title_paragraph(self): + return self._second_header_table().rows[0].cells[1].paragraphs[0] + + def test_journal_title_is_not_split_into_lines(self): + # Regression: the running header used to reuse the masthead's + # _format_journal_title_two_lines() treatment, which is sized for + # the large first-page title and pushed titles that already fit + # the masthead in two lines into three lines in this narrower, + # smaller-font running header column instead. docx_pipe.docx_second_header_pipe(self.docx, 'Brazilian Journal of Biology', 'Some Article Title') - para = self._second_header_paragraph() - self.assertEqual(para.runs[0].text, 'Brazilian\nJournal of Biology') + self.assertEqual(self._journal_paragraph().runs[0].text, 'Brazilian Journal of Biology') + + def test_journal_title_uses_the_small_header_style_not_the_masthead_style(self): + docx_pipe.docx_second_header_pipe(self.docx, 'Acta Amazonica', 'Some Article Title') + self.assertEqual(self._journal_paragraph().runs[0].style.name, 'SCL Header Paragraph Char') + + def test_article_title_is_in_its_own_cell(self): + docx_pipe.docx_second_header_pipe(self.docx, 'Acta Amazonica', 'Some Article Title') + self.assertEqual(self._title_paragraph().runs[0].text, 'Some Article Title') + + def test_article_title_is_right_aligned(self): + docx_pipe.docx_second_header_pipe(self.docx, 'Acta Amazonica', 'Some Article Title') + self.assertEqual(self._title_paragraph().alignment, WD_ALIGN_PARAGRAPH.RIGHT) - def test_article_title_is_appended_after_a_tab(self): + def test_columns_split_content_width_evenly(self): docx_pipe.docx_second_header_pipe(self.docx, 'Acta Amazonica', 'Some Article Title') - para = self._second_header_paragraph() - self.assertEqual(para.runs[1].text, '\tSome Article Title') + table = self._second_header_table() + self.assertEqual(table.columns[0].width, table.columns[1].width) + + def test_table_keeps_the_borderless_default_style(self): + # No style is assigned explicitly, so it stays 'Normal Table' - + # the borderless default any new python-docx table gets. + docx_pipe.docx_second_header_pipe(self.docx, 'Acta Amazonica', 'Some Article Title') + table = self._second_header_table() + self.assertEqual(table.style.name, 'Normal Table') class TestDocxSecondFooterPipe(unittest.TestCase): @@ -295,6 +402,67 @@ def test_uses_elocation_id_when_fpage_is_absent(self): self.assertEqual(para.text, 'VOL. 33 (3) 2024: e282794') +class TestAddTwoColumnHeaderTable(unittest.TestCase): + + def test_default_split_is_even(self): + docx = Document() + header = docx_renderer.section.get_first_page_header(docx) + left_cell, right_cell = docx_pipe._add_two_column_header_table(header) + table = header.tables[-1] + self.assertEqual(table.columns[0].width, table.columns[1].width) + + def test_custom_ratio_splits_unevenly(self): + docx = Document() + header = docx_renderer.section.get_first_page_header(docx) + docx_pipe._add_two_column_header_table(header, left_ratio=0.65) + table = header.tables[-1] + content_width = int(docx_pipe._content_width()) + self.assertAlmostEqual(table.columns[0].width, int(content_width * 0.65), delta=500) + self.assertAlmostEqual( + table.columns[1].width, content_width - int(content_width * 0.65), delta=500 + ) + + def test_table_keeps_the_borderless_default_style(self): + docx = Document() + header = docx_renderer.section.get_first_page_header(docx) + docx_pipe._add_two_column_header_table(header) + table = header.tables[-1] + self.assertEqual(table.style.name, 'Normal Table') + + def test_removes_the_auto_created_empty_placeholder_paragraph(self): + # python-docx auto-creates one empty paragraph the first time a + # header's body is accessed; add_table() appends after it rather + # than replacing it, so left alone it reserves a blank line's + # worth of vertical space above the table. + docx = Document() + header = docx_renderer.section.get_first_page_header(docx) + docx_pipe._add_two_column_header_table(header) + self.assertEqual(len(header.paragraphs), 0) + + def test_keeps_a_placeholder_paragraph_that_already_has_text(self): + docx = Document() + header = docx_renderer.section.get_first_page_header(docx) + header.paragraphs[0].add_run('not actually empty') + docx_pipe._add_two_column_header_table(header) + self.assertEqual(len(header.paragraphs), 1) + self.assertEqual(header.paragraphs[0].text, 'not actually empty') + + def test_zeroes_left_and_right_cell_margins(self): + # The OOXML default (108 twips = 5.4pt) offsets a header table's + # content from the flush-left/flush-right text used everywhere + # else in the document, since a plain paragraph has no such margin. + docx = Document() + header = docx_renderer.section.get_first_page_header(docx) + docx_pipe._add_two_column_header_table(header) + table = header.tables[-1] + tblCellMar = table._tbl.tblPr.find(docx_pipe.qn('w:tblCellMar')) + self.assertIsNotNone(tblCellMar) + left = tblCellMar.find(docx_pipe.qn('w:left')) + right = tblCellMar.find(docx_pipe.qn('w:right')) + self.assertEqual(left.get(docx_pipe.qn('w:w')), '0') + self.assertEqual(right.get(docx_pipe.qn('w:w')), '0') + + class TestBodyColumnConfiguration(unittest.TestCase): """ Regression tests: body column count (and the count restored after a