44import re
55from collections .abc import Sequence
66from pathlib import Path
7- from typing import Any , TypeVar
7+ from typing import Any
88
99import jinja2
1010
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
2419def 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-
13327def 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+
16274def 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