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

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

26import io 

27import tempfile 

28from typing import TYPE_CHECKING, Any, cast, Optional 

29from marko import Renderer 

30from docx.oxml import OxmlElement 

31from docx.oxml.ns import qn 

32from docx.blkcntnr import BlockItemContainer 

33from pyTRLCConverter.plantuml import PlantUML 

34 

35if TYPE_CHECKING: 

36 from . import block, inline 

37 

38# Variables ******************************************************************** 

39 

40# Classes ********************************************************************** 

41 

42class Singleton(type): 

43 # lobster-trace: SwRequirements.sw_req_docx_render_md 

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

45 

46 _instances = {} 

47 

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

49 """ 

50 Returns the singleton instance of the class. 

51 

52 Returns: 

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

54 """ 

55 

56 if cls not in cls._instances: 

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

58 

59 return cls._instances[cls] 

60 

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

62class Md2DocxRenderer(Renderer, metaclass=Singleton): 

63 # lobster-trace: SwRequirements.sw_req_docx_render_md 

64 """Renderer for docx output.""" 

65 

66 # Docx block item container to add content to. 

67 block_item_container: Optional[BlockItemContainer] = None 

68 

69 def __init__(self) -> None: 

70 """Initialize the renderer.""" 

71 super().__init__() 

72 self._list_indent_level = 0 

73 self._is_italic = False 

74 self._is_bold = False 

75 self._is_underline = False 

76 self._is_heading = False 

77 self._heading_level = 0 

78 self._is_list_item = False 

79 self._list_style = [] 

80 self._is_quote = False 

81 self._current_paragraph = None 

82 self.reset() 

83 

84 def reset(self) -> None: 

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

86 

87 The Singleton pattern means __init__ runs only once. Without an explicit reset, 

88 stale state from a previous convert() call (e.g. _current_paragraph) would persist 

89 into the next call and cause spurious empty paragraphs in the output. 

90 """ 

91 self.root_node = None 

92 self._list_indent_level = 0 

93 self._is_italic = False 

94 self._is_bold = False 

95 self._is_underline = False 

96 self._is_heading = False 

97 self._heading_level = 0 

98 self._is_list_item = False 

99 self._list_style = [] 

100 self._is_quote = False 

101 self._current_paragraph = None 

102 

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

104 """ 

105 Recursively renders child elements of a given element to 

106 a docx document. 

107 

108 Args: 

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

110 """ 

111 for child in element.children: 

112 self.render(child) 

113 

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

115 """ 

116 Renders a paragraph element. 

117 

118 Args: 

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

120 """ 

121 assert self.block_item_container is not None 

122 

123 # The Singleton pattern means __init__ runs only once, so _current_paragraph persists 

124 # across convert() calls. Each paragraph element must own exactly one docx paragraph for 

125 # the duration of rendering its inline children — set it here and clear it after. 

126 self._current_paragraph = self.block_item_container.add_paragraph() 

127 

128 # Apply the paragraph style based on the context set by the enclosing block element. 

129 # render_paragraph is called for any Paragraph node in the AST — including those nested 

130 # inside headings, list items, and block quotes. The outer block renderers set state flags 

131 # but do not create the docx paragraph themselves, so the style must be applied here. 

132 if self._is_heading is True: 

133 self._current_paragraph.style = f"Heading {self._heading_level}" 

134 elif self._is_list_item: 

135 self._current_paragraph.style = self._list_style[-1] 

136 elif self._is_quote: 

137 self._current_paragraph.style = "Quote" 

138 

139 self.render_children(element) 

140 self._current_paragraph = None 

141 

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

143 """ 

144 Renders a list (ordered or unordered) element. 

145 

146 Args: 

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

148 """ 

149 assert self.block_item_container is not None 

150 

151 self._is_list_item = True 

152 self._list_indent_level += 1 

153 

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

155 

156 if self._list_indent_level > 1: 

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

158 

159 self._list_style.append(style) 

160 

161 for child in element.children: 

162 self.render(child) 

163 

164 self._list_style.pop() 

165 

166 self._list_indent_level -= 1 

167 

168 if self._list_indent_level == 0: 

169 self._is_list_item = False 

170 

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

172 """ 

173 Renders a blockquote element. 

174 

175 Args: 

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

177 """ 

178 self._is_quote = True 

179 self.render_children(element) 

180 self._is_quote = False 

181 

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

183 # lobster-trace: SwRequirements.sw_req_docx_render_md 

184 # lobster-trace: SwRequirements.sw_req_docx_render_plantuml 

185 # lobster-trace: SwRequirements.sw_req_plantuml 

186 """ 

187 Renders a fenced code block element. 

188 

189 If the language tag is 'plantuml', the diagram source is rendered as an embedded 

190 PNG image. If PlantUML is not available, an error string is inserted instead. 

191 For all other language tags the block is rendered as plain Consolas text. 

192 

193 Args: 

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

195 """ 

196 assert self.block_item_container is not None 

197 

198 if element.lang == "plantuml": 

199 self._render_plantuml(element.children[0].children) 

200 else: 

201 paragraph = self.block_item_container.add_paragraph() 

202 # Marko includes a trailing newline in the RawText content of code blocks - strip it 

203 # to avoid an extra blank line appearing in the docx output after the code paragraph. 

204 run = paragraph.add_run(element.children[0].children.rstrip("\n")) 

205 run.font.name = "Consolas" 

206 

207 def _render_plantuml(self, diagram_source: str) -> None: 

208 # lobster-trace: SwRequirements.sw_req_docx_render_plantuml 

209 # lobster-trace: SwRequirements.sw_req_plantuml 

210 """Renders a PlantUML diagram as an embedded PNG in the docx document. 

211 

212 Generates PNG bytes via the PlantUML tool and embeds the image directly. 

213 If PlantUML is not available, inserts an error string instead. 

214 

215 Args: 

216 diagram_source (str): The PlantUML diagram source text. 

217 """ 

218 assert self.block_item_container is not None 

219 

220 try: 

221 plantuml = PlantUML() 

222 with tempfile.TemporaryDirectory() as tmp_dir: 

223 # Write the diagram source to a temp file so PlantUML can read it. 

224 with tempfile.NamedTemporaryFile( 

225 dir=tmp_dir, suffix=".puml", mode='w', 

226 encoding='utf-8', delete=False 

227 ) as src_file: 

228 src_file.write(diagram_source) 

229 src_name = src_file.name 

230 # PNG is required: python-docx's add_picture() only accepts raster formats; 

231 # SVG is not supported. 

232 plantuml.generate("png", src_name, tmp_dir) 

233 png_path = src_name.replace(".puml", ".png") 

234 with open(png_path, 'rb') as png_file: 

235 png_bytes = png_file.read() 

236 

237 paragraph = self.block_item_container.add_paragraph() 

238 run = paragraph.add_run() 

239 run.add_picture(io.BytesIO(png_bytes)) 

240 except (FileNotFoundError, OSError) as exc: 

241 paragraph = self.block_item_container.add_paragraph() 

242 paragraph.add_run(f"[PlantUML error: {exc}]") 

243 

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

245 """ 

246 Renders a code block element. 

247 

248 Args: 

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

250 """ 

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

252 

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

254 """ 

255 Renders a raw HTML block element. 

256 

257 Args: 

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

259 """ 

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

261 

262 # pylint: disable-next=unused-argument 

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

264 """ 

265 Renders a thematic break (horizontal rule) element. 

266 

267 Args: 

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

269 """ 

270 assert self.block_item_container is not None 

271 

272 # Remove the preceding blank paragraph if present — the BlankLine node before a 

273 # thematic break is Markdown syntax spacing, not intended visual whitespace in docx. 

274 paragraphs = self.block_item_container.paragraphs # type: ignore 

275 if paragraphs and paragraphs[-1].text == "" and not paragraphs[-1].runs: 

276 paragraphs[-1]._p.getparent().remove(paragraphs[-1]._p) # pylint: disable=protected-access 

277 

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

279 para = self.block_item_container.add_paragraph() 

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

281 

282 paragraph_properties = p.get_or_add_pPr() 

283 p_bdr = OxmlElement('w:pBdr') 

284 bottom = OxmlElement('w:bottom') 

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

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

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

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

289 p_bdr.append(bottom) 

290 paragraph_properties.append(p_bdr) 

291 

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

293 """ 

294 Renders a heading element. 

295 

296 Args: 

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

298 """ 

299 assert self.block_item_container is not None 

300 

301 self._is_heading = True 

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

303 # Heading inline children are direct children of the Heading node, not wrapped in a 

304 # Paragraph node, so render_paragraph is never called — the docx paragraph must be 

305 # created here directly. 

306 self._current_paragraph = self.block_item_container.add_paragraph( 

307 style=f"Heading {self._heading_level}" 

308 ) 

309 

310 self.render_children(element) 

311 

312 self._current_paragraph = None 

313 self._is_heading = False 

314 self._heading_level = 0 

315 

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

317 """ 

318 Renders a setext heading element. 

319 

320 Args: 

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

322 """ 

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

324 

325 # pylint: disable-next=unused-argument 

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

327 """ 

328 Renders a blank line element. 

329 

330 Args: 

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

332 """ 

333 assert self.block_item_container is not None 

334 

335 self.block_item_container.add_paragraph() 

336 

337 # pylint: disable-next=unused-argument 

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

339 """ 

340 Renders a link reference definition element. 

341 

342 Args: 

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

344 """ 

345 # docx uses reference links differently than Markdown. 

346 # It shall not be rendered in the document. 

347 

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

349 """ 

350 Renders an emphasis (italic) element. 

351 

352 Args: 

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

354 """ 

355 assert self.block_item_container is not None 

356 

357 self._is_italic = True 

358 

359 self.render_children(element) 

360 

361 self._is_italic = False 

362 

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

364 """ 

365 Renders a strong emphasis (bold) element. 

366 

367 Args: 

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

369 """ 

370 assert self.block_item_container is not None 

371 

372 self._is_bold = True 

373 

374 self.render_children(element) 

375 

376 self._is_bold = False 

377 

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

379 """ 

380 Renders an inline HTML element. 

381 

382 Args: 

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

384 """ 

385 assert self._current_paragraph is not None 

386 

387 run = self._current_paragraph.add_run(element.children) 

388 run.font.name = "Consolas" 

389 

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

391 """ 

392 Renders plain text or any element with string children. 

393 

394 Args: 

395 element (Any): The element to render. 

396 """ 

397 if isinstance(element.children, str): 

398 assert self._current_paragraph is not None 

399 run = self._current_paragraph.add_run(element.children) 

400 # Only set run properties to True when explicitly active. Setting them to False 

401 # would override the paragraph style (e.g. Quote italic), breaking style inheritance. 

402 # Leaving them as None lets the paragraph style apply instead. 

403 if self._is_bold: 

404 run.bold = True 

405 if self._is_italic: 

406 run.italic = True 

407 if self._is_underline: 

408 run.underline = True 

409 else: 

410 self.render_children(element) 

411 

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

413 """ 

414 Renders a link element as a clickable hyperlink. 

415 

416 Args: 

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

418 """ 

419 assert self._current_paragraph is not None 

420 

421 # python-docx has no high-level API for hyperlinks. The OOXML spec requires: 

422 # 1. A relationship entry in the document part (rel_id) linking the URL. 

423 # 2. A w:hyperlink element referencing that relationship by r:id. 

424 # 3. A w:r run inside it with a w:rStyle "Hyperlink" for standard link styling. 

425 # All three must be built via direct XML manipulation. 

426 rel_id = self.block_item_container.part.relate_to( 

427 element.dest, 

428 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', 

429 is_external=True 

430 ) 

431 

432 hyperlink = OxmlElement('w:hyperlink') 

433 hyperlink.set(qn('r:id'), rel_id) 

434 

435 r = OxmlElement('w:r') 

436 r_pr = OxmlElement('w:rPr') 

437 r_style = OxmlElement('w:rStyle') 

438 r_style.set(qn('w:val'), 'Hyperlink') 

439 r_pr.append(r_style) 

440 r.append(r_pr) 

441 

442 t = OxmlElement('w:t') 

443 t.text = self._collect_link_text(element) 

444 r.append(t) 

445 

446 hyperlink.append(r) 

447 self._current_paragraph._p.append(hyperlink) # pylint: disable=protected-access 

448 

449 @staticmethod 

450 def _collect_link_text(element: Any) -> str: 

451 """Recursively collects the plain text label from a link element's children. 

452 

453 Args: 

454 element (Any): The link element. 

455 

456 Returns: 

457 str: The plain text of the link label. 

458 """ 

459 if isinstance(element.children, str): 

460 result = element.children 

461 else: 

462 result = "".join( 

463 Md2DocxRenderer._collect_link_text(child) for child in element.children 

464 ) 

465 

466 return result 

467 

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

469 """ 

470 Renders an auto link element. 

471 

472 Args: 

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

474 """ 

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

476 

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

478 """ 

479 Renders an image element. 

480 

481 Args: 

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

483 

484 Returns: 

485 DocxDocument: The rendered image as a docx document. 

486 """ 

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

488 if element.title: 

489 assert self.block_item_container is not None 

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

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

492 

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

494 """ 

495 Renders a literal (inline code) element. 

496 

497 Args: 

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

499 """ 

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

501 

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

503 """ 

504 Renders a raw text element. 

505 

506 Args: 

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

508 

509 Returns: 

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

511 """ 

512 assert self._current_paragraph is not None 

513 

514 run = self._current_paragraph.add_run(element.children) 

515 # Only set run properties to True when explicitly active — see render_plain_text. 

516 if self._is_bold: 

517 run.bold = True 

518 if self._is_italic: 

519 run.italic = True 

520 if self._is_underline: 

521 run.underline = True 

522 

523 # pylint: disable-next=unused-argument 

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

525 """ 

526 Renders a line break element. 

527 

528 Args: 

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

530 

531 Returns: 

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

533 """ 

534 assert self._current_paragraph is not None 

535 

536 self._current_paragraph.add_run().add_break() 

537 

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

539 """ 

540 Renders a code span (inline code) element. 

541 

542 Args: 

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

544 

545 Returns: 

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

547 """ 

548 assert self._current_paragraph is not None 

549 

550 run = self._current_paragraph.add_run(cast(str, element.children)) 

551 run.font.name = "Consolas" 

552 

553# Functions ******************************************************************** 

554 

555# Main *************************************************************************