From 874999da1c4907ce33b5c9414767e0611697afba Mon Sep 17 00:00:00 2001 From: Sina Atalay Date: Mon, 29 Jan 2024 17:30:45 +0100 Subject: [PATCH] start working on renderer.py --- rendercv/renderer.py | 516 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) diff --git a/rendercv/renderer.py b/rendercv/renderer.py index e69de29..5ab2b1f 100644 --- a/rendercv/renderer.py +++ b/rendercv/renderer.py @@ -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