Coverage for src/pdfbaker/render.py: 100%
56 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-20 14:42 +1200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-20 14:42 +1200
1"""Classes and functions used for rendering with Jinja"""
3import base64
4import re
5from collections.abc import Sequence
6from pathlib import Path
7from typing import Any
9import jinja2
11from .types import ImageSpec, StyleDict
13__all__ = [
14 "create_env",
15 "prepare_template_context",
16]
19class HighlightingTemplate(jinja2.Template): # pylint: disable=too-few-public-methods
20 """A Jinja template that automatically applies highlighting to text.
22 This template class extends the base Jinja template to automatically
23 convert <highlight> tags to styled <tspan> elements with the highlight color.
24 """
26 def render(self, *args: Any, **kwargs: Any) -> str:
27 """Render the template and apply highlighting to the result."""
28 rendered = super().render(*args, **kwargs)
30 if "style" in kwargs and "highlight_color" in kwargs["style"]:
31 highlight_color = kwargs["style"]["highlight_color"]
33 def replacer(match: re.Match[str]) -> str:
34 content = match.group(1)
35 return f'<tspan style="fill:{highlight_color}">{content}</tspan>'
37 rendered = re.sub(r"<highlight>(.*?)</highlight>", replacer, rendered)
39 return rendered
42def create_env(templates_dir: Path | None = None) -> jinja2.Environment:
43 """Create and configure the Jinja environment."""
44 if templates_dir is None:
45 raise ValueError("templates_dir is required")
47 env = jinja2.Environment(
48 loader=jinja2.FileSystemLoader(str(templates_dir)),
49 autoescape=jinja2.select_autoescape(),
50 # FIXME: extensions configurable
51 extensions=["jinja2.ext.do"],
52 )
53 env.template_class = HighlightingTemplate
54 return env
57def prepare_template_context(
58 config: dict[str], images_dir: Path | None = None
59) -> dict[str]:
60 """Prepare config for template rendering by resolving styles and encoding images.
62 Args:
63 config: Configuration with optional styles and images
64 images_dir: Directory containing images to encode
65 """
66 context = config.copy()
68 # Resolve style references to actual theme colors
69 if "style" in context and "theme" in context:
70 style = context["style"]
71 theme = context["theme"]
72 resolved_style: StyleDict = {}
73 for key, value in style.items():
74 resolved_style[key] = theme[value]
75 context["style"] = resolved_style
77 # Process image references
78 if context.get("images") is not None:
79 context["images"] = encode_images(context["images"], images_dir)
81 return context
84def encode_image(filename: str, images_dir: Path) -> str:
85 """Encode an image file to a base64 data URI."""
86 image_path = images_dir / filename
87 if not image_path.exists():
88 raise FileNotFoundError(f"Image not found: {image_path}")
90 with open(image_path, "rb") as f:
91 binary_fc = f.read()
92 base64_utf8_str = base64.b64encode(binary_fc).decode("utf-8")
93 ext = filename.split(".")[-1]
94 return f"data:image/{ext};base64,{base64_utf8_str}"
97def encode_images(
98 images: Sequence[ImageSpec], images_dir: Path | None
99) -> list[ImageSpec]:
100 """Encode a list of image specifications to include base64 data."""
101 if images_dir is None:
102 raise ValueError("images_dir is required when processing images")
104 result = []
105 for image in images:
106 img: ImageSpec = image.copy()
107 if img.get("type") is None:
108 img["type"] = "default"
109 img["data"] = encode_image(img["name"], images_dir)
110 result.append(img)
111 return result