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

225 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-07 17:51 +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, 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 (dm.RenderCVDataModel): The data model. 

26 environment (jinja2.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.environment = environment 

37 

38 def template( 

39 self, 

40 theme_name: str, 

41 template_name: str, 

42 extension: str, 

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

44 **kwargs, 

45 ) -> str: 

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

47 

48 Args: 

49 template_name (str): The name of the template file. 

50 entry (Optional[dm.Entry]): The title of the section. 

51 

52 Returns: 

53 str: The templated file. 

54 """ 

55 template = self.environment.get_template( 

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

57 ) 

58 

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

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

61 # it's the string representation of None. 

62 

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

64 # setting dates to "" will cause problems. 

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

66 

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

68 entry_dictionary = entry.model_dump() 

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

70 if value is None and key not in fields_to_ignore: 

71 entry.__setattr__(key, "") 

72 

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

74 result = template.render( 

75 cv=self.cv, 

76 design=self.design, 

77 entry=entry, 

78 today=data.format_date(Date.today(), date_style="FULL_MONTH_NAME YEAR"), 

79 **kwargs, 

80 ) 

81 

82 return result 

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 latex_code = main_template.render( 

88 **kwargs, 

89 ) 

90 return latex_code 

91 

92 

93class LaTeXFile(TemplatedFile): 

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

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

96 """ 

97 

98 def __init__( 

99 self, 

100 data_model: data.RenderCVDataModel, 

101 environment: jinja2.Environment, 

102 ): 

103 latex_file_data_model = copy.deepcopy(data_model) 

104 

105 if latex_file_data_model.cv.sections_input is not None: 

106 transformed_sections = transform_markdown_sections_to_latex_sections( 

107 latex_file_data_model.cv.sections_input 

108 ) 

109 latex_file_data_model.cv.sections_input = transformed_sections 

110 

111 super().__init__(latex_file_data_model, environment) 

112 

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

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

115 

116 Returns: 

117 Tuple[str, str, List[Tuple[str, List[str], str]]]: The preamble, header, and 

118 sections of the $\\LaTeX$ file. 

119 """ 

120 # Template the preamble, header, and sections: 

121 preamble = self.template("Preamble") 

122 header = self.template("Header") 

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

124 for section in self.cv.sections: 

125 section_beginning = self.template( 

126 "SectionBeginning", 

127 section_title=section.title, 

128 entry_type=section.entry_type, 

129 ) 

130 entries: list[str] = [] 

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

132 is_first_entry = i == 0 

133 

134 entries.append( 

135 self.template( 

136 section.entry_type, 

137 entry=entry, 

138 section_title=section.title, 

139 entry_type=section.entry_type, 

140 is_first_entry=is_first_entry, 

141 ) 

142 ) 

143 section_ending = self.template( 

144 "SectionEnding", 

145 section_title=section.title, 

146 entry_type=section.entry_type, 

147 ) 

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

149 

150 return preamble, header, sections 

151 

152 def template( 

153 self, 

154 template_name: str, 

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

156 **kwargs, 

157 ) -> str: 

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

159 

160 Args: 

161 template_name (str): The name of the template file. 

162 entry (Optional[dm.Entry]): The data model of the entry. 

163 

164 Returns: 

165 str: The templated file. 

166 """ 

167 result = super().template( 

168 self.design.theme, 

169 template_name, 

170 "tex", 

171 entry, 

172 **kwargs, 

173 ) 

174 

175 result = revert_nested_latex_style_commands(result) 

176 

177 return result 

178 

179 def get_full_code(self) -> str: 

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

181 

182 Returns: 

183 str: The $\\LaTeX$ code. 

184 """ 

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

186 latex_code: str = super().get_full_code( 

187 "main.j2.tex", 

188 preamble=preamble, 

189 header=header, 

190 sections=sections, 

191 ) 

192 return latex_code 

193 

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

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

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

197 

198 

199class MarkdownFile(TemplatedFile): 

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

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

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

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

204 """ 

205 

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

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

208 

209 Returns: 

210 tuple[str, List[Tuple[str, List[str]]]]: The header and sections of the 

211 Markdown file. 

212 """ 

213 # Template the header and sections: 

214 header = self.template("Header") 

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

216 for section in self.cv.sections: 

217 section_beginning = self.template( 

218 "SectionBeginning", 

219 section_title=section.title, 

220 entry_type=section.entry_type, 

221 ) 

222 entries: list[str] = [] 

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

224 if i == 0: 

225 is_first_entry = True 

226 else: 

227 is_first_entry = False 

228 entries.append( 

229 self.template( 

230 section.entry_type, 

231 entry=entry, 

232 section_title=section.title, 

233 entry_type=section.entry_type, 

234 is_first_entry=is_first_entry, 

235 ) 

236 ) 

237 sections.append((section_beginning, entries)) 

238 

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

240 return result 

241 

242 def template( 

243 self, 

244 template_name: str, 

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

246 **kwargs, 

247 ) -> str: 

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

249 

250 Args: 

251 template_name (str): The name of the template file. 

252 entry (Optional[dm.Entry]): The data model of the entry. 

253 

254 Returns: 

255 str: The templated file. 

256 """ 

257 result = super().template( 

258 "markdown", 

259 template_name, 

260 "md", 

261 entry, 

262 **kwargs, 

263 ) 

264 return result 

265 

266 def get_full_code(self) -> str: 

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

268 

269 Returns: 

270 str: The Markdown code. 

271 """ 

272 header, sections = self.render_templates() 

273 markdown_code: str = super().get_full_code( 

274 "main.j2.md", 

275 header=header, 

276 sections=sections, 

277 ) 

278 return markdown_code 

279 

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

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

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

283 

284 

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

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

287 unitalicize a bold or italicized text. 

288 

289 Args: 

290 latex_string (str): The string to revert the nested $\\LaTeX$ style 

291 commands. 

292 

293 Returns: 

294 str: The string with the reverted nested $\\LaTeX$ style commands. 

295 """ 

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

297 # ones with \textnormal: 

298 nested_commands_to_look_for = [ 

299 "textbf", 

300 "textit", 

301 "underline", 

302 ] 

303 

304 for command in nested_commands_to_look_for: 

305 nested_commands = True 

306 while nested_commands: 

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

308 # nested commands left: 

309 

310 # find the first nested command: 

311 nested_commands = re.findall( 

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

313 ) 

314 

315 # replace the nested command with \textnormal: 

316 for nested_command in nested_commands: 

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

318 latex_string = latex_string.replace(nested_command, new_command) 

319 

320 return latex_string 

321 

322 

323def escape_latex_characters(latex_string: str, strict: bool = True) -> str: 

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

325 

326 Example: 

327 ```python 

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

329 ``` 

330 returns 

331 `#!python "This is a \\# string."` 

332 

333 Args: 

334 latex_string (str): The string to escape. 

335 strict (bool): Whether to escape all the special $\\LaTeX$ characters or not. If 

336 you want to allow math input, set it to False. 

337 

338 Returns: 

339 str: The escaped string. 

340 """ 

341 

342 # Dictionary of escape characters: 

343 escape_characters = { 

344 "#": "\\#", 

345 "%": "\\%", 

346 "&": "\\&", 

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

348 } 

349 

350 strict_escape_characters = { 

351 "$": "\\$", 

352 "_": "\\_", 

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

354 } 

355 

356 if strict: 

357 # To allow math input, users can use this function with strict = False 

358 escape_characters.update(strict_escape_characters) 

359 

360 translation_map = str.maketrans(escape_characters) 

361 strict_translation_map = str.maketrans(strict_escape_characters) 

362 

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

364 # Also always escape link placeholders strictly (as we don't expect any math in 

365 # them): 

366 # Find all the links in the sentence: 

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

368 

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

370 new_links = [] 

371 for i, link in enumerate(links): 

372 placeholder = link[0] 

373 escaped_placeholder = placeholder.translate(strict_translation_map) 

374 escaped_placeholder = escaped_placeholder.translate(translation_map) 

375 url = link[1] 

376 

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

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

379 

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

381 new_links.append(new_link) 

382 

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

384 # replace it with its LaTeX equivalent: 

385 latex_string = latex_string.translate(translation_map) 

386 

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

388 for i, new_link in enumerate(new_links): 

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

390 

391 return latex_string 

392 

393 

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

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

396 

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

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

399 

400 Example: 

401 ```python 

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

403 ``` 

404 

405 returns 

406 

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

408 

409 Args: 

410 markdown_string (str): The Markdown string to convert. 

411 

412 Returns: 

413 str: The $\\LaTeX$ string. 

414 """ 

415 # convert links 

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

417 if links is not None: 

418 for link in links: 

419 link_text = link[0] 

420 link_url = link[1] 

421 

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

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

424 

425 markdown_string = markdown_string.replace(old_link_string, new_link_string) 

426 

427 # convert bold 

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

429 if bolds is not None: 

430 for bold_text in bolds: 

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

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

433 

434 markdown_string = markdown_string.replace(old_bold_text, new_bold_text) 

435 

436 # convert italic 

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

438 if italics is not None: 

439 for italic_text in italics: 

440 old_italic_text = f"*{italic_text}*" 

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

442 

443 markdown_string = markdown_string.replace(old_italic_text, new_italic_text) 

444 

445 # convert code 

446 # not supported by rendercv currently 

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

448 # if codes is not None: 

449 # for code_text in codes: 

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

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

452 

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

454 

455 latex_string = markdown_string 

456 

457 return latex_string 

458 

459 

460def transform_markdown_sections_to_latex_sections( 

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

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

463 """ 

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

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

466 characters. 

467 

468 Args: 

469 sections (Optional[dict[str, dm.SectionInput]]): Sections with Markdown strings. 

470 

471 Returns: 

472 Optional[dict[str, dm.SectionInput]]: Sections with $\\LaTeX$ strings. 

473 """ 

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

475 # loop through the list and apply markdown_to_latex and escape_latex_characters 

476 # to each item: 

477 transformed_list = [] 

478 for entry in value: 

479 if isinstance(entry, str): 

480 # Then it means it's a TextEntry. 

481 result = markdown_to_latex(escape_latex_characters(entry, strict=False)) 

482 transformed_list.append(result) 

483 else: 

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

485 entry_as_dict = entry.model_dump() 

486 for entry_key, value in entry_as_dict.items(): 

487 if isinstance(value, str): 

488 result = markdown_to_latex( 

489 escape_latex_characters(value, strict=False) 

490 ) 

491 setattr(entry, entry_key, result) 

492 elif isinstance(value, list): 

493 for j, item in enumerate(value): 

494 if isinstance(item, str): 

495 value[j] = markdown_to_latex( 

496 escape_latex_characters(item, strict=False) 

497 ) 

498 setattr(entry, entry_key, value) 

499 transformed_list.append(entry) 

500 

501 sections[key] = transformed_list 

502 

503 return sections 

504 

505 

506def replace_placeholders_with_actual_values( 

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

508) -> str: 

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

510 

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

512 

513 Args: 

514 text (str): The text with placeholders. 

515 placeholders (dict[str, str]): The placeholders and their values. 

516 

517 Returns: 

518 str: The string with actual values. 

519 """ 

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

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

522 

523 return text 

524 

525 

526def make_matched_part_something( 

527 value: str, something: str, match_str: Optional[str] = None 

528) -> str: 

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

530 whole string will be made something. 

531 

532 Warning: 

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

534 `make_matched_part_underlined`, `make_matched_part_italic`, or 

535 `make_matched_part_non_line_breakable instead. 

536 Args: 

537 value (str): The string to make something. 

538 something (str): The $\\LaTeX$ command to use. 

539 match_str (str): The string to match. 

540 

541 Returns: 

542 str: The string with the matched part something. 

543 """ 

544 if match_str is None: 

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

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

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

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

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

550 

551 return value 

552 

553 

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

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

556 string will be made bold. 

557 

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

559 

560 Example: 

561 ```python 

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

563 ``` 

564 

565 returns 

566 

567 `#!python "\\textbf{Hello} World!"` 

568 

569 Args: 

570 value (str): The string to make bold. 

571 match_str (str): The string to match. 

572 

573 Returns: 

574 str: The string with the matched part bold. 

575 """ 

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

577 

578 

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

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

581 whole string will be made underlined. 

582 

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

584 

585 Example: 

586 ```python 

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

588 ``` 

589 

590 returns 

591 

592 `#!python "\\underline{Hello} World!"` 

593 

594 Args: 

595 value (str): The string to make underlined. 

596 match_str (str): The string to match. 

597 

598 Returns: 

599 str: The string with the matched part underlined. 

600 """ 

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

602 

603 

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

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

606 string will be made italic. 

607 

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

609 

610 Example: 

611 ```python 

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

613 ``` 

614 

615 returns 

616 

617 `#!python "\\textit{Hello} World!"` 

618 

619 Args: 

620 value (str): The string to make italic. 

621 match_str (str): The string to match. 

622 

623 Returns: 

624 str: The string with the matched part italic. 

625 """ 

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

627 

628 

629def make_matched_part_non_line_breakable( 

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

631) -> str: 

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

633 None, the whole string will be made nonbreakable. 

634 

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

636 

637 Example: 

638 ```python 

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

640 ``` 

641 

642 returns 

643 

644 `#!python "\\mbox{Hello} World!"` 

645 

646 Args: 

647 value (str): The string to disable line breaks. 

648 match_str (str): The string to match. 

649 

650 Returns: 

651 str: The string with the matched part non line breakable. 

652 """ 

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

654 

655 

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

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

658 

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

660 

661 Example: 

662 ```python 

663 abbreviate_name("John Doe") 

664 ``` 

665 

666 returns 

667 

668 `#!python "J. Doe"` 

669 

670 Args: 

671 name (str): The name to abbreviate. 

672 

673 Returns: 

674 str: The abbreviated name. 

675 """ 

676 if name is None: 

677 return "" 

678 

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

680 

681 if number_of_words == 1: 

682 return name 

683 

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

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

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

687 abbreviated_name = " ".join(first_names_initials) + " " + last_name 

688 

689 return abbreviated_name 

690 

691 

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

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

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

695 

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

697 

698 Example: 

699 ```python 

700 divide_length_by("10.4cm", 2) 

701 ``` 

702 

703 returns 

704 

705 `#!python "5.2cm"` 

706 

707 Args: 

708 length (str): The length to divide. 

709 divider (float): The number to divide the length by. 

710 

711 Returns: 

712 str: The divided length. 

713 """ 

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

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

716 

717 if value is None: 

718 raise ValueError(f"Invalid length {length}!") 

719 else: 

720 value = value.group() 

721 

722 if divider <= 0: 

723 raise ValueError(f"The divider must be greater than 0, but got {divider}!") 

724 

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

726 

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

728 

729 

730def get_an_item_with_a_specific_attribute_value( 

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

732) -> Any: 

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

734 

735 Example: 

736 ```python 

737 get_an_item_with_a_specific_attribute_value( 

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

739 "name", 

740 "Jane" 

741 ) 

742 ``` 

743 returns 

744 `item2` 

745 

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

747 

748 Args: 

749 items (list[Any]): The list of items. 

750 attribute (str): The attribute to check. 

751 value (Any): The value of the attribute. 

752 

753 Returns: 

754 Any: The item with the specific attribute value. 

755 """ 

756 if items is not None: 

757 for item in items: 

758 if not hasattr(item, attribute): 

759 raise AttributeError( 

760 f"The attribute {attribute} doesn't exist in the item {item}!" 

761 ) 

762 else: 

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

764 return item 

765 else: 

766 return None 

767 

768 

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

770jinja2_environment: Optional[jinja2.Environment] = None 

771 

772 

773def setup_jinja2_environment() -> jinja2.Environment: 

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

775 

776 Returns: 

777 jinja2.Environment: The theme environment. 

778 """ 

779 global jinja2_environment 

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

781 

782 if jinja2_environment is None: 

783 # create a Jinja2 environment: 

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

785 environment = jinja2.Environment( 

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

787 trim_blocks=True, 

788 lstrip_blocks=True, 

789 ) 

790 

791 # set custom delimiters for LaTeX templating: 

792 environment.block_start_string = "((*" 

793 environment.block_end_string = "*))" 

794 environment.variable_start_string = "<<" 

795 environment.variable_end_string = ">>" 

796 environment.comment_start_string = "((#" 

797 environment.comment_end_string = "#))" 

798 

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

800 # themes: 

801 environment.filters["make_it_bold"] = make_matched_part_bold 

802 environment.filters["make_it_underlined"] = make_matched_part_underlined 

803 environment.filters["make_it_italic"] = make_matched_part_italic 

804 environment.filters["make_it_nolinebreak"] = ( 

805 make_matched_part_non_line_breakable 

806 ) 

807 environment.filters["make_it_something"] = make_matched_part_something 

808 environment.filters["divide_length_by"] = divide_length_by 

809 environment.filters["abbreviate_name"] = abbreviate_name 

810 environment.filters["replace_placeholders_with_actual_values"] = ( 

811 replace_placeholders_with_actual_values 

812 ) 

813 environment.filters["get_an_item_with_a_specific_attribute_value"] = ( 

814 get_an_item_with_a_specific_attribute_value 

815 ) 

816 environment.filters["escape_latex_characters"] = escape_latex_characters 

817 

818 jinja2_environment = environment 

819 else: 

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

821 jinja2_environment.loader = jinja2.FileSystemLoader( 

822 [pathlib.Path.cwd(), themes_directory] 

823 ) 

824 

825 return jinja2_environment