From b4bea9e94844ec373dc24e76d4468b2cf72bf0ba Mon Sep 17 00:00:00 2001 From: Sina Atalay <79940989+sinaatalay@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:35:30 +0300 Subject: [PATCH] Allow URLs for `cv.photo` field --- src/rendercv/renderer/pdf_png.py | 9 ++-- .../renderer/templater/model_processor.py | 42 +++++++++++++++++++ src/rendercv/renderer/templater/templater.py | 3 +- src/rendercv/schema/models/cv/cv.py | 7 ++-- .../schema/models/design/test_font_family.py | 6 ++- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/rendercv/renderer/pdf_png.py b/src/rendercv/renderer/pdf_png.py index db7c1416..790df958 100644 --- a/src/rendercv/renderer/pdf_png.py +++ b/src/rendercv/renderer/pdf_png.py @@ -99,14 +99,11 @@ def copy_photo_next_to_typst_file( rendercv_model: CV model containing photo path. typst_path: Path to Typst source file. """ - if rendercv_model.cv.photo: - photo_path = rendercv_model.cv.photo + photo_path = rendercv_model.cv.photo + if isinstance(photo_path, pathlib.Path): copy_to = typst_path.parent / photo_path.name if photo_path != copy_to: - shutil.copy( - rendercv_model.cv.photo, - typst_path.parent / rendercv_model.cv.photo.name, - ) + shutil.copy(photo_path, copy_to) @functools.lru_cache(maxsize=1) diff --git a/src/rendercv/renderer/templater/model_processor.py b/src/rendercv/renderer/templater/model_processor.py index c60dd360..747566b7 100644 --- a/src/rendercv/renderer/templater/model_processor.py +++ b/src/rendercv/renderer/templater/model_processor.py @@ -1,6 +1,10 @@ +import pathlib +import urllib.parse +import urllib.request from collections.abc import Callable from typing import Literal +from rendercv.exception import RenderCVUserError from rendercv.schema.models.cv.section import Entry from rendercv.schema.models.rendercv_model import RenderCVModel @@ -16,6 +20,44 @@ from .string_processor import ( ) +def download_photo_from_url(rendercv_model: RenderCVModel) -> None: + """Download photo from URL to output directory and update model to local path. + + Why: + Templates and Typst compiler require cv.photo to be a local pathlib.Path. + When user provides a URL, this downloads the image before template + rendering, preserving the local-path invariant for all downstream code. + + Args: + rendercv_model: CV model whose photo URL will be downloaded in-place. + """ + if rendercv_model.cv.photo is None or isinstance( + rendercv_model.cv.photo, pathlib.Path + ): + return + + url_str = str(rendercv_model.cv.photo) + + parsed = urllib.parse.urlparse(url_str) + filename = pathlib.PurePosixPath(parsed.path).name + if not filename or "." not in filename: + filename = "photo.jpg" + + output_dir = rendercv_model.settings.render_command.output_folder + output_dir.mkdir(parents=True, exist_ok=True) + destination = output_dir / filename + + if not destination.exists(): + try: + urllib.request.urlretrieve(url_str, destination) + except Exception as e: + raise RenderCVUserError( + message=f"Failed to download photo from {url_str}: {e}" + ) from e + + rendercv_model.cv.photo = destination + + def process_model( rendercv_model: RenderCVModel, file_type: Literal["typst", "markdown"] ) -> RenderCVModel: diff --git a/src/rendercv/renderer/templater/templater.py b/src/rendercv/renderer/templater/templater.py index b4a4c200..9014cddc 100644 --- a/src/rendercv/renderer/templater/templater.py +++ b/src/rendercv/renderer/templater/templater.py @@ -8,7 +8,7 @@ import jinja2 from rendercv.schema.models.rendercv_model import RenderCVModel from .markdown_parser import markdown_to_html -from .model_processor import process_model +from .model_processor import download_photo_from_url, process_model from .string_processor import clean_url templates_directory = pathlib.Path(__file__).parent / "templates" @@ -79,6 +79,7 @@ def render_full_template( "markdown": "md", }[file_type] + download_photo_from_url(rendercv_model) rendercv_model = process_model(rendercv_model, file_type) header = render_single_template( diff --git a/src/rendercv/schema/models/cv/cv.py b/src/rendercv/schema/models/cv/cv.py index 9585b3e8..9c529d94 100644 --- a/src/rendercv/schema/models/cv/cv.py +++ b/src/rendercv/schema/models/cv/cv.py @@ -49,10 +49,11 @@ class Cv(BaseModelWithoutExtraKeys): ["john.doe.1@example.com", "john.doe.2@example.com"], ], ) - photo: ExistingPathRelativeToInput | None = pydantic.Field( + photo: ExistingPathRelativeToInput | pydantic.HttpUrl | None = pydantic.Field( default=None, - description="Photo file path, relative to the YAML file.", - examples=["photo.jpg", "images/profile.png"], + union_mode="left_to_right", + description="Photo file path (relative to the YAML file) or a URL.", + examples=["photo.jpg", "images/profile.png", "https://example.com/photo.jpg"], ) phone: ( pydantic_phone_numbers.PhoneNumber diff --git a/tests/schema/models/design/test_font_family.py b/tests/schema/models/design/test_font_family.py index 111b3a59..db853b1b 100644 --- a/tests/schema/models/design/test_font_family.py +++ b/tests/schema/models/design/test_font_family.py @@ -4,7 +4,11 @@ import rendercv_fonts from rendercv.schema.models.design.font_family import available_font_families icon_font_families = {"Font Awesome 7"} -typst_built_in_font_families = {"Libertinus Serif", "New Computer Modern", "DejaVu Sans Mono"} +typst_built_in_font_families = { + "Libertinus Serif", + "New Computer Modern", + "DejaVu Sans Mono", +} @pytest.mark.parametrize(