Coverage for src/pyTRLCConverter/docx_converter.py: 93%

165 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-26 12:41 +0000

1"""Converter to Word docx format. 

2 

3 Author: Norbert Schulz (norbert.schulz@newtec.de) 

4""" 

5 

6# pyTRLCConverter - A tool to convert TRLC files to specific formats. 

7# Copyright (c) 2024 - 2026 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/>. 

21 

22# Imports ********************************************************************** 

23import os 

24from typing import Optional, Any 

25import docx 

26from docx.blkcntnr import BlockItemContainer 

27from docx.text.paragraph import Paragraph 

28from docx.oxml import OxmlElement 

29from docx.oxml.ns import qn 

30from docx.enum.style import WD_STYLE_TYPE 

31from marko import Markdown 

32from trlc.ast import Implicit_Null, Record_Object, Record_Reference, String_Literal, Array_Aggregate, Expression 

33from pyTRLCConverter.base_converter import BaseConverter 

34from pyTRLCConverter.marko.md2docx_renderer import Md2DocxRenderer 

35from pyTRLCConverter.marko.gfm2docx_renderer import Gfm2DocxRenderer 

36from pyTRLCConverter.ret import Ret 

37from pyTRLCConverter.trlc_helper import TrlcAstWalker 

38from pyTRLCConverter.logger import log_verbose 

39 

40# Variables ******************************************************************** 

41 

42# Classes ********************************************************************** 

43 

44class DocxConverter(BaseConverter): 

45 """Converter to docx format. 

46 

47 The following Word docx objects are used: 

48 

49 - **Document**: Represents the entire Word document. 

50 - **Paragraph**: A block of text in the document with its own formatting properties. 

51 - **Run**: A contiguous run of text with the same formatting within a paragraph. 

52 - **Table**: A two-dimensional structure for presenting data in rows and columns. 

53 """ 

54 

55 OUTPUT_FILE_NAME_DEFAULT = "output.docx" 

56 

57 def __init__(self, args: Any) -> None: 

58 # lobster-trace: SwRequirements.sw_req_no_prj_spec 

59 # lobster-trace: SwRequirements.sw_req_docx 

60 # lobster-trace: SwRequirements.sw_req_docx_template 

61 """ 

62 Initialize the docx converter. 

63 

64 Args: 

65 args (Any): The parsed program arguments. 

66 """ 

67 super().__init__(args) 

68 

69 if args.template is not None: 

70 log_verbose(f"Loading template file {args.template}.") 

71 

72 self._docx = docx.Document(docx=args.template) 

73 

74 # Ensure default table style is present in the document. 

75 if not 'Table Grid' in self._docx.styles: 

76 self._docx.styles.add_style('Table Grid', WD_STYLE_TYPE.TABLE, builtin=True) 

77 

78 # The AST walker meta data for processing the record object fields. 

79 # This will hold the information about the current package, type and attribute being processed. 

80 self._ast_meta_data = None 

81 

82 # Current list item indentation level. 

83 self._list_item_indent_level = 0 

84 

85 # Docx block item container to add content to during conversion and markdown rendering. 

86 self._block_item_container: Optional[BlockItemContainer] = None 

87 

88 @staticmethod 

89 def get_subcommand() -> str: 

90 # lobster-trace: SwRequirements.sw_req_docx 

91 """ Return subcommand token for this converter. 

92 

93 Returns: 

94 Ret: Status 

95 """ 

96 return "docx" 

97 

98 @staticmethod 

99 def get_description() -> str: 

100 # lobster-trace: SwRequirements.sw_req_docx 

101 """ Return converter description. 

102  

103 Returns: 

104 Ret: Status 

105 """ 

106 return "Convert into docx format." 

107 

108 @classmethod 

109 def register(cls, args_parser: Any) -> None: 

110 # lobster-trace: SwRequirements.sw_req_docx 

111 """Register converter specific argument parser. 

112 

113 Args: 

114 args_parser (Any): Argument parser 

115 """ 

116 super().register(args_parser) 

117 

118 assert BaseConverter._parser is not None 

119 

120 BaseConverter._parser.add_argument( 

121 "-t", 

122 "--template", 

123 type=str, 

124 default=None, 

125 required=False, 

126 help="Load the given docx file as a template to append to." 

127 ) 

128 BaseConverter._parser.add_argument( 

129 "-n", 

130 "--name", 

131 type=str, 

132 default=DocxConverter.OUTPUT_FILE_NAME_DEFAULT, 

133 required=False, 

134 help="Name of the generated output file inside the output folder " \ 

135 f"(default = {DocxConverter.OUTPUT_FILE_NAME_DEFAULT})." 

136 ) 

137 

138 def convert_section(self, section: str, level: int) -> Ret: 

139 # lobster-trace: SwRequirements.sw_req_docx_section 

140 """Process the given section item. 

141 

142 Args: 

143 section (str): The section name 

144 level (int): The section indentation level 

145 

146 Returns: 

147 Ret: Status 

148 """ 

149 assert self._docx is not None 

150 

151 self._docx.add_heading(section, level) 

152 

153 return Ret.OK 

154 

155 def convert_record_object_generic(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret: 

156 # lobster-trace: SwRequirements.sw_req_docx_record 

157 """ 

158 Process the given record object in a generic way. 

159 

160 The handler is called by the base converter if no specific handler is 

161 defined for the record type. 

162 

163 Args: 

164 record (Record_Object): The record object. 

165 level (int): The record level. 

166 translation (Optional[dict]): Translation dictionary for the record object. 

167 If None, no translation is applied. 

168 

169  

170 Returns: 

171 Ret: Status 

172 """ 

173 return self._convert_record_object(record, level, translation) 

174 

175 def finish(self) -> Ret: 

176 # lobster-trace: SwRequirements.sw_req_docx_file 

177 """Finish the conversion. 

178 

179 Returns: 

180 Ret: Status 

181 """ 

182 result = Ret.ERROR 

183 

184 if self._docx is not None: 

185 output_file_name = self._args.name 

186 if 0 < len(self._args.out): 

187 output_file_name = os.path.join(self._args.out, self._args.name) 

188 

189 log_verbose(f"Writing docx {output_file_name}.") 

190 self._docx.save(output_file_name) 

191 self._docx = None 

192 result = Ret.OK 

193 

194 return result 

195 

196 def _on_implict_null(self, _: Implicit_Null) -> None: 

197 # lobster-trace: SwRequirements.sw_req_docx_record 

198 """ 

199 Process the given implicit null value.  

200 """ 

201 assert self._block_item_container is not None 

202 self._block_item_container.add_paragraph(self._empty_attribute_value) 

203 

204 def _on_record_reference(self, record_reference: Record_Reference) -> None: 

205 # lobster-trace: SwRequirements.sw_req_docx_record 

206 # lobster-trace: SwRequirements.sw_req_docx_reference 

207 """ 

208 Process the given record reference value and return a hyperlink paragraph. 

209 

210 Args: 

211 record_reference (Record_Reference): The record reference value.  

212 """ 

213 assert record_reference.target is not None 

214 assert self._block_item_container is not None 

215 

216 paragraph = self._block_item_container.add_paragraph() 

217 

218 DocxConverter.docx_add_link_to_bookmark(paragraph, 

219 record_reference.target.name, 

220 f"{record_reference.package.name}.{record_reference.target.name}") 

221 

222 def _on_string_literal(self, string_literal: String_Literal) -> None: 

223 # lobster-trace: SwRequirements.sw_req_docx_render_md 

224 # lobster-trace: SwRequirements.sw_req_docx_render_gfm 

225 """ 

226 Process the given string literal value. 

227 

228 Args: 

229 string_literal (String_Literal): The string literal value. 

230 """ 

231 assert self._block_item_container is not None 

232 

233 is_handled = False 

234 

235 if self._ast_meta_data is not None: 

236 package_name = self._ast_meta_data.get("package_name", "") 

237 type_name = self._ast_meta_data.get("type_name", "") 

238 attribute_name = self._ast_meta_data.get("attribute_name", "") 

239 

240 self._render(package_name, type_name, attribute_name, string_literal.to_string()) 

241 is_handled = True 

242 

243 if is_handled is False: 

244 self._block_item_container.add_paragraph(string_literal.to_string()) 

245 

246 # pylint: disable-next=unused-argument 

247 def _on_array_aggregate_begin(self, array_aggregate: Array_Aggregate) -> None: 

248 # lobster-trace: SwRequirements.sw_req_docx_record 

249 """ 

250 Handle the beginning of a list. 

251 

252 Args: 

253 array_aggregate (Array_Aggregate): The AST node. 

254 """ 

255 self._list_item_indent_level += 1 

256 

257 # pylint: disable-next=unused-argument 

258 def _on_list_item(self, expression: Expression, item_result: Any) -> Any: 

259 # lobster-trace: SwRequirements.sw_req_docx_record 

260 """ 

261 Handle the list item by adding a bullet point. 

262 

263 Args: 

264 expression (Expression): The AST node. 

265 item_result (Union[list[DocumentObject],DocumentObject]): The result of processing the list item. 

266 

267 Returns: 

268 Any: The processed list item. 

269 """ 

270 assert self._block_item_container is not None 

271 

272 # Add list item style to last added paragraph. 

273 last_paragraph = self._block_item_container.paragraphs[-1] 

274 

275 style = 'List Bullet' 

276 

277 if 1 < self._list_item_indent_level: 

278 style += f' {self._list_item_indent_level}' 

279 

280 last_paragraph.style = style 

281 

282 return item_result 

283 

284 # pylint: disable-next=unused-argument 

285 def _on_array_aggregate_finish(self, array_aggregate: Array_Aggregate) -> None: 

286 # lobster-trace: SwRequirements.sw_req_docx_record 

287 """ 

288 Handle the end of a list. 

289 

290 Args: 

291 array_aggregate (Array_Aggregate): The AST node. 

292 """ 

293 self._list_item_indent_level -= 1 

294 

295 def _other_dispatcher(self, expression: Expression) -> None: 

296 # lobster-trace: SwRequirements.sw_req_docx_record 

297 """ 

298 Dispatcher for all other expressions. 

299 

300 Args: 

301 expression (Expression): The expression to process. 

302 """ 

303 assert self._block_item_container is not None 

304 self._block_item_container.add_paragraph(expression.to_string()) 

305 

306 def _get_trlc_ast_walker(self) -> TrlcAstWalker: 

307 # lobster-trace: SwRequirements.sw_req_docx_record 

308 """ 

309 If a record object contains a record reference, the record reference will be converted to 

310 a hyperlink. 

311 If a record object contains an array of record references, the array will be converted to 

312 a list of links. 

313 Otherwise the record object fields attribute values will be written to the table. 

314 

315 Returns: 

316 TrlcAstWalker: The TRLC AST walker. 

317 """ 

318 trlc_ast_walker = TrlcAstWalker() 

319 trlc_ast_walker.add_dispatcher( 

320 Implicit_Null, 

321 None, 

322 self._on_implict_null, 

323 None 

324 ) 

325 trlc_ast_walker.add_dispatcher( 

326 Record_Reference, 

327 None, 

328 self._on_record_reference, 

329 None 

330 ) 

331 trlc_ast_walker.add_dispatcher( 

332 String_Literal, 

333 None, 

334 self._on_string_literal, 

335 None 

336 ) 

337 trlc_ast_walker.add_dispatcher( 

338 Array_Aggregate, 

339 self._on_array_aggregate_begin, 

340 None, 

341 self._on_array_aggregate_finish 

342 ) 

343 trlc_ast_walker.set_other_dispatcher(self._other_dispatcher) 

344 trlc_ast_walker.set_list_item_dispatcher(self._on_list_item) 

345 

346 return trlc_ast_walker 

347 

348 def _render(self, package_name: str, type_name: str, attribute_name: str, attribute_value: str) -> None: 

349 # lobster-trace: SwRequirements.sw_req_docx_render_md 

350 # lobster-trace: SwRequirements.sw_req_docx_render_gfm 

351 """Render the attribute value depened on its format. 

352 

353 Args: 

354 package_name (str): The package name. 

355 type_name (str): The type name. 

356 attribute_name (str): The attribute name. 

357 attribute_value (str): The attribute value. 

358 """ 

359 assert self._block_item_container is not None 

360 

361 # If the attribute is marked as CommonMark Markdown format, convert it. 

362 if self._render_cfg.is_format_md(package_name, type_name, attribute_name) is True: 

363 Md2DocxRenderer.block_item_container = self._block_item_container 

364 Md2DocxRenderer().reset() 

365 markdown = Markdown(renderer=Md2DocxRenderer) 

366 markdown.convert(attribute_value) 

367 

368 # If the attribute is marked as GitHub Flavored Markdown format, convert it. 

369 elif self._render_cfg.is_format_gfm(package_name, type_name, attribute_name) is True: 

370 Gfm2DocxRenderer.block_item_container = self._block_item_container 

371 Gfm2DocxRenderer().reset() 

372 markdown = Markdown(renderer=Gfm2DocxRenderer, extensions=['gfm']) 

373 markdown.convert(attribute_value) 

374 

375 else: 

376 self._block_item_container.add_paragraph(attribute_value) 

377 

378 def _convert_record_object(self, record: Record_Object, level: int, translation: Optional[dict]) -> Ret: 

379 # lobster-trace: SwRequirements.sw_req_docx_record 

380 """ 

381 Process the given record object. 

382 

383 Args: 

384 record (Record_Object): The record object. 

385 level (int): The record level. 

386 translation (Optional[dict]): Translation dictionary for the record object. 

387 If None, no translation is applied. 

388 

389 Returns: 

390 Ret: Status 

391 """ 

392 assert self._docx is not None 

393 

394 heading = self._docx.add_heading(f"{record.name} ({record.n_typ.name})", level + 1) 

395 DocxConverter.docx_add_bookmark(heading, record.name) 

396 

397 table = self._docx.add_table(rows=1, cols=2) 

398 table.style = 'Table Grid' 

399 table.autofit = True 

400 

401 # Set table headers 

402 header_cells = table.rows[0].cells 

403 header_cells[0].text = "Element" 

404 header_cells[1].text = "Value" 

405 

406 # Walk through the record object fields and write the table rows. 

407 trlc_ast_walker = self._get_trlc_ast_walker() 

408 

409 for name, value in record.field.items(): 

410 attribute_name = self._translate_attribute_name(translation, name) 

411 

412 cells = table.add_row().cells 

413 cells[0].text = attribute_name 

414 

415 self._ast_meta_data = { 

416 "package_name": record.n_package.name, 

417 "type_name": record.n_typ.name, 

418 "attribute_name": name 

419 } 

420 self._block_item_container = cells[1] 

421 trlc_ast_walker.walk(value) 

422 

423 # Remove first empty paragraph added by default to the table cell. 

424 if 1 < len(cells[1].paragraphs): 

425 first_paragraph = cells[1].paragraphs[0] 

426 

427 if first_paragraph.text == "": 

428 p_element = first_paragraph._element # pylint: disable=protected-access 

429 p_element.getparent().remove(p_element) 

430 p_element._p = p_element._element = None # pylint: disable=protected-access 

431 

432 # Add a paragraph with the record object location 

433 paragraph = self._docx.add_paragraph() 

434 paragraph.add_run(f"from {record.location.file_name}:{record.location.line_no}").italic = True 

435 

436 return Ret.OK 

437 

438 @staticmethod 

439 def docx_add_bookmark(paragraph: Paragraph, bookmark_name: str) -> None: 

440 # lobster-trace: SwRequirements.sw_req_docx_record 

441 """ 

442 Adds a bookmark to a paragraph. 

443 

444 Args: 

445 paragraph (Paragraph): The paragraph to add the bookmark to. 

446 bookmark_name (str): The name of the bookmark. 

447 """ 

448 element = paragraph._p # pylint: disable=protected-access 

449 

450 # Create a bookmark start element. 

451 bookmark_start = OxmlElement('w:bookmarkStart') 

452 bookmark_start.set(qn('w:id'), '0') # ID must be unique 

453 bookmark_start.set(qn('w:name'), bookmark_name) 

454 

455 # Create a bookmark end element. 

456 bookmark_end = OxmlElement('w:bookmarkEnd') 

457 bookmark_end.set(qn('w:id'), '0') 

458 

459 # Add the bookmark to the paragraph. 

460 element.insert(0, bookmark_start) 

461 element.append(bookmark_end) 

462 

463 @staticmethod 

464 def docx_add_link_to_bookmark(paragraph: Paragraph, bookmark_name: str, link_text: str) -> None: 

465 # lobster-trace: SwRequirements.sw_req_docx_reference 

466 """ 

467 Add a hyperlink to a bookmark in a paragraph. 

468 

469 Args: 

470 paragraph (Paragraph): The paragraph to add the hyperlink to. 

471 bookmark_name (str): The name of the bookmark. 

472 link_text (str): The text to display for the hyperlink. 

473 """ 

474 # Create hyperlink element pointing to the bookmark. 

475 hyperlink = OxmlElement('w:hyperlink') 

476 hyperlink.set(qn('w:anchor'), bookmark_name) 

477 

478 # Create a run and run properties for the hyperlink. 

479 new_run = OxmlElement('w:r') 

480 run_properties = OxmlElement('w:rPr') 

481 

482 # Use the built-in Hyperlink run style so Word will display it correctly (blue/underline). 

483 r_style = OxmlElement('w:rStyle') 

484 r_style.set(qn('w:val'), 'Hyperlink') 

485 run_properties.append(r_style) 

486 

487 new_run.append(run_properties) 

488 

489 # Add the text node inside the run (w:t). 

490 text_element = OxmlElement('w:t') 

491 text_element.text = link_text 

492 new_run.append(text_element) 

493 

494 hyperlink.append(new_run) 

495 

496 # Append the hyperlink element directly to the paragraph XML so Word renders it. 

497 paragraph._p.append(hyperlink) # pylint: disable=protected-access 

498 

499# Functions ******************************************************************** 

500 

501# Main *************************************************************************