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

1"""reStructuredText renderer for Marko. 

2 It is used to convert CommonMark AST to reStructuredText 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 hashlib 

27import os 

28from typing import TYPE_CHECKING, Any, cast, Optional 

29from marko import Renderer 

30from pyTRLCConverter.plantuml import PlantUML 

31 

32if TYPE_CHECKING: 

33 from . import block, inline 

34 

35# Variables ******************************************************************** 

36 

37# Classes ********************************************************************** 

38 

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. 

45 

46 Converts CommonMark Markdown to reStructuredText format. Fenced code 

47 blocks tagged ``plantuml`` are rendered as SVG images referenced via 

48 ``.. image::`` directives. 

49 """ 

50 

51 # Destination directory where generated PNG images are written. 

52 image_dir: Optional[str] = None 

53 

54 # List of ``(source_path, local_name)`` tuples collected during rendering. 

55 external_files: Optional[list] = None 

56 

57 def __init__(self) -> None: 

58 """ 

59 Initializes the renderer. 

60 """ 

61 super().__init__() 

62 self._list_indent_level = 0 

63 

64 def render_paragraph(self, element: block.Paragraph) -> str: 

65 """ 

66 Renders a paragraph element. 

67 

68 Args: 

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

70 

71 Returns: 

72 str: The rendered paragraph as a string. 

73 """ 

74 return self.render_children(element) + "\n\n" 

75 

76 def render_list(self, element: block.List) -> str: 

77 """ 

78 Renders a list (ordered or unordered) element. 

79 

80 Args: 

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

82 

83 Returns: 

84 str: The rendered list as a string. 

85 """ 

86 items = [] 

87 

88 self._list_indent_level += 1 

89 

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) 

94 

95 self._list_indent_level -= 1 

96 

97 return "\n".join(items) + "\n" 

98 

99 def render_list_item(self, element: block.ListItem, marker="-") -> str: 

100 """ 

101 Renders a list item element. 

102 

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 "*". 

106 

107 Returns: 

108 str: The rendered list item as a string. 

109 """ 

110 indent = 2 

111 content = self.render_children(element) 

112 

113 return f"{' ' * indent * (self._list_indent_level - 1)}{marker} {content}" 

114 

115 def render_quote(self, element: block.Quote) -> str: 

116 """ 

117 Renders a blockquote element. 

118 

119 Args: 

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

121 

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()]) 

127 

128 return quoted + "\n\n" 

129 

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. 

134 

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. 

139 

140 Args: 

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

142 

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) 

148 

149 lang = element.lang or "" 

150 code = element.children[0].children # type: ignore 

151 

152 return f".. code-block:: {lang}\n\n " + "\n ".join(code.splitlines()) + "\n\n" 

153 

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. 

158 

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. 

164 

165 On failure an error paragraph is emitted instead. 

166 

167 Args: 

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

169 

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 

176 

177 try: 

178 plantuml = PlantUML() 

179 svg_bytes = plantuml.generate_to_bytes("svg", diagram_source) 

180 

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) 

186 

187 Md2RstRenderer.external_files.append((svg_path, local_name)) 

188 

189 result = f".. image:: {local_name}\n\n" 

190 except (FileNotFoundError, OSError) as exc: 

191 result = f"[PlantUML error: {exc}]\n\n" 

192 

193 return result 

194 

195 def render_code_block(self, element: block.CodeBlock) -> str: 

196 """ 

197 Renders a code block element. 

198 

199 Args: 

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

201 

202 Returns: 

203 str: The rendered code block as a string. 

204 """ 

205 return self.render_fenced_code(cast("block.FencedCode", element)) 

206 

207 def render_html_block(self, element: block.HTMLBlock) -> str: 

208 """ 

209 Renders a raw HTML block element. 

210 

211 Args: 

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

213 

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 

219 

220 return "::\n\n " + "\n ".join(body.splitlines()) + "\n\n" 

221 

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. 

226 

227 Args: 

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

229 

230 Returns: 

231 str: The rendered thematic break as a empty rst line. 

232 """ 

233 return "\n|\n" 

234 

235 def render_heading(self, element: block.Heading) -> str: 

236 """ 

237 Renders a heading element as a rubric. 

238 

239 Args: 

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

241 

242 Returns: 

243 str: The rendered heading as a string. 

244 """ 

245 text = self.render_children(element) 

246 return f".. rubric:: {text}\n" 

247 

248 def render_setext_heading(self, element: block.SetextHeading) -> str: 

249 """ 

250 Renders a setext heading element. 

251 

252 Args: 

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

254 

255 Returns: 

256 str: The rendered setext heading as a string. 

257 """ 

258 return self.render_heading(cast("block.Heading", element)) 

259 

260 # pylint: disable-next=unused-argument 

261 def render_blank_line(self, element: block.BlankLine) -> str: 

262 """ 

263 Renders a blank line element. 

264 

265 Args: 

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

267 

268 Returns: 

269 str: The rendered blank line as a string. 

270 """ 

271 return "\n" 

272 

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. 

277 

278 Args: 

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

280 

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

287 

288 def render_emphasis(self, element: inline.Emphasis) -> str: 

289 """ 

290 Renders an emphasis (italic) element. 

291 

292 Args: 

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

294 

295 Returns: 

296 str: The rendered emphasis as a string. 

297 """ 

298 return f"*{self.render_children(element)}*" 

299 

300 def render_strong_emphasis(self, element: inline.StrongEmphasis) -> str: 

301 """ 

302 Renders a strong emphasis (bold) element. 

303 

304 Args: 

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

306 

307 Returns: 

308 str: The rendered strong emphasis as a string. 

309 """ 

310 return f"**{self.render_children(element)}**" 

311 

312 def render_inline_html(self, element: inline.InlineHTML) -> str: 

313 """ 

314 Renders an inline HTML element. 

315 

316 Args: 

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

318 

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}``" 

325 

326 def render_plain_text(self, element: Any) -> str: 

327 """ 

328 Renders plain text or any element with string children. 

329 

330 Args: 

331 element (Any): The element to render. 

332 

333 Returns: 

334 str: The rendered plain text as a string. 

335 """ 

336 if isinstance(element.children, str): 

337 return element.children 

338 

339 return self.render_children(element) 

340 

341 def render_link(self, element: inline.Link) -> str: 

342 """ 

343 Renders a link element. 

344 

345 Args: 

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

347 

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

354 

355 return f"`{body} <{url}>`_{title}" 

356 

357 def render_auto_link(self, element: inline.AutoLink) -> str: 

358 """ 

359 Renders an auto link element. 

360 

361 Args: 

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

363 

364 Returns: 

365 str: The rendered auto link as a string. 

366 """ 

367 return self.render_link(cast("inline.Link", element)) 

368 

369 def render_url(self, element: Any) -> str: 

370 """Renders a GitHub Flavored Markdown URL element. 

371 

372 Args: 

373 element (Any): The URL element. 

374 

375 Returns: 

376 str: The rendered URL as an RST link. 

377 """ 

378 return self.render_link(cast("inline.Link", element)) 

379 

380 def render_image(self, element: inline.Image) -> str: 

381 """ 

382 Renders an image element. 

383 

384 Args: 

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

386 

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

394 

395 return f".. image:: {url}\n{title}\n{extra_title}\n" 

396 

397 def render_literal(self, element: inline.Literal) -> str: 

398 """ 

399 Renders a literal (inline code) element. 

400 

401 Args: 

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

403 

404 Returns: 

405 str: The rendered literal as a string. 

406 """ 

407 return self.render_raw_text(cast("inline.RawText", element)) 

408 

409 def render_raw_text(self, element: inline.RawText) -> str: 

410 """ 

411 Renders a raw text element. 

412 

413 Args: 

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

415 

416 Returns: 

417 str: The rendered raw text as a string. 

418 """ 

419 return f"{element.children}" 

420 

421 # pylint: disable-next=unused-argument 

422 def render_line_break(self, element: inline.LineBreak) -> str: 

423 """ 

424 Renders a line break element. 

425 

426 Args: 

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

428 

429 Returns: 

430 str: The rendered line break as a string. 

431 """ 

432 return "\n" 

433 

434 def render_code_span(self, element: inline.CodeSpan) -> str: 

435 """ 

436 Renders a code span (inline code) element. 

437 

438 Args: 

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

440 

441 Returns: 

442 str: The rendered code span as a string. 

443 """ 

444 return f"``{cast(str, element.children)}``" 

445 

446# Functions ******************************************************************** 

447 

448# Main *************************************************************************