Coverage for rendercv/renderer/templater.py: 100%

233 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-12-25 23:06 +0000

1""" 

2The `rendercv.renderer.templater` module contains all the necessary classes and 

3functions for templating the $\\LaTeX$ and Markdown files from the `RenderCVDataModel` 

4object. 

5""" 

6 

7import copy 

8import pathlib 

9import re 

10from datetime import date as Date 

11from typing import Any, Literal, Optional 

12 

13import jinja2 

14 

15from .. import data 

16 

17 

18class TemplatedFile: 

19 """This class is a base class for `LaTeXFile` and `MarkdownFile` classes. It 

20 contains the common methods and attributes for both classes. These classes are used 

21 to generate the $\\LaTeX$ and Markdown files with the data model and Jinja2 

22 templates. 

23 

24 Args: 

25 data_model: The data model. 

26 environment: The Jinja2 environment. 

27 """ 

28 

29 def __init__( 

30 self, 

31 data_model: data.RenderCVDataModel, 

32 environment: jinja2.Environment, 

33 ): 

34 self.cv = data_model.cv 

35 self.design = data_model.design 

36 self.locale_catalog = data_model.locale_catalog 

37 self.environment = environment 

38 

39 def template( 

40 self, 

41 theme_name: str, 

42 template_name: str, 

43 extension: str, 

44 entry: Optional[data.Entry] = None, 

45 **kwargs, 

46 ) -> str: 

47 """Template one of the files in the `themes` directory. 

48 

49 Args: 

50 template_name: The name of the template file. 

51 entry: The title of the section. 

52 

53 Returns: 

54 The templated file. 

55 """ 

56 template = self.environment.get_template( 

57 f"{theme_name}/{template_name}.j2.{extension}" 

58 ) 

59 

60 # Loop through the entry attributes and make them "" if they are None: 

61 # This is necessary because otherwise they will be templated as "None" since 

62 # it's the string representation of None. 

63 

64 # Only don't touch the date fields, because only date_string is called and 

65 # setting dates to "" will cause problems. 

66 fields_to_ignore = ["start_date", "end_date", "date"] 

67 

68 if entry is not None and not isinstance(entry, str): 

69 entry_dictionary = entry.model_dump() 

70 for key, value in entry_dictionary.items(): 

71 if value is None and key not in fields_to_ignore: 

72 entry.__setattr__(key, "") 

73 

74 # The arguments of the template can be used in the template file: 

75 return template.render( 

76 cv=self.cv, 

77 design=self.design, 

78 locale_catalog=self.locale_catalog, 

79 entry=entry, 

80 today=data.format_date(Date.today()), 

81 **kwargs, 

82 ) 

83 

84 def get_full_code(self, main_template_name: str, **kwargs) -> str: 

85 """Combine all the templates to get the full code of the file.""" 

86 main_template = self.environment.get_template(main_template_name) 

87 return main_template.render( 

88 **kwargs, 

89 ) 

90 

91 

92class LaTeXFile(TemplatedFile): 

93 """This class represents a $\\LaTeX$ file. It generates the $\\LaTeX$ code with the 

94 data model and Jinja2 templates. It inherits from the `TemplatedFile` class. 

95 """ 

96 

97 def __init__( 

98 self, 

99 data_model: data.RenderCVDataModel, 

100 environment: jinja2.Environment, 

101 ): 

102 latex_file_data_model = copy.deepcopy(data_model) 

103 

104 if latex_file_data_model.cv.sections_input is not None: 

105 transformed_sections = transform_markdown_sections_to_latex_sections( 

106 latex_file_data_model.cv.sections_input 

107 ) 

108 latex_file_data_model.cv.sections_input = transformed_sections 

109 

110 super().__init__(latex_file_data_model, environment) 

111 

112 def render_templates(self) -> tuple[str, str, list[tuple[str, list[str], str]]]: 

113 """Render and return all the templates for the $\\LaTeX$ file. 

114 

115 Returns: 

116 The preamble, header, and sections of the $\\LaTeX$ file. 

117 """ 

118 # Template the preamble, header, and sections: 

119 preamble = self.template("Preamble") 

120 header = self.template("Header") 

121 sections: list[tuple[str, list[str], str]] = [] 

122 for section in self.cv.sections: 

123 section_beginning = self.template( 

124 "SectionBeginning", 

125 section_title=escape_latex_characters(section.title), 

126 entry_type=section.entry_type, 

127 ) 

128 entries: list[str] = [] 

129 for i, entry in enumerate(section.entries): 

130 is_first_entry = i == 0 

131 

132 entries.append( 

133 self.template( 

134 section.entry_type, 

135 entry=entry, 

136 section_title=section.title, 

137 entry_type=section.entry_type, 

138 is_first_entry=is_first_entry, 

139 ) 

140 ) 

141 section_ending = self.template( 

142 "SectionEnding", 

143 section_title=section.title, 

144 entry_type=section.entry_type, 

145 ) 

146 sections.append((section_beginning, entries, section_ending)) 

147 

148 return preamble, header, sections 

149 

150 def template( 

151 self, 

152 template_name: str, 

153 entry: Optional[data.Entry] = None, 

154 **kwargs, 

155 ) -> str: 

156 """Template one of the files in the `themes` directory. 

157 

158 Args: 

159 template_name: The name of the template file. 

160 entry: The data model of the entry. 

161 

162 Returns: 

163 The templated file. 

164 """ 

165 result = super().template( 

166 self.design.theme, 

167 template_name, 

168 "tex", 

169 entry, 

170 **kwargs, 

171 ) 

172 

173 return revert_nested_latex_style_commands(result) 

174 

175 def get_full_code(self) -> str: 

176 """Get the $\\LaTeX$ code of the file. 

177 

178 Returns: 

179 The $\\LaTeX$ code. 

180 """ 

181 preamble, header, sections = self.render_templates() 

182 latex_code: str = super().get_full_code( 

183 "main.j2.tex", 

184 preamble=preamble, 

185 header=header, 

186 sections=sections, 

187 ) 

188 return latex_code 

189 

190 def create_file(self, file_path: pathlib.Path): 

191 """Write the $\\LaTeX$ code to a file.""" 

192 file_path.write_text(self.get_full_code(), encoding="utf-8") 

193 

194 

195class MarkdownFile(TemplatedFile): 

196 """This class represents a Markdown file. It generates the Markdown code with the 

197 data model and Jinja2 templates. It inherits from the `TemplatedFile` class. 

198 Markdown files are generated to produce an HTML which can be copy-pasted to 

199 [Grammarly](https://app.grammarly.com/) for proofreading. 

200 """ 

201 

202 def render_templates(self) -> tuple[str, list[tuple[str, list[str]]]]: 

203 """Render and return all the templates for the Markdown file. 

204 

205 Returns: 

206 The header and sections of the Markdown file. 

207 """ 

208 # Template the header and sections: 

209 header = self.template("Header") 

210 sections: list[tuple[str, list[str]]] = [] 

211 for section in self.cv.sections: 

212 section_beginning = self.template( 

213 "SectionBeginning", 

214 section_title=section.title, 

215 entry_type=section.entry_type, 

216 ) 

217 entries: list[str] = [] 

218 for i, entry in enumerate(section.entries): 

219 is_first_entry = bool(i == 0) 

220 entries.append( 

221 self.template( 

222 section.entry_type, 

223 entry=entry, 

224 section_title=section.title, 

225 entry_type=section.entry_type, 

226 is_first_entry=is_first_entry, 

227 ) 

228 ) 

229 sections.append((section_beginning, entries)) 

230 

231 result: tuple[str, list[tuple[str, list[str]]]] = (header, sections) 

232 return result 

233 

234 def template( 

235 self, 

236 template_name: str, 

237 entry: Optional[data.Entry] = None, 

238 **kwargs, 

239 ) -> str: 

240 """Template one of the files in the `themes` directory. 

241 

242 Args: 

243 template_name: The name of the template file. 

244 entry: The data model of the entry. 

245 

246 Returns: 

247 The templated file. 

248 """ 

249 return super().template( 

250 "markdown", 

251 template_name, 

252 "md", 

253 entry, 

254 **kwargs, 

255 ) 

256 

257 def get_full_code(self) -> str: 

258 """Get the Markdown code of the file. 

259 

260 Returns: 

261 The Markdown code. 

262 """ 

263 header, sections = self.render_templates() 

264 markdown_code: str = super().get_full_code( 

265 "main.j2.md", 

266 header=header, 

267 sections=sections, 

268 ) 

269 return markdown_code 

270 

271 def create_file(self, file_path: pathlib.Path): 

272 """Write the Markdown code to a file.""" 

273 file_path.write_text(self.get_full_code(), encoding="utf-8") 

274 

275 

276def revert_nested_latex_style_commands(latex_string: str) -> str: 

277 """Revert the nested $\\LaTeX$ style commands to allow users to unbold or 

278 unitalicize a bold or italicized text. 

279 

280 Args: 

281 latex_string: The string to revert the nested $\\LaTeX$ style commands. 

282 

283 Returns: 

284 The string with the reverted nested $\\LaTeX$ style commands. 

285 """ 

286 # If there is nested \textbf, \textit, or \underline commands, replace the inner 

287 # ones with \textnormal: 

288 nested_commands_to_look_for = [ 

289 "textbf", 

290 "textit", 

291 "underline", 

292 ] 

293 

294 for command in nested_commands_to_look_for: 

295 nested_commands = True 

296 while nested_commands: 

297 # replace all the inner commands with \textnormal until there are no 

298 # nested commands left: 

299 

300 # find the first nested command: 

301 nested_commands = re.findall( 

302 rf"\\{command}{ [^} ]*?(\\{command}{ .*?} )", latex_string 

303 ) 

304 

305 # replace the nested command with \textnormal: 

306 for nested_command in nested_commands: 

307 new_command = nested_command.replace(command, "textnormal") 

308 latex_string = latex_string.replace(nested_command, new_command) 

309 

310 return latex_string 

311 

312 

313def escape_latex_characters(latex_string: str) -> str: 

314 """Escape $\\LaTeX$ characters in a string by adding a backslash before them. 

315 

316 Example: 

317 ```python 

318 escape_latex_characters("This is a # string.") 

319 ``` 

320 returns 

321 `"This is a \\# string."` 

322 

323 Args: 

324 latex_string: The string to escape. 

325 

326 Returns: 

327 The escaped string. 

328 """ 

329 

330 # Dictionary of escape characters: 

331 escape_characters = { 

332 "{": "\\{", 

333 "}": "\\}", 

334 # "\\": "\\textbackslash{}", 

335 "#": "\\#", 

336 "%": "\\%", 

337 "&": "\\&", 

338 "~": "\\textasciitilde{}", 

339 "$": "\\$", 

340 "_": "\\_", 

341 "^": "\\textasciicircum{}", 

342 } 

343 translation_map = str.maketrans(escape_characters) 

344 

345 # Don't escape urls as hyperref package will do it automatically: 

346 # Find all the links in the sentence: 

347 links = re.findall(r"\[(.*?)\]\((.*?)\)", latex_string) 

348 

349 # Replace the links with a dummy string and save links with escaped characters: 

350 new_links = [] 

351 for i, link in enumerate(links): 

352 placeholder = link[0] 

353 escaped_placeholder = placeholder.translate(translation_map) 

354 url = link[1] 

355 

356 original_link = f"[{placeholder}]({url})" 

357 latex_string = latex_string.replace(original_link, f"!!-link{i}-!!") 

358 

359 new_link = f"[{escaped_placeholder}]({url})" 

360 new_links.append(new_link) 

361 

362 # If there are equations in the sentence, don't escape the special characters: 

363 # Find all the equations in the sentence: 

364 equations = re.findall(r"(\$\$.*?\$\$)", latex_string) 

365 new_equations = [] 

366 for i, equation in enumerate(equations): 

367 latex_string = latex_string.replace(equation, f"!!-equation{i}-!!") 

368 

369 # Keep only one dollar sign for inline equations: 

370 new_equation = equation.replace("$$", "$") 

371 new_equations.append(new_equation) 

372 

373 # Don't touch latex commands: 

374 # Find all the latex commands in the sentence: 

375 latex_commands = re.findall(r"\\[a-zA-Z]+\{.*?\}", latex_string) 

376 for i, latex_command in enumerate(latex_commands): 

377 latex_string = latex_string.replace(latex_command, f"!!-latex{i}-!!") 

378 

379 # Loop through the letters of the sentence and if you find an escape character, 

380 # replace it with its LaTeX equivalent: 

381 latex_string = latex_string.translate(translation_map) 

382 

383 # Replace !!-link{i}-!!" with the original urls: 

384 for i, new_link in enumerate(new_links): 

385 latex_string = latex_string.replace(f"!!-link{i}-!!", new_link) 

386 

387 # Replace !!-equation{i}-!!" with the original equations: 

388 for i, new_equation in enumerate(new_equations): 

389 latex_string = latex_string.replace(f"!!-equation{i}-!!", new_equation) 

390 

391 # Replace !!-latex{i}-!!" with the original latex commands: 

392 for i, latex_command in enumerate(latex_commands): 

393 latex_string = latex_string.replace(f"!!-latex{i}-!!", latex_command) 

394 

395 return latex_string 

396 

397 

398def markdown_to_latex(markdown_string: str) -> str: 

399 """Convert a Markdown string to $\\LaTeX$. 

400 

401 This function is called during the reading of the input file. Before the validation 

402 process, each input field is converted from Markdown to $\\LaTeX$. 

403 

404 Example: 

405 ```python 

406 markdown_to_latex( 

407 "This is a **bold** text with an [*italic link*](https://google.com)." 

408 ) 

409 ``` 

410 

411 returns 

412 

413 `"This is a \\textbf{bold} text with a \\href{https://google.com}{\\textit{link}}."` 

414 

415 Args: 

416 markdown_string: The Markdown string to convert. 

417 

418 Returns: 

419 The $\\LaTeX$ string. 

420 """ 

421 # convert links 

422 links = re.findall(r"\[([^\]\[]*)\]\((.*?)\)", markdown_string) 

423 if links is not None: 

424 for link in links: 

425 link_text = link[0] 

426 link_url = link[1] 

427 

428 old_link_string = f"[{link_text}]({link_url})" 

429 new_link_string = "\\href{" + link_url + "}{" + link_text + "}" 

430 

431 markdown_string = markdown_string.replace(old_link_string, new_link_string) 

432 

433 # convert bold 

434 bolds = re.findall(r"\*\*(.+?)\*\*", markdown_string) 

435 if bolds is not None: 

436 for bold_text in bolds: 

437 old_bold_text = f"**{bold_text}**" 

438 new_bold_text = "\\textbf{" + bold_text + "}" 

439 

440 markdown_string = markdown_string.replace(old_bold_text, new_bold_text) 

441 

442 # convert italic 

443 italics = re.findall(r"\*(.+?)\*", markdown_string) 

444 if italics is not None: 

445 for italic_text in italics: 

446 old_italic_text = f"*{italic_text}*" 

447 new_italic_text = "\\textit{" + italic_text + "}" 

448 

449 markdown_string = markdown_string.replace(old_italic_text, new_italic_text) 

450 

451 # convert code 

452 # not supported by rendercv currently 

453 # codes = re.findall(r"`([^`]*)`", markdown_string) 

454 # if codes is not None: 

455 # for code_text in codes: 

456 # old_code_text = f"`{code_text}`" 

457 # new_code_text = "\\texttt{" + code_text + "}" 

458 

459 # markdown_string = markdown_string.replace(old_code_text, new_code_text) 

460 

461 return markdown_string 

462 

463 

464def transform_markdown_sections_to_latex_sections( 

465 sections: dict[str, data.SectionContents], 

466) -> Optional[dict[str, data.SectionContents]]: 

467 """ 

468 Recursively loop through sections and convert all the Markdown strings (user input 

469 is in Markdown format) to $\\LaTeX$ strings. Also, escape special $\\LaTeX$ 

470 characters. 

471 

472 Args: 

473 sections: Sections with Markdown strings. 

474 

475 Returns: 

476 Sections with $\\LaTeX$ strings. 

477 """ 

478 for key, value in sections.items(): 

479 # loop through the list and apply markdown_to_latex and escape_latex_characters 

480 # to each item: 

481 transformed_list = [] 

482 for entry in value: 

483 if isinstance(entry, str): 

484 # Then it means it's a TextEntry. 

485 result = markdown_to_latex(escape_latex_characters(entry)) 

486 transformed_list.append(result) 

487 else: 

488 # Then it means it's one of the other entries. 

489 fields_to_skip = ["doi"] 

490 entry_as_dict = entry.model_dump() 

491 for entry_key, inner_value in entry_as_dict.items(): 

492 if entry_key in fields_to_skip: 

493 continue 

494 if isinstance(inner_value, str): 

495 result = markdown_to_latex(escape_latex_characters(inner_value)) 

496 setattr(entry, entry_key, result) 

497 elif isinstance(inner_value, list): 

498 for j, item in enumerate(inner_value): 

499 if isinstance(item, str): 

500 inner_value[j] = markdown_to_latex( 

501 escape_latex_characters(item) 

502 ) 

503 setattr(entry, entry_key, inner_value) 

504 transformed_list.append(entry) 

505 

506 sections[key] = transformed_list 

507 

508 return sections 

509 

510 

511def replace_placeholders_with_actual_values( 

512 text: str, placeholders: dict[str, Optional[str]] 

513) -> str: 

514 """Replace the placeholders in a string with actual values. 

515 

516 This function can be used as a Jinja2 filter in templates. 

517 

518 Args: 

519 text: The text with placeholders. 

520 placeholders: The placeholders and their values. 

521 

522 Returns: 

523 The string with actual values. 

524 """ 

525 for placeholder, value in placeholders.items(): 

526 text = text.replace(placeholder, str(value)) 

527 

528 return text 

529 

530 

531def make_matched_part_something( 

532 value: str, 

533 something: Literal["textbf", "underline", "textit", "mbox"], 

534 match_str: Optional[str] = None, 

535) -> str: 

536 """Make the matched parts of the string something. If the match_str is None, the 

537 whole string will be made something. 

538 

539 Warning: 

540 This function shouldn't be used directly. Use `make_matched_part_bold`, 

541 `make_matched_part_underlined`, `make_matched_part_italic`, or 

542 `make_matched_part_non_line_breakable instead. 

543 Args: 

544 value: The string to make something. 

545 something: The $\\LaTeX$ command to use. 

546 match_str: The string to match. 

547 

548 Returns: 

549 The string with the matched part something. 

550 """ 

551 if match_str is None: 

552 # If the match_str is None, the whole string will be made something: 

553 value = f"\\{something}{ {value}} " 

554 elif match_str in value and match_str != "": 

555 # If the match_str is in the value, then make the matched part something: 

556 value = value.replace(match_str, f"\\{something}{ {match_str}} ") 

557 

558 return value 

559 

560 

561def make_matched_part_bold(value: str, match_str: Optional[str] = None) -> str: 

562 """Make the matched parts of the string bold. If the match_str is None, the whole 

563 string will be made bold. 

564 

565 This function can be used as a Jinja2 filter in templates. 

566 

567 Example: 

568 ```python 

569 make_it_bold("Hello World!", "Hello") 

570 ``` 

571 

572 returns 

573 

574 `"\\textbf{Hello} World!"` 

575 

576 Args: 

577 value: The string to make bold. 

578 match_str: The string to match. 

579 

580 Returns: 

581 The string with the matched part bold. 

582 """ 

583 return make_matched_part_something(value, "textbf", match_str) 

584 

585 

586def make_matched_part_underlined(value: str, match_str: Optional[str] = None) -> str: 

587 """Make the matched parts of the string underlined. If the match_str is None, the 

588 whole string will be made underlined. 

589 

590 This function can be used as a Jinja2 filter in templates. 

591 

592 Example: 

593 ```python 

594 make_it_underlined("Hello World!", "Hello") 

595 ``` 

596 

597 returns 

598 

599 `"\\underline{Hello} World!"` 

600 

601 Args: 

602 value: The string to make underlined. 

603 match_str: The string to match. 

604 

605 Returns: 

606 The string with the matched part underlined. 

607 """ 

608 return make_matched_part_something(value, "underline", match_str) 

609 

610 

611def make_matched_part_italic(value: str, match_str: Optional[str] = None) -> str: 

612 """Make the matched parts of the string italic. If the match_str is None, the whole 

613 string will be made italic. 

614 

615 This function can be used as a Jinja2 filter in templates. 

616 

617 Example: 

618 ```python 

619 make_it_italic("Hello World!", "Hello") 

620 ``` 

621 

622 returns 

623 

624 `"\\textit{Hello} World!"` 

625 

626 Args: 

627 value: The string to make italic. 

628 match_str: The string to match. 

629 

630 Returns: 

631 The string with the matched part italic. 

632 """ 

633 return make_matched_part_something(value, "textit", match_str) 

634 

635 

636def make_matched_part_non_line_breakable( 

637 value: str, match_str: Optional[str] = None 

638) -> str: 

639 """Make the matched parts of the string non line breakable. If the match_str is 

640 None, the whole string will be made nonbreakable. 

641 

642 This function can be used as a Jinja2 filter in templates. 

643 

644 Example: 

645 ```python 

646 make_it_nolinebreak("Hello World!", "Hello") 

647 ``` 

648 

649 returns 

650 

651 `"\\mbox{Hello} World!"` 

652 

653 Args: 

654 value: The string to disable line breaks. 

655 match_str: The string to match. 

656 

657 Returns: 

658 The string with the matched part non line breakable. 

659 """ 

660 return make_matched_part_something(value, "mbox", match_str) 

661 

662 

663def abbreviate_name(name: Optional[str]) -> str: 

664 """Abbreviate a name by keeping the first letters of the first names. 

665 

666 This function can be used as a Jinja2 filter in templates. 

667 

668 Example: 

669 ```python 

670 abbreviate_name("John Doe") 

671 ``` 

672 

673 returns 

674 

675 `"J. Doe"` 

676 

677 Args: 

678 name: The name to abbreviate. 

679 

680 Returns: 

681 The abbreviated name. 

682 """ 

683 if name is None: 

684 return "" 

685 

686 number_of_words = len(name.split(" ")) 

687 

688 if number_of_words == 1: 

689 return name 

690 

691 first_names = name.split(" ")[:-1] 

692 first_names_initials = [first_name[0] + "." for first_name in first_names] 

693 last_name = name.split(" ")[-1] 

694 

695 return " ".join(first_names_initials) + " " + last_name 

696 

697 

698def divide_length_by(length: str, divider: float) -> str: 

699 r"""Divide a length by a number. Length is a string with the following regex 

700 pattern: `\d+\.?\d* *(cm|in|pt|mm|ex|em)` 

701 

702 This function can be used as a Jinja2 filter in templates. 

703 

704 Example: 

705 ```python 

706 divide_length_by("10.4cm", 2) 

707 ``` 

708 

709 returns 

710 

711 `"5.2cm"` 

712 

713 Args: 

714 length: The length to divide. 

715 divider: The number to divide the length by. 

716 

717 Returns: 

718 The divided length. 

719 """ 

720 # Get the value as a float and the unit as a string: 

721 value = re.search(r"\d+\.?\d*", length) 

722 

723 if value is None: 

724 message = f"Invalid length {length}!" 

725 raise ValueError(message) 

726 

727 value = value.group() 

728 

729 if divider <= 0: 

730 message = f"The divider must be greater than 0, but got {divider}!" 

731 raise ValueError(message) 

732 

733 unit = re.findall(r"[^\d\.\s]+", length)[0] 

734 

735 return str(float(value) / divider) + " " + unit 

736 

737 

738def get_an_item_with_a_specific_attribute_value( 

739 items: Optional[list[Any]], attribute: str, value: Any 

740) -> Any: 

741 """Get an item from a list of items with a specific attribute value. 

742 

743 Example: 

744 ```python 

745 get_an_item_with_a_specific_attribute_value( 

746 [item1, item2], # where item1.name = "John" and item2.name = "Jane" 

747 "name", 

748 "Jane", 

749 ) 

750 ``` 

751 returns 

752 `item2` 

753 

754 This function can be used as a Jinja2 filter in templates. 

755 

756 Args: 

757 items: The list of items. 

758 attribute: The attribute to check. 

759 value: The value of the attribute. 

760 

761 Returns: 

762 The item with the specific attribute value. 

763 """ 

764 if items is not None: 

765 for item in items: 

766 if not hasattr(item, attribute): 

767 message = f"The attribute {attribute} doesn't exist in the item {item}!" 

768 raise AttributeError(message) 

769 

770 if getattr(item, attribute) == value: 

771 return item 

772 

773 return None 

774 

775 

776# Only one Jinja2 environment is needed for all the templates: 

777jinja2_environment: Optional[jinja2.Environment] = None 

778 

779 

780def setup_jinja2_environment() -> jinja2.Environment: 

781 """Setup and return the Jinja2 environment for templating the $\\LaTeX$ files. 

782 

783 Returns: 

784 The theme environment. 

785 """ 

786 global jinja2_environment # noqa: PLW0603 

787 themes_directory = pathlib.Path(__file__).parent.parent / "themes" 

788 

789 if jinja2_environment is None: 

790 # create a Jinja2 environment: 

791 # we need to add the current working directory because custom themes might be used. 

792 environment = jinja2.Environment( 

793 loader=jinja2.FileSystemLoader([pathlib.Path.cwd(), themes_directory]), 

794 trim_blocks=True, 

795 lstrip_blocks=True, 

796 ) 

797 

798 # set custom delimiters for LaTeX templating: 

799 environment.block_start_string = "((*" 

800 environment.block_end_string = "*))" 

801 environment.variable_start_string = "<<" 

802 environment.variable_end_string = ">>" 

803 environment.comment_start_string = "((#" 

804 environment.comment_end_string = "#))" 

805 

806 # add custom filters to make it easier to template the LaTeX files and add new 

807 # themes: 

808 environment.filters["make_it_bold"] = make_matched_part_bold 

809 environment.filters["make_it_underlined"] = make_matched_part_underlined 

810 environment.filters["make_it_italic"] = make_matched_part_italic 

811 environment.filters["make_it_nolinebreak"] = ( 

812 make_matched_part_non_line_breakable 

813 ) 

814 environment.filters["make_it_something"] = make_matched_part_something 

815 environment.filters["divide_length_by"] = divide_length_by 

816 environment.filters["abbreviate_name"] = abbreviate_name 

817 environment.filters["replace_placeholders_with_actual_values"] = ( 

818 replace_placeholders_with_actual_values 

819 ) 

820 environment.filters["get_an_item_with_a_specific_attribute_value"] = ( 

821 get_an_item_with_a_specific_attribute_value 

822 ) 

823 environment.filters["escape_latex_characters"] = escape_latex_characters 

824 environment.filters["markdown_to_latex"] = markdown_to_latex 

825 

826 jinja2_environment = environment 

827 else: 

828 # update the loader in case the current working directory has changed: 

829 jinja2_environment.loader = jinja2.FileSystemLoader( 

830 [ 

831 pathlib.Path.cwd(), 

832 themes_directory, 

833 ] 

834 ) 

835 

836 return jinja2_environment