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
« 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"""
7import copy
8import pathlib
9import re
10from datetime import date as Date
11from typing import Any, 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 (dm.RenderCVDataModel): The data model.
26 environment (jinja2.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.environment = environment
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.
48 Args:
49 template_name (str): The name of the template file.
50 entry (Optional[dm.Entry]): The title of the section.
52 Returns:
53 str: The templated file.
54 """
55 template = self.environment.get_template(
56 f"{theme_name}/{template_name}.j2.{extension}"
57 )
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.
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"]
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, "")
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 )
82 return result
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
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 """
98 def __init__(
99 self,
100 data_model: data.RenderCVDataModel,
101 environment: jinja2.Environment,
102 ):
103 latex_file_data_model = copy.deepcopy(data_model)
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
111 super().__init__(latex_file_data_model, environment)
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.
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
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))
150 return preamble, header, sections
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.
160 Args:
161 template_name (str): The name of the template file.
162 entry (Optional[dm.Entry]): The data model of the entry.
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 )
175 result = revert_nested_latex_style_commands(result)
177 return result
179 def get_full_code(self) -> str:
180 """Get the $\\LaTeX$ code of the file.
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
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")
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 """
206 def render_templates(self) -> tuple[str, list[tuple[str, list[str]]]]:
207 """Render and return all the templates for the Markdown file.
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))
239 result: tuple[str, list[tuple[str, list[str]]]] = (header, sections)
240 return result
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.
250 Args:
251 template_name (str): The name of the template file.
252 entry (Optional[dm.Entry]): The data model of the entry.
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
266 def get_full_code(self) -> str:
267 """Get the Markdown code of the file.
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
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")
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.
289 Args:
290 latex_string (str): The string to revert the nested $\\LaTeX$ style
291 commands.
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 ]
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:
310 # find the first nested command:
311 nested_commands = re.findall(
312 rf"\\{command}{ [^} ]*?(\\{command}{ .*?} )", latex_string
313 )
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)
320 return latex_string
323def escape_latex_characters(latex_string: str, strict: bool = True) -> str:
324 """Escape $\\LaTeX$ characters in a string by adding a backslash before them.
326 Example:
327 ```python
328 escape_latex_characters("This is a # string.")
329 ```
330 returns
331 `#!python "This is a \\# string."`
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.
338 Returns:
339 str: The escaped string.
340 """
342 # Dictionary of escape characters:
343 escape_characters = {
344 "#": "\\#",
345 "%": "\\%",
346 "&": "\\&",
347 "~": "\\textasciitilde{}",
348 }
350 strict_escape_characters = {
351 "$": "\\$",
352 "_": "\\_",
353 "^": "\\textasciicircum{}",
354 }
356 if strict:
357 # To allow math input, users can use this function with strict = False
358 escape_characters.update(strict_escape_characters)
360 translation_map = str.maketrans(escape_characters)
361 strict_translation_map = str.maketrans(strict_escape_characters)
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)
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]
377 original_link = f"[{placeholder}]({url})"
378 latex_string = latex_string.replace(original_link, f"!!-link{i}-!!")
380 new_link = f"[{escaped_placeholder}]({url})"
381 new_links.append(new_link)
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)
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)
391 return latex_string
394def markdown_to_latex(markdown_string: str) -> str:
395 """Convert a Markdown string to $\\LaTeX$.
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$.
400 Example:
401 ```python
402 markdown_to_latex("This is a **bold** text with an [*italic link*](https://google.com).")
403 ```
405 returns
407 `#!python "This is a \\textbf{bold} text with a \\href{https://google.com}{\\textit{link}}."`
409 Args:
410 markdown_string (str): The Markdown string to convert.
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]
422 old_link_string = f"[{link_text}]({link_url})"
423 new_link_string = "\\href{" + link_url + "}{" + link_text + "}"
425 markdown_string = markdown_string.replace(old_link_string, new_link_string)
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 + "}"
434 markdown_string = markdown_string.replace(old_bold_text, new_bold_text)
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 + "}"
443 markdown_string = markdown_string.replace(old_italic_text, new_italic_text)
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 + "}"
453 # markdown_string = markdown_string.replace(old_code_text, new_code_text)
455 latex_string = markdown_string
457 return latex_string
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.
468 Args:
469 sections (Optional[dict[str, dm.SectionInput]]): Sections with Markdown strings.
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)
501 sections[key] = transformed_list
503 return sections
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.
511 This function can be used as a Jinja2 filter in templates.
513 Args:
514 text (str): The text with placeholders.
515 placeholders (dict[str, str]): The placeholders and their values.
517 Returns:
518 str: The string with actual values.
519 """
520 for placeholder, value in placeholders.items():
521 text = text.replace(placeholder, str(value))
523 return text
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.
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.
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}} ")
551 return value
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.
558 This function can be used as a Jinja2 filter in templates.
560 Example:
561 ```python
562 make_it_bold("Hello World!", "Hello")
563 ```
565 returns
567 `#!python "\\textbf{Hello} World!"`
569 Args:
570 value (str): The string to make bold.
571 match_str (str): The string to match.
573 Returns:
574 str: The string with the matched part bold.
575 """
576 return make_matched_part_something(value, "textbf", match_str)
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.
583 This function can be used as a Jinja2 filter in templates.
585 Example:
586 ```python
587 make_it_underlined("Hello World!", "Hello")
588 ```
590 returns
592 `#!python "\\underline{Hello} World!"`
594 Args:
595 value (str): The string to make underlined.
596 match_str (str): The string to match.
598 Returns:
599 str: The string with the matched part underlined.
600 """
601 return make_matched_part_something(value, "underline", match_str)
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.
608 This function can be used as a Jinja2 filter in templates.
610 Example:
611 ```python
612 make_it_italic("Hello World!", "Hello")
613 ```
615 returns
617 `#!python "\\textit{Hello} World!"`
619 Args:
620 value (str): The string to make italic.
621 match_str (str): The string to match.
623 Returns:
624 str: The string with the matched part italic.
625 """
626 return make_matched_part_something(value, "textit", match_str)
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.
635 This function can be used as a Jinja2 filter in templates.
637 Example:
638 ```python
639 make_it_nolinebreak("Hello World!", "Hello")
640 ```
642 returns
644 `#!python "\\mbox{Hello} World!"`
646 Args:
647 value (str): The string to disable line breaks.
648 match_str (str): The string to match.
650 Returns:
651 str: The string with the matched part non line breakable.
652 """
653 return make_matched_part_something(value, "mbox", match_str)
656def abbreviate_name(name: Optional[str]) -> str:
657 """Abbreviate a name by keeping the first letters of the first names.
659 This function can be used as a Jinja2 filter in templates.
661 Example:
662 ```python
663 abbreviate_name("John Doe")
664 ```
666 returns
668 `#!python "J. Doe"`
670 Args:
671 name (str): The name to abbreviate.
673 Returns:
674 str: The abbreviated name.
675 """
676 if name is None:
677 return ""
679 number_of_words = len(name.split(" "))
681 if number_of_words == 1:
682 return name
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
689 return abbreviated_name
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)`
696 This function can be used as a Jinja2 filter in templates.
698 Example:
699 ```python
700 divide_length_by("10.4cm", 2)
701 ```
703 returns
705 `#!python "5.2cm"`
707 Args:
708 length (str): The length to divide.
709 divider (float): The number to divide the length by.
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)
717 if value is None:
718 raise ValueError(f"Invalid length {length}!")
719 else:
720 value = value.group()
722 if divider <= 0:
723 raise ValueError(f"The divider must be greater than 0, but got {divider}!")
725 unit = re.findall(r"[^\d\.\s]+", length)[0]
727 return str(float(value) / divider) + " " + unit
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.
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`
746 This function can be used as a Jinja2 filter in templates.
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.
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
769# Only one Jinja2 environment is needed for all the templates:
770jinja2_environment: Optional[jinja2.Environment] = None
773def setup_jinja2_environment() -> jinja2.Environment:
774 """Setup and return the Jinja2 environment for templating the $\\LaTeX$ files.
776 Returns:
777 jinja2.Environment: The theme environment.
778 """
779 global jinja2_environment
780 themes_directory = pathlib.Path(__file__).parent.parent / "themes"
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 )
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 = "#))"
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
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 )
825 return jinja2_environment