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
« 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"""
7import copy
8import pathlib
9import re
10from datetime import date as Date
11from typing import Any, Literal, Optional
13import jinja2
15from .. import data
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.
24 Args:
25 data_model: The data model.
26 environment: The Jinja2 environment.
27 """
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
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.
49 Args:
50 template_name: The name of the template file.
51 entry: The title of the section.
53 Returns:
54 The templated file.
55 """
56 template = self.environment.get_template(
57 f"{theme_name}/{template_name}.j2.{extension}"
58 )
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.
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"]
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, "")
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 )
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 )
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 """
97 def __init__(
98 self,
99 data_model: data.RenderCVDataModel,
100 environment: jinja2.Environment,
101 ):
102 latex_file_data_model = copy.deepcopy(data_model)
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
110 super().__init__(latex_file_data_model, environment)
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.
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
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))
148 return preamble, header, sections
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.
158 Args:
159 template_name: The name of the template file.
160 entry: The data model of the entry.
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 )
173 return revert_nested_latex_style_commands(result)
175 def get_full_code(self) -> str:
176 """Get the $\\LaTeX$ code of the file.
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
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")
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 """
202 def render_templates(self) -> tuple[str, list[tuple[str, list[str]]]]:
203 """Render and return all the templates for the Markdown file.
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))
231 result: tuple[str, list[tuple[str, list[str]]]] = (header, sections)
232 return result
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.
242 Args:
243 template_name: The name of the template file.
244 entry: The data model of the entry.
246 Returns:
247 The templated file.
248 """
249 return super().template(
250 "markdown",
251 template_name,
252 "md",
253 entry,
254 **kwargs,
255 )
257 def get_full_code(self) -> str:
258 """Get the Markdown code of the file.
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
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")
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.
280 Args:
281 latex_string: The string to revert the nested $\\LaTeX$ style commands.
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 ]
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:
300 # find the first nested command:
301 nested_commands = re.findall(
302 rf"\\{command}{ [^} ]*?(\\{command}{ .*?} )", latex_string
303 )
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)
310 return latex_string
313def escape_latex_characters(latex_string: str) -> str:
314 """Escape $\\LaTeX$ characters in a string by adding a backslash before them.
316 Example:
317 ```python
318 escape_latex_characters("This is a # string.")
319 ```
320 returns
321 `"This is a \\# string."`
323 Args:
324 latex_string: The string to escape.
326 Returns:
327 The escaped string.
328 """
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)
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)
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]
356 original_link = f"[{placeholder}]({url})"
357 latex_string = latex_string.replace(original_link, f"!!-link{i}-!!")
359 new_link = f"[{escaped_placeholder}]({url})"
360 new_links.append(new_link)
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}-!!")
369 # Keep only one dollar sign for inline equations:
370 new_equation = equation.replace("$$", "$")
371 new_equations.append(new_equation)
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}-!!")
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)
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)
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)
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)
395 return latex_string
398def markdown_to_latex(markdown_string: str) -> str:
399 """Convert a Markdown string to $\\LaTeX$.
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$.
404 Example:
405 ```python
406 markdown_to_latex(
407 "This is a **bold** text with an [*italic link*](https://google.com)."
408 )
409 ```
411 returns
413 `"This is a \\textbf{bold} text with a \\href{https://google.com}{\\textit{link}}."`
415 Args:
416 markdown_string: The Markdown string to convert.
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]
428 old_link_string = f"[{link_text}]({link_url})"
429 new_link_string = "\\href{" + link_url + "}{" + link_text + "}"
431 markdown_string = markdown_string.replace(old_link_string, new_link_string)
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 + "}"
440 markdown_string = markdown_string.replace(old_bold_text, new_bold_text)
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 + "}"
449 markdown_string = markdown_string.replace(old_italic_text, new_italic_text)
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 + "}"
459 # markdown_string = markdown_string.replace(old_code_text, new_code_text)
461 return markdown_string
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.
472 Args:
473 sections: Sections with Markdown strings.
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)
506 sections[key] = transformed_list
508 return sections
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.
516 This function can be used as a Jinja2 filter in templates.
518 Args:
519 text: The text with placeholders.
520 placeholders: The placeholders and their values.
522 Returns:
523 The string with actual values.
524 """
525 for placeholder, value in placeholders.items():
526 text = text.replace(placeholder, str(value))
528 return text
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.
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.
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}} ")
558 return value
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.
565 This function can be used as a Jinja2 filter in templates.
567 Example:
568 ```python
569 make_it_bold("Hello World!", "Hello")
570 ```
572 returns
574 `"\\textbf{Hello} World!"`
576 Args:
577 value: The string to make bold.
578 match_str: The string to match.
580 Returns:
581 The string with the matched part bold.
582 """
583 return make_matched_part_something(value, "textbf", match_str)
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.
590 This function can be used as a Jinja2 filter in templates.
592 Example:
593 ```python
594 make_it_underlined("Hello World!", "Hello")
595 ```
597 returns
599 `"\\underline{Hello} World!"`
601 Args:
602 value: The string to make underlined.
603 match_str: The string to match.
605 Returns:
606 The string with the matched part underlined.
607 """
608 return make_matched_part_something(value, "underline", match_str)
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.
615 This function can be used as a Jinja2 filter in templates.
617 Example:
618 ```python
619 make_it_italic("Hello World!", "Hello")
620 ```
622 returns
624 `"\\textit{Hello} World!"`
626 Args:
627 value: The string to make italic.
628 match_str: The string to match.
630 Returns:
631 The string with the matched part italic.
632 """
633 return make_matched_part_something(value, "textit", match_str)
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.
642 This function can be used as a Jinja2 filter in templates.
644 Example:
645 ```python
646 make_it_nolinebreak("Hello World!", "Hello")
647 ```
649 returns
651 `"\\mbox{Hello} World!"`
653 Args:
654 value: The string to disable line breaks.
655 match_str: The string to match.
657 Returns:
658 The string with the matched part non line breakable.
659 """
660 return make_matched_part_something(value, "mbox", match_str)
663def abbreviate_name(name: Optional[str]) -> str:
664 """Abbreviate a name by keeping the first letters of the first names.
666 This function can be used as a Jinja2 filter in templates.
668 Example:
669 ```python
670 abbreviate_name("John Doe")
671 ```
673 returns
675 `"J. Doe"`
677 Args:
678 name: The name to abbreviate.
680 Returns:
681 The abbreviated name.
682 """
683 if name is None:
684 return ""
686 number_of_words = len(name.split(" "))
688 if number_of_words == 1:
689 return name
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]
695 return " ".join(first_names_initials) + " " + last_name
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)`
702 This function can be used as a Jinja2 filter in templates.
704 Example:
705 ```python
706 divide_length_by("10.4cm", 2)
707 ```
709 returns
711 `"5.2cm"`
713 Args:
714 length: The length to divide.
715 divider: The number to divide the length by.
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)
723 if value is None:
724 message = f"Invalid length {length}!"
725 raise ValueError(message)
727 value = value.group()
729 if divider <= 0:
730 message = f"The divider must be greater than 0, but got {divider}!"
731 raise ValueError(message)
733 unit = re.findall(r"[^\d\.\s]+", length)[0]
735 return str(float(value) / divider) + " " + unit
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.
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`
754 This function can be used as a Jinja2 filter in templates.
756 Args:
757 items: The list of items.
758 attribute: The attribute to check.
759 value: The value of the attribute.
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)
770 if getattr(item, attribute) == value:
771 return item
773 return None
776# Only one Jinja2 environment is needed for all the templates:
777jinja2_environment: Optional[jinja2.Environment] = None
780def setup_jinja2_environment() -> jinja2.Environment:
781 """Setup and return the Jinja2 environment for templating the $\\LaTeX$ files.
783 Returns:
784 The theme environment.
785 """
786 global jinja2_environment # noqa: PLW0603
787 themes_directory = pathlib.Path(__file__).parent.parent / "themes"
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 )
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 = "#))"
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
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 )
836 return jinja2_environment