mirror of https://github.com/eyhc1/rendercv.git
finalize renderer.py
This commit is contained in:
parent
b33d9716a5
commit
6b9723fda1
|
@ -1,40 +1,48 @@
|
||||||
"""This module implements LaTeX file generation and LaTeX runner utilities for RenderCV."""
|
"""
|
||||||
|
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 subprocess
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import pathlib
|
||||||
from datetime import date as Date
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Optional, Literal
|
|
||||||
import sys
|
import sys
|
||||||
from importlib.resources import files
|
from datetime import date as Date
|
||||||
|
from typing import Optional, Literal
|
||||||
from . import data_models as dm
|
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from . import data_models as dm
|
||||||
|
from .terminal_reporter import time_the_event_below
|
||||||
|
|
||||||
|
|
||||||
class LaTeXFile:
|
class LaTeXFile:
|
||||||
|
"""This class represents a $\\LaTeX$ file. It generates the $\\LaTeX$ code with the
|
||||||
|
data model and Jinja2 templates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_model (dm.RenderCVDataModel): The data model.
|
||||||
|
environment (jinja2.Environment): The Jinja2 environment.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
cv: dm.CurriculumVitae,
|
data_model: dm.RenderCVDataModel,
|
||||||
design: dm.Design,
|
|
||||||
environment: jinja2.Environment,
|
environment: jinja2.Environment,
|
||||||
file_path,
|
|
||||||
):
|
):
|
||||||
self.file_path = file_path
|
self.cv = data_model.cv
|
||||||
self.cv = cv
|
self.design = data_model.design
|
||||||
self.design = design
|
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
|
||||||
|
# Template the preamble, header, and sections:
|
||||||
self.preamble = self.template("Preamble")
|
self.preamble = self.template("Preamble")
|
||||||
self.header = self.template("Header")
|
self.header = self.template("Header")
|
||||||
self.sections = []
|
self.sections = []
|
||||||
for section in cv.sections:
|
for section in self.cv.sections:
|
||||||
title = self.template("SectionTitle", section_title=section.title)
|
title = self.template("SectionTitle", section_title=section.title)
|
||||||
entries = []
|
entries = []
|
||||||
for i, entry in enumerate(section.entries):
|
for i, entry in enumerate(section.entries):
|
||||||
|
@ -76,33 +84,38 @@ class LaTeXFile:
|
||||||
section_title: Optional[str] = None,
|
section_title: Optional[str] = None,
|
||||||
is_first_entry: Optional[bool] = None,
|
is_first_entry: Optional[bool] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Template one of the files in the `templates` directory.
|
"""Template one of the files in the `themes` directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cv (dm.CurriculumVitae): The CV.
|
template_name (str): The name of the template file.
|
||||||
design (dm.Design): The design.
|
entry (Optional[
|
||||||
entry (dm.EducationEntry): The education entry.
|
dm.EducationEntry,
|
||||||
enty_type (Literal["EducationEntry", "ExperienceEntry", "NormalEntry", "PublicationEntry", "OneLineEntry", "TextEntry"]): The type of the entry.
|
dm.ExperienceEntry,
|
||||||
environment (jinja2.Environment): The Jinja2 environment.
|
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:
|
Returns:
|
||||||
str: The rendered education entry.
|
str: The templated $\\LaTeX$ code.
|
||||||
"""
|
"""
|
||||||
education_entry_template = self.environment.get_template(
|
template = self.environment.get_template(
|
||||||
f"{self.design.theme}/{template_name}.j2.tex"
|
f"{self.design.theme}/{template_name}.j2.tex"
|
||||||
)
|
)
|
||||||
|
|
||||||
# loop through the entry attributes and make them "" if they are None:
|
# Loop through the entry attributes and make them "" if they are None:
|
||||||
|
# This is necessary because otherwise Jinja2 will template them as "None".
|
||||||
if entry is not None and not isinstance(entry, str):
|
if entry is not None and not isinstance(entry, str):
|
||||||
for key, value in entry.model_dump().items():
|
for key, value in entry.model_dump().items():
|
||||||
if value is None:
|
if value is None:
|
||||||
try:
|
|
||||||
entry.__setattr__(key, "")
|
entry.__setattr__(key, "")
|
||||||
except ValueError:
|
|
||||||
# then it means it's a computed property, can be ignored
|
|
||||||
pass
|
|
||||||
|
|
||||||
latex_code = education_entry_template.render(
|
# The arguments of the template can be used in the template file:
|
||||||
|
latex_code = template.render(
|
||||||
cv=self.cv,
|
cv=self.cv,
|
||||||
design=self.design,
|
design=self.design,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
|
@ -114,6 +127,7 @@ class LaTeXFile:
|
||||||
return latex_code
|
return latex_code
|
||||||
|
|
||||||
def get_latex_code(self):
|
def get_latex_code(self):
|
||||||
|
"""Get the $\\LaTeX$ code of the file."""
|
||||||
main_template = self.environment.get_template("main.j2.tex")
|
main_template = self.environment.get_template("main.j2.tex")
|
||||||
latex_code = main_template.render(
|
latex_code = main_template.render(
|
||||||
header=self.header,
|
header=self.header,
|
||||||
|
@ -122,6 +136,11 @@ class LaTeXFile:
|
||||||
)
|
)
|
||||||
return latex_code
|
return latex_code
|
||||||
|
|
||||||
|
def write_to_file(self, file_path: pathlib.Path):
|
||||||
|
"""Write the $\\LaTeX$ code to a file."""
|
||||||
|
with open(file_path, "w") as latex_file:
|
||||||
|
latex_file.write(self.get_latex_code())
|
||||||
|
|
||||||
|
|
||||||
def make_matched_part_something(
|
def make_matched_part_something(
|
||||||
value: str, something: str, match_str: Optional[str] = None
|
value: str, something: str, match_str: Optional[str] = None
|
||||||
|
@ -132,7 +151,7 @@ def make_matched_part_something(
|
||||||
Warning:
|
Warning:
|
||||||
This function shouldn't be used directly. Use
|
This function shouldn't be used directly. Use
|
||||||
[make_matched_part_bold](renderer.md#rendercv.rendering.make_matched_part_bold),
|
[make_matched_part_bold](renderer.md#rendercv.rendering.make_matched_part_bold),
|
||||||
[make_matched_pard_underlined](renderer.md#rendercv.rendering.make_matched_pard_underlined),
|
[make_matched_part_underlined](renderer.md#rendercv.rendering.make_matched_part_underlined),
|
||||||
[make_matched_part_italic](renderer.md#rendercv.rendering.make_matched_part_italic),
|
[make_matched_part_italic](renderer.md#rendercv.rendering.make_matched_part_italic),
|
||||||
or
|
or
|
||||||
[make_matched_part_non_line_breakable](renderer.md#rendercv.rendering.make_matched_part_non_line_breakable)
|
[make_matched_part_non_line_breakable](renderer.md#rendercv.rendering.make_matched_part_non_line_breakable)
|
||||||
|
@ -153,7 +172,7 @@ 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
|
"""Make the matched parts of the string bold. If the match_str is None, the whole
|
||||||
string will be made bold.
|
string will be made bold.
|
||||||
|
|
||||||
This function is used as a Jinja2 filter.
|
This function can be used as a Jinja2 filter in templates.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
@ -175,7 +194,7 @@ def make_matched_part_underlined(value: str, match_str: Optional[str] = None) ->
|
||||||
"""Make the matched parts of the string underlined. If the match_str is None, the
|
"""Make the matched parts of the string underlined. If the match_str is None, the
|
||||||
whole string will be made underlined.
|
whole string will be made underlined.
|
||||||
|
|
||||||
This function is used as a Jinja2 filter.
|
This function can be used as a Jinja2 filter in templates.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
@ -197,7 +216,7 @@ 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
|
"""Make the matched parts of the string italic. If the match_str is None, the whole
|
||||||
string will be made italic.
|
string will be made italic.
|
||||||
|
|
||||||
This function is used as a Jinja2 filter.
|
This function can be used as a Jinja2 filter in templates.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
@ -215,13 +234,13 @@ def make_matched_part_italic(value: str, match_str: Optional[str] = None) -> str
|
||||||
return make_matched_part_something(value, "textit", match_str)
|
return make_matched_part_something(value, "textit", match_str)
|
||||||
|
|
||||||
|
|
||||||
def make_matced_part_non_line_breakable(
|
def make_matched_part_non_line_breakable(
|
||||||
value: str, match_str: Optional[str] = None
|
value: str, match_str: Optional[str] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Make the matched parts of the string non line breakable. If the match_str is
|
"""Make the matched parts of the string non line breakable. If the match_str is
|
||||||
None, the whole string will be made nonbreakable.
|
None, the whole string will be made nonbreakable.
|
||||||
|
|
||||||
This function is used as a Jinja2 filter.
|
This function can be used as a Jinja2 filter in templates.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
@ -242,7 +261,7 @@ def make_matced_part_non_line_breakable(
|
||||||
def abbreviate_name(name: str) -> str:
|
def abbreviate_name(name: str) -> str:
|
||||||
"""Abbreviate a name by keeping the first letters of the first names.
|
"""Abbreviate a name by keeping the first letters of the first names.
|
||||||
|
|
||||||
This function is used as a Jinja2 filter.
|
This function can be used as a Jinja2 filter in templates.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
@ -270,7 +289,7 @@ def divide_length_by(length: str, divider: float) -> str:
|
||||||
r"""Divide a length by a number. Length is a string with the following regex
|
r"""Divide a length by a number. Length is a string with the following regex
|
||||||
pattern: `\d+\.?\d* *(cm|in|pt|mm|ex|em)`
|
pattern: `\d+\.?\d* *(cm|in|pt|mm|ex|em)`
|
||||||
|
|
||||||
This function is used as a Jinja2 filter.
|
This function can be used as a Jinja2 filter in templates.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
@ -294,25 +313,19 @@ def divide_length_by(length: str, divider: float) -> str:
|
||||||
return str(float(value) / divider) + " " + unit
|
return str(float(value) / divider) + " " + unit
|
||||||
|
|
||||||
|
|
||||||
def setup_theme_environment(theme_name: str) -> jinja2.Environment:
|
def setup_jinja2_environment() -> jinja2.Environment:
|
||||||
"""Setup and return the theme environment.
|
"""Setup and return the Jinja2 environment for templating the $\\LaTeX$ files.
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_name (str): The name of the theme to use.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
jinja2.Environment: The theme environment.
|
jinja2.Environment: The theme environment.
|
||||||
"""
|
"""
|
||||||
# create a Jinja2 environment:
|
# create a Jinja2 environment:
|
||||||
environment = jinja2.Environment(
|
environment = jinja2.Environment(
|
||||||
loader=jinja2.PackageLoader("rendercv", os.path.join("themes")),
|
loader=jinja2.PackageLoader("rendercv", "themes"),
|
||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
lstrip_blocks=True,
|
lstrip_blocks=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# add new functions to the environment:
|
|
||||||
environment.globals.update(str=str)
|
|
||||||
|
|
||||||
# set custom delimiters for LaTeX templating:
|
# set custom delimiters for LaTeX templating:
|
||||||
environment.block_start_string = "((*"
|
environment.block_start_string = "((*"
|
||||||
environment.block_end_string = "*))"
|
environment.block_end_string = "*))"
|
||||||
|
@ -321,12 +334,12 @@ def setup_theme_environment(theme_name: str) -> jinja2.Environment:
|
||||||
environment.comment_start_string = "((#"
|
environment.comment_start_string = "((#"
|
||||||
environment.comment_end_string = "#))"
|
environment.comment_end_string = "#))"
|
||||||
|
|
||||||
# add custom filters:
|
# add custom filters to make it easier to template the LaTeX files and add new
|
||||||
environment.filters["markdown_to_latex"] = markdown_to_latex
|
# themes:
|
||||||
environment.filters["make_it_bold"] = make_matched_part_bold
|
environment.filters["make_it_bold"] = make_matched_part_bold
|
||||||
environment.filters["make_it_underlined"] = make_matched_part_underlined
|
environment.filters["make_it_underlined"] = make_matched_part_underlined
|
||||||
environment.filters["make_it_italic"] = make_matched_part_italic
|
environment.filters["make_it_italic"] = make_matched_part_italic
|
||||||
environment.filters["make_it_nolinebreak"] = make_matced_part_non_line_breakable
|
environment.filters["make_it_nolinebreak"] = make_matched_part_non_line_breakable
|
||||||
environment.filters["make_it_something"] = make_matched_part_something
|
environment.filters["make_it_something"] = make_matched_part_something
|
||||||
environment.filters["divide_length_by"] = divide_length_by
|
environment.filters["divide_length_by"] = divide_length_by
|
||||||
environment.filters["abbreviate_name"] = abbreviate_name
|
environment.filters["abbreviate_name"] = abbreviate_name
|
||||||
|
@ -334,118 +347,74 @@ def setup_theme_environment(theme_name: str) -> jinja2.Environment:
|
||||||
return environment
|
return environment
|
||||||
|
|
||||||
|
|
||||||
def generate_the_latex_file(
|
@time_the_event_below("Generating the LaTeX file")
|
||||||
rendercv_data_model: dm.RenderCVDataModel, output_file_path: str
|
def generate_latex_file(
|
||||||
) -> str:
|
rendercv_data_model: dm.RenderCVDataModel, latex_file_path: pathlib.Path
|
||||||
""" """
|
):
|
||||||
environment = setup_theme_environment(rendercv_data_model.design.theme)
|
"""Generate the $\\LaTeX$ file with the given data model and write it to the given
|
||||||
|
path.
|
||||||
|
"""
|
||||||
|
jinja2_environment = setup_jinja2_environment()
|
||||||
latex_file_object = LaTeXFile(
|
latex_file_object = LaTeXFile(
|
||||||
rendercv_data_model.cv,
|
rendercv_data_model,
|
||||||
rendercv_data_model.design,
|
jinja2_environment,
|
||||||
environment,
|
|
||||||
output_file_path,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(output_file_path, "w") as latex_file:
|
latex_file_object.write_to_file(latex_file_path)
|
||||||
latex_file.write(latex_file_object.get_latex_code())
|
|
||||||
|
|
||||||
return latex_file_object.get_latex_code()
|
|
||||||
|
|
||||||
|
|
||||||
def render_the_latex_file(latex_file_path: str) -> str:
|
@time_the_event_below("Generating the PDF file")
|
||||||
"""
|
def latex_to_pdf(latex_file_path: pathlib.Path) -> pathlib.Path:
|
||||||
Run TinyTeX with the given LaTeX file and generate a PDF.
|
"""Run TinyTeX with the given $\\LaTeX$ file to generate the PDF.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
latex_file_path (str): The path to the LaTeX file to compile.
|
latex_file_path (str): The path to the $\\LaTeX$ file to compile.
|
||||||
|
Returns:
|
||||||
|
pathlib.Path: The path to the generated PDF file.
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
|
||||||
logger.info("Running TinyTeX to generate the PDF has started.")
|
|
||||||
latex_file_name = os.path.basename(latex_file_path)
|
|
||||||
latex_file_path = os.path.normpath(latex_file_path)
|
|
||||||
|
|
||||||
# check if the file exists:
|
# check if the file exists:
|
||||||
if not os.path.exists(latex_file_path):
|
if not latex_file_path.is_file():
|
||||||
raise FileNotFoundError(f"The file {latex_file_path} doesn't exist!")
|
raise FileNotFoundError(f"The file {latex_file_path} doesn't exist!")
|
||||||
|
|
||||||
output_file_name = latex_file_name.replace(".tex", ".pdf")
|
tinytex_binaries_directory = (
|
||||||
output_file_path = os.path.join(os.path.dirname(latex_file_path), output_file_name)
|
pathlib.Path(__file__).parent / "tinytex-release" / "TinyTeX" / "bin"
|
||||||
|
|
||||||
tinytex_binaries = files("rendercv").joinpath("tinytex-release", "TinyTeX", "bin")
|
|
||||||
if sys.platform == "win32":
|
|
||||||
# Windows
|
|
||||||
executable = str(tinytex_binaries.joinpath("windows", "lualatex.exe"))
|
|
||||||
|
|
||||||
elif sys.platform == "linux" or sys.platform == "linux2":
|
|
||||||
# Linux
|
|
||||||
executable = str(tinytex_binaries.joinpath("x86_64-linux", "lualatex"))
|
|
||||||
elif sys.platform == "darwin":
|
|
||||||
# MacOS
|
|
||||||
executable = str(tinytex_binaries.joinpath("universal-darwin", "lualatex"))
|
|
||||||
else:
|
|
||||||
raise OSError(f"Unknown OS {os.name}!")
|
|
||||||
|
|
||||||
# Check if the executable exists:
|
|
||||||
if not os.path.exists(executable):
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"The TinyTeX executable ({executable}) doesn't exist! Please install"
|
|
||||||
" RenderCV again."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
executables = {
|
||||||
|
"win32": tinytex_binaries_directory / "windows" / "latexmk.exe",
|
||||||
|
"linux": tinytex_binaries_directory / "x86_64-linux" / "latexmk",
|
||||||
|
"darwin": tinytex_binaries_directory / "universal-darwin" / "latexmk",
|
||||||
|
}
|
||||||
|
|
||||||
|
if sys.platform not in executables:
|
||||||
|
raise OSError(f"TinyTeX doesn't support the platform {sys.platform}!")
|
||||||
|
|
||||||
# Run TinyTeX:
|
# Run TinyTeX:
|
||||||
def run():
|
|
||||||
with subprocess.Popen(
|
with subprocess.Popen(
|
||||||
[
|
[
|
||||||
executable,
|
executables[sys.platform],
|
||||||
f"{latex_file_name}",
|
str(latex_file_path),
|
||||||
|
"-lualatex",
|
||||||
],
|
],
|
||||||
cwd=os.path.dirname(latex_file_path),
|
cwd=latex_file_path.parent,
|
||||||
stdout=subprocess.PIPE,
|
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
|
stdin=subprocess.DEVNULL, # don't allow TinyTeX to ask for user input
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
) as latex_process:
|
) as latex_process:
|
||||||
output, error = latex_process.communicate()
|
latex_process.communicate() # wait for the process to finish
|
||||||
|
|
||||||
if latex_process.returncode != 0:
|
if latex_process.returncode != 0:
|
||||||
# Find the error line:
|
|
||||||
for line in output.split("\n"):
|
|
||||||
if line.startswith("! "):
|
|
||||||
error_line = line.replace("! ", "")
|
|
||||||
break
|
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Running TinyTeX has failed with the following error:",
|
"Running TinyTeX has failed! For debugging, we suggest running the"
|
||||||
f"{error_line}",
|
" LaTeX file manually in overleaf.com or another LaTeX editor. If you"
|
||||||
"If you can't solve the problem, please try to re-install RenderCV,"
|
" can't solve the problem, please open an issue on GitHub.",
|
||||||
" or open an issue on GitHub.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
run()
|
|
||||||
run() # run twice for cross-references
|
|
||||||
|
|
||||||
# check if the PDF file is generated:
|
# check if the PDF file is generated:
|
||||||
if not os.path.exists(output_file_path):
|
pdf_file_path = latex_file_path.with_suffix(".pdf")
|
||||||
|
if not pdf_file_path.is_file():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"The PDF file {output_file_path} couldn't be generated! If you can't"
|
"The PDF file couldn't be generated! If you can't solve the problem,"
|
||||||
" solve the problem, please try to re-install RenderCV, or open an issue"
|
" please try to re-install RenderCV, or open an issue on GitHub."
|
||||||
" on GitHub."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# remove the unnecessary files:
|
return pdf_file_path
|
||||||
for file_name in os.listdir(os.path.dirname(latex_file_path)):
|
|
||||||
if (
|
|
||||||
file_name.endswith(".aux")
|
|
||||||
or file_name.endswith(".log")
|
|
||||||
or file_name.endswith(".out")
|
|
||||||
):
|
|
||||||
os.remove(os.path.join(os.path.dirname(latex_file_path), file_name))
|
|
||||||
|
|
||||||
end_time = time.time()
|
|
||||||
time_taken = end_time - start_time
|
|
||||||
logger.info(
|
|
||||||
f"Running TinyTeX to generate the PDF ({output_file_path}) has finished in"
|
|
||||||
f" {time_taken:.2f} s."
|
|
||||||
)
|
|
||||||
|
|
||||||
return output_file_path
|
|
||||||
|
|
Loading…
Reference in New Issue