Coverage for src/pdfbaker/render.py: 100%

56 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-20 04:55 +1200

1"""Classes and functions used for rendering with Jinja""" 

2 

3import base64 

4import re 

5from collections.abc import Sequence 

6from pathlib import Path 

7from typing import Any 

8 

9import jinja2 

10 

11from .types import ImageSpec, StyleDict 

12 

13__all__ = [ 

14 "create_env", 

15 "prepare_template_context", 

16] 

17 

18 

19class HighlightingTemplate(jinja2.Template): # pylint: disable=too-few-public-methods 

20 """A Jinja template that automatically applies highlighting to text. 

21 

22 This template class extends the base Jinja template to automatically 

23 convert <highlight> tags to styled <tspan> elements with the highlight color. 

24 """ 

25 

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) 

29 

30 if "style" in kwargs and "highlight_color" in kwargs["style"]: 

31 highlight_color = kwargs["style"]["highlight_color"] 

32 

33 def replacer(match: re.Match[str]) -> str: 

34 content = match.group(1) 

35 return f'<tspan style="fill:{highlight_color}">{content}</tspan>' 

36 

37 rendered = re.sub(r"<highlight>(.*?)</highlight>", replacer, rendered) 

38 

39 return rendered 

40 

41 

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") 

46 

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 

55 

56 

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. 

61 

62 Args: 

63 config: Configuration with optional styles and images 

64 images_dir: Directory containing images to encode 

65 """ 

66 context = config.copy() 

67 

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 

76 

77 # Process image references 

78 if context.get("images") is not None: 

79 context["images"] = encode_images(context["images"], images_dir) 

80 

81 return context 

82 

83 

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}") 

89 

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}" 

95 

96 

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") 

103 

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