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

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. 

5 

6 Author: Gabryel Reyes (gabryel.reyes@newtec.de) 

7""" 

8 

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/>. 

24 

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 

31 

32# Variables ******************************************************************** 

33 

34# Heading underline characters per heading level [1; 7]. 

35_UNDERLINE_CHARS = ["=", "#", "~", "^", "\"", "+", "'"] 

36 

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

38 

39# pylint: disable-next=too-few-public-methods 

40class RstElement(ABC): 

41 """Abstract base class for reStructuredText block elements. 

42 """ 

43 

44 @abstractmethod 

45 def render(self) -> str: 

46 """Render the element into a reStructuredText string. 

47 

48 Returns: 

49 str: Rendered reStructuredText 

50 """ 

51 

52# pylint: disable-next=too-few-public-methods 

53class RstHeading(RstElement): 

54 """reStructuredText heading block element with a label. 

55 """ 

56 

57 def __init__(self, text: str, level: int, file_name: str, escape: bool = True) -> None: 

58 """Initialize the heading element. 

59 

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 

70 

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. 

75 

76 Returns: 

77 str: reStructuredText heading with a label 

78 """ 

79 result = "" 

80 

81 if 1 <= self._level <= 7: 

82 text_raw = self._text 

83 

84 if self._escape is True: 

85 text_raw = RstText.escape(self._text) 

86 

87 label = f"{self._file_name}-{text_raw.lower().replace(' ', '-')}" 

88 

89 underline_char = _UNDERLINE_CHARS[self._level - 1] 

90 underline = underline_char * len(text_raw) 

91 

92 result = f".. _{label}:\n\n{text_raw}\n{underline}\n" 

93 

94 else: 

95 log_error(f"Invalid heading level {self._level} for {self._text}.") 

96 

97 return result 

98 

99# pylint: disable-next=too-few-public-methods 

100class RstAdmonition(RstElement): 

101 """reStructuredText admonition block element with a label. 

102 """ 

103 

104 def __init__(self, text: str, file_name: str, escape: bool = True) -> None: 

105 """Initialize the admonition element. 

106 

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 

115 

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. 

120 

121 Returns: 

122 str: reStructuredText admonition with a label 

123 """ 

124 text_raw = self._text 

125 

126 if self._escape is True: 

127 text_raw = RstText.escape(self._text) 

128 

129 label = f"{self._file_name}-{text_raw.lower().replace(' ', '-')}" 

130 admonition_label = f".. admonition:: {text_raw}" 

131 

132 return f".. _{label}:\n\n{admonition_label}\n" 

133 

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

140 

141 def __init__(self, column_titles: List[str], rows: List[List[str]]) -> None: 

142 """Initialize the table element. 

143 

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 

150 

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. 

154 

155 Returns: 

156 List[int]: Maximum width per column. 

157 """ 

158 max_widths = [len(title) for title in self._column_titles] 

159 

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

164 

165 return max_widths 

166 

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. 

171 

172 Args: 

173 column_titles (List[str]): List of column titles. 

174 max_widths (List[int]): List of maximum widths for each column. 

175 

176 Returns: 

177 str: Table head 

178 """ 

179 escaped_titles = [RstText.escape(title) for title in column_titles] 

180 

181 # Create the top border of the table. 

182 table_head = " +" + "+".join(["-" * (width + 2) for width in max_widths]) + "+\n" 

183 

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" 

188 

189 # Create the separator row. 

190 table_head += " +" + "+".join(["=" * (width + 2) for width in max_widths]) + "+\n" 

191 

192 return table_head 

193 

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. 

198 

199 Args: 

200 row_values (List[str]): List of row values. 

201 max_widths (List[int]): List of maximum widths for each column. 

202 

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) 

209 

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" 

221 

222 # Create the separator row. 

223 separator_row = " +" + "+".join(["-" * (width + 2) for width in max_widths]) + "+\n" 

224 

225 return table_row + separator_row 

226 

227 def render(self) -> str: 

228 # lobster-trace: SwRequirements.sw_req_rst_table 

229 """Render the table in grid format. 

230 

231 Returns: 

232 str: reStructuredText table 

233 """ 

234 max_widths = self._calculate_widths() 

235 

236 result = RstTable._render_head(self._column_titles, max_widths) 

237 

238 for row in self._rows: 

239 result += RstTable._render_row(row, max_widths) 

240 

241 return result 

242 

243# pylint: disable-next=too-few-public-methods 

244class RstBulletList(RstElement): 

245 """Unordered reStructuredText list block element. 

246 """ 

247 

248 def __init__(self, values: List[str], escape: bool = True) -> None: 

249 """Initialize the list element. 

250 

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 

257 

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. 

263 

264 Returns: 

265 str: reStructuredText list 

266 """ 

267 list_str = "" 

268 

269 for idx, value_raw in enumerate(self._values): 

270 value = value_raw 

271 

272 if self._escape is True: # Escape the value if necessary. 

273 value = RstText.escape(value) 

274 

275 list_str += f"* {value}" 

276 

277 # The last list value must not have a newline at the end. 

278 if idx < len(self._values) - 1: 

279 list_str += "\n" 

280 

281 return list_str 

282 

283# pylint: disable-next=too-few-public-methods 

284class RstImage(RstElement): 

285 """reStructuredText image / diagram link block element. 

286 """ 

287 

288 def __init__(self, file_name: str, caption: str, escape: bool = True) -> None: 

289 """Initialize the image element. 

290 

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 

299 

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. 

304 

305 Returns: 

306 str: reStructuredText diagram link 

307 """ 

308 caption_raw = self._caption 

309 

310 if self._escape is True: 

311 caption_raw = RstText.escape(self._caption) 

312 

313 # Allowed are absolute and relative to source paths. 

314 file_name = os.path.normpath(self._file_name) 

315 

316 result = f".. figure:: {file_name}\n :alt: {caption_raw}\n" 

317 

318 if caption_raw: 

319 result += f"\n {caption_raw}\n" 

320 

321 return result 

322 

323# pylint: disable-next=too-few-public-methods 

324class RstRawText(RstElement): 

325 """Raw reStructuredText block element passed through unchanged. 

326 """ 

327 

328 def __init__(self, text: str) -> None: 

329 """Initialize the raw text element. 

330 

331 Args: 

332 text (str): Raw reStructuredText 

333 """ 

334 self._text = text 

335 

336 def render(self) -> str: 

337 # lobster-trace: SwRequirements.sw_req_rst_record 

338 """Render the raw text. 

339 

340 Returns: 

341 str: Raw reStructuredText 

342 """ 

343 return self._text 

344 

345# Functions ******************************************************************** 

346 

347# Main *************************************************************************