implement custom theme feature

This commit is contained in:
Sina Atalay 2024-02-10 21:30:29 +01:00
parent a742e03c30
commit 2f300144ec
2 changed files with 109 additions and 23 deletions

View File

@ -13,7 +13,8 @@ has provided a valid RenderCV input. This is achieved through the use of
from datetime import date as Date from datetime import date as Date
from typing import Literal, Any, Type from typing import Literal, Any, Type
from typing_extensions import Annotated, Optional from typing_extensions import Annotated, Optional, get_args
import importlib
import functools import functools
from urllib.request import urlopen, HTTPError from urllib.request import urlopen, HTTPError
import json import json
@ -927,7 +928,9 @@ class CurriculumVitae(RenderCVBaseModel):
# the theme field, thanks Pydantic's discriminator feature. # the theme field, thanks Pydantic's discriminator feature.
# See https://docs.pydantic.dev/2.5/concepts/fields/#discriminator for more information # See https://docs.pydantic.dev/2.5/concepts/fields/#discriminator for more information
# about discriminators. # about discriminators.
Design = ClassicThemeOptions | ModerncvThemeOptions RenderCVDesign = Annotated[
ClassicThemeOptions | ModerncvThemeOptions, pydantic.Field(discriminator="theme")
]
class RenderCVDataModel(RenderCVBaseModel): class RenderCVDataModel(RenderCVBaseModel):
@ -937,13 +940,74 @@ class RenderCVDataModel(RenderCVBaseModel):
title="Curriculum Vitae", title="Curriculum Vitae",
description="The data of the CV.", description="The data of the CV.",
) )
design: Design = pydantic.Field( design: RenderCVDesign | Any = pydantic.Field(
default=ClassicThemeOptions(theme="classic"), default=ClassicThemeOptions(theme="classic"),
title="Design", title="Design",
description="The design information of the CV.", 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: def escape_latex_characters(string: str) -> str:
"""Escape $\\LaTeX$ characters in a string. """Escape $\\LaTeX$ characters in a string.

View File

@ -10,6 +10,7 @@ distribution.
import subprocess import subprocess
import re import re
import os
import pathlib import pathlib
import importlib.resources import importlib.resources
import shutil import shutil
@ -111,16 +112,27 @@ class LaTeXFile:
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". # # 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: # if "date" not in key:
entry.__setattr__(key, "") # # don't touch the date fields, because only date_string is
except ValueError: # # called and setting dates to "" will cause problems.
# Then it means it's a computed_field, so it can be ignored. # try:
pass # 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: # The arguments of the template can be used in the template file:
latex_code = template.render( latex_code = template.render(
@ -380,8 +392,10 @@ def setup_jinja2_environment() -> jinja2.Environment:
jinja2.Environment: The theme environment. jinja2.Environment: The theme environment.
""" """
# create a Jinja2 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( environment = jinja2.Environment(
loader=jinja2.PackageLoader("rendercv", "themes"), loader=jinja2.FileSystemLoader([os.getcwd(), themes_directory]),
trim_blocks=True, trim_blocks=True,
lstrip_blocks=True, lstrip_blocks=True,
) )
@ -450,7 +464,12 @@ def copy_theme_files_to_output_directory(
theme_name (str): The name of the theme. theme_name (str): The name of the theme.
output_directory (pathlib.Path): Path to the output directory. 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(): for theme_file in theme_directory.iterdir():
if not ("j2.tex" in theme_file.name or "py" in theme_file.name): if not ("j2.tex" in theme_file.name or "py" in theme_file.name):
if theme_file.is_dir(): 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}!") raise OSError(f"TinyTeX doesn't support the platform {sys.platform}!")
# Run TinyTeX: # Run TinyTeX:
command = [
executables[sys.platform],
str(latex_file_path.absolute()),
"-lualatex",
]
with subprocess.Popen( with subprocess.Popen(
[ command,
executables[sys.platform],
str(latex_file_path.name),
"-lualatex",
],
cwd=latex_file_path.parent, cwd=latex_file_path.parent,
stdout=subprocess.DEVNULL, # don't capture the output stdout=subprocess.DEVNULL, # don't capture the output
stderr=subprocess.DEVNULL, # don't capture the error 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: if latex_process.returncode != 0:
raise RuntimeError( raise RuntimeError(
"Running TinyTeX has failed! For debugging, we suggest running the" "Running TinyTeX has failed! For debugging, we suggest running the"
" LaTeX file manually in overleaf.com or another LaTeX editor. If you" " LaTeX file manually in https://overleaf.com.",
" can't solve the problem, please open an issue on GitHub.", "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: # check if the PDF file is generated: