Coverage for src / pyTRLCConverter / marko / md2docx_renderer.py: 23%

153 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 12:20 +0000

1"""Docx Renderer for Marko. 

2 It is used to convert CommonMark AST to docx format. 

3 

4 Author: Andreas Merkle (andreas.merkle@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 TYPE_CHECKING, Any, cast, Optional 

27from marko import Renderer 

28from docx.oxml import OxmlElement 

29from docx.oxml.ns import qn 

30from docx.blkcntnr import BlockItemContainer 

31 

32if TYPE_CHECKING: 

33 from . import block, inline 

34 

35# Variables ******************************************************************** 

36 

37# Classes ********************************************************************** 

38 

39class Singleton(type): 

40 # lobster-trace: SwRequirements.sw_req_docx_render_md 

41 """Singleton metaclass to ensure only one instance of a class exists.""" 

42 

43 _instances = {} 

44 

45 def __call__(cls, *args, **kwargs): 

46 """ 

47 Returns the singleton instance of the class. 

48 

49 Returns: 

50 instance (cls): The singleton instance of the class. 

51 """ 

52 

53 if cls not in cls._instances: 

54 cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 

55 

56 return cls._instances[cls] 

57 

58# pylint: disable-next=too-many-public-methods, too-many-instance-attributes 

59class Md2DocxRenderer(Renderer, metaclass=Singleton): 

60 # lobster-trace: SwRequirements.sw_req_docx_render_md 

61 """Renderer for docx output.""" 

62 

63 # Docx block item container to add content to. 

64 block_item_container: Optional[BlockItemContainer] = None 

65 

66 def __init__(self) -> None: 

67 """Initialize the renderer.""" 

68 super().__init__() 

69 self._list_indent_level = 0 

70 self._is_italic = False 

71 self._is_bold = False 

72 self._is_underline = False 

73 self._is_heading = False 

74 self._heading_level = 0 

75 self._is_list_item = False 

76 self._list_style = [] 

77 self._is_quote = False 

78 

79 def render_children(self, element: Any) -> None: 

80 """ 

81 Recursively renders child elements of a given element to 

82 a docx document. 

83 

84 Args: 

85 element (Element): The parent element whose children are to be rendered. 

86 """ 

87 for child in element.children: 

88 self.render(child) 

89 

90 def render_paragraph(self, element: block.Paragraph) -> None: 

91 """ 

92 Renders a paragraph element. 

93 

94 Args: 

95 element (block.Paragraph): The paragraph element to render. 

96 """ 

97 self.render_children(element) 

98 

99 def render_list(self, element: block.List) -> None: 

100 """ 

101 Renders a list (ordered or unordered) element. 

102 

103 Args: 

104 element (block.List): The list element to render. 

105 """ 

106 assert self.block_item_container is not None 

107 

108 self._is_list_item = True 

109 self._list_indent_level += 1 

110 

111 style = "List Number" if element.ordered else "List Bullet" 

112 

113 if self._list_indent_level > 1: 

114 style += f" {self._list_indent_level}" 

115 

116 self._list_style.append(style) 

117 

118 for child in element.children: 

119 self.render_children(child) 

120 

121 self._list_style.pop() 

122 

123 self._list_indent_level -= 1 

124 

125 if self._list_indent_level == 0: 

126 self._is_list_item = False 

127 

128 def render_quote(self, element: block.Quote) -> None: 

129 """ 

130 Renders a blockquote element. 

131 

132 Args: 

133 element (block.Quote): The blockquote element to render. 

134 """ 

135 self._is_quote = True 

136 self.render_children(element) 

137 self._is_quote = False 

138 

139 def render_fenced_code(self, element: block.FencedCode) -> None: 

140 """ 

141 Renders a fenced code block element. 

142 

143 Args: 

144 element (block.FencedCode): The fenced code block element to render. 

145 """ 

146 assert self.block_item_container is not None 

147 

148 paragraph = self.block_item_container.add_paragraph() 

149 run = paragraph.add_run(element.children[0].children) 

150 run.font.name = "Consolas" 

151 

152 def render_code_block(self, element: block.CodeBlock) -> None: 

153 """ 

154 Renders a code block element. 

155 

156 Args: 

157 element (block.CodeBlock): The code block element to render. 

158 """ 

159 self.render_fenced_code(cast("block.FencedCode", element)) 

160 

161 def render_html_block(self, element: block.HTMLBlock) -> None: 

162 """ 

163 Renders a raw HTML block element. 

164 

165 Args: 

166 element (block.HTMLBlock): The HTML block element to render. 

167 """ 

168 self.render_fenced_code(cast("block.FencedCode", element)) 

169 

170 # pylint: disable-next=unused-argument 

171 def render_thematic_break(self, element: block.ThematicBreak) -> None: 

172 """ 

173 Renders a thematic break (horizontal rule) element. 

174 

175 Args: 

176 element (block.ThematicBreak): The thematic break element to render. 

177 """ 

178 assert self.block_item_container is not None 

179 

180 # Add a horizontal rule by inserting a paragraph with a bottom border 

181 para = self.block_item_container.add_paragraph() 

182 p = para._element # pylint: disable=protected-access 

183 

184 paragraph_properties = p.get_or_add_pPr() 

185 p_bdr = OxmlElement('w:pBdr') 

186 bottom = OxmlElement('w:bottom') 

187 bottom.set(qn('w:val'), 'single') 

188 bottom.set(qn('w:sz'), '6') 

189 bottom.set(qn('w:space'), '1') 

190 bottom.set(qn('w:color'), 'auto') 

191 p_bdr.append(bottom) 

192 paragraph_properties.append(p_bdr) 

193 

194 def render_heading(self, element: block.Heading) -> None: 

195 """ 

196 Renders a heading element. 

197 

198 Args: 

199 element (block.Heading): The heading element to render. 

200 """ 

201 assert self.block_item_container is not None 

202 

203 self._is_heading = True 

204 self._heading_level = min(max(element.level, 1), 9) # docx supports levels 1-9 

205 

206 self.render_children(element) 

207 

208 self._is_heading = False 

209 self._heading_level = 0 

210 

211 def render_setext_heading(self, element: block.SetextHeading) -> None: 

212 """ 

213 Renders a setext heading element. 

214 

215 Args: 

216 element (block.SetextHeading): The setext heading element to render. 

217 """ 

218 self.render_heading(cast("block.Heading", element)) 

219 

220 # pylint: disable-next=unused-argument 

221 def render_blank_line(self, element: block.BlankLine) -> None: 

222 """ 

223 Renders a blank line element. 

224 

225 Args: 

226 element (block.BlankLine): The blank line element to render. 

227 """ 

228 assert self.block_item_container is not None 

229 

230 paragraph = self.block_item_container.add_paragraph() 

231 run = paragraph.add_run("") 

232 run.bold = self._is_bold 

233 run.italic = self._is_italic 

234 run.underline = self._is_underline 

235 

236 if self._is_heading is True: 

237 paragraph.style = f"Heading {self._heading_level}" 

238 elif self._is_list_item: 

239 paragraph.style = self._list_style[-1] 

240 

241 # pylint: disable-next=unused-argument 

242 def render_link_ref_def(self, element: block.LinkRefDef) -> None: 

243 """ 

244 Renders a link reference definition element. 

245 

246 Args: 

247 element (block.LinkRefDef): The link reference definition element to render. 

248 """ 

249 # docx uses reference links differently than Markdown. 

250 # It shall not be rendered in the document. 

251 

252 def render_emphasis(self, element: inline.Emphasis) -> None: 

253 """ 

254 Renders an emphasis (italic) element. 

255 

256 Args: 

257 element (inline.Emphasis): The emphasis element to render. 

258 """ 

259 assert self.block_item_container is not None 

260 

261 self._is_italic = True 

262 

263 self.render_children(element) 

264 

265 self._is_italic = False 

266 

267 def render_strong_emphasis(self, element: inline.StrongEmphasis) -> None: 

268 """ 

269 Renders a strong emphasis (bold) element. 

270 

271 Args: 

272 element (inline.StrongEmphasis): The strong emphasis element to render. 

273 """ 

274 assert self.block_item_container is not None 

275 

276 self._is_bold = True 

277 

278 self.render_children(element) 

279 

280 self._is_bold = False 

281 

282 def render_inline_html(self, element: inline.InlineHTML) -> None: 

283 """ 

284 Renders an inline HTML element. 

285 

286 Args: 

287 element (inline.InlineHTML): The inline HTML element to render. 

288 """ 

289 assert self.block_item_container is not None 

290 

291 paragraph = self.block_item_container.add_paragraph() 

292 run = paragraph.add_run(element.children) 

293 run.font.name = "Consolas" 

294 

295 def render_plain_text(self, element: Any) -> None: 

296 """ 

297 Renders plain text or any element with string children. 

298 

299 Args: 

300 element (Any): The element to render. 

301 """ 

302 assert self.block_item_container is not None 

303 

304 if isinstance(element.children, str): 

305 paragraph = self.block_item_container.add_paragraph() 

306 run = paragraph.add_run(element.children) 

307 run.bold = self._is_bold 

308 run.italic = self._is_italic 

309 run.underline = self._is_underline 

310 

311 if self._is_heading is True: 

312 paragraph.style = f"Heading {self._heading_level}" 

313 elif self._is_list_item: 

314 paragraph.style = self._list_style[-1] 

315 else: 

316 self.render_children(element) 

317 

318 def render_link(self, element: inline.Link) -> None: 

319 """ 

320 Renders a link element. 

321 

322 Args: 

323 element (inline.Link): The link element to render. 

324 """ 

325 # Link handling in docx is non-trivial; for simplicity, render link text only. 

326 self.render_children(element) 

327 

328 def render_auto_link(self, element: inline.AutoLink) -> None: 

329 """ 

330 Renders an auto link element. 

331 

332 Args: 

333 element (inline.AutoLink): The auto link element to render. 

334 """ 

335 self.render_link(cast("inline.Link", element)) 

336 

337 def render_image(self, element: inline.Image) -> None: 

338 """ 

339 Renders an image element. 

340 

341 Args: 

342 element (inline.Image): The image element to render. 

343 

344 Returns: 

345 DocxDocument: The rendered image as a docx document. 

346 """ 

347 # Image handling in docx is non-trivial; for simplicity just render title and URL. 

348 if element.title: 

349 assert self.block_item_container is not None 

350 self.block_item_container.add_paragraph(text=element.title) 

351 self.block_item_container.add_paragraph(text=f" ({element.dest})") 

352 

353 def render_literal(self, element: inline.Literal) -> None: 

354 """ 

355 Renders a literal (inline code) element. 

356 

357 Args: 

358 element (inline.Literal): The literal element to render. 

359 """ 

360 self.render_raw_text(cast("inline.RawText", element)) 

361 

362 def render_raw_text(self, element: inline.RawText) -> None: 

363 """ 

364 Renders a raw text element. 

365 

366 Args: 

367 element (inline.RawText): The raw text element to render. 

368 

369 Returns: 

370 DocxDocument: The rendered raw text as a docx document. 

371 """ 

372 assert self.block_item_container is not None 

373 

374 paragraph = self.block_item_container.add_paragraph() 

375 run = paragraph.add_run(element.children) 

376 run.bold = self._is_bold 

377 run.italic = self._is_italic 

378 run.underline = self._is_underline 

379 

380 if self._is_heading is True: 

381 paragraph.style = f"Heading {self._heading_level}" 

382 elif self._is_list_item is True: 

383 paragraph.style = self._list_style[-1] 

384 elif self._is_quote is True: 

385 paragraph.style = "Quote" 

386 

387 # pylint: disable-next=unused-argument 

388 def render_line_break(self, element: inline.LineBreak) -> None: 

389 """ 

390 Renders a line break element. 

391 

392 Args: 

393 element (inline.LineBreak): The line break element to render. 

394 

395 Returns: 

396 DocxDocument: The rendered line break as a docx document. 

397 """ 

398 assert self.block_item_container is not None 

399 

400 paragraph = self.block_item_container.add_paragraph() 

401 run = paragraph.add_run() 

402 run.add_break() 

403 

404 def render_code_span(self, element: inline.CodeSpan) -> None: 

405 """ 

406 Renders a code span (inline code) element. 

407 

408 Args: 

409 element (inline.CodeSpan): The code span element to render. 

410 

411 Returns: 

412 DocxDocument: The rendered code span as a docx document. 

413 """ 

414 assert self.block_item_container is not None 

415 

416 paragraph = self.block_item_container.add_paragraph() 

417 run = paragraph.add_run(cast(str, element.children)) 

418 run.font.name = "Consolas" 

419 

420# Functions ******************************************************************** 

421 

422# Main *************************************************************************