mirror of https://github.com/eyhc1/rendercv.git
implement custom theme feature
This commit is contained in:
parent
a742e03c30
commit
2f300144ec
|
@ -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.
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in New Issue