Completed
Push — develop ( c2cafd...3dfc07 )
by Jace
24s queued 14s
created

doorstop/core/publisher_latex.py (2 issues)

1
# SPDX-License-Identifier: LGPL-3.0-only
2
3
"""Functions to publish LaTeX documents."""
4
5
import os
6
import re
7
8
from doorstop import common, settings
9
from doorstop.common import DoorstopError
10
from doorstop.core.types import is_item, iter_items
11
12
log = common.logger(__name__)
13
14
15
def _lines_latex(obj, **kwargs):
16
    """Yield lines for a LaTeX report.
17
18
    :param obj: Item, list of Items, or Document to publish
19
    :param linkify: turn links into hyperlinks
20
21
    :return: iterator of lines of text
22
23
    """
24
    linkify = kwargs.get("linkify", False)
25
    for item in iter_items(obj):
26
        heading = "\\" + "sub" * (item.depth - 1) + "section*{"
27
        headingLev = "\\" + "sub" * (item.depth - 1) + "section{"
28
29
        if item.heading:
30
            text_lines = item.text.splitlines()
31
            # Level and Text
32
            if settings.PUBLISH_HEADING_LEVELS:
33
                standard = "{h}{t}{he}".format(
34
                    h=headingLev, t=text_lines[0] if text_lines else "", he="}"
35
                )
36
            else:
37
                standard = "{h}{t}{he}".format(
38
                    h=heading, t=text_lines[0] if text_lines else "", he="}"
39
                )
40
            attr_list = _format_latex_attr_list(item, True)
41
            yield standard + attr_list
42
            yield from text_lines[1:]
43
        else:
44
            uid = item.uid
45
            if settings.ENABLE_HEADERS:
46
                if item.header:
47
                    uid = "{h}{{\\small{{}}{u}}}".format(h=item.header, u=item.uid)
48
                else:
49
                    uid = "{u}".format(u=item.uid)
50
51
            # Level and UID
52
            if settings.PUBLISH_BODY_LEVELS:
53
                standard = "{h}{u}{he}".format(h=headingLev, u=uid, he="}")
54
            else:
55
                standard = "{h}{u}{he}".format(h=heading, u=uid, he="}")
56
57
            attr_list = _format_latex_attr_list(item, True)
58
            yield standard + attr_list
59
60
            # Text
61
            if item.text:
62
                yield ""  # break before text
63
                yield from _format_latex_text(item.text.splitlines())
64
65
            # Reference
66
            if item.ref:
67
                yield ""  # break before reference
68
                yield _format_latex_ref(item)
69
70
            # Reference
71
            if item.references:
72
                yield ""  # break before reference
73
                yield _format_latex_references(item)
74
75
            # Parent links
76
            if item.links:
77
                yield ""  # break before links
78
                items2 = item.parent_items
79
                if settings.PUBLISH_CHILD_LINKS:
80
                    label = "Parent links:"
81
                else:
82
                    label = "Links:"
83
                links = _format_latex_links(items2, linkify)
84
                label_links = _format_latex_label_links(label, links, linkify)
85
                yield label_links
86
87
            # Child links
88
            if settings.PUBLISH_CHILD_LINKS:
89
                items2 = item.find_child_items()
90
                if items2:
91
                    yield ""  # break before links
92
                    label = "Child links:"
93
                    links = _format_latex_links(items2, linkify)
94
                    label_links = _format_latex_label_links(label, links, linkify)
95
                    yield label_links
96
97
            # Add custom publish attributes
98 View Code Duplication
            if item.document and item.document.publish:
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
99
                header_printed = False
100
                for attr in item.document.publish:
101
                    if not item.attribute(attr):
102
                        continue
103
                    if not header_printed:
104
                        header_printed = True
105
                        yield "\\begin{longtable}{|l|l|}"
106
                        yield "Attribute & Value\\\\"
107
                        yield "\\hline"
108
                    yield "{} & {}".format(attr, item.attribute(attr))
109
                if header_printed:
110
                    yield "\\end{longtable}"
111
                else:
112
                    yield ""
113
114
        yield ""  # break between items
115
116
117
def _format_latex_attr_list(item, linkify):
118
    """Create a LaTeX attribute list for a heading."""
119
    return (
120
        "{l}{u}{le}{zl}{u}{le}".format(l="\\label{", zl="\\zlabel{", u=item.uid, le="}")
121
        if linkify
122
        else ""
123
    )
124
125
126
def _format_latex_ref(item):
127
    """Format an external reference in LaTeX."""
128
    if settings.CHECK_REF:
129
        path, line = item.find_ref()
130
        path = path.replace("\\", "/")  # always use unix-style paths
131
        if line:
132
            return "\\begin{{quote}} \\verb|{p}| (line {line})\\end{{quote}}".format(
133
                p=path, line=line
134
            )
135
        else:
136
            return "\\begin{{quote}} \\verb|{p}|\\end{{quote}}".format(p=path)
137
    else:
138
        return "\\begin{{quote}} \\verb|{r}|\\end{{quote}}".format(r=item.ref)
139
140
141 View Code Duplication
def _format_latex_references(item):
0 ignored issues
show
This code seems to be duplicated in your project.
Loading history...
142
    """Format an external reference in LaTeX."""
143
    if settings.CHECK_REF:
144
        references = item.find_references()
145
        text_refs = []
146
        for ref_item in references:
147
            path, line = ref_item
148
            path = path.replace("\\", "/")  # always use unix-style paths
149
150
            if line:
151
                text_refs.append(
152
                    "\\begin{{quote}} \\verb|{p}| (line {line})\\end{{quote}}".format(
153
                        p=path, line=line
154
                    )
155
                )
156
            else:
157
                text_refs.append(
158
                    "\\begin{{quote}} \\verb|{p}|\\end{{quote}}".format(p=path)
159
                )
160
161
        return "\n".join(ref for ref in text_refs)
162
    else:
163
        references = item.references
164
        text_refs = []
165
        for ref_item in references:
166
            path = ref_item["path"]
167
            path = path.replace("\\", "/")  # always use unix-style paths
168
            text_refs.append(
169
                "\\begin{{quote}} \\verb|{r}|\\end{{quote}}".format(r=path)
170
            )
171
        return "\n".join(ref for ref in text_refs)
172
173
174
def _format_latex_links(items, linkify):
175
    """Format a list of linked items in LaTeX."""
176
    links = []
177
    for item in items:
178
        link = _format_latex_item_link(item, linkify=linkify)
179
        links.append(link)
180
    return ", ".join(links)
181
182
183
def _format_latex_item_link(item, linkify=True):
184
    """Format an item link in LaTeX."""
185
    if linkify and is_item(item):
186
        if item.header:
187
            return "\\hyperref[{u}]{{{u}}}".format(u=item.uid)
188
        return "\\hyperref[{u}]{{{u}}}".format(u=item.uid)
189
    else:
190
        return str(item.uid)  # if not `Item`, assume this is an `UnknownItem`
191
192
193
def _format_latex_label_links(label, links, linkify):
194
    """Join a string of label and links with formatting."""
195
    if linkify:
196
        return "\\textbf{{{lb}}} {ls}".format(lb=label, ls=links)
197
    else:
198
        return "\\textbf{{{lb} {ls}}}".format(lb=label, ls=links)
199
200
201
def _latex_convert(line):
202
    """Single string conversion for LaTeX."""
203
    # Replace $.
204
    line = re.sub("\\$", "\\\\$", line)
205
    #############################
206
    ## Fix BOLD and ITALICS and Strikethrough.
207
    #############################
208
    # Replace **.
209
    line = re.sub("\\*\\*(.*)\\*\\*", "\\\\textbf{\\1}", line)
210
    # Replace __.
211
    line = re.sub("__(.*)__", "\\\\textbf{\\1}", line)
212
    # Replace *.
213
    line = re.sub("\\*(.*)\\*", "\\\\textit{\\1}", line)
214
    # Replace _.
215
    line = re.sub("_(.*)_", "\\\\textit{\\1}", line)
216
    # Replace ~~.
217
    line = re.sub("~~(.*)~~", "\\\\sout{\\1}", line)
218
    #############################
219
    ## Fix manual heading levels
220
    #############################
221
    if settings.PUBLISH_BODY_LEVELS:
222
        star = ""
223
    else:
224
        star = "*"
225
    # Replace ######.
226
    line = re.sub(
227
        "###### (.*)",
228
        "\\\\subparagraph" + star + "{\\1 \\\\textbf{NOTE: This level is too deep.}}",
229
        line,
230
    )
231
    # Replace #####.
232
    line = re.sub("##### (.*)", "\\\\subparagraph" + star + "{\\1}", line)
233
    # Replace ####.
234
    line = re.sub("#### (.*)", "\\\\paragraph" + star + "{\\1}", line)
235
    # Replace ###.
236
    line = re.sub("### (.*)", "\\\\subsubsection" + star + "{\\1}", line)
237
    # Replace ##.
238
    line = re.sub("## (.*)", "\\\\subsection" + star + "{\\1}", line)
239
    # Replace #.
240
    line = re.sub("# (.*)", "\\\\section" + star + "{\\1}", line)
241
    return line
242
243
244
def _format_latex_text(text):
245
    """Fix all general text formatting to use LaTeX-macros."""
246
    block = []
247
    tableFound = False
248
    headerDone = False
249
    codeFound = False
250
    mathFound = False
251
    plantUMLFound = False
252
    enumerationFound = False
253
    itemizeFound = False
254
    for i, line in enumerate(text):
255
        noParagraph = False
256
        #############################
257
        ## Fix images.
258
        #############################
259
        image_match = re.findall(r"!\[(.*)\]\((.*)\)", line)
260
        if image_match:
261
            image_title, image_path = image_match[0]
262
            # Check for title. If not found, alt_text will be used as caption.
263
            title_match = re.findall(r'(.*)\s+"(.*)"', image_path)
264
            if title_match:
265
                image_path, image_title = title_match[0]
266
            # Make a safe label.
267
            label = "fig:{l}".format(l=re.sub("[^0-9a-zA-Z]+", "", image_title))
268
            # Make the string to replace!
269
            replacement = (
270
                r"\includegraphics[width=0.8\textwidth]{"
271
                + image_path
272
                + r"}}\label{{{l}}}\zlabel{{{l}}}".format(l=label)
273
                + r"\caption{"
274
                + _latex_convert(image_title)
275
                + r"}"
276
            ).replace("\\", "\\\\")
277
            # Replace with LaTeX format.
278
            line = re.sub(
279
                r"!\[(.*)\]\((.*)\)",
280
                replacement,
281
                line,
282
            )
283
            # Create the figure.
284
            block.append(r"\begin{figure}[h!]\center")
285
            block.append(line)
286
            line = r"\end{figure}"
287
288
        #############################
289
        ## Fix $ and MATH.
290
        #############################
291
        math_match = re.split("\\$\\$", line)
292
        if len(math_match) > 1:
293
            if mathFound and len(math_match) == 2:
294
                mathFound = False
295
                line = math_match[0] + "$" + _latex_convert(math_match[1])
296
            elif len(math_match) == 2:
297
                mathFound = True
298
                line = _latex_convert(math_match[0]) + "$" + math_match[1]
299
            elif len(math_match) == 3:
300
                line = (
301
                    _latex_convert(math_match[0])
302
                    + "$"
303
                    + math_match[1]
304
                    + "$"
305
                    + _latex_convert(math_match[2])
306
                )
307
            else:
308
                raise DoorstopError(
309
                    "Cannot handle multiple math environments on one row."
310
                )
311
        else:
312
            line = _latex_convert(line)
313
        # Skip all other changes if in MATH!
314
        if mathFound:
315
            line = line + "\\\\"
316
            block.append(line)
317
            continue
318
        #############################
319
        ## Fix code blocks.
320
        #############################
321
        code_match = re.findall("```", line)
322
        if codeFound:
323
            noParagraph = True
324
        if code_match:
325
            if codeFound:
326
                block.append("\\end{lstlisting}")
327
                codeFound = False
328
            else:
329
                block.append("\\begin{lstlisting}")
330
                codeFound = True
331
            # Replace ```.
332
            line = re.sub("```", "", line)
333
        # Replace ` for inline code.
334
        line = re.sub("`(.*)`", "\\\\lstinline`\\1`", line)
335
        #############################
336
        ## Fix enumeration.
337
        #############################
338
        enumeration_match = re.findall("^[0-9]+\\.\\s(.*)", line)
339
        if enumeration_match and not enumerationFound:
340
            block.append("\\begin{enumerate}")
341
            enumerationFound = True
342
        if enumerationFound:
343
            noParagraph = True
344
            if enumeration_match:
345
                # Replace the number.
346
                line = re.sub("^[0-9]+\\.\\s", "\\\\item ", line)
347
                # Look ahead - need empty line to end enumeration!
348
                if i < len(text) - 1:
349
                    nextLine = text[i + 1]
350
                    if nextLine == "":
351
                        block.append(line)
352
                        line = "\\end{enumerate}"
353
                        enumerationFound = False
354
            else:
355
                # Look ahead - need empty line to end enumeration!
356
                if i < len(text) - 1:
357
                    nextLine = text[i + 1]
358
                    if nextLine == "":
359
                        block.append(line)
360
                        line = "\\end{enumerate}"
361
                        enumerationFound = False
362
        #############################
363
        ## Fix itemize.
364
        #############################
365
        itemize_match = re.findall("^[\\*+-]\\s(.*)", line)
366
        if itemize_match and not itemizeFound:
367
            block.append("\\begin{itemize}")
368
            itemizeFound = True
369
        if itemizeFound:
370
            noParagraph = True
371
            if itemize_match:
372
                # Replace the number.
373
                line = re.sub("^[\\*+-]\\s", "\\\\item ", line)
374
                # Look ahead - need empty line to end itemize!
375
                if i < len(text) - 1:
376
                    nextLine = text[i + 1]
377
                    if nextLine == "":
378
                        block.append(line)
379
                        line = "\\end{itemize}"
380
                        itemizeFound = False
381
            else:
382
                # Look ahead - need empty line to end itemize!
383
                if i < len(text) - 1:
384
                    nextLine = text[i + 1]
385
                    if nextLine == "":
386
                        block.append(line)
387
                        line = "\\end{itemize}"
388
                        itemizeFound = False
389
390
        #############################
391
        ## Fix tables.
392
        #############################
393
        # Check if line is part of table.
394
        table_match = re.findall("\\|", line)
395
        if table_match:
396
            if not tableFound:
397
                # Check next line for minimum 3 dashes and the same count of |.
398
                if i < len(text) - 1:
399
                    nextLine = text[i + 1]
400
                    table_match_next = re.findall("\\|", nextLine)
401
                    if table_match_next:
402
                        if len(table_match) == len(table_match_next):
403
                            table_match_dashes = re.findall("-{3,}", nextLine)
404
                            if table_match_dashes:
405
                                tableFound = True
406
                                endPipes = bool(
407
                                    len(table_match) > len(table_match_dashes)
408
                                )
409
                                nextLine = re.sub(":-+:", "c", nextLine)
410
                                nextLine = re.sub("-+:", "r", nextLine)
411
                                nextLine = re.sub("-+", "l", nextLine)
412
                                tableHeader = "\\begin{longtable}{" + nextLine + "}"
413
                                block.append(tableHeader)
414
                                # Fix the header.
415
                                line = re.sub("\\|", "&", line)
416
                                if endPipes:
417
                                    line = re.sub("^\\s*&", "", line)
418
                                    line = re.sub("&\\s*$", "\\\\\\\\", line)
419
                                else:
420
                                    line = line + "\\\\"
421
                            else:
422
                                log.warning(
423
                                    "Possibly incorrectly specified table found."
424
                                )
425
                        else:
426
                            log.warning("Possibly unbalanced table found.")
427
428
            else:
429
                if not headerDone:
430
                    line = "\\hline"
431
                    headerDone = True
432
                else:
433
                    # Fix the line.
434
                    line = re.sub("\\|", "&", line)
435
                    if endPipes:
436
                        line = re.sub("^\\s*&", "", line)
437
                        line = re.sub("&\\s*$", "\\\\\\\\", line)
438
                    else:
439
                        line = line + "\\\\"
440
        else:
441
            if tableFound:
442
                block.append("\\end{longtable}")
443
            tableFound = False
444
            headerDone = False
445
        #############################
446
        ## Fix plantuml.
447
        #############################
448
        if plantUMLFound:
449
            noParagraph = True
450
        if re.findall("^plantuml\\s", line):
451
            plantUML_title = re.search('title="(.*)"', line)
452
            if plantUML_title:
453
                plantUMLName = plantUML_title.groups(0)[0]
454
            else:
455
                raise DoorstopError(
456
                    "'title' is required for plantUML processing in LaTeX."
457
                )
458
            plantUMLFile = re.sub("\\s", "-", plantUMLName)
459
            line = "\\begin{plantuml}{" + plantUMLFile + "}"
460
            plantUMLFound = True
461
        if re.findall("@enduml", line):
462
            block.append(line)
463
            block.append("\\end{plantuml}")
464
            line = (
465
                "\\process{" + plantUMLFile + "}{0.8\\textwidth}{" + plantUMLName + "}"
466
            )
467
            plantUMLFound = False
468
469
        # Look ahead for empty line and add paragraph.
470
        if i < len(text) - 1:
471
            nextLine = text[i + 1]
472
            if nextLine == "" and not re.search("\\\\", line) and not noParagraph:
473
                line = line + "\\\\"
474
475
        #############################
476
        ## All done. Add the line.
477
        #############################
478
        block.append(line)
479
480
        # Check for end of file and end all environments.
481
        if i == len(text) - 1:
482
            if codeFound:
483
                block.append("\\end{lstlisting}")
484
            if enumerationFound:
485
                block.append("\\end{enumerate}")
486
            if itemizeFound:
487
                block.append("\\end{itemize}")
488
            if plantUMLFound:
489
                block.append("\\end{plantuml}")
490
                block.append(
491
                    "\\process{"
492
                    + plantUMLFile
493
                    + "}{0.8\\textwidth}{"
494
                    + plantUMLName
495
                    + "}"
496
                )
497
            if tableFound:
498
                block.append("\\end{longtable}")
499
    return block
500
501
502
def _matrix_latex(table, path):
503
    """Create a traceability table for LaTeX."""
504
    # Setup.
505
    traceability = []
506
    head, tail = os.path.split(path)
507
    tail = "traceability.tex"
508
    file = os.path.join(head, tail)
509
    count = 0
510
    # Start the table.
511
    table_start = "\\begin{longtable}{"
512
    table_head = ""
513
    header_data = table.__next__()
514
    for column in header_data:
515
        count = count + 1
516
        table_start = table_start + "|l"
517
        if len(table_head) > 0:
518
            table_head = table_head + " & "
519
        table_head = table_head + "\\textbf{" + str(column) + "}"
520
    table_start = table_start + "|}"
521
    table_head = table_head + "\\\\"
522
    traceability.append(table_start)
523
    traceability.append(
524
        "\\caption{Traceability matrix.}\\label{tbl:trace}\\zlabel{tbl:trace}\\\\"
525
    )
526
    traceability.append("\\hline")
527
    traceability.append(table_head)
528
    traceability.append("\\hline")
529
    traceability.append("\\endfirsthead")
530
    traceability.append("\\caption{\\textit{(Continued)} Traceability matrix.}\\\\")
531
    traceability.append("\\hline")
532
    traceability.append(table_head)
533
    traceability.append("\\hline")
534
    traceability.append("\\endhead")
535
    traceability.append("\\hline")
536
    traceability.append(
537
        "\\multicolumn{{{n}}}{{r}}{{\\textit{{Continued on next page.}}}}\\\\".format(
538
            n=count
539
        )
540
    )
541
    traceability.append("\\endfoot")
542
    traceability.append("\\hline")
543
    traceability.append("\\endlastfoot")
544
    # Add rows.
545
    for row in table:
546
        row_text = ""
547
        for column in row:
548
            if len(row_text) > 0:
549
                row_text = row_text + " & "
550
            if column:
551
                row_text = row_text + "\\hyperref[{u}]{{{u}}}".format(u=str(column))
552
            else:
553
                row_text = row_text + " "
554
        row_text = row_text + "\\\\"
555
        traceability.append(row_text)
556
        traceability.append("\\hline")
557
    # End the table.
558
    traceability.append("\\end{longtable}")
559
    common.write_lines(traceability, file)
560