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 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.

View File

@ -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: