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
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 12:41 +0000
1"""Converter to Word docx format.
3 Author: Norbert Schulz (norbert.schulz@newtec.de)
4"""
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/>.
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
40# Variables ********************************************************************
42# Classes **********************************************************************
44class DocxConverter(BaseConverter):
45 """Converter to docx format.
47 The following Word docx objects are used:
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 """
55 OUTPUT_FILE_NAME_DEFAULT = "output.docx"
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.
64 Args:
65 args (Any): The parsed program arguments.
66 """
67 super().__init__(args)
69 if args.template is not None:
70 log_verbose(f"Loading template file {args.template}.")
72 self._docx = docx.Document(docx=args.template)
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)
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
82 # Current list item indentation level.
83 self._list_item_indent_level = 0
85 # Docx block item container to add content to during conversion and markdown rendering.
86 self._block_item_container: Optional[BlockItemContainer] = None
88 @staticmethod
89 def get_subcommand() -> str:
90 # lobster-trace: SwRequirements.sw_req_docx
91 """ Return subcommand token for this converter.
93 Returns:
94 Ret: Status
95 """
96 return "docx"
98 @staticmethod
99 def get_description() -> str:
100 # lobster-trace: SwRequirements.sw_req_docx
101 """ Return converter description.
103 Returns:
104 Ret: Status
105 """
106 return "Convert into docx format."
108 @classmethod
109 def register(cls, args_parser: Any) -> None:
110 # lobster-trace: SwRequirements.sw_req_docx
111 """Register converter specific argument parser.
113 Args:
114 args_parser (Any): Argument parser
115 """
116 super().register(args_parser)
118 assert BaseConverter._parser is not None
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 )
138 def convert_section(self, section: str, level: int) -> Ret:
139 # lobster-trace: SwRequirements.sw_req_docx_section
140 """Process the given section item.
142 Args:
143 section (str): The section name
144 level (int): The section indentation level
146 Returns:
147 Ret: Status
148 """
149 assert self._docx is not None
151 self._docx.add_heading(section, level)
153 return Ret.OK
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.
160 The handler is called by the base converter if no specific handler is
161 defined for the record type.
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.
170 Returns:
171 Ret: Status
172 """
173 return self._convert_record_object(record, level, translation)
175 def finish(self) -> Ret:
176 # lobster-trace: SwRequirements.sw_req_docx_file
177 """Finish the conversion.
179 Returns:
180 Ret: Status
181 """
182 result = Ret.ERROR
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)
189 log_verbose(f"Writing docx {output_file_name}.")
190 self._docx.save(output_file_name)
191 self._docx = None
192 result = Ret.OK
194 return result
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)
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.
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
216 paragraph = self._block_item_container.add_paragraph()
218 DocxConverter.docx_add_link_to_bookmark(paragraph,
219 record_reference.target.name,
220 f"{record_reference.package.name}.{record_reference.target.name}")
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.
228 Args:
229 string_literal (String_Literal): The string literal value.
230 """
231 assert self._block_item_container is not None
233 is_handled = False
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", "")
240 self._render(package_name, type_name, attribute_name, string_literal.to_string())
241 is_handled = True
243 if is_handled is False:
244 self._block_item_container.add_paragraph(string_literal.to_string())
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.
252 Args:
253 array_aggregate (Array_Aggregate): The AST node.
254 """
255 self._list_item_indent_level += 1
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.
263 Args:
264 expression (Expression): The AST node.
265 item_result (Union[list[DocumentObject],DocumentObject]): The result of processing the list item.
267 Returns:
268 Any: The processed list item.
269 """
270 assert self._block_item_container is not None
272 # Add list item style to last added paragraph.
273 last_paragraph = self._block_item_container.paragraphs[-1]
275 style = 'List Bullet'
277 if 1 < self._list_item_indent_level:
278 style += f' {self._list_item_indent_level}'
280 last_paragraph.style = style
282 return item_result
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.
290 Args:
291 array_aggregate (Array_Aggregate): The AST node.
292 """
293 self._list_item_indent_level -= 1
295 def _other_dispatcher(self, expression: Expression) -> None:
296 # lobster-trace: SwRequirements.sw_req_docx_record
297 """
298 Dispatcher for all other expressions.
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())
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.
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)
346 return trlc_ast_walker
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.
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
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)
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)
375 else:
376 self._block_item_container.add_paragraph(attribute_value)
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.
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.
389 Returns:
390 Ret: Status
391 """
392 assert self._docx is not None
394 heading = self._docx.add_heading(f"{record.name} ({record.n_typ.name})", level + 1)
395 DocxConverter.docx_add_bookmark(heading, record.name)
397 table = self._docx.add_table(rows=1, cols=2)
398 table.style = 'Table Grid'
399 table.autofit = True
401 # Set table headers
402 header_cells = table.rows[0].cells
403 header_cells[0].text = "Element"
404 header_cells[1].text = "Value"
406 # Walk through the record object fields and write the table rows.
407 trlc_ast_walker = self._get_trlc_ast_walker()
409 for name, value in record.field.items():
410 attribute_name = self._translate_attribute_name(translation, name)
412 cells = table.add_row().cells
413 cells[0].text = attribute_name
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)
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]
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
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
436 return Ret.OK
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.
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
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)
455 # Create a bookmark end element.
456 bookmark_end = OxmlElement('w:bookmarkEnd')
457 bookmark_end.set(qn('w:id'), '0')
459 # Add the bookmark to the paragraph.
460 element.insert(0, bookmark_start)
461 element.append(bookmark_end)
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.
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)
478 # Create a run and run properties for the hyperlink.
479 new_run = OxmlElement('w:r')
480 run_properties = OxmlElement('w:rPr')
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)
487 new_run.append(run_properties)
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)
494 hyperlink.append(new_run)
496 # Append the hyperlink element directly to the paragraph XML so Word renders it.
497 paragraph._p.append(hyperlink) # pylint: disable=protected-access
499# Functions ********************************************************************
501# Main *************************************************************************