Coverage for src/pyTRLCConverter/marko/md2rst_renderer.py: 81%
103 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"""reStructuredText renderer for Marko.
2 It is used to convert CommonMark AST to reStructuredText 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 hashlib
27import os
28from typing import TYPE_CHECKING, Any, cast, Optional
29from marko import Renderer
30from pyTRLCConverter.plantuml import PlantUML
32if TYPE_CHECKING:
33 from . import block, inline
35# Variables ********************************************************************
37# Classes **********************************************************************
39# pylint: disable-next=too-many-public-methods
40class Md2RstRenderer(Renderer):
41 # lobster-trace: SwRequirements.sw_req_rst_render_md
42 # lobster-trace: SwRequirements.sw_req_rst_render_plantuml
43 # lobster-trace: SwRequirements.sw_req_plantuml
44 """Renderer for reStructuredText output.
46 Converts CommonMark Markdown to reStructuredText format. Fenced code
47 blocks tagged ``plantuml`` are rendered as SVG images referenced via
48 ``.. image::`` directives.
49 """
51 # Destination directory where generated PNG images are written.
52 image_dir: Optional[str] = None
54 # List of ``(source_path, local_name)`` tuples collected during rendering.
55 external_files: Optional[list] = None
57 def __init__(self) -> None:
58 """
59 Initializes the renderer.
60 """
61 super().__init__()
62 self._list_indent_level = 0
64 def render_paragraph(self, element: block.Paragraph) -> str:
65 """
66 Renders a paragraph element.
68 Args:
69 element (block.Paragraph): The paragraph element to render.
71 Returns:
72 str: The rendered paragraph as a string.
73 """
74 return self.render_children(element) + "\n\n"
76 def render_list(self, element: block.List) -> str:
77 """
78 Renders a list (ordered or unordered) element.
80 Args:
81 element (block.List): The list element to render.
83 Returns:
84 str: The rendered list as a string.
85 """
86 items = []
88 self._list_indent_level += 1
90 for index, child in enumerate(element.children):
91 marker = f"{index + 1}." if element.ordered else "-"
92 item = self.render_list_item(child, marker)
93 items.append(item)
95 self._list_indent_level -= 1
97 return "\n".join(items) + "\n"
99 def render_list_item(self, element: block.ListItem, marker="-") -> str:
100 """
101 Renders a list item element.
103 Args:
104 element (block.ListItem): The list item element to render.
105 marker (str, optional): The marker to use for the list item. Defaults to "*".
107 Returns:
108 str: The rendered list item as a string.
109 """
110 indent = 2
111 content = self.render_children(element)
113 return f"{' ' * indent * (self._list_indent_level - 1)}{marker} {content}"
115 def render_quote(self, element: block.Quote) -> str:
116 """
117 Renders a blockquote element.
119 Args:
120 element (block.Quote): The blockquote element to render.
122 Returns:
123 str: The rendered blockquote as a string.
124 """
125 quote = self.render_children(element)
126 quoted = "\n".join([f" {line}" if line.strip() else "" for line in quote.splitlines()])
128 return quoted + "\n\n"
130 def render_fenced_code(self, element: block.FencedCode) -> str:
131 # lobster-trace: SwRequirements.sw_req_rst_render_md
132 # lobster-trace: SwRequirements.sw_req_plantuml
133 """Render a fenced code block as reStructuredText.
135 If the language tag is ``plantuml``, the diagram source is rendered as
136 a PNG image referenced via a ``.. image::`` directive. If PlantUML is
137 not available, an error paragraph is emitted instead. All other fenced
138 code blocks are rendered as ``.. code-block::`` directives.
140 Args:
141 element (block.FencedCode): The fenced code block element to render.
143 Returns:
144 str: The rendered fenced code block as a string.
145 """
146 if element.lang == "plantuml":
147 return self._render_plantuml(element.children[0].children)
149 lang = element.lang or ""
150 code = element.children[0].children # type: ignore
152 return f".. code-block:: {lang}\n\n " + "\n ".join(code.splitlines()) + "\n\n"
154 def _render_plantuml(self, diagram_source: str) -> str:
155 # lobster-trace: SwRequirements.sw_req_rst_render_md
156 # lobster-trace: SwRequirements.sw_req_plantuml
157 """Render a PlantUML diagram as a PNG image reference.
159 Generates SVG bytes via the PlantUML tool, writes them to a temporary
160 file under :attr:`image_dir`, registers the file in
161 :attr:`external_files` so the converter can copy it next to the RST
162 document, and returns a ``.. image::`` directive referencing the image
163 by its local name.
165 On failure an error paragraph is emitted instead.
167 Args:
168 diagram_source (str): The PlantUML diagram source text.
170 Returns:
171 str: RST fragment referencing the generated image, or an error
172 paragraph if image generation failed.
173 """
174 assert Md2RstRenderer.image_dir is not None
175 assert Md2RstRenderer.external_files is not None
177 try:
178 plantuml = PlantUML()
179 svg_bytes = plantuml.generate_to_bytes("svg", diagram_source)
181 digest = hashlib.sha1(diagram_source.encode("utf-8")).hexdigest()[:12]
182 local_name = f"plantuml_{digest}.svg"
183 svg_path = os.path.join(Md2RstRenderer.image_dir, local_name)
184 with open(svg_path, "wb") as svg_file:
185 svg_file.write(svg_bytes)
187 Md2RstRenderer.external_files.append((svg_path, local_name))
189 result = f".. image:: {local_name}\n\n"
190 except (FileNotFoundError, OSError) as exc:
191 result = f"[PlantUML error: {exc}]\n\n"
193 return result
195 def render_code_block(self, element: block.CodeBlock) -> str:
196 """
197 Renders a code block element.
199 Args:
200 element (block.CodeBlock): The code block element to render.
202 Returns:
203 str: The rendered code block as a string.
204 """
205 return self.render_fenced_code(cast("block.FencedCode", element))
207 def render_html_block(self, element: block.HTMLBlock) -> str:
208 """
209 Renders a raw HTML block element.
211 Args:
212 element (block.HTMLBlock): The HTML block element to render.
214 Returns:
215 str: The rendered HTML block as a string.
216 """
217 # reStructuredText does not support raw HTML, so output as a literal block.
218 body = element.body
220 return "::\n\n " + "\n ".join(body.splitlines()) + "\n\n"
222 # pylint: disable-next=unused-argument
223 def render_thematic_break(self, element: block.ThematicBreak) -> str:
224 """
225 Renders a thematic break (horizontal rule) element as a empty line.
227 Args:
228 element (block.ThematicBreak): The thematic break element to render.
230 Returns:
231 str: The rendered thematic break as a empty rst line.
232 """
233 return "\n|\n"
235 def render_heading(self, element: block.Heading) -> str:
236 """
237 Renders a heading element as a rubric.
239 Args:
240 element (block.Heading): The heading element to render.
242 Returns:
243 str: The rendered heading as a string.
244 """
245 text = self.render_children(element)
246 return f".. rubric:: {text}\n"
248 def render_setext_heading(self, element: block.SetextHeading) -> str:
249 """
250 Renders a setext heading element.
252 Args:
253 element (block.SetextHeading): The setext heading element to render.
255 Returns:
256 str: The rendered setext heading as a string.
257 """
258 return self.render_heading(cast("block.Heading", element))
260 # pylint: disable-next=unused-argument
261 def render_blank_line(self, element: block.BlankLine) -> str:
262 """
263 Renders a blank line element.
265 Args:
266 element (block.BlankLine): The blank line element to render.
268 Returns:
269 str: The rendered blank line as a string.
270 """
271 return "\n"
273 # pylint: disable-next=unused-argument
274 def render_link_ref_def(self, element: block.LinkRefDef) -> str:
275 """
276 Renders a link reference definition element.
278 Args:
279 element (block.LinkRefDef): The link reference definition element to render.
281 Returns:
282 str: The rendered link reference definition as a string.
283 """
284 # reStructuredText uses reference links differently than Markdown.
285 # It shall not be rendered in the document.
286 return ""
288 def render_emphasis(self, element: inline.Emphasis) -> str:
289 """
290 Renders an emphasis (italic) element.
292 Args:
293 element (inline.Emphasis): The emphasis element to render.
295 Returns:
296 str: The rendered emphasis as a string.
297 """
298 return f"*{self.render_children(element)}*"
300 def render_strong_emphasis(self, element: inline.StrongEmphasis) -> str:
301 """
302 Renders a strong emphasis (bold) element.
304 Args:
305 element (inline.StrongEmphasis): The strong emphasis element to render.
307 Returns:
308 str: The rendered strong emphasis as a string.
309 """
310 return f"**{self.render_children(element)}**"
312 def render_inline_html(self, element: inline.InlineHTML) -> str:
313 """
314 Renders an inline HTML element.
316 Args:
317 element (inline.InlineHTML): The inline HTML element to render.
319 Returns:
320 str: The rendered inline HTML as a string.
321 """
322 # Output as literal block.
323 html_content = cast(str, element.children)
324 return f"``{html_content}``"
326 def render_plain_text(self, element: Any) -> str:
327 """
328 Renders plain text or any element with string children.
330 Args:
331 element (Any): The element to render.
333 Returns:
334 str: The rendered plain text as a string.
335 """
336 if isinstance(element.children, str):
337 return element.children
339 return self.render_children(element)
341 def render_link(self, element: inline.Link) -> str:
342 """
343 Renders a link element.
345 Args:
346 element (inline.Link): The link element to render.
348 Returns:
349 str: The rendered link as a string.
350 """
351 body = self.render_children(element)
352 url = element.dest
353 title = f" ({element.title})" if element.title else ""
355 return f"`{body} <{url}>`_{title}"
357 def render_auto_link(self, element: inline.AutoLink) -> str:
358 """
359 Renders an auto link element.
361 Args:
362 element (inline.AutoLink): The auto link element to render.
364 Returns:
365 str: The rendered auto link as a string.
366 """
367 return self.render_link(cast("inline.Link", element))
369 def render_url(self, element: Any) -> str:
370 """Renders a GitHub Flavored Markdown URL element.
372 Args:
373 element (Any): The URL element.
375 Returns:
376 str: The rendered URL as an RST link.
377 """
378 return self.render_link(cast("inline.Link", element))
380 def render_image(self, element: inline.Image) -> str:
381 """
382 Renders an image element.
384 Args:
385 element (inline.Image): The image element to render.
387 Returns:
388 str: The rendered image as a string.
389 """
390 url = element.dest
391 alt = self.render_children(element)
392 title = f" :alt: {alt}" if alt else ""
393 extra_title = f" :title: {element.title}" if element.title else ""
395 return f".. image:: {url}\n{title}\n{extra_title}\n"
397 def render_literal(self, element: inline.Literal) -> str:
398 """
399 Renders a literal (inline code) element.
401 Args:
402 element (inline.Literal): The literal element to render.
404 Returns:
405 str: The rendered literal as a string.
406 """
407 return self.render_raw_text(cast("inline.RawText", element))
409 def render_raw_text(self, element: inline.RawText) -> str:
410 """
411 Renders a raw text element.
413 Args:
414 element (inline.RawText): The raw text element to render.
416 Returns:
417 str: The rendered raw text as a string.
418 """
419 return f"{element.children}"
421 # pylint: disable-next=unused-argument
422 def render_line_break(self, element: inline.LineBreak) -> str:
423 """
424 Renders a line break element.
426 Args:
427 element (inline.LineBreak): The line break element to render.
429 Returns:
430 str: The rendered line break as a string.
431 """
432 return "\n"
434 def render_code_span(self, element: inline.CodeSpan) -> str:
435 """
436 Renders a code span (inline code) element.
438 Args:
439 element (inline.CodeSpan): The code span element to render.
441 Returns:
442 str: The rendered code span as a string.
443 """
444 return f"``{cast(str, element.children)}``"
446# Functions ********************************************************************
448# Main *************************************************************************