Coverage for rendercv/renderer/templater.py: 99%
234 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-26 00:25 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-26 00:25 +0000
1"""
2The `rendercv.renderer.templater` module contains all the necessary classes and
3functions for templating the Typst and Markdown files from the `RenderCVDataModel`
4object.
5"""
7import copy
8import pathlib
9import re
10from collections.abc import Callable
11from typing import Optional
13import jinja2
14import pydantic
16from .. import data
19class TemplatedFile:
20 """This class is a base class for `TypstFile`, and `MarkdownFile` classes. It
21 contains the common methods and attributes for both classes. These classes are used
22 to generate the Typst and Markdown files with the data model and Jinja2
23 templates.
25 Args:
26 data_model: The data model.
27 environment: The Jinja2 environment.
28 """
30 def __init__(
31 self,
32 data_model: data.RenderCVDataModel,
33 environment: jinja2.Environment,
34 ):
35 self.cv = data_model.cv
36 self.design = data_model.design
37 self.locale = data_model.locale
38 self.environment = environment
40 def template(
41 self,
42 theme_name: str,
43 template_name: str,
44 extension: str,
45 entry: Optional[data.Entry] = None,
46 **kwargs,
47 ) -> str:
48 """Template one of the files in the `themes` directory.
50 Args:
51 template_name: The name of the template file.
52 entry: The title of the section.
54 Returns:
55 The templated file.
56 """
57 template = self.environment.get_template(
58 f"{theme_name}/{template_name}.j2.{extension}"
59 )
61 # Loop through the entry attributes and make them "" if they are None:
62 # This is necessary because otherwise they will be templated as "None" since
63 # it's the string representation of None.
65 # Only don't touch the date fields, because only date_string is called and
66 # setting dates to "" will cause problems.
67 fields_to_ignore = ["start_date", "end_date", "date"]
69 if entry is not None and not isinstance(entry, str):
70 entry_dictionary = entry.model_dump()
71 for key, value in entry_dictionary.items():
72 if value is None and key not in fields_to_ignore:
73 entry.__setattr__(key, "")
75 # The arguments of the template can be used in the template file:
76 return template.render(
77 cv=self.cv,
78 design=self.design,
79 locale=self.locale,
80 entry=entry,
81 today=data.format_date(data.get_date_input()),
82 **kwargs,
83 )
85 def get_full_code(self, main_template_name: str, **kwargs) -> str:
86 """Combine all the templates to get the full code of the file."""
87 main_template = self.environment.get_template(main_template_name)
88 return main_template.render(
89 **kwargs,
90 )
93class TypstFile(TemplatedFile):
94 """This class represents a Typst file. It generates the Typst code with the
95 data model and Jinja2 templates.
96 """
98 def __init__(
99 self,
100 data_model: data.RenderCVDataModel,
101 environment: jinja2.Environment,
102 ):
103 typst_file_data_model = copy.deepcopy(data_model)
105 if typst_file_data_model.cv.sections_input is not None:
106 transformed_sections = transform_markdown_sections_to_typst_sections(
107 typst_file_data_model.cv.sections_input
108 )
109 typst_file_data_model.cv.sections_input = transformed_sections
111 super().__init__(typst_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 Typst file.
116 Returns:
117 The preamble, header, and sections of the Typst file.
118 """
119 # All the template field names:
120 all_template_names = [
121 "main_column_first_row_template",
122 "main_column_second_row_template",
123 "main_column_second_row_without_url_template",
124 "main_column_second_row_without_journal_template",
125 "date_and_location_column_template",
126 "template",
127 "degree_column_template",
128 ]
130 # All the placeholders used in the templates:
131 sections_input: dict[str, list[pydantic.BaseModel]] = self.cv.sections_input # type: ignore
132 # Loop through the sections and entries to find all the field names:
133 placeholder_keys: set[str] = set()
134 if sections_input:
135 for section in sections_input.values():
136 for entry in section:
137 if isinstance(entry, str):
138 break
139 entry_dictionary = entry.model_dump()
140 for key in entry_dictionary:
141 placeholder_keys.add(key.upper())
143 pattern = re.compile(r"(?<!^)(?=[A-Z])")
145 def camel_to_snake(name: str) -> str:
146 return pattern.sub("_", name).lower()
148 # Template the preamble, header, and sections:
149 preamble = self.template("Preamble")
150 header = self.template("Header")
151 sections: list[tuple[str, list[str], str]] = []
152 for section in self.cv.sections:
153 section_beginning = self.template(
154 "SectionBeginning",
155 section_title=escape_typst_characters(section.title),
156 entry_type=section.entry_type,
157 )
159 templates = {
160 template_name: getattr(
161 getattr(
162 getattr(self.design, "entry_types", None),
163 camel_to_snake(section.entry_type),
164 None,
165 ),
166 template_name,
167 None,
168 )
169 for template_name in all_template_names
170 }
172 entries: list[str] = []
173 for i, entry in enumerate(section.entries):
174 # Prepare placeholders:
175 placeholders = {}
176 for placeholder_key in placeholder_keys:
177 components_path = (
178 pathlib.Path(__file__).parent.parent / "themes" / "components"
179 )
180 lowercase_placeholder_key = placeholder_key.lower()
181 if (
182 components_path / f"{lowercase_placeholder_key}.j2.typ"
183 ).exists():
184 placeholder_value = super().template(
185 "components",
186 lowercase_placeholder_key,
187 "typ",
188 entry,
189 section_title=section.title,
190 )
191 else:
192 placeholder_value = getattr(entry, placeholder_key, None)
194 placeholders[placeholder_key] = (
195 placeholder_value if placeholder_value != "None" else None
196 )
198 # Substitute the placeholders in the templates:
199 templates_with_substitutions = {
200 template_name: (
201 input_template_to_typst(
202 templates[template_name],
203 placeholders, # type: ignore
204 )
205 if templates.get(template_name)
206 else None
207 )
208 for template_name in all_template_names
209 }
211 entries.append(
212 self.template(
213 section.entry_type,
214 entry=entry,
215 section_title=section.title,
216 entry_type=section.entry_type,
217 is_first_entry=i == 0,
218 **templates_with_substitutions, # all the templates
219 )
220 )
221 section_ending = self.template(
222 "SectionEnding",
223 section_title=section.title,
224 entry_type=section.entry_type,
225 )
226 sections.append((section_beginning, entries, section_ending))
228 return preamble, header, sections
230 def template(
231 self,
232 template_name: str,
233 entry: Optional[data.Entry] = None,
234 **kwargs,
235 ) -> str:
236 """Template one of the files in the `themes` directory.
238 Args:
239 template_name: The name of the template file.
240 entry: The data model of the entry.
242 Returns:
243 The templated file.
244 """
245 return super().template(
246 self.design.theme,
247 template_name,
248 "typ",
249 entry,
250 **kwargs,
251 )
253 def get_full_code(self) -> str:
254 """Get the Typst code of the file.
256 Returns:
257 The Typst code.
258 """
259 preamble, header, sections = self.render_templates()
260 code: str = super().get_full_code(
261 "main.j2.typ",
262 preamble=preamble,
263 header=header,
264 sections=sections,
265 )
266 return code
268 def create_file(self, file_path: pathlib.Path):
269 """Write the Typst code to a file."""
270 file_path.write_text(self.get_full_code(), encoding="utf-8")
273class MarkdownFile(TemplatedFile):
274 """This class represents a Markdown file. It generates the Markdown code with the
275 data model and Jinja2 templates. Markdown files are generated to produce an HTML
276 which can be copy-pasted to [Grammarly](https://app.grammarly.com/) for
277 proofreading.
278 """
280 def render_templates(self) -> tuple[str, list[tuple[str, list[str]]]]:
281 """Render and return all the templates for the Markdown file.
283 Returns:
284 The header and sections of the Markdown file.
285 """
286 # Template the header and sections:
287 header = self.template("Header")
288 sections: list[tuple[str, list[str]]] = []
289 for section in self.cv.sections:
290 section_beginning = self.template(
291 "SectionBeginning",
292 section_title=section.title,
293 entry_type=section.entry_type,
294 )
295 entries: list[str] = []
296 for i, entry in enumerate(section.entries):
297 is_first_entry = bool(i == 0)
298 entries.append(
299 self.template(
300 section.entry_type,
301 entry=entry,
302 section_title=section.title,
303 entry_type=section.entry_type,
304 is_first_entry=is_first_entry,
305 )
306 )
307 sections.append((section_beginning, entries))
309 result: tuple[str, list[tuple[str, list[str]]]] = (header, sections)
310 return result
312 def template(
313 self,
314 template_name: str,
315 entry: Optional[data.Entry] = None,
316 **kwargs,
317 ) -> str:
318 """Template one of the files in the `themes` directory.
320 Args:
321 template_name: The name of the template file.
322 entry: The data model of the entry.
324 Returns:
325 The templated file.
326 """
327 return super().template(
328 "markdown",
329 template_name,
330 "md",
331 entry,
332 **kwargs,
333 )
335 def get_full_code(self) -> str:
336 """Get the Markdown code of the file.
338 Returns:
339 The Markdown code.
340 """
341 header, sections = self.render_templates()
342 code: str = super().get_full_code(
343 "main.j2.md",
344 header=header,
345 sections=sections,
346 )
347 return code
349 def create_file(self, file_path: pathlib.Path):
350 """Write the Markdown code to a file."""
351 file_path.write_text(self.get_full_code(), encoding="utf-8")
354def input_template_to_typst(
355 input_template: Optional[str], placeholders: dict[str, Optional[str]]
356) -> str:
357 """Convert an input template to Typst.
359 Args:
360 input_template: The input template.
361 placeholders: The placeholders and their values.
363 Returns:
364 Typst string.
365 """
366 if input_template is None:
367 return ""
369 output = replace_placeholders_with_actual_values(
370 markdown_to_typst(input_template),
371 placeholders,
372 )
374 # If \n is escaped, revert:
375 output = output.replace("\\n", "\n")
377 # If there are blank italics and bolds, remove them:
378 output = output.replace("#emph[]", "")
379 output = output.replace("#strong[]", "")
381 # Check if there are any letters in the input template. If not, return an empty
382 if not re.search(r"[a-zA-Z]", input_template):
383 return ""
385 # Find italic and bold links and fix them:
386 # For example:
387 # Convert `#emph[#link("https://google.com")[italic link]]` to
388 # `#link("https://google.com")[#emph[italic link]]`
389 output = re.sub(
390 r"#emph\[#link\(\"(.*?)\"\)\[(.*?)\]\]",
391 r'#link("\1")[#emph[\2]]',
392 output,
393 )
394 output = re.sub(
395 r"#strong\[#link\(\"(.*?)\"\)\[(.*?)\]\]",
396 r'#link("\1")[#strong[\2]]',
397 output,
398 )
399 output = re.sub(
400 r"#strong\[#emph\[#link\(\"(.*?)\"\)\[(.*?)\]\]\]",
401 r'#link("\1")[#strong[#emph[\2]]]',
402 output,
403 )
405 # Replace all multiple \n with a double \n:
406 output = re.sub(r"\n+", r"\n\n", output)
408 # Strip whitespace
409 output = output.strip()
411 # Strip non-alphanumeric, non-typst characters from the beginning and end of the
412 # string. For example, when location is not given in a template like this:
413 # "NAME -- LOCATION", "NAME -- " should become "NAME".
414 output = re.sub(r"^[^\w\s#\[\]\n\(\)]*", "", output)
415 output = re.sub(r"[^\w\s#\[\]\n\(\)]*$", "", output)
417 return output # noqa: RET504
420def escape_characters(string: str, escape_dictionary: dict[str, str]) -> str:
421 """Escape characters in a string by using `escape_dictionary`, where keys are
422 characters to escape and values are their escaped versions.
424 Example:
425 ```python
426 escape_characters("This is a # string.", {"#": "\\#"})
427 ```
428 returns
429 `"This is a \\# string."`
431 Args:
432 string: The string to escape.
433 escape_dictionary: The dictionary of escape characters.
435 Returns:
436 The escaped string.
437 """
439 translation_map = str.maketrans(escape_dictionary)
441 # Don't escape urls as hyperref package will do it automatically:
442 # Find all the links in the sentence:
443 links = re.findall(r"\[(.*?)\]\((.*?)\)", string)
445 # Replace the links with a dummy string and save links with escaped characters:
446 new_links = []
447 for i, link in enumerate(links):
448 placeholder = link[0]
449 escaped_placeholder = placeholder.translate(translation_map)
450 url = link[1]
452 original_link = f"[{placeholder}]({url})"
453 string = string.replace(original_link, f"!!-link{i}-!!")
455 new_link = f"[{escaped_placeholder}]({url})"
456 new_links.append(new_link)
458 # If there are equations in the sentence, don't escape the special characters:
459 # Find all the equations in the sentence:
460 equations = re.findall(r"(\$\$.*?\$\$)", string)
461 new_equations = []
462 for i, equation in enumerate(equations):
463 string = string.replace(equation, f"!!-equation{i}-!!")
465 # Keep only one dollar sign for inline equations:
466 new_equation = equation.replace("$$", "$")
467 new_equations.append(new_equation)
469 # Loop through the letters of the sentence and if you find an escape character,
470 # replace it with their equivalent:
471 string = string.translate(translation_map)
473 # Replace !!-link{i}-!!" with the original urls:
474 for i, new_link in enumerate(new_links):
475 string = string.replace(f"!!-link{i}-!!", new_link)
477 # Replace !!-equation{i}-!!" with the original equations:
478 for i, new_equation in enumerate(new_equations):
479 string = string.replace(f"!!-equation{i}-!!", new_equation)
481 return string
484def escape_typst_characters(string: str) -> str:
485 """Escape Typst characters in a string by adding a backslash before them.
487 Example:
488 ```python
489 escape_typst_characters("This is a # string.")
490 ```
491 returns
492 `"This is a \\# string."`
494 Args:
495 string: The string to escape.
497 Returns:
498 The escaped string.
499 """
500 escape_dictionary = {
501 "[": "\\[",
502 "]": "\\]",
503 "(": "\\(",
504 ")": "\\)",
505 "\\": "\\\\",
506 '"': '\\"',
507 "#": "\\#",
508 "$": "\\$",
509 "@": "\\@",
510 "%": "\\%",
511 "~": "\\~",
512 "_": "\\_",
513 }
515 return escape_characters(string, escape_dictionary)
518def markdown_to_typst(markdown_string: str) -> str:
519 """Convert a Markdown string to Typst.
521 Example:
522 ```python
523 markdown_to_typst(
524 "This is a **bold** text with an [*italic link*](https://google.com)."
525 )
526 ```
528 returns
530 `"This is a *bold* text with an #link("https://google.com")[_italic link_]."`
532 Args:
533 markdown_string: The Markdown string to convert.
535 Returns:
536 The Typst string.
537 """
538 # convert links
539 links = re.findall(r"\[([^\]\[]*)\]\((.*?)\)", markdown_string)
540 if links is not None:
541 for link in links:
542 link_text = link[0]
543 link_url = link[1]
545 old_link_string = f"[{link_text}]({link_url})"
546 new_link_string = f'#link("{link_url}")[{link_text}]'
548 markdown_string = markdown_string.replace(old_link_string, new_link_string)
550 # Process escaped asterisks in the yaml (such that they are actual asterisks,
551 # and not markers for bold/italics). We need to temporarily replace them with
552 # a dummy string.
554 ONE_STAR = "ONE_STAR"
556 # NOTE: We get a mix of escape levels depending on whether the star is in a quoted
557 # or unquoted yaml entry. This is a bit of a mess but below seems to work
558 # as i would instinctively expect.
559 markdown_string = markdown_string.replace("\\\\*", ONE_STAR)
560 markdown_string = markdown_string.replace("\\*", ONE_STAR)
562 # convert bold and italic:
563 bold_and_italics = re.findall(r"\*\*\*(.+?)\*\*\*", markdown_string)
564 if bold_and_italics is not None:
565 for bold_and_italic_text in bold_and_italics:
566 old_bold_and_italic_text = f"***{bold_and_italic_text}***"
567 new_bold_and_italic_text = f"#strong[#emph[{bold_and_italic_text}]]"
569 markdown_string = markdown_string.replace(
570 old_bold_and_italic_text, new_bold_and_italic_text
571 )
573 # convert bold
574 bolds = re.findall(r"\*\*(.+?)\*\*", markdown_string)
575 if bolds is not None:
576 for bold_text in bolds:
577 old_bold_text = f"**{bold_text}**"
578 new_bold_text = f"#strong[{bold_text}]"
579 markdown_string = markdown_string.replace(old_bold_text, new_bold_text)
581 # convert italic
582 italics = re.findall(r"\*(.+?)\*", markdown_string)
583 if italics is not None:
584 for italic_text in italics:
585 old_italic_text = f"*{italic_text}*"
586 new_italic_text = f"#emph[{italic_text}]"
588 markdown_string = markdown_string.replace(old_italic_text, new_italic_text)
590 # Revert normal asterisks then convert them to Typst's asterisks
591 markdown_string = markdown_string.replace(ONE_STAR, "*")
593 # convert any remaining asterisks to Typst's asterisk
594 # - Asterisk with a space can just be replaced.
595 # - Asterisk without a space needs a zero-width box to delimit it.
596 TYPST_AST = "#sym.ast.basic"
597 ZERO_BOX = "#h(0pt, weak: true)"
598 markdown_string = markdown_string.replace("* ", TYPST_AST + " ")
599 markdown_string = markdown_string.replace("*", TYPST_AST + ZERO_BOX)
601 # At this point, the document ought to have absolutely no '*' characters left!
602 # NOTE: The final typst file might still have some asterisks when specifying a
603 # size, for example `#v(design-text-font-size * 0.4)`
604 # XXX: Maybe put this behind some kind of debug flag? -MK
605 # assert "*" not in markdown_string
607 return markdown_string # noqa: RET504
610def transform_markdown_sections_to_something_else_sections(
611 sections: dict[str, data.SectionContents],
612 functions_to_apply: list[Callable],
613) -> Optional[dict[str, data.SectionContents]]:
614 """
615 Recursively loop through sections and update all the strings by applying the
616 `functions_to_apply` functions, given as an argument.
618 Args:
619 sections: Sections with Markdown strings.
620 functions_to_apply: Functions to apply to the strings.
622 Returns:
623 Sections with updated strings.
624 """
626 def apply_functions_to_string(string: str):
627 for function in functions_to_apply:
628 string = function(string)
629 return string
631 for key, value in sections.items():
632 transformed_list = []
633 for entry in value:
634 if isinstance(entry, str):
635 # Then it means it's a TextEntry.
636 result = apply_functions_to_string(entry)
637 transformed_list.append(result)
638 else:
639 # Then it means it's one of the other entries.
640 fields_to_skip = ["doi"]
641 entry_as_dict = entry.model_dump()
642 for entry_key, inner_value in entry_as_dict.items():
643 if entry_key in fields_to_skip:
644 continue
645 if isinstance(inner_value, str):
646 result = apply_functions_to_string(inner_value)
647 setattr(entry, entry_key, result)
648 elif isinstance(inner_value, list):
649 for j, item in enumerate(inner_value):
650 if isinstance(item, str):
651 inner_value[j] = apply_functions_to_string(item)
652 setattr(entry, entry_key, inner_value)
653 transformed_list.append(entry)
655 sections[key] = transformed_list
657 return sections
660def transform_markdown_sections_to_typst_sections(
661 sections: dict[str, data.SectionContents],
662) -> Optional[dict[str, data.SectionContents]]:
663 """
664 Recursively loop through sections and convert all the Markdown strings (user input
665 is in Markdown format) to Typst strings.
667 Args:
668 sections: Sections with Markdown strings.
670 Returns:
671 Sections with Typst strings.
672 """
673 return transform_markdown_sections_to_something_else_sections(
674 sections,
675 [escape_typst_characters, markdown_to_typst],
676 )
679def replace_placeholders_with_actual_values(
680 text: str,
681 placeholders: dict[str, Optional[str]],
682) -> str:
683 """Replace the placeholders in a string with actual values.
685 This function can be used as a Jinja2 filter in templates.
687 Args:
688 text: The text with placeholders.
689 placeholders: The placeholders and their values.
691 Returns:
692 The string with actual values.
693 """
694 for placeholder, value in placeholders.items():
695 if value:
696 text = text.replace(placeholder, str(value))
697 else:
698 text = text.replace(placeholder, "")
700 return text
703class Jinja2Environment:
704 instance: "Jinja2Environment"
705 environment: jinja2.Environment
706 current_working_directory: Optional[pathlib.Path] = None
708 def __new__(cls):
709 if (
710 not hasattr(cls, "instance")
711 or cls.current_working_directory != pathlib.Path.cwd()
712 ):
713 cls.instance = super().__new__(cls)
715 themes_directory = pathlib.Path(__file__).parent.parent / "themes"
717 # create a Jinja2 environment:
718 # we need to add the current working directory because custom themes might be used.
719 environment = jinja2.Environment(
720 loader=jinja2.FileSystemLoader([pathlib.Path.cwd(), themes_directory]),
721 trim_blocks=True,
722 lstrip_blocks=True,
723 )
725 # set custom delimiters:
726 environment.block_start_string = "((*"
727 environment.block_end_string = "*))"
728 environment.variable_start_string = "<<"
729 environment.variable_end_string = ">>"
730 environment.comment_start_string = "((#"
731 environment.comment_end_string = "#))"
733 # add custom Jinja2 filters:
734 environment.filters["replace_placeholders_with_actual_values"] = (
735 replace_placeholders_with_actual_values
736 )
737 environment.filters["escape_typst_characters"] = escape_typst_characters
738 environment.filters["markdown_to_typst"] = markdown_to_typst
739 environment.filters["make_a_url_clean"] = data.make_a_url_clean
741 cls.environment = environment
743 return cls.instance