Skip to content

Commit 4e50ef0

Browse files
committed
Refactor to make template rendering configurable
- Add `template_renderers` setting and make `render_highlight` the built-in default, so `<highlight>` is still rendered - Add `template_filters` setting and make a new filter `wordwrap` available by default (these filters can also be used as regular functions, sitting in processing.py for custom processing) - Adjust tests, add wordwrap tests
1 parent 01db49e commit 4e50ef0

6 files changed

Lines changed: 181 additions & 24 deletions

File tree

src/pdfbaker/baker.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
"build": "build",
2626
"dist": "dist",
2727
},
28+
# Highlighting support enabled by default
29+
"template_renderers": ["render_highlight"],
30+
# Make all filters available by default
31+
"template_filters": ["wordwrap"],
2832
}
2933

3034

src/pdfbaker/page.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def process(self) -> Path:
9999
jinja_env = create_env(
100100
templates_dir=self.config.template.parent,
101101
extensions=jinja_extensions,
102+
template_filters=self.config.get("template_filters", []),
102103
)
103104
template = jinja_env.get_template(self.config.template.name)
104105
except TemplateNotFound as exc:
@@ -122,7 +123,10 @@ def process(self) -> Path:
122123

123124
self.log_debug("Rendering template...")
124125
try:
125-
rendered_template = template.render(**template_context)
126+
rendered_template = template.render(
127+
**template_context,
128+
renderers=self.config.get("template_renderers", []),
129+
)
126130
with open(output_svg, "w", encoding="utf-8") as f:
127131
f.write(rendered_template)
128132
except TemplateError as exc:

src/pdfbaker/processing.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Helper functions for custom processing."""
2+
3+
4+
def wordwrap(text: str, max_chars: int = 60) -> list[str]:
5+
"""Split text into lines with a maximum width, breaking at word boundaries.
6+
7+
Args:
8+
text: The text to wrap
9+
max_chars: Maximum number of characters per line
10+
11+
Returns:
12+
List of strings, one for each line
13+
"""
14+
if not text:
15+
return []
16+
17+
words = text.split()
18+
lines = []
19+
current_line = []
20+
current_width = 0
21+
22+
for word in words:
23+
if current_width + len(word) + int(current_width > 0) <= max_chars:
24+
# Word still fits in current line
25+
current_line.append(word)
26+
current_width += len(word) + int(current_width > 0)
27+
else:
28+
# Word is too long, start a new line
29+
if current_line:
30+
lines.append(" ".join(current_line))
31+
current_line = [word]
32+
current_width = len(word)
33+
34+
if current_line:
35+
lines.append(" ".join(current_line))
36+
37+
return lines

src/pdfbaker/render.py

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,39 +8,66 @@
88

99
import jinja2
1010

11+
from . import processing
12+
from .config import render_config
1113
from .types import ImageSpec, StyleDict
1214

1315
__all__ = [
1416
"create_env",
17+
"PDFBakerTemplate",
1518
"prepare_template_context",
1619
]
1720

1821

19-
class HighlightingTemplate(jinja2.Template): # pylint: disable=too-few-public-methods
20-
"""A Jinja template that automatically applies highlighting to text.
22+
class PDFBakerTemplate(jinja2.Template): # pylint: disable=too-few-public-methods
23+
"""A Jinja template with custom rendering capabilities for PDFBaker.
2124
22-
This template class extends the base Jinja template to automatically
23-
convert <highlight> tags to styled <tspan> elements with the highlight color.
25+
This template class extends the base Jinja template to apply
26+
additional rendering transformations to the template output.
2427
"""
2528

2629
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+
"""Render the template and apply custom transformations.
2931
30-
if "style" in kwargs and "highlight_color" in kwargs["style"]:
31-
highlight_color = kwargs["style"]["highlight_color"]
32+
Args:
33+
*args: Positional arguments for template rendering
34+
**kwargs: Keyword arguments for template rendering
35+
renderers: Optional list of renderer function names to apply
3236
33-
def replacer(match: re.Match[str]) -> str:
34-
content = match.group(1)
35-
return f'<tspan style="fill:{highlight_color}">{content}</tspan>'
37+
Returns:
38+
Rendered template with transformations applied
39+
"""
40+
rendered = super().render(*args, **kwargs)
3641

37-
rendered = re.sub(r"<highlight>(.*?)</highlight>", replacer, rendered)
42+
for renderer_name in kwargs.get("renderers", []):
43+
renderer_func = globals().get(renderer_name)
44+
if callable(renderer_func):
45+
rendered = renderer_func(rendered, **kwargs)
3846

3947
return rendered
4048

4149

50+
def render_highlight(rendered: str, **kwargs: Any) -> str:
51+
"""Apply highlight tags to the rendered template content.
52+
53+
Convert <highlight> tags to styled <tspan> elements with the highlight color.
54+
"""
55+
if "style" in kwargs and "highlight_color" in kwargs["style"]:
56+
highlight_color = kwargs["style"]["highlight_color"]
57+
58+
def replacer(match: re.Match[str]) -> str:
59+
content = match.group(1)
60+
return f'<tspan style="fill:{highlight_color}">{content}</tspan>'
61+
62+
rendered = re.sub(r"<highlight>(.*?)</highlight>", replacer, rendered)
63+
64+
return rendered
65+
66+
4267
def create_env(
43-
templates_dir: Path | None = None, extensions: list[str] | None = None
68+
templates_dir: Path | None = None,
69+
extensions: list[str] | None = None,
70+
template_filters: list[str] | None = None,
4471
) -> jinja2.Environment:
4572
"""Create and configure the Jinja environment."""
4673
if templates_dir is None:
@@ -51,20 +78,29 @@ def create_env(
5178
autoescape=jinja2.select_autoescape(),
5279
extensions=extensions or [],
5380
)
54-
env.template_class = HighlightingTemplate
81+
env.template_class = PDFBakerTemplate
82+
83+
if template_filters:
84+
for filter_name in template_filters:
85+
if hasattr(processing, filter_name):
86+
env.filters[filter_name] = getattr(processing, filter_name)
87+
5588
return env
5689

5790

5891
def prepare_template_context(
5992
config: dict[str], images_dir: Path | None = None
6093
) -> dict[str]:
61-
"""Prepare config for template rendering by resolving styles and encoding images.
94+
"""Prepare template context with variables/styles/images
95+
96+
Resolves variables, styles and encodes images.
6297
6398
Args:
6499
config: Configuration with optional styles and images
65100
images_dir: Directory containing images to encode
66101
"""
67-
context = config.copy()
102+
# Render configuration to resolve template strings inside strings
103+
context = render_config(config)
68104

69105
# Resolve style references to actual theme colors
70106
if "style" in context and "theme" in context:

tests/test_processing.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Tests for processing functions."""
2+
3+
from pdfbaker.processing import wordwrap
4+
5+
6+
def test_wordwrap_empty_input():
7+
"""Test wordwrap with empty inputs."""
8+
assert not wordwrap("")
9+
assert not wordwrap(" ")
10+
assert not wordwrap(" ")
11+
12+
13+
def test_wordwrap_normal_text():
14+
"""Test wordwrap with normal text."""
15+
text = "This is a simple test for word wrapping functionality."
16+
# Max width 20 chars
17+
expected = ["This is a simple", "test for word", "wrapping", "functionality."]
18+
assert wordwrap(text, max_chars=20) == expected
19+
20+
# Default max width (60 chars)
21+
assert wordwrap(text) == [text] # Should fit on one line with default width
22+
23+
24+
def test_wordwrap_long_words():
25+
"""Test wordwrap with words longer than the max width."""
26+
# Single word longer than max width
27+
assert wordwrap("supercalifragilisticexpialidocious", max_chars=10) == [
28+
"supercalifragilisticexpialidocious"
29+
]
30+
31+
# Mixed text with one long word
32+
text = "This has a supercalifragilisticexpialidocious word in it."
33+
expected = ["This has a", "supercalifragilisticexpialidocious", "word in it."]
34+
assert wordwrap(text, max_chars=15) == expected
35+
36+
37+
def test_wordwrap_edge_cases():
38+
"""Test wordwrap with various edge cases."""
39+
# Text with exactly max width
40+
assert wordwrap("1234567890", max_chars=10) == ["1234567890"]
41+
42+
# Text with multiple spaces
43+
assert wordwrap("word1 word2", max_chars=20) == ["word1 word2"]
44+
45+
# Line just at the boundary
46+
text = "one two three four"
47+
assert wordwrap(text, max_chars=13) == ["one two three", "four"]
48+
49+
# Really large max_chars
50+
assert wordwrap("short text", max_chars=1000) == ["short text"]
51+
52+
53+
def test_wordwrap_newlines():
54+
"""Test that newlines in the input are treated as spaces."""
55+
text = "Line one\nLine two\nLine three"
56+
expected = ["Line one Line two", "Line three"]
57+
assert wordwrap(text, max_chars=20) == expected
58+
59+
60+
def test_wordwrap_long_word_with_following_words():
61+
"""Test that processing continues after a long word."""
62+
text = "supercalifragilisticexpialidocious is followed by more words"
63+
result = wordwrap(text, 10)
64+
65+
assert result == [
66+
"supercalifragilisticexpialidocious",
67+
"is",
68+
"followed",
69+
"by more",
70+
"words",
71+
]

tests/test_render.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import pytest
88

99
from pdfbaker.render import (
10-
HighlightingTemplate,
10+
PDFBakerTemplate,
1111
create_env,
1212
encode_image,
1313
encode_images,
@@ -20,7 +20,7 @@ def test_create_env(tmp_path: Path) -> None:
2020
"""Test creating Jinja environment."""
2121
env = create_env(tmp_path)
2222
assert isinstance(env, jinja2.Environment)
23-
assert env.template_class == HighlightingTemplate
23+
assert env.template_class == PDFBakerTemplate
2424
assert isinstance(env.loader, jinja2.FileSystemLoader)
2525

2626

@@ -33,15 +33,20 @@ def test_create_env_no_directory() -> None:
3333
# Template rendering tests
3434
def test_highlighting_template() -> None:
3535
"""Test highlighting template functionality."""
36-
template = HighlightingTemplate("<highlight>test</highlight>")
37-
result = template.render(style={"highlight_color": "red"})
36+
template = PDFBakerTemplate("<highlight>test</highlight>")
37+
result = template.render(
38+
renderers=["render_highlight"], style={"highlight_color": "red"}
39+
)
3840
assert result == '<tspan style="fill:red">test</tspan>'
3941

4042

41-
def test_highlighting_template_no_highlight() -> None:
43+
def test_highlighting_template_no_style() -> None:
4244
"""Test highlighting template with no highlight color."""
43-
template = HighlightingTemplate("<highlight>test</highlight>")
44-
result = template.render() # No style provided
45+
template = PDFBakerTemplate("<highlight>test</highlight>")
46+
result = template.render(
47+
renderers=["render_highlight"],
48+
# No style provided
49+
)
4550
assert result == "<highlight>test</highlight>"
4651

4752

0 commit comments

Comments
 (0)