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

1"""DOCX renderer for Marko. 

2 It is used to convert GitHub Flavored Markdown AST to DOCX format. 

3 

4 Author: Stefan Vogel (stefan.vogel@newtec.de) 

5""" 

6 

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/>. 

22 

23# Imports ********************************************************************** 

24 

25from __future__ import annotations 

26from typing import Any, Optional 

27from docx.blkcntnr import BlockItemContainer 

28from pyTRLCConverter.marko.md2docx_renderer import Md2DocxRenderer, Singleton 

29 

30# Variables ******************************************************************** 

31 

32# Classes ********************************************************************** 

33 

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 """ 

39 

40 # DOCX block item container to add content to. 

41 block_item_container: Optional[BlockItemContainer] = None 

42 

43 def __init__(self) -> None: 

44 """Initialize the renderer.""" 

45 self._checkbox_prefix: Optional[str] = None 

46 super().__init__() 

47 self.reset() 

48 

49 def reset(self) -> None: 

50 """Resets the renderer state before a new convert() call.""" 

51 super().reset() 

52 self._checkbox_prefix = None 

53 

54 def render_paragraph(self, element: Any) -> None: 

55 """Renders a paragraph element, prepending a checkbox prefix if set by render_list_item. 

56 

57 Args: 

58 element (Any): The paragraph element to render. 

59 """ 

60 assert self.block_item_container is not None 

61 

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 

68 

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 

71 

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 

78 

79 self._checkbox_prefix = None 

80 self.render_children(element) 

81 self._current_paragraph = None 

82 else: 

83 super().render_paragraph(element) 

84 

85 def render_list_item(self, element: Any) -> None: 

86 """Renders a list item element, prepending a checkbox prefix for GFM task list items. 

87 

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 = "☐ " 

101 

102 self.render_children(element) 

103 

104 def render_table(self, element: Any) -> None: 

105 """Renders a GFM table as a python-docx table. 

106 

107 Args: 

108 element (Any): The GFM table element. 

109 """ 

110 assert self.block_item_container is not None 

111 

112 if element.children: 

113 header_row = element.children[0] 

114 body_rows = element.children[1:] 

115 

116 num_cols = len(header_row.children) 

117 num_rows = 1 + len(body_rows) 

118 

119 table = self.block_item_container.add_table(rows=num_rows, cols=num_cols) # type: ignore 

120 table.style = "Table Grid" 

121 

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 

128 

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 

134 

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) 

142 

143 def render_table_row(self, element: Any) -> None: 

144 """No-op: table rows are handled inside render_table. 

145 

146 Args: 

147 element (Any): The table row element. 

148 """ 

149 

150 def render_table_cell(self, element: Any) -> None: 

151 """No-op: table cells are handled inside render_table. 

152 

153 Args: 

154 element (Any): The table cell element. 

155 """ 

156 

157 def render_strikethrough(self, element: Any) -> None: 

158 """Renders a GFM strikethrough element by applying the strike run property. 

159 

160 Args: 

161 element (Any): The strikethrough element. 

162 """ 

163 assert self._current_paragraph is not None 

164 

165 run = self._current_paragraph.add_run(self._collect_text(element)) 

166 run.font.strike = True 

167 

168 def render_url(self, element: Any) -> None: 

169 """Renders a GFM bare URL by delegating to render_auto_link. 

170 

171 Args: 

172 element (Any): The URL element. 

173 """ 

174 self.render_auto_link(element) 

175 

176 @staticmethod 

177 def _collect_text(element: Any) -> str: 

178 """Recursively collects plain text from an element's children. 

179 

180 Args: 

181 element (Any): The element to collect text from. 

182 

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) 

193 

194 return result 

195 

196# Functions ******************************************************************** 

197 

198# Main *************************************************************************