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
« 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.
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
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
32if TYPE_CHECKING:
33 from . import block, inline
35# Variables ********************************************************************
37# Classes **********************************************************************
39class Singleton(type):
40 # lobster-trace: SwRequirements.sw_req_docx_render_md
41 """Singleton metaclass to ensure only one instance of a class exists."""
43 _instances = {}
45 def __call__(cls, *args, **kwargs):
46 """
47 Returns the singleton instance of the class.
49 Returns:
50 instance (cls): The singleton instance of the class.
51 """
53 if cls not in cls._instances:
54 cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
56 return cls._instances[cls]
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."""
63 # Docx block item container to add content to.
64 block_item_container: Optional[BlockItemContainer] = None
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
79 def render_children(self, element: Any) -> None:
80 """
81 Recursively renders child elements of a given element to
82 a docx document.
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)
90 def render_paragraph(self, element: block.Paragraph) -> None:
91 """
92 Renders a paragraph element.
94 Args:
95 element (block.Paragraph): The paragraph element to render.
96 """
97 self.render_children(element)
99 def render_list(self, element: block.List) -> None:
100 """
101 Renders a list (ordered or unordered) element.
103 Args:
104 element (block.List): The list element to render.
105 """
106 assert self.block_item_container is not None
108 self._is_list_item = True
109 self._list_indent_level += 1
111 style = "List Number" if element.ordered else "List Bullet"
113 if self._list_indent_level > 1:
114 style += f" {self._list_indent_level}"
116 self._list_style.append(style)
118 for child in element.children:
119 self.render_children(child)
121 self._list_style.pop()
123 self._list_indent_level -= 1
125 if self._list_indent_level == 0:
126 self._is_list_item = False
128 def render_quote(self, element: block.Quote) -> None:
129 """
130 Renders a blockquote element.
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
139 def render_fenced_code(self, element: block.FencedCode) -> None:
140 """
141 Renders a fenced code block element.
143 Args:
144 element (block.FencedCode): The fenced code block element to render.
145 """
146 assert self.block_item_container is not None
148 paragraph = self.block_item_container.add_paragraph()
149 run = paragraph.add_run(element.children[0].children)
150 run.font.name = "Consolas"
152 def render_code_block(self, element: block.CodeBlock) -> None:
153 """
154 Renders a code block element.
156 Args:
157 element (block.CodeBlock): The code block element to render.
158 """
159 self.render_fenced_code(cast("block.FencedCode", element))
161 def render_html_block(self, element: block.HTMLBlock) -> None:
162 """
163 Renders a raw HTML block element.
165 Args:
166 element (block.HTMLBlock): The HTML block element to render.
167 """
168 self.render_fenced_code(cast("block.FencedCode", element))
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.
175 Args:
176 element (block.ThematicBreak): The thematic break element to render.
177 """
178 assert self.block_item_container is not None
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
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)
194 def render_heading(self, element: block.Heading) -> None:
195 """
196 Renders a heading element.
198 Args:
199 element (block.Heading): The heading element to render.
200 """
201 assert self.block_item_container is not None
203 self._is_heading = True
204 self._heading_level = min(max(element.level, 1), 9) # docx supports levels 1-9
206 self.render_children(element)
208 self._is_heading = False
209 self._heading_level = 0
211 def render_setext_heading(self, element: block.SetextHeading) -> None:
212 """
213 Renders a setext heading element.
215 Args:
216 element (block.SetextHeading): The setext heading element to render.
217 """
218 self.render_heading(cast("block.Heading", element))
220 # pylint: disable-next=unused-argument
221 def render_blank_line(self, element: block.BlankLine) -> None:
222 """
223 Renders a blank line element.
225 Args:
226 element (block.BlankLine): The blank line element to render.
227 """
228 assert self.block_item_container is not None
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
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]
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.
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.
252 def render_emphasis(self, element: inline.Emphasis) -> None:
253 """
254 Renders an emphasis (italic) element.
256 Args:
257 element (inline.Emphasis): The emphasis element to render.
258 """
259 assert self.block_item_container is not None
261 self._is_italic = True
263 self.render_children(element)
265 self._is_italic = False
267 def render_strong_emphasis(self, element: inline.StrongEmphasis) -> None:
268 """
269 Renders a strong emphasis (bold) element.
271 Args:
272 element (inline.StrongEmphasis): The strong emphasis element to render.
273 """
274 assert self.block_item_container is not None
276 self._is_bold = True
278 self.render_children(element)
280 self._is_bold = False
282 def render_inline_html(self, element: inline.InlineHTML) -> None:
283 """
284 Renders an inline HTML element.
286 Args:
287 element (inline.InlineHTML): The inline HTML element to render.
288 """
289 assert self.block_item_container is not None
291 paragraph = self.block_item_container.add_paragraph()
292 run = paragraph.add_run(element.children)
293 run.font.name = "Consolas"
295 def render_plain_text(self, element: Any) -> None:
296 """
297 Renders plain text or any element with string children.
299 Args:
300 element (Any): The element to render.
301 """
302 assert self.block_item_container is not None
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
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)
318 def render_link(self, element: inline.Link) -> None:
319 """
320 Renders a link element.
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)
328 def render_auto_link(self, element: inline.AutoLink) -> None:
329 """
330 Renders an auto link element.
332 Args:
333 element (inline.AutoLink): The auto link element to render.
334 """
335 self.render_link(cast("inline.Link", element))
337 def render_image(self, element: inline.Image) -> None:
338 """
339 Renders an image element.
341 Args:
342 element (inline.Image): The image element to render.
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})")
353 def render_literal(self, element: inline.Literal) -> None:
354 """
355 Renders a literal (inline code) element.
357 Args:
358 element (inline.Literal): The literal element to render.
359 """
360 self.render_raw_text(cast("inline.RawText", element))
362 def render_raw_text(self, element: inline.RawText) -> None:
363 """
364 Renders a raw text element.
366 Args:
367 element (inline.RawText): The raw text element to render.
369 Returns:
370 DocxDocument: The rendered raw text as a docx document.
371 """
372 assert self.block_item_container is not None
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
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"
387 # pylint: disable-next=unused-argument
388 def render_line_break(self, element: inline.LineBreak) -> None:
389 """
390 Renders a line break element.
392 Args:
393 element (inline.LineBreak): The line break element to render.
395 Returns:
396 DocxDocument: The rendered line break as a docx document.
397 """
398 assert self.block_item_container is not None
400 paragraph = self.block_item_container.add_paragraph()
401 run = paragraph.add_run()
402 run.add_break()
404 def render_code_span(self, element: inline.CodeSpan) -> None:
405 """
406 Renders a code span (inline code) element.
408 Args:
409 element (inline.CodeSpan): The code span element to render.
411 Returns:
412 DocxDocument: The rendered code span as a docx document.
413 """
414 assert self.block_item_container is not None
416 paragraph = self.block_item_container.add_paragraph()
417 run = paragraph.add_run(cast(str, element.children))
418 run.font.name = "Consolas"
420# Functions ********************************************************************
422# Main *************************************************************************