Coverage for src/pyTRLCConverter/rst/element.py: 98%
112 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 block elements.
2 Each element renders itself into a reStructuredText string. Elements are
3 collected by the RstDocument and rendered once when the output file is
4 written.
6 Author: Gabryel Reyes (gabryel.reyes@newtec.de)
7"""
9# pyTRLCConverter - A tool to convert TRLC files to specific formats.
10# Copyright (c) 2024 - 2026 NewTec GmbH
11#
12# This file is part of pyTRLCConverter program.
13#
14# The pyTRLCConverter program is free software: you can redistribute it and/or modify it under
15# the terms of the GNU General Public License as published by the Free Software Foundation,
16# either version 3 of the License, or (at your option) any later version.
17#
18# The pyTRLCConverter program is distributed in the hope that it will be useful, but
19# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
20# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
21#
22# You should have received a copy of the GNU General Public License along with pyTRLCConverter.
23# If not, see <https://www.gnu.org/licenses/>.
25# Imports **********************************************************************
26import os
27from abc import ABC, abstractmethod
28from typing import List
29from pyTRLCConverter.logger import log_error
30from pyTRLCConverter.rst.text import RstText
32# Variables ********************************************************************
34# Heading underline characters per heading level [1; 7].
35_UNDERLINE_CHARS = ["=", "#", "~", "^", "\"", "+", "'"]
37# Classes **********************************************************************
39# pylint: disable-next=too-few-public-methods
40class RstElement(ABC):
41 """Abstract base class for reStructuredText block elements.
42 """
44 @abstractmethod
45 def render(self) -> str:
46 """Render the element into a reStructuredText string.
48 Returns:
49 str: Rendered reStructuredText
50 """
52# pylint: disable-next=too-few-public-methods
53class RstHeading(RstElement):
54 """reStructuredText heading block element with a label.
55 """
57 def __init__(self, text: str, level: int, file_name: str, escape: bool = True) -> None:
58 """Initialize the heading element.
60 Args:
61 text (str): Heading text
62 level (int): Heading level [1; 7]
63 file_name (str): File name where the heading is found
64 escape (bool): Escape the text (default: True).
65 """
66 self._text = text
67 self._level = level
68 self._file_name = file_name
69 self._escape = escape
71 def render(self) -> str:
72 # lobster-trace: SwRequirements.sw_req_rst_heading
73 """Render the heading with a label.
74 The text will be automatically escaped for reStructuredText if necessary.
76 Returns:
77 str: reStructuredText heading with a label
78 """
79 result = ""
81 if 1 <= self._level <= 7:
82 text_raw = self._text
84 if self._escape is True:
85 text_raw = RstText.escape(self._text)
87 label = f"{self._file_name}-{text_raw.lower().replace(' ', '-')}"
89 underline_char = _UNDERLINE_CHARS[self._level - 1]
90 underline = underline_char * len(text_raw)
92 result = f".. _{label}:\n\n{text_raw}\n{underline}\n"
94 else:
95 log_error(f"Invalid heading level {self._level} for {self._text}.")
97 return result
99# pylint: disable-next=too-few-public-methods
100class RstAdmonition(RstElement):
101 """reStructuredText admonition block element with a label.
102 """
104 def __init__(self, text: str, file_name: str, escape: bool = True) -> None:
105 """Initialize the admonition element.
107 Args:
108 text (str): Admonition text
109 file_name (str): File name where the admonition is found
110 escape (bool): Escape the text (default: True).
111 """
112 self._text = text
113 self._file_name = file_name
114 self._escape = escape
116 def render(self) -> str:
117 # lobster-trace: SwRequirements.sw_req_rst_admonition
118 """Render the admonition with a label.
119 The text will be automatically escaped for reStructuredText if necessary.
121 Returns:
122 str: reStructuredText admonition with a label
123 """
124 text_raw = self._text
126 if self._escape is True:
127 text_raw = RstText.escape(self._text)
129 label = f"{self._file_name}-{text_raw.lower().replace(' ', '-')}"
130 admonition_label = f".. admonition:: {text_raw}"
132 return f".. _{label}:\n\n{admonition_label}\n"
134# pylint: disable-next=too-few-public-methods
135class RstTable(RstElement):
136 """reStructuredText table block element in grid format.
137 The column titles are escaped, the row values are taken as is. Multi-line
138 cell values are supported.
139 """
141 def __init__(self, column_titles: List[str], rows: List[List[str]]) -> None:
142 """Initialize the table element.
144 Args:
145 column_titles (List[str]): List of column titles.
146 rows (List[List[str]]): List of row values.
147 """
148 self._column_titles = column_titles
149 self._rows = rows
151 def _calculate_widths(self) -> List[int]:
152 # lobster-trace: SwRequirements.sw_req_rst_table
153 """Calculate the maximum width of each column based on titles and row values.
155 Returns:
156 List[int]: Maximum width per column.
157 """
158 max_widths = [len(title) for title in self._column_titles]
160 for row in self._rows:
161 for idx, value in enumerate(row):
162 for line in value.split('\n'):
163 max_widths[idx] = max(max_widths[idx], len(line))
165 return max_widths
167 @staticmethod
168 def _render_head(column_titles: List[str], max_widths: List[int]) -> str:
169 # lobster-trace: SwRequirements.sw_req_rst_table
170 """Render the table head. The titles are escaped for reStructuredText.
172 Args:
173 column_titles (List[str]): List of column titles.
174 max_widths (List[int]): List of maximum widths for each column.
176 Returns:
177 str: Table head
178 """
179 escaped_titles = [RstText.escape(title) for title in column_titles]
181 # Create the top border of the table.
182 table_head = " +" + "+".join(["-" * (width + 2) for width in max_widths]) + "+\n"
184 # Create the title row.
185 table_head += " |"
186 table_head += "|".join(
187 [f" {title.ljust(max_widths[idx])} " for idx, title in enumerate(escaped_titles)]) + "|\n"
189 # Create the separator row.
190 table_head += " +" + "+".join(["=" * (width + 2) for width in max_widths]) + "+\n"
192 return table_head
194 @staticmethod
195 def _render_row(row_values: List[str], max_widths: List[int]) -> str:
196 # lobster-trace: SwRequirements.sw_req_rst_table
197 """Render a table row. The values are taken as is. Multi-line values are supported.
199 Args:
200 row_values (List[str]): List of row values.
201 max_widths (List[int]): List of maximum widths for each column.
203 Returns:
204 str: Table row
205 """
206 # Split each cell value into lines.
207 split_values = [value.split('\n') for value in row_values]
208 max_lines = max(len(lines) for lines in split_values)
210 # Create the row with multi-line support.
211 table_row = ""
212 for line_idx in range(max_lines):
213 table_row += " |"
214 for col_idx, lines in enumerate(split_values):
215 if line_idx < len(lines):
216 table_row += f" {lines[line_idx].ljust(max_widths[col_idx])} "
217 else:
218 table_row += " " * (max_widths[col_idx] + 2)
219 table_row += "|"
220 table_row += "\n"
222 # Create the separator row.
223 separator_row = " +" + "+".join(["-" * (width + 2) for width in max_widths]) + "+\n"
225 return table_row + separator_row
227 def render(self) -> str:
228 # lobster-trace: SwRequirements.sw_req_rst_table
229 """Render the table in grid format.
231 Returns:
232 str: reStructuredText table
233 """
234 max_widths = self._calculate_widths()
236 result = RstTable._render_head(self._column_titles, max_widths)
238 for row in self._rows:
239 result += RstTable._render_row(row, max_widths)
241 return result
243# pylint: disable-next=too-few-public-methods
244class RstBulletList(RstElement):
245 """Unordered reStructuredText list block element.
246 """
248 def __init__(self, values: List[str], escape: bool = True) -> None:
249 """Initialize the list element.
251 Args:
252 values (List[str]): List of list values.
253 escape (bool): Escapes every list value (default: True).
254 """
255 self._values = values
256 self._escape = escape
258 def render(self) -> str:
259 # lobster-trace: SwRequirements.sw_req_rst_list
260 """Render the unordered list.
261 The values will be automatically escaped for reStructuredText if necessary.
262 The last list value has no newline at the end.
264 Returns:
265 str: reStructuredText list
266 """
267 list_str = ""
269 for idx, value_raw in enumerate(self._values):
270 value = value_raw
272 if self._escape is True: # Escape the value if necessary.
273 value = RstText.escape(value)
275 list_str += f"* {value}"
277 # The last list value must not have a newline at the end.
278 if idx < len(self._values) - 1:
279 list_str += "\n"
281 return list_str
283# pylint: disable-next=too-few-public-methods
284class RstImage(RstElement):
285 """reStructuredText image / diagram link block element.
286 """
288 def __init__(self, file_name: str, caption: str, escape: bool = True) -> None:
289 """Initialize the image element.
291 Args:
292 file_name (str): Diagram file name
293 caption (str): Diagram caption
294 escape (bool): Escapes caption (default: True).
295 """
296 self._file_name = file_name
297 self._caption = caption
298 self._escape = escape
300 def render(self) -> str:
301 # lobster-trace: SwRequirements.sw_req_rst_image
302 """Render the diagram link.
303 The caption will be automatically escaped for reStructuredText if necessary.
305 Returns:
306 str: reStructuredText diagram link
307 """
308 caption_raw = self._caption
310 if self._escape is True:
311 caption_raw = RstText.escape(self._caption)
313 # Allowed are absolute and relative to source paths.
314 file_name = os.path.normpath(self._file_name)
316 result = f".. figure:: {file_name}\n :alt: {caption_raw}\n"
318 if caption_raw:
319 result += f"\n {caption_raw}\n"
321 return result
323# pylint: disable-next=too-few-public-methods
324class RstRawText(RstElement):
325 """Raw reStructuredText block element passed through unchanged.
326 """
328 def __init__(self, text: str) -> None:
329 """Initialize the raw text element.
331 Args:
332 text (str): Raw reStructuredText
333 """
334 self._text = text
336 def render(self) -> str:
337 # lobster-trace: SwRequirements.sw_req_rst_record
338 """Render the raw text.
340 Returns:
341 str: Raw reStructuredText
342 """
343 return self._text
345# Functions ********************************************************************
347# Main *************************************************************************