mirror of https://github.com/eyhc1/rendercv.git
start working on renderer.py
This commit is contained in:
parent
c6a747a38c
commit
874999da1c
|
@ -0,0 +1,516 @@
|
|||
"""This module implements LaTeX file generation and LaTeX runner utilities for RenderCV.
|
||||
"""
|
||||
import subprocess
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import date as Date
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Literal
|
||||
import sys
|
||||
from importlib.resources import files
|
||||
|
||||
from . import data_models as dm
|
||||
|
||||
import jinja2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def markdown_to_latex(markdown_string: str) -> str:
|
||||
"""Convert a markdown string to LaTeX.
|
||||
|
||||
This function is used as a Jinja2 filter.
|
||||
|
||||
Example:
|
||||
```python
|
||||
markdown_to_latex("This is a **bold** text with an [*italic link*](https://google.com).")
|
||||
```
|
||||
|
||||
will return:
|
||||
|
||||
`#!pytjon "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 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_pard_underlined](renderer.md#rendercv.rendering.make_matched_pard_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.
|
||||
"""
|
||||
if match_str is None:
|
||||
return f"\\{something}{{{value}}}"
|
||||
|
||||
elif match_str in value:
|
||||
value = value.replace(match_str, f"\\{something}{{{match_str}}}")
|
||||
return value
|
||||
|
||||
else:
|
||||
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 is used as a Jinja2 filter.
|
||||
|
||||
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.
|
||||
"""
|
||||
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 is used as a Jinja2 filter.
|
||||
|
||||
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.
|
||||
"""
|
||||
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 is used as a Jinja2 filter.
|
||||
|
||||
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.
|
||||
"""
|
||||
return make_matched_part_something(value, "textit", match_str)
|
||||
|
||||
|
||||
def make_matced_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 is used as a Jinja2 filter.
|
||||
|
||||
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.
|
||||
"""
|
||||
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 is used as a Jinja2 filter.
|
||||
|
||||
Example:
|
||||
```python
|
||||
abbreviate_name("John Doe")
|
||||
```
|
||||
|
||||
will return:
|
||||
|
||||
`#!python "J. Doe"`
|
||||
|
||||
Args:
|
||||
name (str): The name to abbreviate.
|
||||
Returns:
|
||||
str: The abbreviated 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 is used as a Jinja2 filter.
|
||||
|
||||
Example:
|
||||
```python
|
||||
divide_length_by("10.4cm", 2)
|
||||
```
|
||||
|
||||
will return:
|
||||
|
||||
`#!python "5.2cm"`
|
||||
"""
|
||||
# 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()
|
||||
|
||||
unit = re.findall(r"[^\d\.\s]+", length)[0]
|
||||
|
||||
return str(float(value) / divider) + " " + unit
|
||||
|
||||
|
||||
def setup_theme_environment(theme_name: str) -> jinja2.Environment:
|
||||
"""Setup and return the theme environment.
|
||||
|
||||
Args:
|
||||
theme_name (str): The name of the theme to use.
|
||||
|
||||
Returns:
|
||||
jinja2.Environment: The theme environment.
|
||||
"""
|
||||
# create a Jinja2 environment:
|
||||
environment = jinja2.Environment(
|
||||
loader=jinja2.PackageLoader("rendercv", os.path.join("themes", theme_name)),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
# add new functions to the environment:
|
||||
environment.globals.update(str=str)
|
||||
|
||||
# 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:
|
||||
environment.filters["markdown_to_latex"] = markdown_to_latex
|
||||
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_matced_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
|
||||
|
||||
return environment
|
||||
|
||||
|
||||
def template(
|
||||
cv: dm.CurriculumVitae,
|
||||
design: dm.Design,
|
||||
template_name: Literal[
|
||||
"EducationEntry",
|
||||
"ExperienceEntry",
|
||||
"NormalEntry",
|
||||
"PublicationEntry",
|
||||
"OneLineEntry",
|
||||
"TextEntry",
|
||||
"Header",
|
||||
"Preamble",
|
||||
"SectionTitle",
|
||||
],
|
||||
environment: jinja2.Environment,
|
||||
entry: Optional[
|
||||
dm.EducationEntry
|
||||
| dm.ExperienceEntry
|
||||
| dm.NormalEntry
|
||||
| dm.PublicationEntry
|
||||
| dm.OneLineEntry
|
||||
| str # TextEntry
|
||||
] = None,
|
||||
section_title: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Template one of the files in the `templates` directory.
|
||||
|
||||
Args:
|
||||
cv (dm.CurriculumVitae): The CV.
|
||||
design (dm.Design): The design.
|
||||
entry (dm.EducationEntry): The education entry.
|
||||
enty_type (Literal["EducationEntry", "ExperienceEntry", "NormalEntry", "PublicationEntry", "OneLineEntry", "TextEntry"]): The type of the entry.
|
||||
environment (jinja2.Environment): The Jinja2 environment.
|
||||
|
||||
Returns:
|
||||
str: The rendered education entry.
|
||||
"""
|
||||
education_entry_template = environment.get_template(f"{template_name}.j2.tex")
|
||||
|
||||
latex_code = education_entry_template.render(
|
||||
cv=cv,
|
||||
design=design,
|
||||
entry=entry,
|
||||
section_title=section_title,
|
||||
today=Date.today().strftime("%B %Y"),
|
||||
)
|
||||
|
||||
return latex_code
|
||||
|
||||
|
||||
def generate_the_latex_file(
|
||||
rendercv_data_model: dm.RenderCVDataModel, output_file_path: str
|
||||
) -> str:
|
||||
"""
|
||||
"""
|
||||
environment = setup_theme_environment(rendercv_data_model.design.theme)
|
||||
|
||||
latex_file = "\\begin{document}\n"
|
||||
|
||||
# render the preamble:
|
||||
preamble = template(
|
||||
cv=rendercv_data_model.cv,
|
||||
design=rendercv_data_model.design,
|
||||
entry=None,
|
||||
template_name="Preamble",
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
latex_file = latex_file + preamble + "\n"
|
||||
|
||||
latex_file = latex_file + "\\begin{document}\n"
|
||||
|
||||
# render the header:
|
||||
header = template(
|
||||
cv=rendercv_data_model.cv,
|
||||
design=rendercv_data_model.design,
|
||||
template_name="Header",
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
latex_file = latex_file + header + "\n"
|
||||
|
||||
# render the sections:
|
||||
for section in rendercv_data_model.cv.sections:
|
||||
title = template(
|
||||
cv=rendercv_data_model.cv,
|
||||
design=rendercv_data_model.design,
|
||||
template_name="SectionTitle",
|
||||
environment=environment,
|
||||
section_title=section.title,
|
||||
)
|
||||
|
||||
latex_file = latex_file + title + "\n"
|
||||
|
||||
for entry in section.entries:
|
||||
entry = template(
|
||||
cv=rendercv_data_model.cv,
|
||||
design=rendercv_data_model.design,
|
||||
template_name=section.entry_type,
|
||||
environment=environment,
|
||||
entry=entry,
|
||||
)
|
||||
latex_file = latex_file + entry + "\n"
|
||||
|
||||
latex_file = latex_file + "\\end{document}"
|
||||
|
||||
# write the LaTeX file:
|
||||
with open(output_file_path, "w") as file:
|
||||
file.write(latex_file)
|
||||
|
||||
return latex_file
|
||||
|
||||
|
||||
def render_the_latex_file(latex_file_path: str) -> str:
|
||||
"""
|
||||
Run TinyTeX with the given LaTeX file and generate a PDF.
|
||||
|
||||
Args:
|
||||
latex_file_path (str): The path to the LaTeX file to compile.
|
||||
"""
|
||||
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:
|
||||
if not os.path.exists(latex_file_path):
|
||||
raise FileNotFoundError(f"The file {latex_file_path} doesn't exist!")
|
||||
|
||||
output_file_name = latex_file_name.replace(".tex", ".pdf")
|
||||
output_file_path = os.path.join(os.path.dirname(latex_file_path), output_file_name)
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Windows
|
||||
executable = str(
|
||||
files("rendercv").joinpath(
|
||||
"vendor", "TinyTeX", "bin", "windows", "lualatex.exe"
|
||||
)
|
||||
)
|
||||
|
||||
elif sys.platform == "linux" or sys.platform == "linux2":
|
||||
# Linux
|
||||
executable = str(
|
||||
files("rendercv").joinpath(
|
||||
"vendor", "TinyTeX", "bin", "x86_64-linux", "lualatex"
|
||||
)
|
||||
)
|
||||
elif sys.platform == "darwin":
|
||||
# MacOS
|
||||
executable = str(
|
||||
files("rendercv").joinpath(
|
||||
"vendor", "TinyTeX", "bin", "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."
|
||||
)
|
||||
|
||||
# Run TinyTeX:
|
||||
def run():
|
||||
with subprocess.Popen(
|
||||
[
|
||||
executable,
|
||||
f"{latex_file_name}",
|
||||
],
|
||||
cwd=os.path.dirname(latex_file_path),
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL, # don't allow TinyTeX to ask for user input
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
) as latex_process:
|
||||
output, error = latex_process.communicate()
|
||||
|
||||
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(
|
||||
"Running TinyTeX has failed with the following error:",
|
||||
f"{error_line}",
|
||||
"If you can't solve the problem, please try to re-install RenderCV,"
|
||||
" or open an issue on GitHub.",
|
||||
)
|
||||
|
||||
run()
|
||||
run() # run twice for cross-references
|
||||
|
||||
# check if the PDF file is generated:
|
||||
if not os.path.exists(output_file_path):
|
||||
raise FileNotFoundError(
|
||||
f"The PDF file {output_file_path} couldn't be generated! If you can't"
|
||||
" solve the problem, please try to re-install RenderCV, or open an issue"
|
||||
" on GitHub."
|
||||
)
|
||||
|
||||
# remove the unnecessary files:
|
||||
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