From 2f300144ece7d2f9c6945f8f1a58e22fc265bcea Mon Sep 17 00:00:00 2001 From: Sina Atalay Date: Sat, 10 Feb 2024 21:30:29 +0100 Subject: [PATCH] implement custom theme feature --- rendercv/data_models.py | 72 ++++++++++++++++++++++++++++++++++++++--- rendercv/renderer.py | 60 +++++++++++++++++++++++----------- 2 files changed, 109 insertions(+), 23 deletions(-) diff --git a/rendercv/data_models.py b/rendercv/data_models.py index bd0ef78..e4c44f5 100644 --- a/rendercv/data_models.py +++ b/rendercv/data_models.py @@ -13,7 +13,8 @@ has provided a valid RenderCV input. This is achieved through the use of from datetime import date as Date from typing import Literal, Any, Type -from typing_extensions import Annotated, Optional +from typing_extensions import Annotated, Optional, get_args +import importlib import functools from urllib.request import urlopen, HTTPError import json @@ -927,7 +928,9 @@ class CurriculumVitae(RenderCVBaseModel): # the theme field, thanks Pydantic's discriminator feature. # See https://docs.pydantic.dev/2.5/concepts/fields/#discriminator for more information # about discriminators. -Design = ClassicThemeOptions | ModerncvThemeOptions +RenderCVDesign = Annotated[ + ClassicThemeOptions | ModerncvThemeOptions, pydantic.Field(discriminator="theme") +] class RenderCVDataModel(RenderCVBaseModel): @@ -937,13 +940,74 @@ class RenderCVDataModel(RenderCVBaseModel): title="Curriculum Vitae", description="The data of the CV.", ) - design: Design = pydantic.Field( + design: RenderCVDesign | Any = pydantic.Field( default=ClassicThemeOptions(theme="classic"), title="Design", description="The design information of the CV.", - discriminator="theme", ) + @pydantic.field_validator("design") + @classmethod + def initialize_if_custom_theme_is_used( + cls, design: RenderCVDesign | Any + ) -> RenderCVDesign | Any: + """Initialize the custom theme if it is used and validate it. Otherwise, return + the built-in theme.""" + # `get_args` for an Annotated object returns the arguments when Annotated is + # used. The first argument is actually the union of the types, so we need to + # access the first argument to use isinstance function. + theme_data_model_types = get_args(RenderCVDesign)[0] + if isinstance(design, theme_data_model_types): + # it is a built-in theme + return design + else: + # check if the theme name is valid: + if not design["theme"].isalpha(): # type: ignore + raise ValueError( + "The custom theme name should contain only letters.", + "theme", # this is the location of the error + design["theme"], # this is value of the error # type: ignore + ) + + # then it is a custom theme + custom_theme_folder = pathlib.Path(design["theme"]) # type: ignore + + # check if all the necessary files are provided in the custom theme folder: + required_files = [ + "__init__.py", # design's data model + "EducationEntry.j2.tex", # education entry template + "ExperienceEntry.j2.tex", # experience entry template + "NormalEntry.j2.tex", # normal entry template + "OneLineEntry.j2.tex", # one line entry template + "PublicationEntry.j2.tex", # publication entry template + "TextEntry.j2.tex", # text entry template + "SectionTitle.j2.tex", # section title template + "Preamble.j2.tex", # preamble template + "Header.j2.tex", # header template + ] + + for file in required_files: + file_path = custom_theme_folder / file + if not file_path.exists(): + raise ValueError( + f"You provided a custom theme, but the file `{file}` is not" + f" found in the folder `{custom_theme_folder}`.", + "", # this is the location of the error + design["theme"], # this is value of the error # type: ignore + ) + + # import __init__.py file from the custom theme folder: + theme_module = importlib.import_module(design["theme"]) # type: ignore + + ThemeDataModel = getattr( + theme_module, f"{design['theme'].title()}ThemeOptions" # type: ignore + ) + + # initialize and validate the custom theme data model: + theme_data_model = ThemeDataModel(**design) + + return theme_data_model + def escape_latex_characters(string: str) -> str: """Escape $\\LaTeX$ characters in a string. diff --git a/rendercv/renderer.py b/rendercv/renderer.py index ebc059a..b63e1d2 100644 --- a/rendercv/renderer.py +++ b/rendercv/renderer.py @@ -10,6 +10,7 @@ distribution. import subprocess import re +import os import pathlib import importlib.resources import shutil @@ -111,16 +112,27 @@ class LaTeXFile: f"{self.design.theme}/{template_name}.j2.tex" ) - # 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): - for key, value in entry.model_dump().items(): - if value is None: - try: - entry.__setattr__(key, "") - except ValueError: - # Then it means it's a computed_field, so it can be ignored. - pass + # # 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): + # for key, value in entry.model_dump().items(): + # if value is None: + # if "date" not in key: + # # don't touch the date fields, because only date_string is + # # called and setting dates to "" will cause problems. + # try: + # entry.__setattr__(key, "") + # except ValueError: + # # Then it means it's a computed_field, so it can be ignored. + # pass + + # do the below with a loop: + if hasattr(entry, "highlights") and entry.highlights is None: # type: ignore + entry.highlights = "" # type: ignore + if hasattr(entry, "location") and entry.location is None: # type: ignore + entry.location = "" # type: ignore + if hasattr(entry, "degree") and entry.degree is None: # type: ignore + entry.degree = "" # type: ignore # The arguments of the template can be used in the template file: latex_code = template.render( @@ -380,8 +392,10 @@ def setup_jinja2_environment() -> jinja2.Environment: 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.PackageLoader("rendercv", "themes"), + loader=jinja2.FileSystemLoader([os.getcwd(), themes_directory]), trim_blocks=True, lstrip_blocks=True, ) @@ -450,7 +464,12 @@ def copy_theme_files_to_output_directory( theme_name (str): The name of the theme. output_directory (pathlib.Path): Path to the output directory. """ - theme_directory = importlib.resources.files(f"rendercv.themes.{theme_name}") + 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(): @@ -508,12 +527,13 @@ def latex_to_pdf(latex_file_path: pathlib.Path) -> pathlib.Path: raise OSError(f"TinyTeX doesn't support the platform {sys.platform}!") # Run TinyTeX: + command = [ + executables[sys.platform], + str(latex_file_path.absolute()), + "-lualatex", + ] with subprocess.Popen( - [ - executables[sys.platform], - str(latex_file_path.name), - "-lualatex", - ], + command, cwd=latex_file_path.parent, stdout=subprocess.DEVNULL, # don't capture the output stderr=subprocess.DEVNULL, # don't capture the error @@ -523,8 +543,10 @@ def latex_to_pdf(latex_file_path: pathlib.Path) -> pathlib.Path: if latex_process.returncode != 0: raise RuntimeError( "Running TinyTeX has failed! For debugging, we suggest running the" - " LaTeX file manually in overleaf.com or another LaTeX editor. If you" - " can't solve the problem, please open an issue on GitHub.", + " 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.", ) # check if the PDF file is generated: