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
« 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.
4 Author: Andreas Merkle (andreas.merkle@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
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
35if TYPE_CHECKING:
36 from . import block, inline
38# Variables ********************************************************************
40# Classes **********************************************************************
42class Singleton(type):
43 # lobster-trace: SwRequirements.sw_req_docx_render_md
44 """Singleton metaclass to ensure only one instance of a class exists."""
46 _instances = {}
48 def __call__(cls, *args, **kwargs):
49 """
50 Returns the singleton instance of the class.
52 Returns:
53 instance (cls): The singleton instance of the class.
54 """
56 if cls not in cls._instances:
57 cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
59 return cls._instances[cls]
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."""
66 # Docx block item container to add content to.
67 block_item_container: Optional[BlockItemContainer] = None
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()
84 def reset(self) -> None:
85 """Resets the renderer state before a new convert() call.
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
103 def render_children(self, element: Any) -> None:
104 """
105 Recursively renders child elements of a given element to
106 a docx document.
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)
114 def render_paragraph(self, element: block.Paragraph) -> None:
115 """
116 Renders a paragraph element.
118 Args:
119 element (block.Paragraph): The paragraph element to render.
120 """
121 assert self.block_item_container is not None
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()
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"
139 self.render_children(element)
140 self._current_paragraph = None
142 def render_list(self, element: block.List) -> None:
143 """
144 Renders a list (ordered or unordered) element.
146 Args:
147 element (block.List): The list element to render.
148 """
149 assert self.block_item_container is not None
151 self._is_list_item = True
152 self._list_indent_level += 1
154 style = "List Number" if element.ordered else "List Bullet"
156 if self._list_indent_level > 1:
157 style += f" {self._list_indent_level}"
159 self._list_style.append(style)
161 for child in element.children:
162 self.render(child)
164 self._list_style.pop()
166 self._list_indent_level -= 1
168 if self._list_indent_level == 0:
169 self._is_list_item = False
171 def render_quote(self, element: block.Quote) -> None:
172 """
173 Renders a blockquote element.
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
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.
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.
193 Args:
194 element (block.FencedCode): The fenced code block element to render.
195 """
196 assert self.block_item_container is not None
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"
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.
212 Generates PNG bytes via the PlantUML tool and embeds the image directly.
213 If PlantUML is not available, inserts an error string instead.
215 Args:
216 diagram_source (str): The PlantUML diagram source text.
217 """
218 assert self.block_item_container is not None
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()
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}]")
244 def render_code_block(self, element: block.CodeBlock) -> None:
245 """
246 Renders a code block element.
248 Args:
249 element (block.CodeBlock): The code block element to render.
250 """
251 self.render_fenced_code(cast("block.FencedCode", element))
253 def render_html_block(self, element: block.HTMLBlock) -> None:
254 """
255 Renders a raw HTML block element.
257 Args:
258 element (block.HTMLBlock): The HTML block element to render.
259 """
260 self.render_fenced_code(cast("block.FencedCode", element))
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.
267 Args:
268 element (block.ThematicBreak): The thematic break element to render.
269 """
270 assert self.block_item_container is not None
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
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
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)
292 def render_heading(self, element: block.Heading) -> None:
293 """
294 Renders a heading element.
296 Args:
297 element (block.Heading): The heading element to render.
298 """
299 assert self.block_item_container is not None
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 )
310 self.render_children(element)
312 self._current_paragraph = None
313 self._is_heading = False
314 self._heading_level = 0
316 def render_setext_heading(self, element: block.SetextHeading) -> None:
317 """
318 Renders a setext heading element.
320 Args:
321 element (block.SetextHeading): The setext heading element to render.
322 """
323 self.render_heading(cast("block.Heading", element))
325 # pylint: disable-next=unused-argument
326 def render_blank_line(self, element: block.BlankLine) -> None:
327 """
328 Renders a blank line element.
330 Args:
331 element (block.BlankLine): The blank line element to render.
332 """
333 assert self.block_item_container is not None
335 self.block_item_container.add_paragraph()
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.
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.
348 def render_emphasis(self, element: inline.Emphasis) -> None:
349 """
350 Renders an emphasis (italic) element.
352 Args:
353 element (inline.Emphasis): The emphasis element to render.
354 """
355 assert self.block_item_container is not None
357 self._is_italic = True
359 self.render_children(element)
361 self._is_italic = False
363 def render_strong_emphasis(self, element: inline.StrongEmphasis) -> None:
364 """
365 Renders a strong emphasis (bold) element.
367 Args:
368 element (inline.StrongEmphasis): The strong emphasis element to render.
369 """
370 assert self.block_item_container is not None
372 self._is_bold = True
374 self.render_children(element)
376 self._is_bold = False
378 def render_inline_html(self, element: inline.InlineHTML) -> None:
379 """
380 Renders an inline HTML element.
382 Args:
383 element (inline.InlineHTML): The inline HTML element to render.
384 """
385 assert self._current_paragraph is not None
387 run = self._current_paragraph.add_run(element.children)
388 run.font.name = "Consolas"
390 def render_plain_text(self, element: Any) -> None:
391 """
392 Renders plain text or any element with string children.
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)
412 def render_link(self, element: inline.Link) -> None:
413 """
414 Renders a link element as a clickable hyperlink.
416 Args:
417 element (inline.Link): The link element to render.
418 """
419 assert self._current_paragraph is not None
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 )
432 hyperlink = OxmlElement('w:hyperlink')
433 hyperlink.set(qn('r:id'), rel_id)
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)
442 t = OxmlElement('w:t')
443 t.text = self._collect_link_text(element)
444 r.append(t)
446 hyperlink.append(r)
447 self._current_paragraph._p.append(hyperlink) # pylint: disable=protected-access
449 @staticmethod
450 def _collect_link_text(element: Any) -> str:
451 """Recursively collects the plain text label from a link element's children.
453 Args:
454 element (Any): The link element.
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 )
466 return result
468 def render_auto_link(self, element: inline.AutoLink) -> None:
469 """
470 Renders an auto link element.
472 Args:
473 element (inline.AutoLink): The auto link element to render.
474 """
475 self.render_link(cast("inline.Link", element))
477 def render_image(self, element: inline.Image) -> None:
478 """
479 Renders an image element.
481 Args:
482 element (inline.Image): The image element to render.
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})")
493 def render_literal(self, element: inline.Literal) -> None:
494 """
495 Renders a literal (inline code) element.
497 Args:
498 element (inline.Literal): The literal element to render.
499 """
500 self.render_raw_text(cast("inline.RawText", element))
502 def render_raw_text(self, element: inline.RawText) -> None:
503 """
504 Renders a raw text element.
506 Args:
507 element (inline.RawText): The raw text element to render.
509 Returns:
510 DocxDocument: The rendered raw text as a docx document.
511 """
512 assert self._current_paragraph is not None
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
523 # pylint: disable-next=unused-argument
524 def render_line_break(self, element: inline.LineBreak) -> None:
525 """
526 Renders a line break element.
528 Args:
529 element (inline.LineBreak): The line break element to render.
531 Returns:
532 DocxDocument: The rendered line break as a docx document.
533 """
534 assert self._current_paragraph is not None
536 self._current_paragraph.add_run().add_break()
538 def render_code_span(self, element: inline.CodeSpan) -> None:
539 """
540 Renders a code span (inline code) element.
542 Args:
543 element (inline.CodeSpan): The code span element to render.
545 Returns:
546 DocxDocument: The rendered code span as a docx document.
547 """
548 assert self._current_paragraph is not None
550 run = self._current_paragraph.add_run(cast(str, element.children))
551 run.font.name = "Consolas"
553# Functions ********************************************************************
555# Main *************************************************************************