Skip to content

Word

Bases: ReportOutput

Implementation of Report Output interface that can create a Microsoft Word .docx document.

Source code in smartreport/engine/outputs/word_document.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
class WordReportOutput(ReportOutput):
    """Implementation of Report Output interface that can create a Microsoft Word `.docx` document."""

    export_extension = "docx"
    is_interactive = False
    DEFAULT_TEMPLATE_FILE = Path(__file__).parent.parent / "static" / "word_report_template.docx"
    OUTPUT_ORIENTATION = ReportOutputOrientation.PORTRAIT
    FIGURE_FORMAT = "png"
    FIGURE_ENGINE = "kaleido"
    WORKING_PAGE_WIDTH = 6.4  # Inches
    MARGIN = 0.0

    def __init__(self, template_file: Optional[str] = DEFAULT_TEMPLATE_FILE) -> None:
        """
        Initializes WordReportOutput.

        Args:
          template_file: Path to Word document will be used as a starting point for the report document.

        """
        self._template_file = template_file
        self.document = self._create_new_document(template_file=self._template_file)
        self.current_column_width = 1.0
        self.current_number_of_columns = 1
        self._update_version_in_footer(version=__version__)

        # Custom id generator - it solves picture id clash problem (see _add_single_picture). Note that it starts from
        # 100 - it is important, as it gives *room* of 100 images to be added to headers/footers without causing id
        # clash. In real word usage it's more than enough.
        self._element_id_gen = count(start=100)

    def add_heading(self, text: str, level: int, **kwargs) -> None:
        """Adds heading to the report document.

        Each heading has `text` and `level`, where `text` is what the heading says and `level` is how bold it is.
        Levels is an integer from range <1;6>. The lower the number is more important the heading is.
        That corresponds to the HTML concept of H tags.

        Heading are also used for building interactive Table of Content.
        By default, all headings are included in the Table of Content up to the ToC depth level.
        Heading can be explicitly excluded from ToC by providing `include_in_toc=False` parameter.

        Args:
          text: Text of the heading
          level: Level of the heading. Integer from range 1 to 6

        Keyword Args:
          include_in_toc (bool): Flag that defines if given heading should be included
              in Tabel of Content. (default: `True`)
          min_length (int): Minimum length of header. If provided this will extend the length of the header to prevent
              invalid formatting of header row in table of content. If `None` is provided then no formatting is applied.
              Default `8`

        """
        # Because headings are used to generate automatic Table of Content, and because Word have problems
        # with rendering a good-looking ToC for short text the minimum length of the heading text has to be enforced
        include_in_toc = kwargs.get("include_in_toc", True)
        min_length = kwargs.get("min_length", 8)

        if min_length:
            text = extend_text_with_spaces(text=text, length=min_length)

        if include_in_toc:
            self.document.add_heading(text=text, level=level)
            log.debug(f"Heading {level}: '{text}' added.")
        else:
            self.add_text(text=text, style=TextStyle.NO_TOC_HEADING)
            # if we don't want to include this heading in toc, we need to use special style from word template

        return None

    def add_page_break(self, **kwargs) -> None:
        """Adds a page break to the report document."""
        self.document.add_page_break()
        log.debug("Page Break added.")
        return self.document

    def add_hyperlink(self, text: str, url: str, **kwargs) -> None:
        """Adds hyperlink to the report document.

        Hyperlink object can link the report reader to external url or to the another section of the report.

        Args:
          text: Text of the hyperlink.
          url: URL of the hyperlink.

        Keyword Args:
          paragraph (Paragraph): paragraph object that will be converted into hyperlink. If not provided,
            a new paragraph is created.
          color (str): Color of the hyperlink text. Should be provided as RRGGBB string.
            If `None` then no styling is applied. Default: `0000DD`
          underline (str): Underline style of the hyperlink text. Should be provided as a string.
            If `None` then no styling is applied. Default `single`
        """
        color = kwargs.get("color", "0000DD")
        underline = kwargs.get("underline", "single")

        # Define the paragraph to be processed
        _paragraph = kwargs.get("paragraph", None)
        if isinstance(_paragraph, _Cell):  # fix for Table Cells
            _paragraph = _paragraph.add_paragraph()
        paragraph = _paragraph if _paragraph else self.document.add_paragraph()

        # This gets access to the document.xml.rels file and gets a new relation id value
        part = paragraph.part
        r_id = part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
        # Create the w:hyperlink tag and add needed values
        hyperlink = OxmlElement("w:hyperlink")
        hyperlink.set(qn("r:id"), r_id)
        # Create a w:r element and a new w:rPr element
        new_run = OxmlElement("w:r")
        r_pr = OxmlElement("w:rPr")

        # Extra formatting for color and underline
        # It has to be done like this to be preserved in the after PDF conversion
        if color is not None:
            c = docx.oxml.shared.OxmlElement("w:color")
            c.set(docx.oxml.shared.qn("w:val"), color)
            r_pr.append(c)
        if underline is not None:
            u = docx.oxml.shared.OxmlElement("w:u")
            u.set(docx.oxml.shared.qn("w:val"), underline)
            r_pr.append(u)

        # Join all the xml elements together, and add the required text to the w:r element
        new_run.append(r_pr)
        new_run.text = text
        hyperlink.append(new_run)
        # Create a new Run object and add the hyperlink into it
        r = paragraph.add_run()
        # noinspection PyProtectedMember
        r._r.append(hyperlink)
        log.debug(f"Hyperlink '{text}' to URL: {url} added.")
        return None

    def add_text(
        self, text: str, style: TextStyle = TextStyle.DEFAULT, dictionary: Optional[Dict] = None, **kwargs
    ) -> None:
        """Adds text paragraph to the report document.

        `text` is a string and `style` is an identifier of style that is applied to it.
        For Word document, style's value is a name of the paragraph style, defined in the template document. Styles
        section is available in Home->Styles in Word editor.

        This method supports a subset of Markdown formatting in provided text string.
        Following elements are supported:

        * Empty line adds a new paragraph.

        * Hashes (#) at the beginning of the line adds heading.

        * Pattern `[TEXT](URL)` adds a hyperlink with TEXT that points to URL.

        * Any occurrence of `{{VARIABLE}}` will be replaced by the string that comes from
        provided `dictionary` keyword argument.

        Refer to [interface documentation](00_overview.md) for more details.

        Args:
          text: Input text
          style: Identifier of a text style that will be applied to the text
          dictionary: Dictionary that would be used to replace Variables located in input text.

        """
        dictionary = dictionary if isinstance(dictionary, dict) else {}

        for line in text.split("\n"):
            # Extract Headings and text using markdown
            heading_text, heading_level = get_heading_from_line(line)
            if heading_level is not None:
                self.add_heading(text=heading_text, level=heading_level)
                continue
            # replace variables names in text with their values using dictionary
            new_line = replace_variables_in_line(line, dictionary)
            # Create a new paragraph and add styling if possible
            paragraph = self.document.add_paragraph()
            if style.value in self.document.styles:
                paragraph.style = self.document.styles[style.value]
            else:
                log.info(f"Could not find style '{style.value}' in template document")
            # Search for hyperlinks
            for sub_line in split_line_on_hyperlinks(line=new_line):
                # add text to current paragraph if it is not a hyperlink
                if sub_line.get("type") == "text" and sub_line.get("text"):
                    paragraph.add_run(sub_line.get("text"))
                # add hyperlink
                elif sub_line.get("type") == "link" and sub_line.get("text") and sub_line.get("url"):
                    self.add_hyperlink(paragraph=paragraph, text=sub_line.get("text"), url=sub_line.get("url"))
            # If line is not empty, log it in the trimmed version
            if new_line:
                log.debug(f'Paragraph "{trim_text(new_line, max_length=40)}" added.')
        return None

    def add_toc(self, **kwargs):
        """Adds automatic Table of Content placeholder to the report document.

        This solution is based on the discussion at:
        [https://github.com/python-openxml/python-docx/issues/36](https://github.com/python-openxml/python-docx/issues/36)

        Word Report does it by creating a dynamic element that will scan all the headings
        up to the `depth` level and produce a Table of Content with valid page number.
        Because ToC is added at the beginning of the report, we don't know in advance
        what heading will be there to add. Therefore, ToC rendering is not happening
        in the report generation phase, but later, when the report reader will click 'Update Field'
        option on the ToC placeholder text. This text can be defined in `placeholder_text`
        :key depth: Depth of the ToC. It defines the maximum heading level to be included in the ToC. (default: 2)

        Keyword Args:
          depth (int): Depth of the ToC. It defines the maximum heading level included in the ToC. (default: `2`)
          heading_text (str): If provided, the ToC will be preceded with the heading component
          heading_level (int): Level of the preceding heading component (default: `1`)
          page_break (bool): If provided, ToC will be followed by a page break (default: `False`)

        """
        depth = kwargs.get("depth", 2)
        heading_text = kwargs.get("heading_text")
        heading_level = kwargs.get("heading_level", 1)
        page_break = kwargs.get("page_break", False)
        placeholder_text = kwargs.get("placeholder_text", _("Right-click to update field."))

        if heading_text:
            self.add_heading(text=heading_text, level=heading_level)

        paragraph = self.document.add_paragraph()
        run = paragraph.add_run()
        fld_char = OxmlElement("w:fldChar")  # creates a new element
        fld_char.set(qn("w:fldCharType"), "begin")  # sets attribute on element
        instr_text = OxmlElement("w:instrText")
        instr_text.set(qn("xml:space"), "preserve")  # sets attribute on element
        instr_text.text = rf'TOC \o "1-{depth}" \h \z \u'  # change 1-3 depending on heading levels you need

        fld_char2 = OxmlElement("w:fldChar")
        fld_char2.set(qn("w:fldCharType"), "separate")
        fld_char3 = OxmlElement("w:t")
        fld_char3.text = placeholder_text
        fld_char2.append(fld_char3)

        fld_char4 = OxmlElement("w:fldChar")
        fld_char4.set(qn("w:fldCharType"), "end")
        # noinspection PyProtectedMember
        r_element = run._r
        r_element.append(fld_char)
        r_element.append(instr_text)
        r_element.append(fld_char2)
        r_element.append(fld_char4)

        log.debug("TOC added.")

        if page_break:
            self.add_page_break()

        return None

    def add_comment_box(self, **kwargs) -> None:
        """
        This method's implementation in WordReportOutput is some kind of the response
        for recommendations section needs. We need to show text that is an initial text in
        DashReportOutput.

        Keyword Args:
            initial_value (str): value that should be shown as the paragraph text in report output
        """
        initial_text: str = kwargs.get("initial_text", "")
        if initial_text:
            self.add_text(text=initial_text)

    def add_table(
        self,
        table_data: TableData,
        style: TableStyle = TableStyle.DEFAULT,
        column_widths: Sequence[float] = None,
        **kwargs,
    ) -> None:
        """Adds table to the report document.

        Input data should be provided as sequence of rows, where row is a sequence
        of cells and cells is either as simple string or a TableCell object that carries extra formatting information.
        Only 2-D structures with constant number of rows and columns are allowed.
        Merging can be achieved by using a special cell values `TableCell.MERGE_LEFT` and `TableCell.MERGE_ABOVE` that
        are located in .components module.

        Implementation details:

        * The implementation of this method does not allow to merge a 2x2 structure of cells (and similar ones).
        In case of incorrect merge structure no table will be returned. Probably the simplest solution
        extending method's functionality is to add `TableCell.MERGE_RIGHT` option.

        * The nonstandard access to cells is chosen due to efficiency reasons.
        See more [here](https://github.com/python-openxml/python-docx/issues/174)

        Args:
          table_data: Sequence rows, where row is a sequence of cells. Cell might be a simple string
            or a TableCell object, that also carries the information about cell formatting.
          style: TableStyle, Identifier of a style that will be applied to created table
          column_widths: Sequence of column widths as percents. They should sum up to 100%. If not provided,
            each implementation will figure out the best widths based on provided data.

        Keyword Args:
          use_markdown (bool): If true, Allow for markdown styling in table cells (default: `False`)
          word_rows_limit (int): Maximum limit of rows in the table (default: `100`)

        """
        use_markdown: bool = kwargs.get("use_markdown", False)
        # row limiting for events table
        word_rows_limit: int = kwargs.get("word_rows_limit", 100)
        no_of_row_headers: int = kwargs.get("no_of_row_headers", 0)

        # Limit the number of rows
        if isinstance(word_rows_limit, int):
            limit = min(word_rows_limit + 1, len(table_data))  # +1 stays for single header row
            table_data = table_data[:limit]
        else:
            raise TypeError(f"word_rows_limit expected int got {type(word_rows_limit)}")

        # Check the sizes of table
        t_rows, t_cols = get_table_size(table_data)
        if t_rows is None or t_cols is None:
            return None

        # Parse column widths
        parsed_column_widths = self._parse_column_widths(widths=column_widths, no_of_columns=t_cols)

        word_table = self.document.add_table(rows=t_rows, cols=t_cols)

        # column widths will not be determined by text length
        if column_widths is not None:
            word_table.autofit = False

        # Try find in report template, and then apply table style
        if style.value in self.document.styles:
            word_table.style = self.document.styles[style.value]
        else:
            log.info(
                f"Could not find table style '{style.value}' in template document, defaulting to {TableStyle.DEFAULT}"
            )

        left_offset, top_offset = 1, 1

        # noinspection PyProtectedMember
        wc = word_table._cells[0 : (t_cols * t_rows)]
        for i, data_row in enumerate(table_data):
            for j, data_cell in enumerate(data_row):
                left_offset, top_offset, ret = self._update_cells(
                    i,
                    j,
                    t_cols,
                    data_cell,
                    use_markdown,
                    wc,
                    parsed_column_widths,
                    word_table,
                    left_offset,
                    top_offset,
                )
                if ret is not None:
                    return ret
        if no_of_row_headers:
            self._set_repeat_header_row(word_table, no_of_row_headers)
        log.debug(f"Table [{table_data[0]}]({t_rows}, {t_cols}) added.")
        return None

    def _update_cells(
        self,
        i: int,
        j: int,
        t_cols: int,
        data_cell: str,
        use_markdown: bool,
        wc: List[docx.table._Cell],
        parsed_column_widths: Sequence[Union[float, int, Inches]],
        word_table: docx.table.Table,
        left_offset: int,
        top_offset: int,
    ) -> Union[Tuple[Optional[float]], Document]:
        # Calculate positions of cells
        pos = (i * t_cols) + j
        pos_left = pos - left_offset
        pos_up = pos - (t_cols * top_offset)

        # Update the cell width
        column_width = parsed_column_widths[j]
        if column_width is not None:
            wc[pos].width = column_width
        if data_cell is None:
            data_cell = ""
        # check if data_cell is a HeaderCell object
        if isinstance(data_cell, HeaderCell):
            data_cell = "\n".join(data_cell.text) if isinstance(data_cell.text, list) else data_cell.text
        if isinstance(data_cell, Base64TableCell):
            converter = MarkdownToWordConverter(cell=wc[pos])
            wc[pos] = converter.convert(data_cell.value)

        if isinstance(data_cell, TableCell) and data_cell.float_val:
            data_cell = data_cell.float_val

        # Merge cells if needed and write data to the cell.
        if (str(data_cell) == TableCell.MERGE_LEFT and pos_left < 0) or (
            str(data_cell) == TableCell.MERGE_ABOVE and pos_up < 0
        ):
            return left_offset, top_offset, None
        elif str(data_cell) == TableCell.MERGE_LEFT:
            try:
                wc[pos_left].merge(wc[pos])
                left_offset += 1
            except (IndexError, InvalidSpanError):
                log.debug(f"Incorrect merge structure for MERGE_LEFT ({i},{j})")
                self._pop_table(-1)
                return left_offset, top_offset, self.document
            return left_offset, top_offset, None
        elif str(data_cell) == TableCell.MERGE_ABOVE:
            try:
                wc[pos_up].merge(wc[pos])
                wc[pos_up].vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
                top_offset += 1
            except (IndexError, InvalidSpanError):
                log.debug(f"Incorrect merge structure for MERGE_ABOVE ({i},{j})")
                self._pop_table(-1)
                return left_offset, top_offset, self.document
            return left_offset, top_offset, None
        if not isinstance(data_cell, Base64TableCell):
            left_offset, top_offset, wc = self._support_links(use_markdown, data_cell, pos, wc)

        # Format cell contents
        # Set font size
        # For some reason all information about text styling (color, font, bold, highlight, etc..) is by default
        # copied from table style (Table style is defined in word_template_report.docx),
        # only font size is overridden from other source. To fix that issue
        # following explicit font style assigment is implemented, which should not be necessary.
        wc[pos].paragraphs[0].runs[0].font.size = self.get_font_size(word_table.style)
        # and the rest of the styling
        self._apply_cell_styling(table_cell=wc[pos], data_cell=data_cell)

        return left_offset, top_offset, None

    def _support_links(
        self, use_markdown: bool, data_cell: str, pos: int, wc: List[docx.table._Cell]
    ) -> Tuple[int, int, Optional[List[docx.table._Cell]]]:
        if use_markdown:
            # Support for links in text elements
            str_cell = str(data_cell)
            txt, link = get_hyperlink_and_text_from_line(str_cell)
            if link != (None, None):
                wc[pos].text = txt
                self.add_hyperlink(text=link[0], url=link[1], paragraph=wc[pos])
            else:
                wc[pos].text = str_cell
            left_offset, top_offset = 1, 1
        else:
            str_cell = str(data_cell)
            wc[pos].text = str_cell
            left_offset, top_offset = 1, 1

        return left_offset, top_offset, wc

    def add_table_legend(self, legend_cells: Sequence[LegendCell], **kwargs) -> None:
        """Adds legend with cells defined by legend_cells and their colors.

        Legend can be used to explain color encoding
        of a table. Widths of legend cells are automatically calculated. Legend is adjusted to the right.
        Font size of a legend table is set to 8pt

        Args:
          legend_cells: Sequence of LegendCell objects.

        """
        if not legend_cells:
            return None

        inches_per_char = 0.11
        # add color gradient info
        legend_cells = [
            (
                LegendCell(
                    title=cell.title + " (color gradient)",
                    color=cell.gradient_colors[int(len(cell.gradient_colors) / 2)],
                )
                if cell.gradient_colors
                else cell
            )
            for cell in legend_cells
        ]
        # add color cells
        color_cells = [LegendCell(title="", color=cell.color) for cell in legend_cells]
        color_cell_width = 0.3
        # add color cells to their places and one empty cell on the left of legend for better display
        _legend_cells: List[Any] = [None] * (len(legend_cells) + len(color_cells) + 1)
        _legend_cells[0] = LegendCell(title="first", color="")
        _legend_cells[1::2] = color_cells
        _legend_cells[2::2] = legend_cells
        legend_cells = _legend_cells

        row_no_of_cells = len(legend_cells)
        # legend is a simple 2 row table without any border, aligned to right if possible
        # second row is for adding space between data table and the legend table as well as giving space for long texts
        # without making color cells too tall
        word_table = self.document.add_table(rows=2, cols=row_no_of_cells)
        word_table.alignment = WD_TABLE_ALIGNMENT.RIGHT
        word_table.autofit = True

        if TableStyle.LEGEND_TABLE.value in self.document.styles:
            word_table.style = self.document.styles[TableStyle.LEGEND_TABLE.value]
        else:
            log.info(f"Could not find table style '{TableStyle.LEGEND_TABLE.value}' in template document")

        first_cell_width = 10 if row_no_of_cells < 10 else 2

        # the nonstandard access to cells is chosen due to efficiency reasons
        # see more at: https://github.com/python-openxml/python-docx/issues/174
        # noinspection PyProtectedMember

        word_cells = word_table._cells[:]
        top_offset = 1
        for idx_row in range(0, 2):
            for idx, legend_cell in enumerate(legend_cells):
                pos = (idx_row * row_no_of_cells) + idx
                pos_up = pos - (row_no_of_cells * top_offset)
                word_cell = word_cells[idx]

                # try to set width for cell
                if legend_cell.title:
                    title_len = len(legend_cell.title)
                    if title_len < 13:
                        word_cell.width = Inches((title_len + 4) * inches_per_char)
                    elif title_len > 14:
                        word_cell.width = Inches(title_len / 2 * inches_per_char)
                    else:
                        word_cell.width = Inches((title_len + 1) * inches_per_char)
                else:
                    word_cell.width = Inches(color_cell_width)
                # merge text cells
                if idx_row == 1 and legend_cell.title:
                    word_cells[pos_up].merge(word_cells[pos])

                word_cell.text = f" {legend_cell.title}"
                # For some reason all information about text styling (color, font, bold, highlight, etc..) is by default
                # copied from table style (Table style is defined in word_template_report.docx),
                # only font size is overridden from other source. To fix that issue
                # following explicit font style assigment is implemented, which should not be necessary.
                word_cell.paragraphs[0].runs[0].font.size = self.get_font_size(word_table.style)

                # make cell font bold
                word_cell.paragraphs[0].runs[0].font.bold = True

                # add color to cell background if color cell
                color = legend_cell.color if not legend_cell.title else None
                shading_elm = parse_xml(r'<w:shd {} w:fill="{}"/>'.format(nsdecls("w"), color))
                # noinspection PyProtectedMember
                word_cell._tc.get_or_add_tcPr().append(shading_elm)

                # first cell to be empty for better look
                if idx == 0 and legend_cell.title == "first":
                    word_cell.width = Inches(first_cell_width)
                    word_cell.text = ""
                # text on the left within cell
                word_cell.paragraphs[0].alignment = WD_PARAGRAPH_ALIGNMENT.LEFT

        log.debug("Table legend added.")
        return None

    def add_pictures(
        self, pictures: Sequence[Union[Path, str, bytes]], widths: Sequence[float] = None, **kwargs
    ) -> None:
        """Adds a sequence of static pictures to a single row in the report document.

        Each picture should be provided either as a sequence of bytes or as a path to the picture file.

        Args:
          pictures: Sequence of picture data provided either as a bytes sequence or picture file path.
          widths: Optional sequence of pictures widths. They should sum up to 100%. If not provided,
            each implementation will figure out the best widths based on provided data.

        Keyword Args:
          height (int): Height of the pictures. If not provided then the best height will be calculated.

        """
        if not pictures:
            return None
        default_content_width = 100.0 - (2 * self.MARGIN / self.WORKING_PAGE_WIDTH * 100.0)
        default_width = default_content_width / len(pictures)
        _widths = widths if widths is not None else [default_width] * len(pictures)
        _height = kwargs.pop("height", None)

        scale = self.WORKING_PAGE_WIDTH * self.current_column_width / 100.0
        pic_widths = [Inches(width * scale) if width else None for width in _widths]
        pic_height = Inches(_height) if _height is not None else None

        p = self.document.add_paragraph()
        _run = p.add_run()
        for picture, pic_width in zip(pictures, pic_widths):
            try:
                self._add_single_picture(run=_run, picture_data=picture, width=pic_width, height=pic_height)
            except Exception as e:
                log.warning(f"Unable to add provided picture: {e}")
                self.add_text(text="Unable to add this picture.")
        return None

    def add_figures(self, figures: Sequence[Figure], widths: Optional[Sequence[float]] = None, **kwargs) -> None:
        """Adds a sequence of Plotly figures to a single row in the report document.

        Word reports don't support Plotly figures, so each figure has to be converted to a static image first.
        Luckily there is **kaleido** package available, that makes it as simple as `fig.to_img(engine="kaleido")`

        Args:
          figures: Sequence of Plotly figure objects data provided either as a bytes sequence or string.
          widths: Optional sequence of pictures widths. They should sum up to 100%. If not provided,
            each implementation will figure out the best widths based on provided data.

        Keyword Args:
          plot_title (str): Plot title. It will be added as a heading just before the row with figures if provided.

        """
        plot_title = kwargs.pop("plot_title", "")
        # Add a heading with plot title, if provided
        if plot_title:
            self.add_text(text=plot_title, style=TextStyle.PLOT_TITLE)

        pictures = []
        for figure in figures:
            # Rescale the figure to match the current column layout
            figure = self.rescale_figure_size(figure=figure, scale=self.current_column_width)
            pic = figure.to_image(format=self.FIGURE_FORMAT, engine=self.FIGURE_ENGINE)
            pictures.append(pic)
        self.add_pictures(pictures=pictures, widths=widths)
        return None

    def export(self, filepath: Union[str, Path], **kwargs):
        """Exports a report document to a file path provided in the `filepath` argument.

        Args:
          filepath: Path of the exported document file.

        """
        filepath = Path(filepath).with_suffix(f".{self.export_extension}")
        try:
            self.document.save(filepath)
            log.info(f"Report saved to file: {filepath}")
        except PermissionError:
            new_filepath = filepath.with_stem(f"{filepath.stem}_1")
            self.export(filepath=new_filepath, **kwargs)

    def to_bytes(self, **kwargs) -> bytes:
        """Converts content of the report document to bytes.

        Returns:
            Bytes that contains the binary content of the report document.
        """
        with io.BytesIO() as buf:
            self.document.save(buf)
            buf.seek(0)
            bytes_stream = buf.read()
            return bytes_stream

    def reset(self) -> None:
        """Resets the document to the initial state."""
        self.document = self._create_new_document(template_file=self._template_file)

    @staticmethod
    def _create_new_document(template_file: Optional[str]) -> Type[Document]:
        """
        Creates new document based on provided template. If template is `None` then the empty Word document without
        any custom styling is used

        Args:
          template_file (Optional[str]): Path to the template file

        """
        if template_file is not None:
            document = Document(docx=template_file)
            log.debug(f"New document created from template file: {template_file}")
        else:
            document = Document()
            log.debug("New empty document created.")
        # set default author and last modified by to 'Motion Service'
        core_properties = document.core_properties
        core_properties.author = ReportConstants.DEFAULT_AUTHOR
        core_properties.last_modified_by = ReportConstants.DEFAULT_AUTHOR
        return document

    def _update_version_in_footer(self, version: str):
        """
        Replaces second line of the "REV" footer's cell with the provided version.

        Args:
          version (str): version to be placed in the report footer
        """

        # read version for smartreport and update version in footers in report
        def replace_revision_in_tables(tables):
            for table in tables:
                for row in table.rows:
                    for cell in row.cells:
                        if "REV" not in cell.text:
                            continue

                        # since we are using one common template for Word document we know that REV cell contains
                        # actually 2 paragraphs - first for the REV text, second will be modified. Note that we are
                        # modifying run.text, not cell.text - this is done to preserve the original styling
                        cell.paragraphs[1].runs[0].text = version
                        break

        try:
            for section in self.document.sections:
                replace_revision_in_tables(section.first_page_footer.tables)
                replace_revision_in_tables(section.footer.tables)
        except AttributeError:
            log.debug("Could not update Version in document's footer.")

    def _pop_table(self, index: int = -1):
        """Removes a table with given index from the document. Default is the last table."""
        # noinspection PyProtectedMember
        active_table = self.document.tables[index]._element
        active_table.getparent().remove(active_table)

    def _add_single_picture(
        self, run: Run, picture_data: Union[Path, str, bytes], width: Optional[Inches], height: Optional[Inches]
    ) -> None:
        """Function fixes original behaviour of run.add_picture, where header/footer images are not taken into account
        when new picture is placed in the document - in most cases it results in picture id clash.
        To learn about root cause of the picture id clash, see: https://github.com/python-openxml/python-docx/issues/455

        This code is mostly a copy-paste from docx.text.run.Run.add_picture method with one important fix.

        * First we are creating CT_Inline inline object in a same manner as in original code. Here is the place
        which is the root cause of picture id clash issue - original generator for shape_id is not taking into
        account pictures included in headers/footers

        * We will recreate CT_Inline object with new id - this will potentially fix id clash issue (we will simply
        start from high id number and assign unique id to every picture). This code is not present in the original
        add_picture method

        * Rest of the function is very similar to the original code

        Args:
          run: Run object to which the picture should be added.
          picture_data: Picture data
          width: Optional width of the picture
          height: Optional height of the picture

        """

        if isinstance(picture_data, bytes):
            with io.BytesIO(picture_data) as picture_bytes:
                picture_bytes.seek(0)
                inline = run.part.new_pic_inline(picture_bytes, width, height)
        else:
            picture_path = str(picture_data)
            inline = run.part.new_pic_inline(picture_path, width, height)

        _picture_filename = inline.graphic.graphicData.pic.nvPicPr.cNvPr.name
        _new_shape_id = next(self._element_id_gen)
        inline = CT_Inline.new_pic_inline(
            shape_id=_new_shape_id,
            rId=inline.graphic.graphicData.pic.blipFill.blip.embed,
            filename=inline.graphic.graphicData.pic.nvPicPr.cNvPr.name,
            cx=inline.extent.cx,
            cy=inline.extent.cy,
        )
        # noinspection PyProtectedMember
        run._r.add_drawing(inline)

        log.debug(f"Picture added: {_picture_filename} (id={_new_shape_id})")
        return None

    @contextmanager
    def column_section(self, no_of_columns: int = 2) -> Generator[Callable[[], None], None, None]:
        """Creates a context in which the Report Output works in column layout.

        Number of columns is defined 'no_of_columns' parameter.
        You can add components to the document the same way as you would do for a standard (single column) layout,
        but within the context you hove a `next_column()` function available that can move you to the next column.
        When the context is finished the basic layout is restored (single column).

        Example of usage:
        ```python
        ro = WordReportOutput()
        with ro.column_section(3) as nc:  # 'nc' is the name of the function that can jump to next column
            # We start from the first column (on the left)
            # work with ReportOutput the same way as usual
            ro.add_text("Text in first column")
            nc()  # move the context to the next column (second)
            ro.add_heading("Heading in the middle", level=2)
            ro.add_text("Text in second column")
            nc()  # move the context to the next column (third, last)
            ro.add_text("Text in third column")
            nc()  # there were only three columns in this context, so this will do nothing
        ```

        Args:
          no_of_columns: Number of columns for this column section

        Returns:
          A function that will move the report output "cursor" to the next column in the created context.
          This function should be called each time when you are finished with adding content to the current column,
          and you want to move to the next column. If you call it while being in the last column, nothing will happen,
          and the "cursor" will remain in the same (last) column.

        """

        def set_number_of_columns(section, cols: int):
            """Helper functions that changes a number of columns in a current section of a Word document
            Based on https://github.com/python-openxml/python-docx/issues/167#issuecomment-275219527


            """
            # noinspection PyProtectedMember
            section._sectPr.xpath("./w:cols")[0].set(f"{{{nsmap['w']}}}num", str(cols))

        # First we add new continuous section, then we update a number of columns in the new section,
        # and finally we add a new paragraph (empty line)
        _sections = self.document.add_section(WD_SECTION_START.CONTINUOUS)
        set_number_of_columns(_sections, no_of_columns)
        self.document.add_paragraph()
        self.current_column_width = 1.0 / no_of_columns
        self.current_number_of_columns = no_of_columns

        # From now, we work within the first column
        _column_id = 1
        log.debug(f"Working in column: {_column_id} / {no_of_columns}")

        def move_to_next_column():
            """Function that moves the context to the next column."""
            nonlocal _column_id

            # Check if we still have columns available to jump to
            if _column_id >= no_of_columns:
                log.warning(f"There is no next column to move to. ({_column_id} / {no_of_columns})")
                return None

            # Add new paragraph with 'Column Break' word object, and update the current_column id
            p = self.document.add_paragraph()
            p.add_run().add_break(WD_BREAK.COLUMN)
            _column_id += 1
            log.debug(f"Working in column: {_column_id} / {no_of_columns}")

        try:
            yield move_to_next_column
        finally:
            # Clean up the layout after the context is exited
            # Add another continuous section break, restore the layout to a single column
            # and add a new paragraph (empty line)
            _sections = self.document.add_section(WD_SECTION_START.CONTINUOUS)
            set_number_of_columns(_sections, 1)
            self.document.add_paragraph()
            self.current_column_width = 1.0
            self.current_number_of_columns = 1

    @staticmethod
    def _apply_cell_styling(table_cell, data_cell: Union[str, TableCell]) -> None:
        """Applies a styling from provided data to selected table_cell."""
        if getattr(data_cell, "style", None):
            table_cell.paragraphs[0].style = data_cell.style.value
        if getattr(data_cell, "color", None):
            shading_elm = parse_xml(r'<w:shd {} w:fill="{}"/>'.format(nsdecls("w"), data_cell.color))
            # noinspection PyProtectedMember
            table_cell._tc.get_or_add_tcPr().append(shading_elm)
        # Make bold font
        if getattr(data_cell, "bold", None):
            table_cell.paragraphs[0].runs[0].font.bold = data_cell.bold
        # Make italic font
        if getattr(data_cell, "italic", None):
            table_cell.paragraphs[0].runs[0].font.italic = data_cell.italic
        # Center horizontally
        if getattr(data_cell, "h_center", None):
            table_cell.paragraphs[0].alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
        # Left indentation
        if getattr(data_cell, "padding_left", 0):
            table_cell.paragraphs[0].paragraph_format.left_indent = Pt(data_cell.padding_left)
        if getattr(data_cell, "font_color", None):
            table_cell.paragraphs[0].runs[0].font.color.rgb = RGBColor(*tuple(hex_to_rgb(data_cell.font_color)))
        if getattr(data_cell, "font_size", None):
            table_cell.paragraphs[0].runs[0].font.size = Pt(data_cell.font_size)
        return None

    def _parse_column_widths(
        self, widths: Optional[Sequence[float]], no_of_columns: int
    ) -> Sequence[Optional[Inches]]:
        """
        Parses a `column_widths` input from add_table method into a valid sequence of column width values.
        We expect the input to be provided as sequence of percents that sums up to 100%, but to be sure we normalize
        the widths by dividing each width by total widths sum.

        """
        _default_widths = [None] * no_of_columns

        # If no widths are provided or its number doesn't match the number of columns then we return a list of Nones
        if widths is None or len(widths) != no_of_columns:
            return _default_widths

        try:
            total_width = sum(widths)
            normalized_widths = [Inches(w / total_width * self.WORKING_PAGE_WIDTH) for w in widths]
            return normalized_widths
        except (ValueError, TypeError):
            return _default_widths

    @staticmethod
    def get_font_size(word_table_style: _TableStyle) -> Optional[Pt]:
        """
        Exports font size from table style in word template.
        Table template in word program is always created by overriding other table style.
        For example: custom style: "default_table" is based on "ABB Table Style",
        which means that all styling from "ABB Table Style" is present "default_table" style,
        this behaviour resembles polymorphism.
        Unfortunately settings aren't copied from "ABB Table Style" and passed to child "default_table" style,
        just information about parent style is present in "default_table" and changes with respect to it,
        other settings are empty even if they are custom.
        Therefore, to extract full information about styling, it might be necessary to check parent style.

        Args:
            word_table_style (docx.styles.style._TableStyle): table style object loaded from word_report_template

        Returns:
            font size (Optional[Pt]): in case that no style is used (usually test purposes) None value will be returned
        """

        # if styling isn't used (usually for testing purposes)
        if not word_table_style:
            return None

        if not isinstance(word_table_style, _TableStyle):
            return None

        if word_table_style.font.size:
            return word_table_style.font.size
        return WordReportOutput.get_font_size(word_table_style.base_style)

    @staticmethod
    def _set_repeat_header_row(word_table: docx.table.Table, no_of_row_headers: int) -> None:
        """
        Sets repeat header row for word table.

        Args:
            word_table: table
            no_of_row_headers: number of first rows of table that should be repeated
        """
        for row_idx in range(no_of_row_headers):
            tr_pr = word_table.rows[row_idx]._tr.get_or_add_trPr()
            tbl_header = OxmlElement("w:tblHeader")
            tbl_header.set(qn("w:val"), "true")
            tr_pr.append(tbl_header)

add_text(text, style=TextStyle.DEFAULT, dictionary=None, **kwargs)

Adds text paragraph to the report document.

text is a string and style is an identifier of style that is applied to it. For Word document, style's value is a name of the paragraph style, defined in the template document. Styles section is available in Home->Styles in Word editor.

This method supports a subset of Markdown formatting in provided text string. Following elements are supported:

  • Empty line adds a new paragraph.

  • Hashes (#) at the beginning of the line adds heading.

  • Pattern [TEXT](URL) adds a hyperlink with TEXT that points to URL.

  • Any occurrence of {{VARIABLE}} will be replaced by the string that comes from provided dictionary keyword argument.

Refer to interface documentation for more details.

Parameters:

Name Type Description Default
text str

Input text

required
style TextStyle

Identifier of a text style that will be applied to the text

DEFAULT
dictionary Optional[Dict]

Dictionary that would be used to replace Variables located in input text.

None
Source code in smartreport/engine/outputs/word_document.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def add_text(
    self, text: str, style: TextStyle = TextStyle.DEFAULT, dictionary: Optional[Dict] = None, **kwargs
) -> None:
    """Adds text paragraph to the report document.

    `text` is a string and `style` is an identifier of style that is applied to it.
    For Word document, style's value is a name of the paragraph style, defined in the template document. Styles
    section is available in Home->Styles in Word editor.

    This method supports a subset of Markdown formatting in provided text string.
    Following elements are supported:

    * Empty line adds a new paragraph.

    * Hashes (#) at the beginning of the line adds heading.

    * Pattern `[TEXT](URL)` adds a hyperlink with TEXT that points to URL.

    * Any occurrence of `{{VARIABLE}}` will be replaced by the string that comes from
    provided `dictionary` keyword argument.

    Refer to [interface documentation](00_overview.md) for more details.

    Args:
      text: Input text
      style: Identifier of a text style that will be applied to the text
      dictionary: Dictionary that would be used to replace Variables located in input text.

    """
    dictionary = dictionary if isinstance(dictionary, dict) else {}

    for line in text.split("\n"):
        # Extract Headings and text using markdown
        heading_text, heading_level = get_heading_from_line(line)
        if heading_level is not None:
            self.add_heading(text=heading_text, level=heading_level)
            continue
        # replace variables names in text with their values using dictionary
        new_line = replace_variables_in_line(line, dictionary)
        # Create a new paragraph and add styling if possible
        paragraph = self.document.add_paragraph()
        if style.value in self.document.styles:
            paragraph.style = self.document.styles[style.value]
        else:
            log.info(f"Could not find style '{style.value}' in template document")
        # Search for hyperlinks
        for sub_line in split_line_on_hyperlinks(line=new_line):
            # add text to current paragraph if it is not a hyperlink
            if sub_line.get("type") == "text" and sub_line.get("text"):
                paragraph.add_run(sub_line.get("text"))
            # add hyperlink
            elif sub_line.get("type") == "link" and sub_line.get("text") and sub_line.get("url"):
                self.add_hyperlink(paragraph=paragraph, text=sub_line.get("text"), url=sub_line.get("url"))
        # If line is not empty, log it in the trimmed version
        if new_line:
            log.debug(f'Paragraph "{trim_text(new_line, max_length=40)}" added.')
    return None

Adds hyperlink to the report document.

Hyperlink object can link the report reader to external url or to the another section of the report.

Parameters:

Name Type Description Default
text str

Text of the hyperlink.

required
url str

URL of the hyperlink.

required

Other Parameters:

Name Type Description
paragraph Paragraph

paragraph object that will be converted into hyperlink. If not provided, a new paragraph is created.

color str

Color of the hyperlink text. Should be provided as RRGGBB string. If None then no styling is applied. Default: 0000DD

underline str

Underline style of the hyperlink text. Should be provided as a string. If None then no styling is applied. Default single

Source code in smartreport/engine/outputs/word_document.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def add_hyperlink(self, text: str, url: str, **kwargs) -> None:
    """Adds hyperlink to the report document.

    Hyperlink object can link the report reader to external url or to the another section of the report.

    Args:
      text: Text of the hyperlink.
      url: URL of the hyperlink.

    Keyword Args:
      paragraph (Paragraph): paragraph object that will be converted into hyperlink. If not provided,
        a new paragraph is created.
      color (str): Color of the hyperlink text. Should be provided as RRGGBB string.
        If `None` then no styling is applied. Default: `0000DD`
      underline (str): Underline style of the hyperlink text. Should be provided as a string.
        If `None` then no styling is applied. Default `single`
    """
    color = kwargs.get("color", "0000DD")
    underline = kwargs.get("underline", "single")

    # Define the paragraph to be processed
    _paragraph = kwargs.get("paragraph", None)
    if isinstance(_paragraph, _Cell):  # fix for Table Cells
        _paragraph = _paragraph.add_paragraph()
    paragraph = _paragraph if _paragraph else self.document.add_paragraph()

    # This gets access to the document.xml.rels file and gets a new relation id value
    part = paragraph.part
    r_id = part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
    # Create the w:hyperlink tag and add needed values
    hyperlink = OxmlElement("w:hyperlink")
    hyperlink.set(qn("r:id"), r_id)
    # Create a w:r element and a new w:rPr element
    new_run = OxmlElement("w:r")
    r_pr = OxmlElement("w:rPr")

    # Extra formatting for color and underline
    # It has to be done like this to be preserved in the after PDF conversion
    if color is not None:
        c = docx.oxml.shared.OxmlElement("w:color")
        c.set(docx.oxml.shared.qn("w:val"), color)
        r_pr.append(c)
    if underline is not None:
        u = docx.oxml.shared.OxmlElement("w:u")
        u.set(docx.oxml.shared.qn("w:val"), underline)
        r_pr.append(u)

    # Join all the xml elements together, and add the required text to the w:r element
    new_run.append(r_pr)
    new_run.text = text
    hyperlink.append(new_run)
    # Create a new Run object and add the hyperlink into it
    r = paragraph.add_run()
    # noinspection PyProtectedMember
    r._r.append(hyperlink)
    log.debug(f"Hyperlink '{text}' to URL: {url} added.")
    return None

add_heading(text, level, **kwargs)

Adds heading to the report document.

Each heading has text and level, where text is what the heading says and level is how bold it is. Levels is an integer from range <1;6>. The lower the number is more important the heading is. That corresponds to the HTML concept of H tags.

Heading are also used for building interactive Table of Content. By default, all headings are included in the Table of Content up to the ToC depth level. Heading can be explicitly excluded from ToC by providing include_in_toc=False parameter.

Parameters:

Name Type Description Default
text str

Text of the heading

required
level int

Level of the heading. Integer from range 1 to 6

required

Other Parameters:

Name Type Description
include_in_toc bool

Flag that defines if given heading should be included in Tabel of Content. (default: True)

min_length int

Minimum length of header. If provided this will extend the length of the header to prevent invalid formatting of header row in table of content. If None is provided then no formatting is applied. Default 8

Source code in smartreport/engine/outputs/word_document.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def add_heading(self, text: str, level: int, **kwargs) -> None:
    """Adds heading to the report document.

    Each heading has `text` and `level`, where `text` is what the heading says and `level` is how bold it is.
    Levels is an integer from range <1;6>. The lower the number is more important the heading is.
    That corresponds to the HTML concept of H tags.

    Heading are also used for building interactive Table of Content.
    By default, all headings are included in the Table of Content up to the ToC depth level.
    Heading can be explicitly excluded from ToC by providing `include_in_toc=False` parameter.

    Args:
      text: Text of the heading
      level: Level of the heading. Integer from range 1 to 6

    Keyword Args:
      include_in_toc (bool): Flag that defines if given heading should be included
          in Tabel of Content. (default: `True`)
      min_length (int): Minimum length of header. If provided this will extend the length of the header to prevent
          invalid formatting of header row in table of content. If `None` is provided then no formatting is applied.
          Default `8`

    """
    # Because headings are used to generate automatic Table of Content, and because Word have problems
    # with rendering a good-looking ToC for short text the minimum length of the heading text has to be enforced
    include_in_toc = kwargs.get("include_in_toc", True)
    min_length = kwargs.get("min_length", 8)

    if min_length:
        text = extend_text_with_spaces(text=text, length=min_length)

    if include_in_toc:
        self.document.add_heading(text=text, level=level)
        log.debug(f"Heading {level}: '{text}' added.")
    else:
        self.add_text(text=text, style=TextStyle.NO_TOC_HEADING)
        # if we don't want to include this heading in toc, we need to use special style from word template

    return None

add_page_break(**kwargs)

Adds a page break to the report document.

Source code in smartreport/engine/outputs/word_document.py
116
117
118
119
120
def add_page_break(self, **kwargs) -> None:
    """Adds a page break to the report document."""
    self.document.add_page_break()
    log.debug("Page Break added.")
    return self.document

add_toc(**kwargs)

Adds automatic Table of Content placeholder to the report document.

This solution is based on the discussion at: https://github.com/python-openxml/python-docx/issues/36

Word Report does it by creating a dynamic element that will scan all the headings up to the depth level and produce a Table of Content with valid page number. Because ToC is added at the beginning of the report, we don't know in advance what heading will be there to add. Therefore, ToC rendering is not happening in the report generation phase, but later, when the report reader will click 'Update Field' option on the ToC placeholder text. This text can be defined in placeholder_text :key depth: Depth of the ToC. It defines the maximum heading level to be included in the ToC. (default: 2)

Other Parameters:

Name Type Description
depth int

Depth of the ToC. It defines the maximum heading level included in the ToC. (default: 2)

heading_text str

If provided, the ToC will be preceded with the heading component

heading_level int

Level of the preceding heading component (default: 1)

page_break bool

If provided, ToC will be followed by a page break (default: False)

Source code in smartreport/engine/outputs/word_document.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def add_toc(self, **kwargs):
    """Adds automatic Table of Content placeholder to the report document.

    This solution is based on the discussion at:
    [https://github.com/python-openxml/python-docx/issues/36](https://github.com/python-openxml/python-docx/issues/36)

    Word Report does it by creating a dynamic element that will scan all the headings
    up to the `depth` level and produce a Table of Content with valid page number.
    Because ToC is added at the beginning of the report, we don't know in advance
    what heading will be there to add. Therefore, ToC rendering is not happening
    in the report generation phase, but later, when the report reader will click 'Update Field'
    option on the ToC placeholder text. This text can be defined in `placeholder_text`
    :key depth: Depth of the ToC. It defines the maximum heading level to be included in the ToC. (default: 2)

    Keyword Args:
      depth (int): Depth of the ToC. It defines the maximum heading level included in the ToC. (default: `2`)
      heading_text (str): If provided, the ToC will be preceded with the heading component
      heading_level (int): Level of the preceding heading component (default: `1`)
      page_break (bool): If provided, ToC will be followed by a page break (default: `False`)

    """
    depth = kwargs.get("depth", 2)
    heading_text = kwargs.get("heading_text")
    heading_level = kwargs.get("heading_level", 1)
    page_break = kwargs.get("page_break", False)
    placeholder_text = kwargs.get("placeholder_text", _("Right-click to update field."))

    if heading_text:
        self.add_heading(text=heading_text, level=heading_level)

    paragraph = self.document.add_paragraph()
    run = paragraph.add_run()
    fld_char = OxmlElement("w:fldChar")  # creates a new element
    fld_char.set(qn("w:fldCharType"), "begin")  # sets attribute on element
    instr_text = OxmlElement("w:instrText")
    instr_text.set(qn("xml:space"), "preserve")  # sets attribute on element
    instr_text.text = rf'TOC \o "1-{depth}" \h \z \u'  # change 1-3 depending on heading levels you need

    fld_char2 = OxmlElement("w:fldChar")
    fld_char2.set(qn("w:fldCharType"), "separate")
    fld_char3 = OxmlElement("w:t")
    fld_char3.text = placeholder_text
    fld_char2.append(fld_char3)

    fld_char4 = OxmlElement("w:fldChar")
    fld_char4.set(qn("w:fldCharType"), "end")
    # noinspection PyProtectedMember
    r_element = run._r
    r_element.append(fld_char)
    r_element.append(instr_text)
    r_element.append(fld_char2)
    r_element.append(fld_char4)

    log.debug("TOC added.")

    if page_break:
        self.add_page_break()

    return None

add_table(table_data, style=TableStyle.DEFAULT, column_widths=None, **kwargs)

Adds table to the report document.

Input data should be provided as sequence of rows, where row is a sequence of cells and cells is either as simple string or a TableCell object that carries extra formatting information. Only 2-D structures with constant number of rows and columns are allowed. Merging can be achieved by using a special cell values TableCell.MERGE_LEFT and TableCell.MERGE_ABOVE that are located in .components module.

Implementation details:

  • The implementation of this method does not allow to merge a 2x2 structure of cells (and similar ones). In case of incorrect merge structure no table will be returned. Probably the simplest solution extending method's functionality is to add TableCell.MERGE_RIGHT option.

  • The nonstandard access to cells is chosen due to efficiency reasons. See more here

Parameters:

Name Type Description Default
table_data TableData

Sequence rows, where row is a sequence of cells. Cell might be a simple string or a TableCell object, that also carries the information about cell formatting.

required
style TableStyle

TableStyle, Identifier of a style that will be applied to created table

DEFAULT
column_widths Sequence[float]

Sequence of column widths as percents. They should sum up to 100%. If not provided, each implementation will figure out the best widths based on provided data.

None

Other Parameters:

Name Type Description
use_markdown bool

If true, Allow for markdown styling in table cells (default: False)

word_rows_limit int

Maximum limit of rows in the table (default: 100)

Source code in smartreport/engine/outputs/word_document.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
def add_table(
    self,
    table_data: TableData,
    style: TableStyle = TableStyle.DEFAULT,
    column_widths: Sequence[float] = None,
    **kwargs,
) -> None:
    """Adds table to the report document.

    Input data should be provided as sequence of rows, where row is a sequence
    of cells and cells is either as simple string or a TableCell object that carries extra formatting information.
    Only 2-D structures with constant number of rows and columns are allowed.
    Merging can be achieved by using a special cell values `TableCell.MERGE_LEFT` and `TableCell.MERGE_ABOVE` that
    are located in .components module.

    Implementation details:

    * The implementation of this method does not allow to merge a 2x2 structure of cells (and similar ones).
    In case of incorrect merge structure no table will be returned. Probably the simplest solution
    extending method's functionality is to add `TableCell.MERGE_RIGHT` option.

    * The nonstandard access to cells is chosen due to efficiency reasons.
    See more [here](https://github.com/python-openxml/python-docx/issues/174)

    Args:
      table_data: Sequence rows, where row is a sequence of cells. Cell might be a simple string
        or a TableCell object, that also carries the information about cell formatting.
      style: TableStyle, Identifier of a style that will be applied to created table
      column_widths: Sequence of column widths as percents. They should sum up to 100%. If not provided,
        each implementation will figure out the best widths based on provided data.

    Keyword Args:
      use_markdown (bool): If true, Allow for markdown styling in table cells (default: `False`)
      word_rows_limit (int): Maximum limit of rows in the table (default: `100`)

    """
    use_markdown: bool = kwargs.get("use_markdown", False)
    # row limiting for events table
    word_rows_limit: int = kwargs.get("word_rows_limit", 100)
    no_of_row_headers: int = kwargs.get("no_of_row_headers", 0)

    # Limit the number of rows
    if isinstance(word_rows_limit, int):
        limit = min(word_rows_limit + 1, len(table_data))  # +1 stays for single header row
        table_data = table_data[:limit]
    else:
        raise TypeError(f"word_rows_limit expected int got {type(word_rows_limit)}")

    # Check the sizes of table
    t_rows, t_cols = get_table_size(table_data)
    if t_rows is None or t_cols is None:
        return None

    # Parse column widths
    parsed_column_widths = self._parse_column_widths(widths=column_widths, no_of_columns=t_cols)

    word_table = self.document.add_table(rows=t_rows, cols=t_cols)

    # column widths will not be determined by text length
    if column_widths is not None:
        word_table.autofit = False

    # Try find in report template, and then apply table style
    if style.value in self.document.styles:
        word_table.style = self.document.styles[style.value]
    else:
        log.info(
            f"Could not find table style '{style.value}' in template document, defaulting to {TableStyle.DEFAULT}"
        )

    left_offset, top_offset = 1, 1

    # noinspection PyProtectedMember
    wc = word_table._cells[0 : (t_cols * t_rows)]
    for i, data_row in enumerate(table_data):
        for j, data_cell in enumerate(data_row):
            left_offset, top_offset, ret = self._update_cells(
                i,
                j,
                t_cols,
                data_cell,
                use_markdown,
                wc,
                parsed_column_widths,
                word_table,
                left_offset,
                top_offset,
            )
            if ret is not None:
                return ret
    if no_of_row_headers:
        self._set_repeat_header_row(word_table, no_of_row_headers)
    log.debug(f"Table [{table_data[0]}]({t_rows}, {t_cols}) added.")
    return None

add_table_legend(legend_cells, **kwargs)

Adds legend with cells defined by legend_cells and their colors.

Legend can be used to explain color encoding of a table. Widths of legend cells are automatically calculated. Legend is adjusted to the right. Font size of a legend table is set to 8pt

Parameters:

Name Type Description Default
legend_cells Sequence[LegendCell]

Sequence of LegendCell objects.

required
Source code in smartreport/engine/outputs/word_document.py
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
def add_table_legend(self, legend_cells: Sequence[LegendCell], **kwargs) -> None:
    """Adds legend with cells defined by legend_cells and their colors.

    Legend can be used to explain color encoding
    of a table. Widths of legend cells are automatically calculated. Legend is adjusted to the right.
    Font size of a legend table is set to 8pt

    Args:
      legend_cells: Sequence of LegendCell objects.

    """
    if not legend_cells:
        return None

    inches_per_char = 0.11
    # add color gradient info
    legend_cells = [
        (
            LegendCell(
                title=cell.title + " (color gradient)",
                color=cell.gradient_colors[int(len(cell.gradient_colors) / 2)],
            )
            if cell.gradient_colors
            else cell
        )
        for cell in legend_cells
    ]
    # add color cells
    color_cells = [LegendCell(title="", color=cell.color) for cell in legend_cells]
    color_cell_width = 0.3
    # add color cells to their places and one empty cell on the left of legend for better display
    _legend_cells: List[Any] = [None] * (len(legend_cells) + len(color_cells) + 1)
    _legend_cells[0] = LegendCell(title="first", color="")
    _legend_cells[1::2] = color_cells
    _legend_cells[2::2] = legend_cells
    legend_cells = _legend_cells

    row_no_of_cells = len(legend_cells)
    # legend is a simple 2 row table without any border, aligned to right if possible
    # second row is for adding space between data table and the legend table as well as giving space for long texts
    # without making color cells too tall
    word_table = self.document.add_table(rows=2, cols=row_no_of_cells)
    word_table.alignment = WD_TABLE_ALIGNMENT.RIGHT
    word_table.autofit = True

    if TableStyle.LEGEND_TABLE.value in self.document.styles:
        word_table.style = self.document.styles[TableStyle.LEGEND_TABLE.value]
    else:
        log.info(f"Could not find table style '{TableStyle.LEGEND_TABLE.value}' in template document")

    first_cell_width = 10 if row_no_of_cells < 10 else 2

    # the nonstandard access to cells is chosen due to efficiency reasons
    # see more at: https://github.com/python-openxml/python-docx/issues/174
    # noinspection PyProtectedMember

    word_cells = word_table._cells[:]
    top_offset = 1
    for idx_row in range(0, 2):
        for idx, legend_cell in enumerate(legend_cells):
            pos = (idx_row * row_no_of_cells) + idx
            pos_up = pos - (row_no_of_cells * top_offset)
            word_cell = word_cells[idx]

            # try to set width for cell
            if legend_cell.title:
                title_len = len(legend_cell.title)
                if title_len < 13:
                    word_cell.width = Inches((title_len + 4) * inches_per_char)
                elif title_len > 14:
                    word_cell.width = Inches(title_len / 2 * inches_per_char)
                else:
                    word_cell.width = Inches((title_len + 1) * inches_per_char)
            else:
                word_cell.width = Inches(color_cell_width)
            # merge text cells
            if idx_row == 1 and legend_cell.title:
                word_cells[pos_up].merge(word_cells[pos])

            word_cell.text = f" {legend_cell.title}"
            # For some reason all information about text styling (color, font, bold, highlight, etc..) is by default
            # copied from table style (Table style is defined in word_template_report.docx),
            # only font size is overridden from other source. To fix that issue
            # following explicit font style assigment is implemented, which should not be necessary.
            word_cell.paragraphs[0].runs[0].font.size = self.get_font_size(word_table.style)

            # make cell font bold
            word_cell.paragraphs[0].runs[0].font.bold = True

            # add color to cell background if color cell
            color = legend_cell.color if not legend_cell.title else None
            shading_elm = parse_xml(r'<w:shd {} w:fill="{}"/>'.format(nsdecls("w"), color))
            # noinspection PyProtectedMember
            word_cell._tc.get_or_add_tcPr().append(shading_elm)

            # first cell to be empty for better look
            if idx == 0 and legend_cell.title == "first":
                word_cell.width = Inches(first_cell_width)
                word_cell.text = ""
            # text on the left within cell
            word_cell.paragraphs[0].alignment = WD_PARAGRAPH_ALIGNMENT.LEFT

    log.debug("Table legend added.")
    return None

add_pictures(pictures, widths=None, **kwargs)

Adds a sequence of static pictures to a single row in the report document.

Each picture should be provided either as a sequence of bytes or as a path to the picture file.

Parameters:

Name Type Description Default
pictures Sequence[Union[Path, str, bytes]]

Sequence of picture data provided either as a bytes sequence or picture file path.

required
widths Sequence[float]

Optional sequence of pictures widths. They should sum up to 100%. If not provided, each implementation will figure out the best widths based on provided data.

None

Other Parameters:

Name Type Description
height int

Height of the pictures. If not provided then the best height will be calculated.

Source code in smartreport/engine/outputs/word_document.py
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
def add_pictures(
    self, pictures: Sequence[Union[Path, str, bytes]], widths: Sequence[float] = None, **kwargs
) -> None:
    """Adds a sequence of static pictures to a single row in the report document.

    Each picture should be provided either as a sequence of bytes or as a path to the picture file.

    Args:
      pictures: Sequence of picture data provided either as a bytes sequence or picture file path.
      widths: Optional sequence of pictures widths. They should sum up to 100%. If not provided,
        each implementation will figure out the best widths based on provided data.

    Keyword Args:
      height (int): Height of the pictures. If not provided then the best height will be calculated.

    """
    if not pictures:
        return None
    default_content_width = 100.0 - (2 * self.MARGIN / self.WORKING_PAGE_WIDTH * 100.0)
    default_width = default_content_width / len(pictures)
    _widths = widths if widths is not None else [default_width] * len(pictures)
    _height = kwargs.pop("height", None)

    scale = self.WORKING_PAGE_WIDTH * self.current_column_width / 100.0
    pic_widths = [Inches(width * scale) if width else None for width in _widths]
    pic_height = Inches(_height) if _height is not None else None

    p = self.document.add_paragraph()
    _run = p.add_run()
    for picture, pic_width in zip(pictures, pic_widths):
        try:
            self._add_single_picture(run=_run, picture_data=picture, width=pic_width, height=pic_height)
        except Exception as e:
            log.warning(f"Unable to add provided picture: {e}")
            self.add_text(text="Unable to add this picture.")
    return None

add_figures(figures, widths=None, **kwargs)

Adds a sequence of Plotly figures to a single row in the report document.

Word reports don't support Plotly figures, so each figure has to be converted to a static image first. Luckily there is kaleido package available, that makes it as simple as fig.to_img(engine="kaleido")

Parameters:

Name Type Description Default
figures Sequence[Figure]

Sequence of Plotly figure objects data provided either as a bytes sequence or string.

required
widths Optional[Sequence[float]]

Optional sequence of pictures widths. They should sum up to 100%. If not provided, each implementation will figure out the best widths based on provided data.

None

Other Parameters:

Name Type Description
plot_title str

Plot title. It will be added as a heading just before the row with figures if provided.

Source code in smartreport/engine/outputs/word_document.py
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
def add_figures(self, figures: Sequence[Figure], widths: Optional[Sequence[float]] = None, **kwargs) -> None:
    """Adds a sequence of Plotly figures to a single row in the report document.

    Word reports don't support Plotly figures, so each figure has to be converted to a static image first.
    Luckily there is **kaleido** package available, that makes it as simple as `fig.to_img(engine="kaleido")`

    Args:
      figures: Sequence of Plotly figure objects data provided either as a bytes sequence or string.
      widths: Optional sequence of pictures widths. They should sum up to 100%. If not provided,
        each implementation will figure out the best widths based on provided data.

    Keyword Args:
      plot_title (str): Plot title. It will be added as a heading just before the row with figures if provided.

    """
    plot_title = kwargs.pop("plot_title", "")
    # Add a heading with plot title, if provided
    if plot_title:
        self.add_text(text=plot_title, style=TextStyle.PLOT_TITLE)

    pictures = []
    for figure in figures:
        # Rescale the figure to match the current column layout
        figure = self.rescale_figure_size(figure=figure, scale=self.current_column_width)
        pic = figure.to_image(format=self.FIGURE_FORMAT, engine=self.FIGURE_ENGINE)
        pictures.append(pic)
    self.add_pictures(pictures=pictures, widths=widths)
    return None

column_section(no_of_columns=2)

Creates a context in which the Report Output works in column layout.

Number of columns is defined 'no_of_columns' parameter. You can add components to the document the same way as you would do for a standard (single column) layout, but within the context you hove a next_column() function available that can move you to the next column. When the context is finished the basic layout is restored (single column).

Example of usage:

ro = WordReportOutput()
with ro.column_section(3) as nc:  # 'nc' is the name of the function that can jump to next column
    # We start from the first column (on the left)
    # work with ReportOutput the same way as usual
    ro.add_text("Text in first column")
    nc()  # move the context to the next column (second)
    ro.add_heading("Heading in the middle", level=2)
    ro.add_text("Text in second column")
    nc()  # move the context to the next column (third, last)
    ro.add_text("Text in third column")
    nc()  # there were only three columns in this context, so this will do nothing

Parameters:

Name Type Description Default
no_of_columns int

Number of columns for this column section

2

Returns:

Type Description
None

A function that will move the report output "cursor" to the next column in the created context.

None

This function should be called each time when you are finished with adding content to the current column,

None

and you want to move to the next column. If you call it while being in the last column, nothing will happen,

None

and the "cursor" will remain in the same (last) column.

Source code in smartreport/engine/outputs/word_document.py
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
@contextmanager
def column_section(self, no_of_columns: int = 2) -> Generator[Callable[[], None], None, None]:
    """Creates a context in which the Report Output works in column layout.

    Number of columns is defined 'no_of_columns' parameter.
    You can add components to the document the same way as you would do for a standard (single column) layout,
    but within the context you hove a `next_column()` function available that can move you to the next column.
    When the context is finished the basic layout is restored (single column).

    Example of usage:
    ```python
    ro = WordReportOutput()
    with ro.column_section(3) as nc:  # 'nc' is the name of the function that can jump to next column
        # We start from the first column (on the left)
        # work with ReportOutput the same way as usual
        ro.add_text("Text in first column")
        nc()  # move the context to the next column (second)
        ro.add_heading("Heading in the middle", level=2)
        ro.add_text("Text in second column")
        nc()  # move the context to the next column (third, last)
        ro.add_text("Text in third column")
        nc()  # there were only three columns in this context, so this will do nothing
    ```

    Args:
      no_of_columns: Number of columns for this column section

    Returns:
      A function that will move the report output "cursor" to the next column in the created context.
      This function should be called each time when you are finished with adding content to the current column,
      and you want to move to the next column. If you call it while being in the last column, nothing will happen,
      and the "cursor" will remain in the same (last) column.

    """

    def set_number_of_columns(section, cols: int):
        """Helper functions that changes a number of columns in a current section of a Word document
        Based on https://github.com/python-openxml/python-docx/issues/167#issuecomment-275219527


        """
        # noinspection PyProtectedMember
        section._sectPr.xpath("./w:cols")[0].set(f"{{{nsmap['w']}}}num", str(cols))

    # First we add new continuous section, then we update a number of columns in the new section,
    # and finally we add a new paragraph (empty line)
    _sections = self.document.add_section(WD_SECTION_START.CONTINUOUS)
    set_number_of_columns(_sections, no_of_columns)
    self.document.add_paragraph()
    self.current_column_width = 1.0 / no_of_columns
    self.current_number_of_columns = no_of_columns

    # From now, we work within the first column
    _column_id = 1
    log.debug(f"Working in column: {_column_id} / {no_of_columns}")

    def move_to_next_column():
        """Function that moves the context to the next column."""
        nonlocal _column_id

        # Check if we still have columns available to jump to
        if _column_id >= no_of_columns:
            log.warning(f"There is no next column to move to. ({_column_id} / {no_of_columns})")
            return None

        # Add new paragraph with 'Column Break' word object, and update the current_column id
        p = self.document.add_paragraph()
        p.add_run().add_break(WD_BREAK.COLUMN)
        _column_id += 1
        log.debug(f"Working in column: {_column_id} / {no_of_columns}")

    try:
        yield move_to_next_column
    finally:
        # Clean up the layout after the context is exited
        # Add another continuous section break, restore the layout to a single column
        # and add a new paragraph (empty line)
        _sections = self.document.add_section(WD_SECTION_START.CONTINUOUS)
        set_number_of_columns(_sections, 1)
        self.document.add_paragraph()
        self.current_column_width = 1.0
        self.current_number_of_columns = 1

export(filepath, **kwargs)

Exports a report document to a file path provided in the filepath argument.

Parameters:

Name Type Description Default
filepath Union[str, Path]

Path of the exported document file.

required
Source code in smartreport/engine/outputs/word_document.py
670
671
672
673
674
675
676
677
678
679
680
681
682
683
def export(self, filepath: Union[str, Path], **kwargs):
    """Exports a report document to a file path provided in the `filepath` argument.

    Args:
      filepath: Path of the exported document file.

    """
    filepath = Path(filepath).with_suffix(f".{self.export_extension}")
    try:
        self.document.save(filepath)
        log.info(f"Report saved to file: {filepath}")
    except PermissionError:
        new_filepath = filepath.with_stem(f"{filepath.stem}_1")
        self.export(filepath=new_filepath, **kwargs)

add_comment_box(**kwargs)

This method's implementation in WordReportOutput is some kind of the response for recommendations section needs. We need to show text that is an initial text in DashReportOutput.

Other Parameters:

Name Type Description
initial_value str

value that should be shown as the paragraph text in report output

Source code in smartreport/engine/outputs/word_document.py
298
299
300
301
302
303
304
305
306
307
308
309
def add_comment_box(self, **kwargs) -> None:
    """
    This method's implementation in WordReportOutput is some kind of the response
    for recommendations section needs. We need to show text that is an initial text in
    DashReportOutput.

    Keyword Args:
        initial_value (str): value that should be shown as the paragraph text in report output
    """
    initial_text: str = kwargs.get("initial_text", "")
    if initial_text:
        self.add_text(text=initial_text)