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
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-21 12:06 +0000
1"""Docx Renderer for Marko.
3 Author: Andreas Merkle (andreas.merkle@newtec.de)
4"""
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/>.
22# Imports **********************************************************************
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
31if TYPE_CHECKING:
32 from . import block, inline
34# Variables ********************************************************************
36# Classes **********************************************************************
38class Singleton(type):
39 # lobster-trace: SwRequirements.sw_req_docx_render_md
40 """Singleton metaclass to ensure only one instance of a class exists."""
42 _instances = {}
44 def __call__(cls, *args, **kwargs):
45 """
46 Returns the singleton instance of the class.
48 Returns:
49 instance (cls): The singleton instance of the class.
50 """
52 if cls not in cls._instances:
53 cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
55 return cls._instances[cls]
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."""
62 # Docx block item container to add content to.
63 block_item_container: Optional[BlockItemContainer] = None
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
78 def render_children(self, element: Any) -> None:
79 """
80 Recursively renders child elements of a given element to
81 a docx document.
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)
89 def render_paragraph(self, element: block.Paragraph) -> None:
90 """
91 Renders a paragraph element.
93 Args:
94 element (block.Paragraph): The paragraph element to render.
95 """
96 self.render_children(element)
98 def render_list(self, element: block.List) -> None:
99 """
100 Renders a list (ordered or unordered) element.
102 Args:
103 element (block.List): The list element to render.
104 """
105 assert self.block_item_container is not None
107 self._is_list_item = True
108 self._list_indent_level += 1
110 style = "List Number" if element.ordered else "List Bullet"
112 if self._list_indent_level > 1:
113 style += f" {self._list_indent_level}"
115 self._list_style.append(style)
117 for child in element.children:
118 self.render_children(child)
120 self._list_style.pop()
122 self._list_indent_level -= 1
124 if self._list_indent_level == 0:
125 self._is_list_item = False
127 def render_quote(self, element: block.Quote) -> None:
128 """
129 Renders a blockquote element.
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
138 def render_fenced_code(self, element: block.FencedCode) -> None:
139 """
140 Renders a fenced code block element.
142 Args:
143 element (block.FencedCode): The fenced code block element to render.
144 """
145 assert self.block_item_container is not None
147 paragraph = self.block_item_container.add_paragraph()
148 run = paragraph.add_run(element.children[0].children)
149 run.font.name = "Consolas"
151 def render_code_block(self, element: block.CodeBlock) -> None:
152 """
153 Renders a code block element.
155 Args:
156 element (block.CodeBlock): The code block element to render.
157 """
158 self.render_fenced_code(cast("block.FencedCode", element))
160 def render_html_block(self, element: block.HTMLBlock) -> None:
161 """
162 Renders a raw HTML block element.
164 Args:
165 element (block.HTMLBlock): The HTML block element to render.
166 """
167 self.render_fenced_code(cast("block.FencedCode", element))
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.
174 Args:
175 element (block.ThematicBreak): The thematic break element to render.
176 """
177 assert self.block_item_container is not None
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
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)
193 def render_heading(self, element: block.Heading) -> None:
194 """
195 Renders a heading element.
197 Args:
198 element (block.Heading): The heading element to render.
199 """
200 assert self.block_item_container is not None
202 self._is_heading = True
203 self._heading_level = min(max(element.level, 1), 9) # docx supports levels 1-9
205 self.render_children(element)
207 self._is_heading = False
208 self._heading_level = 0
210 def render_setext_heading(self, element: block.SetextHeading) -> None:
211 """
212 Renders a setext heading element.
214 Args:
215 element (block.SetextHeading): The setext heading element to render.
216 """
217 self.render_heading(cast("block.Heading", element))
219 # pylint: disable-next=unused-argument
220 def render_blank_line(self, element: block.BlankLine) -> None:
221 """
222 Renders a blank line element.
224 Args:
225 element (block.BlankLine): The blank line element to render.
226 """
227 assert self.block_item_container is not None
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
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]
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.
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.
251 def render_emphasis(self, element: inline.Emphasis) -> None:
252 """
253 Renders an emphasis (italic) element.
255 Args:
256 element (inline.Emphasis): The emphasis element to render.
257 """
258 assert self.block_item_container is not None
260 self._is_italic = True
262 self.render_children(element)
264 self._is_italic = False
266 def render_strong_emphasis(self, element: inline.StrongEmphasis) -> None:
267 """
268 Renders a strong emphasis (bold) element.
270 Args:
271 element (inline.StrongEmphasis): The strong emphasis element to render.
272 """
273 assert self.block_item_container is not None
275 self._is_bold = True
277 self.render_children(element)
279 self._is_bold = False
281 def render_inline_html(self, element: inline.InlineHTML) -> None:
282 """
283 Renders an inline HTML element.
285 Args:
286 element (inline.InlineHTML): The inline HTML element to render.
287 """
288 assert self.block_item_container is not None
290 paragraph = self.block_item_container.add_paragraph()
291 run = paragraph.add_run(element.children)
292 run.font.name = "Consolas"
294 def render_plain_text(self, element: Any) -> None:
295 """
296 Renders plain text or any element with string children.
298 Args:
299 element (Any): The element to render.
300 """
301 assert self.block_item_container is not None
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
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)
317 def render_link(self, element: inline.Link) -> None:
318 """
319 Renders a link element.
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)
327 def render_auto_link(self, element: inline.AutoLink) -> None:
328 """
329 Renders an auto link element.
331 Args:
332 element (inline.AutoLink): The auto link element to render.
333 """
334 self.render_link(cast("inline.Link", element))
336 def render_image(self, element: inline.Image) -> None:
337 """
338 Renders an image element.
340 Args:
341 element (inline.Image): The image element to render.
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})")
352 def render_literal(self, element: inline.Literal) -> None:
353 """
354 Renders a literal (inline code) element.
356 Args:
357 element (inline.Literal): The literal element to render.
358 """
359 self.render_raw_text(cast("inline.RawText", element))
361 def render_raw_text(self, element: inline.RawText) -> None:
362 """
363 Renders a raw text element.
365 Args:
366 element (inline.RawText): The raw text element to render.
368 Returns:
369 DocxDocument: The rendered raw text as a docx document.
370 """
371 assert self.block_item_container is not None
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
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"
386 # pylint: disable-next=unused-argument
387 def render_line_break(self, element: inline.LineBreak) -> None:
388 """
389 Renders a line break element.
391 Args:
392 element (inline.LineBreak): The line break element to render.
394 Returns:
395 DocxDocument: The rendered line break as a docx document.
396 """
397 assert self.block_item_container is not None
399 paragraph = self.block_item_container.add_paragraph()
400 run = paragraph.add_run()
401 run.add_break()
403 def render_code_span(self, element: inline.CodeSpan) -> None:
404 """
405 Renders a code span (inline code) element.
407 Args:
408 element (inline.CodeSpan): The code span element to render.
410 Returns:
411 DocxDocument: The rendered code span as a docx document.
412 """
413 assert self.block_item_container is not None
415 paragraph = self.block_item_container.add_paragraph()
416 run = paragraph.add_run(cast(str, element.children))
417 run.font.name = "Consolas"
419# Functions ********************************************************************
421# Main *************************************************************************