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

153 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-21 12:06 +0000

1"""Docx Renderer for Marko. 

2 

3 Author: Andreas Merkle (andreas.merkle@newtec.de) 

4""" 

5 

6# pyTRLCConverter - A tool to convert TRLC files to specific formats. 

7# Copyright (c) 2024 - 2025 NewTec GmbH 

8# 

9# This file is part of pyTRLCConverter program. 

10# 

11# The pyTRLCConverter program is free software: you can redistribute it and/or modify it under 

12# the terms of the GNU General Public License as published by the Free Software Foundation, 

13# either version 3 of the License, or (at your option) any later version. 

14# 

15# The pyTRLCConverter program is distributed in the hope that it will be useful, but 

16# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

17# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License along with pyTRLCConverter. 

20# If not, see <https://www.gnu.org/licenses/>. 

21 

22# Imports ********************************************************************** 

23 

24from __future__ import annotations 

25from typing import TYPE_CHECKING, Any, cast, Optional 

26from marko import Renderer 

27from docx.oxml import OxmlElement 

28from docx.oxml.ns import qn 

29from docx.blkcntnr import BlockItemContainer 

30 

31if TYPE_CHECKING: 

32 from . import block, inline 

33 

34# Variables ******************************************************************** 

35 

36# Classes ********************************************************************** 

37 

38class Singleton(type): 

39 # lobster-trace: SwRequirements.sw_req_docx_render_md 

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

41 

42 _instances = {} 

43 

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

45 """ 

46 Returns the singleton instance of the class. 

47 

48 Returns: 

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

50 """ 

51 

52 if cls not in cls._instances: 

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

54 

55 return cls._instances[cls] 

56 

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

58class DocxRenderer(Renderer, metaclass=Singleton): 

59 # lobster-trace: SwRequirements.sw_req_docx_render_md 

60 """Renderer for docx output.""" 

61 

62 # Docx block item container to add content to. 

63 block_item_container: Optional[BlockItemContainer] = None 

64 

65 def __init__(self) -> None: 

66 """Initialize docx renderer.""" 

67 super().__init__() 

68 self._list_indent_level = 0 

69 self._is_italic = False 

70 self._is_bold = False 

71 self._is_underline = False 

72 self._is_heading = False 

73 self._heading_level = 0 

74 self._is_list_item = False 

75 self._list_style = [] 

76 self._is_quote = False 

77 

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

79 """ 

80 Recursively renders child elements of a given element to 

81 a docx document. 

82 

83 Args: 

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

85 """ 

86 for child in element.children: 

87 self.render(child) 

88 

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

90 """ 

91 Renders a paragraph element. 

92 

93 Args: 

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

95 """ 

96 self.render_children(element) 

97 

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

99 """ 

100 Renders a list (ordered or unordered) element. 

101 

102 Args: 

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

104 """ 

105 assert self.block_item_container is not None 

106 

107 self._is_list_item = True 

108 self._list_indent_level += 1 

109 

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

111 

112 if self._list_indent_level > 1: 

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

114 

115 self._list_style.append(style) 

116 

117 for child in element.children: 

118 self.render_children(child) 

119 

120 self._list_style.pop() 

121 

122 self._list_indent_level -= 1 

123 

124 if self._list_indent_level == 0: 

125 self._is_list_item = False 

126 

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

128 """ 

129 Renders a blockquote element. 

130 

131 Args: 

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

133 """ 

134 self._is_quote = True 

135 self.render_children(element) 

136 self._is_quote = False 

137 

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

139 """ 

140 Renders a fenced code block element. 

141 

142 Args: 

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

144 """ 

145 assert self.block_item_container is not None 

146 

147 paragraph = self.block_item_container.add_paragraph() 

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

149 run.font.name = "Consolas" 

150 

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

152 """ 

153 Renders a code block element. 

154 

155 Args: 

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

157 """ 

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

159 

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

161 """ 

162 Renders a raw HTML block element. 

163 

164 Args: 

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

166 """ 

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

168 

169 # pylint: disable-next=unused-argument 

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

171 """ 

172 Renders a thematic break (horizontal rule) element. 

173 

174 Args: 

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

176 """ 

177 assert self.block_item_container is not None 

178 

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

180 para = self.block_item_container.add_paragraph() 

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

182 

183 paragraph_properties = p.get_or_add_pPr() 

184 p_bdr = OxmlElement('w:pBdr') 

185 bottom = OxmlElement('w:bottom') 

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

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

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

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

190 p_bdr.append(bottom) 

191 paragraph_properties.append(p_bdr) 

192 

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

194 """ 

195 Renders a heading element. 

196 

197 Args: 

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

199 """ 

200 assert self.block_item_container is not None 

201 

202 self._is_heading = True 

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

204 

205 self.render_children(element) 

206 

207 self._is_heading = False 

208 self._heading_level = 0 

209 

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

211 """ 

212 Renders a setext heading element. 

213 

214 Args: 

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

216 """ 

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

218 

219 # pylint: disable-next=unused-argument 

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

221 """ 

222 Renders a blank line element. 

223 

224 Args: 

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

226 """ 

227 assert self.block_item_container is not None 

228 

229 paragraph = self.block_item_container.add_paragraph() 

230 run = paragraph.add_run("") 

231 run.bold = self._is_bold 

232 run.italic = self._is_italic 

233 run.underline = self._is_underline 

234 

235 if self._is_heading is True: 

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

237 elif self._is_list_item: 

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

239 

240 # pylint: disable-next=unused-argument 

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

242 """ 

243 Renders a link reference definition element. 

244 

245 Args: 

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

247 """ 

248 # reStructuredText uses reference links differently than Markdown. 

249 # It shall not be rendered in the document. 

250 

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

252 """ 

253 Renders an emphasis (italic) element. 

254 

255 Args: 

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

257 """ 

258 assert self.block_item_container is not None 

259 

260 self._is_italic = True 

261 

262 self.render_children(element) 

263 

264 self._is_italic = False 

265 

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

267 """ 

268 Renders a strong emphasis (bold) element. 

269 

270 Args: 

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

272 """ 

273 assert self.block_item_container is not None 

274 

275 self._is_bold = True 

276 

277 self.render_children(element) 

278 

279 self._is_bold = False 

280 

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

282 """ 

283 Renders an inline HTML element. 

284 

285 Args: 

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

287 """ 

288 assert self.block_item_container is not None 

289 

290 paragraph = self.block_item_container.add_paragraph() 

291 run = paragraph.add_run(element.children) 

292 run.font.name = "Consolas" 

293 

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

295 """ 

296 Renders plain text or any element with string children. 

297 

298 Args: 

299 element (Any): The element to render. 

300 """ 

301 assert self.block_item_container is not None 

302 

303 if isinstance(element.children, str): 

304 paragraph = self.block_item_container.add_paragraph() 

305 run = paragraph.add_run(element.children) 

306 run.bold = self._is_bold 

307 run.italic = self._is_italic 

308 run.underline = self._is_underline 

309 

310 if self._is_heading is True: 

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

312 elif self._is_list_item: 

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

314 else: 

315 self.render_children(element) 

316 

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

318 """ 

319 Renders a link element. 

320 

321 Args: 

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

323 """ 

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

325 self.render_children(element) 

326 

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

328 """ 

329 Renders an auto link element. 

330 

331 Args: 

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

333 """ 

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

335 

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

337 """ 

338 Renders an image element. 

339 

340 Args: 

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

342 

343 Returns: 

344 DocxDocument: The rendered image as a docx document. 

345 """ 

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

347 if element.title: 

348 assert self.block_item_container is not None 

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

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

351 

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

353 """ 

354 Renders a literal (inline code) element. 

355 

356 Args: 

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

358 """ 

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

360 

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

362 """ 

363 Renders a raw text element. 

364 

365 Args: 

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

367 

368 Returns: 

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

370 """ 

371 assert self.block_item_container is not None 

372 

373 paragraph = self.block_item_container.add_paragraph() 

374 run = paragraph.add_run(element.children) 

375 run.bold = self._is_bold 

376 run.italic = self._is_italic 

377 run.underline = self._is_underline 

378 

379 if self._is_heading is True: 

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

381 elif self._is_list_item is True: 

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

383 elif self._is_quote is True: 

384 paragraph.style = "Quote" 

385 

386 # pylint: disable-next=unused-argument 

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

388 """ 

389 Renders a line break element. 

390 

391 Args: 

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

393 

394 Returns: 

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

396 """ 

397 assert self.block_item_container is not None 

398 

399 paragraph = self.block_item_container.add_paragraph() 

400 run = paragraph.add_run() 

401 run.add_break() 

402 

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

404 """ 

405 Renders a code span (inline code) element. 

406 

407 Args: 

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

409 

410 Returns: 

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

412 """ 

413 assert self.block_item_container is not None 

414 

415 paragraph = self.block_item_container.add_paragraph() 

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

417 run.font.name = "Consolas" 

418 

419# Functions ******************************************************************** 

420 

421# Main *************************************************************************