rendercv/rendercv/renderer.py

1001 lines
34 KiB
Python

"""
This module contains functions and classes for generating a $\\LaTeX$ file from the data
model and rendering the $\\LaTeX$ file to produce a PDF.
The $\\LaTeX$ files are generated with
[Jinja2](https://jinja.palletsprojects.com/en/3.1.x/) templates. Then, the $\\LaTeX$
file is rendered into a PDF with [TinyTeX](https://yihui.org/tinytex/), a $\\LaTeX$
distribution.
"""
import subprocess
import re
import os
import pathlib
import importlib.resources
import shutil
import sys
import copy
from datetime import date as Date
from typing import Optional, Literal, Any
import jinja2
import markdown
from . import data_models as dm
class TemplatedFile:
"""This class is a base class for LaTeXFile and MarkdownFile classes. It contains
the common methods and attributes for both classes. These classes are used to
generate the LaTeX and Markdown files with the data model and Jinja2 templates.
Args:
data_model (dm.RenderCVDataModel): The data model.
environment (jinja2.Environment): The Jinja2 environment.
"""
def __init__(
self,
data_model: dm.RenderCVDataModel,
environment: jinja2.Environment,
):
self.cv = data_model.cv
self.design = data_model.design
self.environment = environment
def template(
self,
theme_name,
template_name: Literal[
"EducationEntry",
"ExperienceEntry",
"NormalEntry",
"PublicationEntry",
"OneLineEntry",
"TextEntry",
"Header",
"Preamble",
"SectionBeginning",
"SectionEnding",
],
extension: str,
entry: Optional[
dm.EducationEntry
| dm.ExperienceEntry
| dm.NormalEntry
| dm.PublicationEntry
| dm.OneLineEntry
| str # TextEntry
] = None,
section_title: Optional[str] = None,
is_first_entry: Optional[bool] = None,
) -> str:
"""Template one of the files in the `themes` directory.
Args:
template_name (str): The name of the template file.
entry (Optional[
dm.EducationEntry,
dm.ExperienceEntry,
dm.NormalEntry,
dm.PublicationEntry,
dm.OneLineEntry,
str
]): The data model of the entry.
section_title (Optional[str]): The title of the section.
is_first_entry (Optional[bool]): Whether the entry is the first one in the
section.
Returns:
str: The templated file.
"""
template = self.environment.get_template(
f"{theme_name}/{template_name}.j2.{extension}"
)
# Loop through the entry attributes and make them "" if they are None:
# This is necessary because otherwise they will be templated as "None" since
# it's the string representation of None.
# Only don't touch the date fields, because only date_string is called and
# setting dates to "" will cause problems.
fields_to_ignore = ["start_date", "end_date", "date"]
if entry is not None and not isinstance(entry, str):
entry_dictionary = entry.model_dump()
for key, value in entry_dictionary.items():
if value is None and key not in fields_to_ignore:
entry.__setattr__(key, "")
# The arguments of the template can be used in the template file:
result = template.render(
cv=self.cv,
design=self.design,
entry=entry,
section_title=section_title,
today=Date.today().strftime("%B %Y"),
is_first_entry=is_first_entry,
)
return result
def get_full_code(self, main_template_name: str, **kwargs) -> str:
"""Combine all the templates to get the full code of the file."""
main_template = self.environment.get_template(main_template_name)
latex_code = main_template.render(
**kwargs,
)
return latex_code
class LaTeXFile(TemplatedFile):
"""This class represents a $\\LaTeX$ file. It generates the $\\LaTeX$ code with the
data model and Jinja2 templates. It inherits from the TemplatedFile class.
"""
def __init__(
self,
data_model: dm.RenderCVDataModel,
environment: jinja2.Environment,
):
data_model = transform_markdown_data_model_to_latex_data_model(
copy.deepcopy(data_model)
)
super().__init__(data_model, environment)
def render_templates(self):
"""Render and return all the templates for the $\\LaTeX$ file.
Returns:
Tuple[str, str, List[Tuple[str, List[str], str]]]: The preamble, header, and
sections of the $\\LaTeX$ file.
"""
# Template the preamble, header, and sections:
preamble = self.template("Preamble")
header = self.template("Header")
sections = []
for section in self.cv.sections:
section_beginning = self.template(
"SectionBeginning", section_title=section.title
)
entries = []
for i, entry in enumerate(section.entries):
if i == 0:
is_first_entry = True
else:
is_first_entry = False
entries.append(
self.template(
section.entry_type,
entry=entry,
section_title=section.title,
is_first_entry=is_first_entry,
)
)
section_ending = self.template("SectionEnding", section_title=section.title)
sections.append((section_beginning, entries, section_ending))
return preamble, header, sections
def template(
self,
template_name: Literal[
"EducationEntry",
"ExperienceEntry",
"NormalEntry",
"PublicationEntry",
"OneLineEntry",
"TextEntry",
"Header",
"Preamble",
"SectionBeginning",
"SectionEnding",
],
entry: Optional[
dm.EducationEntry
| dm.ExperienceEntry
| dm.NormalEntry
| dm.PublicationEntry
| dm.OneLineEntry
| str # TextEntry
] = None,
section_title: Optional[str] = None,
is_first_entry: Optional[bool] = None,
) -> str:
"""Template one of the files in the `themes` directory."""
result = super().template(
self.design.theme,
template_name,
"tex",
entry,
section_title,
is_first_entry,
)
return result
def get_latex_code(self):
"""Get the $\\LaTeX$ code of the file."""
preamble, header, sections = self.render_templates()
return self.get_full_code(
"main.j2.tex",
preamble=preamble,
header=header,
sections=sections,
)
def generate_latex_file(self, file_path: pathlib.Path):
"""Write the $\\LaTeX$ code to a file."""
file_path.write_text(self.get_latex_code(), encoding="utf-8")
class MarkdownFile(TemplatedFile):
"""This class represents a Markdown file. It generates the Markdown code with the
data model and Jinja2 templates. It inherits from the TemplatedFile class. Markdown
files are generated to produce a PDF which can be copy-pasted to
[Grammarly](https://app.grammarly.com/) for proofreading.
Args:
data_model (dm.RenderCVDataModel): The data model.
environment (jinja2.Environment): The Jinja2 environment.
"""
def render_templates(self):
"""Render and return all the templates for the Markdown file.
Returns:
Tuple[str, List[Tuple[str, List[str]]]: The header and sections of the
Markdown file.
"""
# Template the header and sections:
header = self.template("Header")
sections = []
for section in self.cv.sections:
section_beginning = self.template(
"SectionBeginning", section_title=section.title
)
entries = []
for i, entry in enumerate(section.entries):
if i == 0:
is_first_entry = True
else:
is_first_entry = False
entries.append(
self.template(
section.entry_type,
entry=entry,
section_title=section.title,
is_first_entry=is_first_entry,
)
)
sections.append((section_beginning, entries))
return header, sections
def template(
self,
template_name: Literal[
"EducationEntry",
"ExperienceEntry",
"NormalEntry",
"PublicationEntry",
"OneLineEntry",
"TextEntry",
"Header",
"Preamble",
"SectionBeginning",
"SectionEnding",
],
entry: Optional[
dm.EducationEntry
| dm.ExperienceEntry
| dm.NormalEntry
| dm.PublicationEntry
| dm.OneLineEntry
| str # TextEntry
] = None,
section_title: Optional[str] = None,
is_first_entry: Optional[bool] = None,
) -> str:
"""Template one of the files in the `themes` directory."""
result = super().template(
"markdown",
template_name,
"md",
entry,
section_title,
is_first_entry,
)
return result
def get_markdown_code(self):
"""Get the Markdown code of the file."""
header, sections = self.render_templates()
return self.get_full_code(
"main.j2.md",
header=header,
sections=sections,
)
def generate_markdown_file(self, file_path: pathlib.Path):
"""Write the Markdown code to a file."""
file_path.write_text(self.get_markdown_code(), encoding="utf-8")
def escape_latex_characters(string: str) -> str:
"""Escape $\\LaTeX$ characters in a string.
This function is called during the reading of the input file. Before the validation
process, each input field's special $\\LaTeX$ characters are escaped.
Example:
```python
escape_latex_characters("This is a # string.")
```
will return:
`#!python "This is a \\# string."`
"""
# Dictionary of escape characters:
escape_characters = {
"#": "\\#",
# "$": "\\$", # Don't escape $ as it is used for math mode
"%": "\\%",
"&": "\\&",
"~": "\\textasciitilde{}",
# "_": "\\_", # Don't escape _ as it is used for math mode
# "^": "\\textasciicircum{}", # Don't escape ^ as it is used for math mode
}
# Don't escape links as hyperref package will do it automatically:
# Find all the links in the sentence:
links = re.findall(r"\[.*?\]\(.*?\)", string)
# Replace the links with a placeholder:
for link in links:
string = string.replace(link, "!!-link-!!")
# Loop through the letters of the sentence and if you find an escape character,
# replace it with its LaTeX equivalent:
copy_of_the_string = list(string)
for i, character in enumerate(copy_of_the_string):
if character in escape_characters:
new_character = escape_characters[character]
copy_of_the_string[i] = new_character
string = "".join(copy_of_the_string)
# Replace the links with the original links:
for link in links:
string = string.replace("!!-link-!!", link)
return string
def markdown_to_latex(markdown_string: str) -> str:
"""Convert a markdown string to LaTeX.
This function is called during the reading of the input file. Before the validation
process, each input field is converted from markdown to LaTeX.
Example:
```python
markdown_to_latex("This is a **bold** text with an [*italic link*](https://google.com).")
```
will return:
`#!python "This is a \\textbf{bold} text with a \\href{https://google.com}{\\textit{link}}."`
Args:
markdown_string (str): The markdown string to convert.
Returns:
str: The LaTeX string.
"""
# convert links
links = re.findall(r"\[([^\]\[]*)\]\((.*?)\)", markdown_string)
if links is not None:
for link in links:
link_text = link[0]
link_url = link[1]
old_link_string = f"[{link_text}]({link_url})"
new_link_string = "\\href{" + link_url + "}{" + link_text + "}"
markdown_string = markdown_string.replace(old_link_string, new_link_string)
# convert bold
bolds = re.findall(r"\*\*([^\*]*)\*\*", markdown_string)
if bolds is not None:
for bold_text in bolds:
old_bold_text = f"**{bold_text}**"
new_bold_text = "\\textbf{" + bold_text + "}"
markdown_string = markdown_string.replace(old_bold_text, new_bold_text)
# convert italic
italics = re.findall(r"\*([^\*]*)\*", markdown_string)
if italics is not None:
for italic_text in italics:
old_italic_text = f"*{italic_text}*"
new_italic_text = "\\textit{" + italic_text + "}"
markdown_string = markdown_string.replace(old_italic_text, new_italic_text)
# convert code
codes = re.findall(r"`([^`]*)`", markdown_string)
if codes is not None:
for code_text in codes:
old_code_text = f"`{code_text}`"
new_code_text = "\\texttt{" + code_text + "}"
markdown_string = markdown_string.replace(old_code_text, new_code_text)
latex_string = markdown_string
return latex_string
def transform_markdown_data_model_to_latex_data_model(
data_model: dm.RenderCVDataModel,
) -> dm.RenderCVDataModel:
"""
Recursively loop through a `RenderCVDataModel` and convert all the markdown strings
(user input is in markdown format) to LaTeX strings. Also, escape special LaTeX
characters.
Args:
data_model (RenderCVDataModel): The data model to transform.
Returns:
dict: The data model with LaTeX strings.
"""
data_model_as_dict = data_model.model_dump()
for key, value in data_model_as_dict.items():
if isinstance(value, str):
# if the value is a string, then apply markdown_to_latex and
# escape_latex_characters to it:
result = markdown_to_latex(escape_latex_characters(value))
# update data_model object's attribute with the new value:
setattr(data_model, key, result)
elif isinstance(value, list):
# if the value is a list, then loop through the list and apply
# markdown_to_latex and escape_latex_characters to each item:
transformed_list = []
for index, item in enumerate(value):
if isinstance(item, str):
result = markdown_to_latex(escape_latex_characters(item))
transformed_list.append(result)
elif isinstance(item, dict):
# if the item is a dictionary, then it means it's a sub data model.
# So, call transform_markdown_data_model_to_latex_data_model again:
sub_data_model = getattr(data_model, key)[index]
transformed_sub_data_model = (
transform_markdown_data_model_to_latex_data_model(
sub_data_model
)
)
transformed_list.append(transformed_sub_data_model)
# update data_model object's attribute with the new value:
setattr(data_model, key, transformed_list)
elif isinstance(value, dict):
if key == "sections_input":
# Then it means it's the `sections` field, it is a dictionary but
# not a sub data model. Therefore the same function cannot be called.
# So, loop through the dictionary and apply markdown_to_latex and
# escape_latex_characters to each item:
sections = getattr(data_model, key)
for section_title, entries in sections.items():
transformed_entries = []
for entry in entries:
if isinstance(entry, str):
result = markdown_to_latex(escape_latex_characters(entry))
transformed_entries.append(result)
else:
transformed_entry = (
transform_markdown_data_model_to_latex_data_model(entry)
)
transformed_entries.append(transformed_entry)
setattr(data_model, key, sections)
else:
# Then it means it's a sub data model.
# So, call transform_markdown_data_model_to_latex_data_model again:
sub_data_model = getattr(data_model, key)
transformed_sub_data_model = (
transform_markdown_data_model_to_latex_data_model(sub_data_model)
)
# update data_model object's attribute with the new value:
setattr(data_model, key, transformed_sub_data_model)
return data_model
def replace_placeholders_with_actual_values(
string: str, placeholders: dict[str, str]
) -> str:
"""Replace the placeholders in a string with actual values.
This function can be used as a Jinja2 filter in templates.
Args:
string (str): The string with placeholders.
placeholders (dict[str, str]): The placeholders and their values.
Returns:
str: The string with actual values.
"""
for placeholder, value in placeholders.items():
string = string.replace(placeholder, value)
return string
def make_matched_part_something(
value: str, something: str, match_str: Optional[str] = None
) -> str:
"""Make the matched parts of the string something. If the match_str is None, the
whole string will be made something.
Warning:
This function shouldn't be used directly. Use
[make_matched_part_bold](renderer.md#rendercv.rendering.make_matched_part_bold),
[make_matched_part_underlined](renderer.md#rendercv.rendering.make_matched_part_underlined),
[make_matched_part_italic](renderer.md#rendercv.rendering.make_matched_part_italic),
or
[make_matched_part_non_line_breakable](renderer.md#rendercv.rendering.make_matched_part_non_line_breakable)
instead.
Args:
value (str): The string to make something.
something (str): The LaTeX command to use.
match_str (str): The string to match.
Returns:
str: The string with the matched part something.
"""
if match_str is None:
value = f"\\{something}{{{value}}}"
elif match_str in value and match_str != "":
value = value.replace(match_str, f"\\{something}{{{match_str}}}")
return value
def make_matched_part_bold(value: str, match_str: Optional[str] = None) -> str:
"""Make the matched parts of the string bold. If the match_str is None, the whole
string will be made bold.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_bold("Hello World!", "Hello")
```
will return:
`#!python "\\textbf{Hello} World!"`
Args:
value (str): The string to make bold.
match_str (str): The string to match.
Returns:
str: The string with the matched part bold.
"""
return make_matched_part_something(value, "textbf", match_str)
def make_matched_part_underlined(value: str, match_str: Optional[str] = None) -> str:
"""Make the matched parts of the string underlined. If the match_str is None, the
whole string will be made underlined.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_underlined("Hello World!", "Hello")
```
will return:
`#!python "\\underline{Hello} World!"`
Args:
value (str): The string to make underlined.
match_str (str): The string to match.
Returns:
str: The string with the matched part underlined.
"""
return make_matched_part_something(value, "underline", match_str)
def make_matched_part_italic(value: str, match_str: Optional[str] = None) -> str:
"""Make the matched parts of the string italic. If the match_str is None, the whole
string will be made italic.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_italic("Hello World!", "Hello")
```
will return:
`#!python "\\textit{Hello} World!"`
Args:
value (str): The string to make italic.
match_str (str): The string to match.
Returns:
str: The string with the matched part italic.
"""
return make_matched_part_something(value, "textit", match_str)
def make_matched_part_non_line_breakable(
value: str, match_str: Optional[str] = None
) -> str:
"""Make the matched parts of the string non line breakable. If the match_str is
None, the whole string will be made nonbreakable.
This function can be used as a Jinja2 filter in templates.
Example:
```python
make_it_nolinebreak("Hello World!", "Hello")
```
will return:
`#!python "\\mbox{Hello} World!"`
Args:
value (str): The string to disable line breaks.
match_str (str): The string to match.
Returns:
str: The string with the matched part non line breakable.
"""
return make_matched_part_something(value, "mbox", match_str)
def abbreviate_name(name: str) -> str:
"""Abbreviate a name by keeping the first letters of the first names.
This function can be used as a Jinja2 filter in templates.
Example:
```python
abbreviate_name("John Doe")
```
will return:
`#!python "J. Doe"`
Args:
name (str): The name to abbreviate.
Returns:
str: The abbreviated name.
"""
number_of_words = len(name.split(" "))
if number_of_words == 1:
return name
first_names = name.split(" ")[:-1]
first_names_initials = [first_name[0] + "." for first_name in first_names]
last_name = name.split(" ")[-1]
abbreviated_name = " ".join(first_names_initials) + " " + last_name
return abbreviated_name
def divide_length_by(length: str, divider: float) -> str:
r"""Divide a length by a number. Length is a string with the following regex
pattern: `\d+\.?\d* *(cm|in|pt|mm|ex|em)`
This function can be used as a Jinja2 filter in templates.
Example:
```python
divide_length_by("10.4cm", 2)
```
will return:
`#!python "5.2cm"`
Args:
length (str): The length to divide.
divider (float): The number to divide the length by.
Returns:
str: The divided length.
"""
# Get the value as a float and the unit as a string:
value = re.search(r"\d+\.?\d*", length)
if value is None:
raise ValueError(f"Invalid length {length}!")
else:
value = value.group()
if divider <= 0:
raise ValueError(f"The divider must be greater than 0, but got {divider}!")
unit = re.findall(r"[^\d\.\s]+", length)[0]
return str(float(value) / divider) + " " + unit
def get_an_item_with_a_specific_attribute_value(
items: list[Any], attribute: str, value: Any
) -> Any:
"""Get an item from a list of items with a specific attribute value.
This function can be used as a Jinja2 filter in templates.
Args:
items (list[Any]): The list of items.
attribute (str): The attribute to check.
value (Any): The value of the attribute.
Returns:
Any: The item with the specific attribute value.
"""
if items is not None:
for item in items:
if not hasattr(item, attribute):
raise AttributeError(
f"The attribute {attribute} doesn't exist in the item {item}!"
)
else:
if getattr(item, attribute) == value:
return item
return None
def setup_jinja2_environment() -> jinja2.Environment:
"""Setup and return the Jinja2 environment for templating the $\\LaTeX$ files.
Returns:
jinja2.Environment: The theme environment.
"""
# create a Jinja2 environment:
# we need to add the current working directory because custom themes might be used.
themes_directory = pathlib.Path(__file__).parent / "themes"
environment = jinja2.Environment(
loader=jinja2.FileSystemLoader([os.getcwd(), themes_directory]),
trim_blocks=True,
lstrip_blocks=True,
)
# set custom delimiters for LaTeX templating:
environment.block_start_string = "((*"
environment.block_end_string = "*))"
environment.variable_start_string = "<<"
environment.variable_end_string = ">>"
environment.comment_start_string = "((#"
environment.comment_end_string = "#))"
# add custom filters to make it easier to template the LaTeX files and add new
# themes:
environment.filters["make_it_bold"] = make_matched_part_bold
environment.filters["make_it_underlined"] = make_matched_part_underlined
environment.filters["make_it_italic"] = make_matched_part_italic
environment.filters["make_it_nolinebreak"] = make_matched_part_non_line_breakable
environment.filters["make_it_something"] = make_matched_part_something
environment.filters["divide_length_by"] = divide_length_by
environment.filters["abbreviate_name"] = abbreviate_name
environment.filters["replace_placeholders_with_actual_values"] = (
replace_placeholders_with_actual_values
)
environment.filters["get_an_item_with_a_specific_attribute_value"] = (
get_an_item_with_a_specific_attribute_value
)
return environment
def generate_latex_file(
rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path
) -> pathlib.Path:
"""Generate the $\\LaTeX$ file with the given data model and write it to the output
directory.
Args:
rendercv_data_model (dm.RenderCVDataModel): The data model.
output_directory (pathlib.Path): Path to the output directory.
Returns:
pathlib.Path: The path to the generated $\\LaTeX$ file.
"""
# create output directory if it doesn't exist:
if not output_directory.is_dir():
output_directory.mkdir(parents=True)
jinja2_environment = setup_jinja2_environment()
latex_file_object = LaTeXFile(
rendercv_data_model,
jinja2_environment,
)
latex_file_name = f"{rendercv_data_model.cv.name.replace(' ', '_')}_CV.tex"
latex_file_path = output_directory / latex_file_name
latex_file_object.generate_latex_file(latex_file_path)
return latex_file_path
def generate_markdown_file(
rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path
) -> pathlib.Path:
"""Generate the Markdown file with the given data model and write it to the output
directory.
Args:
rendercv_data_model (dm.RenderCVDataModel): The data model.
output_directory (pathlib.Path): Path to the output directory.
Returns:
pathlib.Path: The path to the generated Markdown file.
"""
# create output directory if it doesn't exist:
if not output_directory.is_dir():
output_directory.mkdir(parents=True)
jinja2_environment = setup_jinja2_environment()
markdown_file_object = MarkdownFile(
rendercv_data_model,
jinja2_environment,
)
markdown_file_name = f"{rendercv_data_model.cv.name.replace(' ', '_')}_CV.md"
markdown_file_path = output_directory / markdown_file_name
markdown_file_object.generate_markdown_file(markdown_file_path)
return markdown_file_path
def copy_theme_files_to_output_directory(
theme_name: str, output_directory: pathlib.Path
):
"""Copy the auxiliary files (all the files that don't end with `.j2.tex` and `.py`)
of the theme to the output directory. For example, the "classic" theme has custom
fonts, and the $\\LaTeX$ needs it.
Args:
theme_name (str): The name of the theme.
output_directory (pathlib.Path): Path to the output directory.
"""
try:
theme_directory = importlib.resources.files(f"rendercv.themes.{theme_name}")
except ModuleNotFoundError:
# Then it means the theme is a custom theme:
theme_directory = pathlib.Path(os.getcwd()) / theme_name
for theme_file in theme_directory.iterdir():
if not ("j2.tex" in theme_file.name or "py" in theme_file.name):
if theme_file.is_dir():
shutil.copytree(
str(theme_file),
output_directory / theme_file.name,
dirs_exist_ok=True,
)
else:
shutil.copyfile(str(theme_file), output_directory / theme_file.name)
def generate_latex_file_and_copy_theme_files(
rendercv_data_model: dm.RenderCVDataModel, output_directory: pathlib.Path
) -> pathlib.Path:
"""Generate the $\\LaTeX$ file with the given data model in the output directory and
copy the auxiliary theme files to the output directory.
Args:
rendercv_data_model (dm.RenderCVDataModel): The data model.
output_directory (pathlib.Path): Path to the output directory.
Returns:
pathlib.Path: The path to the generated $\\LaTeX$ file.
"""
latex_file_path = generate_latex_file(rendercv_data_model, output_directory)
copy_theme_files_to_output_directory(
rendercv_data_model.design.theme, output_directory
)
return latex_file_path
def latex_to_pdf(latex_file_path: pathlib.Path) -> pathlib.Path:
"""Run TinyTeX with the given $\\LaTeX$ file to generate the PDF.
Args:
latex_file_path (str): The path to the $\\LaTeX$ file to compile.
Returns:
pathlib.Path: The path to the generated PDF file.
"""
# check if the file exists:
if not latex_file_path.is_file():
raise FileNotFoundError(f"The file {latex_file_path} doesn't exist!")
tinytex_binaries_directory = (
pathlib.Path(__file__).parent / "tinytex-release" / "TinyTeX" / "bin"
)
executables = {
"win32": tinytex_binaries_directory / "windows" / "pdflatex.exe",
"linux": tinytex_binaries_directory / "x86_64-linux" / "pdflatex",
"darwin": tinytex_binaries_directory / "universal-darwin" / "pdflatex",
}
if sys.platform not in executables:
raise OSError(f"TinyTeX doesn't support the platform {sys.platform}!")
# Run TinyTeX:
command = [
executables[sys.platform],
str(latex_file_path.absolute()),
]
with subprocess.Popen(
command,
cwd=latex_file_path.parent,
stdout=subprocess.PIPE, # capture the output
stderr=subprocess.DEVNULL, # don't capture the error
stdin=subprocess.DEVNULL, # don't allow TinyTeX to ask for user input
) as latex_process:
output = latex_process.communicate() # wait for the process to finish
if latex_process.returncode != 0:
raise RuntimeError(
"Running TinyTeX has failed! For debugging, we suggest running the"
" LaTeX file manually in https://overleaf.com.",
"If you want to run it locally, run the command below in the terminal:",
" ".join([str(command_part) for command_part in command]),
"If you can't solve the problem, please open an issue on GitHub.",
)
else:
output = output[0].decode("utf-8")
if "Rerun to get" in output:
# Run TinyTeX again to get the references right:
subprocess.run(
command,
cwd=latex_file_path.parent,
stdout=subprocess.DEVNULL, # don't capture the output
stderr=subprocess.DEVNULL, # don't capture the error
stdin=subprocess.DEVNULL, # don't allow TinyTeX to ask for user input
)
# check if the PDF file is generated:
pdf_file_path = latex_file_path.with_suffix(".pdf")
if not pdf_file_path.is_file():
raise RuntimeError(
"The PDF file couldn't be generated! If you can't solve the problem,"
" please try to re-install RenderCV, or open an issue on GitHub."
)
return pdf_file_path
def markdown_to_html(markdown_file_path: pathlib.Path) -> pathlib.Path:
"""Convert a markdown file to HTML.
RenderCV doesn't produce an HTML file as the final output, but generates it for
users to easily copy and paste the HTML into Grammarly for proofreading purposes.
Args:
markdown_file_path (pathlib.Path): The path to the markdown file to convert.
Returns:
pathlib.Path: The path to the generated HTML file.
"""
# check if the file exists:
if not markdown_file_path.is_file():
raise FileNotFoundError(f"The file {markdown_file_path} doesn't exist!")
html_file_path = (
markdown_file_path.parent / f"{markdown_file_path.name}_PASTETOGRAMMARLY.html"
)
# Convert the markdown file to HTML:
html = markdown.markdown(markdown_file_path.read_text(encoding="utf-8"))
# write html into a file:
html_file_path.write_text(html, encoding="utf-8")
return html_file_path