Skip to content

Commit 67df01e

Browse files
committed
Use custom Jinja template class for rendering
Reduced code and no more special positioning (moved that logic into our messy template) Highlighting is now done for all text, not just special "title" and "text" nodes.
1 parent 167c996 commit 67df01e

File tree

1 file changed

+30
-116
lines changed

1 file changed

+30
-116
lines changed

src/pdfbaker/render.py

Lines changed: 30 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
from collections.abc import Sequence
66
from pathlib import Path
7-
from typing import Any, TypeVar
7+
from typing import Any
88

99
import jinja2
1010

@@ -15,127 +15,21 @@
1515
"process_template_data",
1616
]
1717

18-
# Fields that need line counting for positioning
19-
LINE_COUNT_FIELDS: set[str] = {"text", "title"}
20-
21-
T = TypeVar("T")
22-
2318

2419
def process_style(style: StyleDict, theme: ThemeDict) -> StyleDict:
2520
"""Convert style references to actual color values from theme."""
2621
return_dict: StyleDict = {}
27-
for key in style:
28-
return_dict[key] = theme[style[key]]
22+
for key, value in style.items():
23+
return_dict[key] = theme[value]
2924
return return_dict
3025

3126

32-
def process_text_with_jinja(
33-
env: jinja2.Environment, text: str | None, template_data: dict[str, Any]
34-
) -> str | None:
35-
"""Process text through Jinja templating and apply highlighting."""
36-
if text is None:
37-
return None
38-
39-
template = env.from_string(text)
40-
processed = template.render(**template_data)
41-
42-
if "style" in template_data and "highlight_colour" in template_data["style"]:
43-
44-
def replacer(match: re.Match[str]) -> str:
45-
return (
46-
f'<tspan style="fill:{template_data["style"]["highlight_colour"]}">'
47-
f"{match.group(1)}</tspan>"
48-
)
49-
50-
processed = re.sub(r"<highlight>(.*?)</highlight>", replacer, processed)
51-
52-
return processed
53-
54-
55-
def process_list_items(
56-
list_items: list[dict[str, Any]], template_data: dict[str, Any]
57-
) -> list[dict[str, Any]]:
58-
"""Process a list of text items.
59-
60-
Applies Jinja templating and calculates positions for SVG layout.
61-
"""
62-
env = jinja2.Environment()
63-
previous_lines = 0
64-
65-
for i, item in enumerate(list_items):
66-
# Process text fields
67-
if "text" in item:
68-
item["text"] = process_text_with_jinja(env, item["text"], template_data)
69-
if "title" in item:
70-
item["title"] = process_text_with_jinja(env, item["title"], template_data)
71-
72-
# Calculate positions for SVG layout
73-
item["lines"] = previous_lines
74-
item["position"] = i
75-
# Count lines from both text and title fields
76-
for field in LINE_COUNT_FIELDS:
77-
if item.get(field) is not None:
78-
previous_lines = item[field].count("\n") + previous_lines + 1
79-
80-
return list_items
81-
82-
83-
def process_nested_text(template: T, data: dict[str, Any] | None = None) -> T:
84-
"""Process text fields in any nested dictionary or list structure.
85-
86-
Args:
87-
template: The template structure to process
88-
data: Optional data to use for rendering. If None, uses template as data.
89-
"""
90-
env = jinja2.Environment()
91-
render_data = data if data is not None else template
92-
93-
if isinstance(template, dict):
94-
return {
95-
key: process_nested_text(value, render_data)
96-
if isinstance(value, dict | list)
97-
else process_text_with_jinja(env, value, render_data)
98-
if isinstance(value, str)
99-
else value
100-
for key, value in template.items()
101-
} # type: ignore
102-
if isinstance(template, list):
103-
return [
104-
process_nested_text(item, render_data)
105-
if isinstance(item, dict | list)
106-
else process_text_with_jinja(env, item, render_data)
107-
if isinstance(item, str)
108-
else item
109-
for item in template
110-
] # type: ignore
111-
112-
return template
113-
114-
115-
def process_nested_lists(
116-
data: dict[str, Any] | list[Any], template_data: dict[str, Any]
117-
) -> None:
118-
"""Process any nested lists of items that have text fields."""
119-
if isinstance(data, dict):
120-
for key, value in data.items():
121-
if isinstance(value, list) and value and isinstance(value[0], dict):
122-
# Check if any item in the list has text or title fields
123-
if any("text" in item or "title" in item for item in value):
124-
data[key] = process_list_items(value, template_data)
125-
elif isinstance(value, dict | list):
126-
process_nested_lists(value, template_data)
127-
elif isinstance(data, list):
128-
for item in data:
129-
if isinstance(item, dict | list):
130-
process_nested_lists(item, template_data)
131-
132-
13327
def process_template_data(
13428
template_data: dict[str, Any],
13529
defaults: dict[str, Any],
13630
images_dir: Path | None = None,
13731
) -> dict[str, Any]:
138-
"""Process and enhance template data with images, list items, and styling."""
32+
"""Process and enhance template data with styling and images."""
13933
# Process style first
14034
if template_data.get("style") is not None:
14135
default_style = dict(defaults["style"])
@@ -146,19 +40,37 @@ def process_template_data(
14640

14741
template_data["style"] = process_style(template_data["style"], defaults["theme"])
14842

149-
# Process all text fields through Jinja
150-
template_data = process_nested_text(template_data)
151-
152-
# Process any nested lists of items that have text fields
153-
process_nested_lists(template_data, template_data)
154-
15543
# Process images
15644
if template_data.get("images") is not None:
15745
template_data["images"] = encode_images(template_data["images"], images_dir)
15846

15947
return template_data
16048

16149

50+
class HighlightingTemplate(jinja2.Template): # pylint: disable=too-few-public-methods
51+
"""A Jinja template that automatically applies highlighting to text.
52+
53+
This template class extends the base Jinja template to automatically
54+
process <highlight> tags in the rendered output, converting them to
55+
styled <tspan> elements with the highlight color.
56+
"""
57+
58+
def render(self, *args: Any, **kwargs: Any) -> str:
59+
"""Render the template and apply highlighting to the result."""
60+
rendered = super().render(*args, **kwargs)
61+
62+
if "style" in kwargs and "highlight_colour" in kwargs["style"]:
63+
highlight_colour = kwargs["style"]["highlight_colour"]
64+
65+
def replacer(match: re.Match[str]) -> str:
66+
content = match.group(1)
67+
return f'<tspan style="fill:{highlight_colour}">{content}</tspan>'
68+
69+
rendered = re.sub(r"<highlight>(.*?)</highlight>", replacer, rendered)
70+
71+
return rendered
72+
73+
16274
def create_env(templates_dir: Path | None = None) -> jinja2.Environment:
16375
"""Create and configure the Jinja environment."""
16476
if templates_dir is None:
@@ -167,7 +79,9 @@ def create_env(templates_dir: Path | None = None) -> jinja2.Environment:
16779
env = jinja2.Environment(
16880
loader=jinja2.FileSystemLoader(str(templates_dir)),
16981
autoescape=jinja2.select_autoescape(),
82+
extensions=["jinja2.ext.do"],
17083
)
84+
env.template_class = HighlightingTemplate
17185
return env
17286

17387

0 commit comments

Comments
 (0)