Total Complexity | 102 |
Total Lines | 560 |
Duplicated Lines | 8.21 % |
Changes | 0 |
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like doorstop.core.publisher_latex often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
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: |
|
|
|||
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): |
|
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 |