Coverage for src/pyTRLCConverter/marko/gfm2docx_renderer.py: 100%
78 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 12:41 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 12:41 +0000
1"""DOCX renderer for Marko.
2 It is used to convert GitHub Flavored Markdown AST to DOCX format.
4 Author: Stefan Vogel (stefan.vogel@newtec.de)
5"""
7# pyTRLCConverter - A tool to convert TRLC files to specific formats.
8# Copyright (c) 2024 - 2026 NewTec GmbH
9#
10# This file is part of pyTRLCConverter program.
11#
12# The pyTRLCConverter program is free software: you can redistribute it and/or modify it under
13# the terms of the GNU General Public License as published by the Free Software Foundation,
14# either version 3 of the License, or (at your option) any later version.
15#
16# The pyTRLCConverter program is distributed in the hope that it will be useful, but
17# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
18# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License along with pyTRLCConverter.
21# If not, see <https://www.gnu.org/licenses/>.
23# Imports **********************************************************************
25from __future__ import annotations
26from typing import Any, Optional
27from docx.blkcntnr import BlockItemContainer
28from pyTRLCConverter.marko.md2docx_renderer import Md2DocxRenderer, Singleton
30# Variables ********************************************************************
32# Classes **********************************************************************
34class Gfm2DocxRenderer(Md2DocxRenderer, metaclass=Singleton):
35 # lobster-trace: SwRequirements.sw_req_docx_render_gfm
36 """Renderer for DOCX output.
37 It is used to convert GitHub Flavored Markdown to DOCX format.
38 """
40 # DOCX block item container to add content to.
41 block_item_container: Optional[BlockItemContainer] = None
43 def __init__(self) -> None:
44 """Initialize the renderer."""
45 self._checkbox_prefix: Optional[str] = None
46 super().__init__()
47 self.reset()
49 def reset(self) -> None:
50 """Resets the renderer state before a new convert() call."""
51 super().reset()
52 self._checkbox_prefix = None
54 def render_paragraph(self, element: Any) -> None:
55 """Renders a paragraph element, prepending a checkbox prefix if set by render_list_item.
57 Args:
58 element (Any): The paragraph element to render.
59 """
60 assert self.block_item_container is not None
62 # If a checkbox prefix is pending, create the paragraph manually so we can add the
63 # prefix run before rendering inline children — super() would create the paragraph
64 # and immediately render children leaving no hook to insert the prefix first.
65 if self._checkbox_prefix is not None:
66 from docx.oxml import OxmlElement # pylint: disable=import-outside-toplevel
67 from docx.oxml.ns import qn as qname # pylint: disable=import-outside-toplevel
69 style = self._list_style[-1] if self._list_style else "List Bullet"
70 self._current_paragraph = self.block_item_container.add_paragraph(style=style) # type: ignore
72 r = OxmlElement('w:r')
73 t = OxmlElement('w:t')
74 t.text = self._checkbox_prefix
75 t.set(qname('xml:space'), 'preserve')
76 r.append(t)
77 self._current_paragraph._p.append(r) # pylint: disable=protected-access
79 self._checkbox_prefix = None
80 self.render_children(element)
81 self._current_paragraph = None
82 else:
83 super().render_paragraph(element)
85 def render_list_item(self, element: Any) -> None:
86 """Renders a list item element, prepending a checkbox prefix for GFM task list items.
88 Args:
89 element (Any): The list item element to render.
90 """
91 # GFM task-list items expose a "checked" flag on the first child Paragraph node.
92 # Store the prefix as state so render_paragraph can prepend it as a run, keeping
93 # the checkbox and text in the same paragraph rather than creating a separate one.
94 if element.children:
95 first_child = element.children[0]
96 checked = getattr(first_child, "checked", None)
97 if checked is True:
98 self._checkbox_prefix = "☑ "
99 elif checked is False:
100 self._checkbox_prefix = "☐ "
102 self.render_children(element)
104 def render_table(self, element: Any) -> None:
105 """Renders a GFM table as a python-docx table.
107 Args:
108 element (Any): The GFM table element.
109 """
110 assert self.block_item_container is not None
112 if element.children:
113 header_row = element.children[0]
114 body_rows = element.children[1:]
116 num_cols = len(header_row.children)
117 num_rows = 1 + len(body_rows)
119 table = self.block_item_container.add_table(rows=num_rows, cols=num_cols) # type: ignore
120 table.style = "Table Grid"
122 # Header row
123 for col_idx, cell in enumerate(header_row.children):
124 cell_text = self._collect_text(cell)
125 table.rows[0].cells[col_idx].text = cell_text
126 for run in table.rows[0].cells[col_idx].paragraphs[0].runs:
127 run.bold = True
129 # Body rows
130 for row_idx, row in enumerate(body_rows):
131 for col_idx, cell in enumerate(row.children):
132 cell_text = self._collect_text(cell)
133 table.rows[row_idx + 1].cells[col_idx].text = cell_text
135 # python-docx inserts an empty paragraph after every table added to a cell
136 # as an OOXML structural requirement. Remove it to avoid a spurious blank line
137 # between the table and the next content element.
138 tbl = table._tbl # pylint: disable=protected-access
139 tbl_following = tbl.getnext()
140 if tbl_following is not None and tbl_following.tag.endswith("}p"):
141 tbl_following.getparent().remove(tbl_following)
143 def render_table_row(self, element: Any) -> None:
144 """No-op: table rows are handled inside render_table.
146 Args:
147 element (Any): The table row element.
148 """
150 def render_table_cell(self, element: Any) -> None:
151 """No-op: table cells are handled inside render_table.
153 Args:
154 element (Any): The table cell element.
155 """
157 def render_strikethrough(self, element: Any) -> None:
158 """Renders a GFM strikethrough element by applying the strike run property.
160 Args:
161 element (Any): The strikethrough element.
162 """
163 assert self._current_paragraph is not None
165 run = self._current_paragraph.add_run(self._collect_text(element))
166 run.font.strike = True
168 def render_url(self, element: Any) -> None:
169 """Renders a GFM bare URL by delegating to render_auto_link.
171 Args:
172 element (Any): The URL element.
173 """
174 self.render_auto_link(element)
176 @staticmethod
177 def _collect_text(element: Any) -> str:
178 """Recursively collects plain text from an element's children.
180 Args:
181 element (Any): The element to collect text from.
183 Returns:
184 str: The concatenated plain text of the element.
185 """
186 if isinstance(element.children, str):
187 result = element.children
188 else:
189 parts = []
190 for child in element.children:
191 parts.append(Gfm2DocxRenderer._collect_text(child))
192 result = "".join(parts)
194 return result
196# Functions ********************************************************************
198# Main *************************************************************************