Coverage for src / rendercv / renderer / templater / templater.py: 100%

48 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-25 13:49 +0000

1import contextlib 

2import functools 

3import pathlib 

4from typing import Literal 

5 

6import jinja2 

7 

8from rendercv.schema.models.rendercv_model import RenderCVModel 

9 

10from .markdown_parser import markdown_to_html 

11from .model_processor import download_photo_from_url, process_model 

12from .string_processor import clean_url 

13 

14templates_directory = pathlib.Path(__file__).parent / "templates" 

15 

16 

17@functools.lru_cache(maxsize=1) 

18def get_jinja2_environment( 

19 input_file_path: pathlib.Path | None = None, 

20) -> jinja2.Environment: 

21 """Create cached Jinja2 environment with custom filters and template loaders. 

22 

23 Why: 

24 Template rendering is called multiple times per render. Caching environment 

25 prevents repeated filesystem scans. Loader hierarchy enables user template 

26 overrides by checking input file directory before built-in templates. 

27 

28 Args: 

29 input_file_path: Path to input file for user template override resolution. 

30 

31 Returns: 

32 Configured Jinja2 environment with filters and loaders. 

33 """ 

34 env = jinja2.Environment( 

35 loader=jinja2.FileSystemLoader( 

36 [ 

37 ( # To allow users to override the templates: 

38 input_file_path.parent if input_file_path else pathlib.Path.cwd() 

39 ), 

40 templates_directory, 

41 ] 

42 ), 

43 trim_blocks=True, 

44 lstrip_blocks=True, 

45 ) 

46 env.filters["clean_url"] = clean_url 

47 env.filters["strip"] = lambda string: string.strip() 

48 return env 

49 

50 

51def render_full_template( 

52 rendercv_model: RenderCVModel, file_type: Literal["typst", "markdown"] 

53) -> str: 

54 """Render complete CV document by assembling preamble, header, and sections. 

55 

56 Why: 

57 CV generation requires consistent structure across formats. This orchestrates 

58 model processing, template rendering for each component, and assembly into 

59 final document following proper order. 

60 

61 Example: 

62 ```py 

63 typst_document = render_full_template(rendercv_model, "typst") 

64 # Returns complete .typ file with preamble, header, and all sections 

65 

66 markdown_document = render_full_template(rendercv_model, "markdown") 

67 # Returns complete .md file with header and all sections 

68 ``` 

69 

70 Args: 

71 rendercv_model: CV model to render. 

72 file_type: Output format for template selection and processing. 

73 

74 Returns: 

75 Complete rendered document as string. 

76 """ 

77 extension = { 

78 "typst": "typ", 

79 "markdown": "md", 

80 }[file_type] 

81 

82 download_photo_from_url(rendercv_model) 

83 rendercv_model = process_model(rendercv_model, file_type) 

84 

85 header = render_single_template( 

86 file_type, 

87 f"Header.j2.{extension}", 

88 rendercv_model, 

89 ) 

90 if file_type == "typst": 

91 preamble = render_single_template( 

92 file_type, 

93 f"Preamble.j2.{extension}", 

94 rendercv_model, 

95 ) 

96 code = f"{preamble}\n\n{header}\n" 

97 else: 

98 code = f"{header}\n" 

99 

100 for rendercv_section in rendercv_model.cv.rendercv_sections: 

101 section_beginning = render_single_template( 

102 file_type, 

103 f"SectionBeginning.j2.{extension}", 

104 rendercv_model, 

105 section_title=rendercv_section.title, 

106 snake_case_section_title=rendercv_section.snake_case_title, 

107 entry_type=rendercv_section.entry_type, 

108 ) 

109 section_ending = render_single_template( 

110 file_type, 

111 f"SectionEnding.j2.{extension}", 

112 rendercv_model, 

113 entry_type=rendercv_section.entry_type, 

114 ) 

115 entry_codes = [] 

116 for entry in rendercv_section.entries: 

117 entry_code = render_single_template( 

118 file_type, 

119 f"entries/{rendercv_section.entry_type}.j2.{extension}", 

120 rendercv_model, 

121 entry=entry, 

122 ) 

123 entry_codes.append(entry_code) 

124 entries_code = "\n\n".join(entry_codes) 

125 section_code = f"{section_beginning}\n{entries_code}\n{section_ending}" 

126 code += f"\n{section_code}" 

127 

128 return code 

129 

130 

131def render_html(rendercv_model: RenderCVModel, markdown: str) -> str: 

132 """Convert Markdown to HTML and wrap with full HTML template. 

133 

134 Why: 

135 HTML output requires both content conversion (Markdown to HTML body) and 

136 document structure (head, CSS, metadata). Separate function handles HTML- 

137 specific workflow distinct from Typst/Markdown direct generation. 

138 

139 Example: 

140 ```py 

141 markdown_content = render_full_template(rendercv_model, "markdown") 

142 html_document = render_html(rendercv_model, markdown_content) 

143 # Returns complete HTML with <head>, CSS, and converted Markdown body 

144 ``` 

145 

146 Args: 

147 rendercv_model: CV model for template context. 

148 markdown: Markdown content to convert. 

149 

150 Returns: 

151 Complete HTML document. 

152 """ 

153 html_body = markdown_to_html(markdown) 

154 return render_single_template( 

155 "html", "Full.html", rendercv_model, html_body=html_body 

156 ) 

157 

158 

159def render_single_template( 

160 file_type: Literal["markdown", "typst", "html"], 

161 relative_template_path: str, 

162 rendercv_model: RenderCVModel, 

163 **kwargs, 

164) -> str: 

165 """Render single Jinja2 template with user override support. Arbitrary keyword 

166 arguments may be passed to the template as additional template variables. 

167 

168 Why: 

169 Users can override built-in templates by placing custom templates in 

170 theme folder alongside input file. Typst templates check theme-specific 

171 location first, falling back to built-in templates if not found. 

172 

173 Example: 

174 ```py 

175 header = render_single_template("typst", "Header.j2.typ", rendercv_model) 

176 # First checks for classic/Header.j2.typ in input file directory 

177 # Falls back to built-in typst/Header.j2.typ if not found 

178 

179 section = render_single_template( 

180 "typst", 

181 "SectionBeginning.j2.typ", 

182 rendercv_model, 

183 section_title="Experience", 

184 ) 

185 ``` 

186 

187 Args: 

188 file_type: Format for template directory selection. 

189 relative_template_path: Template file path relative to format directory. 

190 rendercv_model: CV model providing template context. 

191 

192 Returns: 

193 Rendered template as string. 

194 """ 

195 jinja2_environment = get_jinja2_environment(rendercv_model._input_file_path) 

196 template = None 

197 if file_type == "typst": 

198 # Try user's own Typst templates first: 

199 with contextlib.suppress(jinja2.TemplateNotFound): 

200 template = jinja2_environment.get_template( 

201 f"{rendercv_model.design.theme}/{relative_template_path}" 

202 ) 

203 

204 if template is None: 

205 template = jinja2_environment.get_template( 

206 f"{file_type}/{relative_template_path}" 

207 ) 

208 

209 return template.render( 

210 cv=rendercv_model.cv, 

211 design=rendercv_model.design, 

212 locale=rendercv_model.locale, 

213 settings=rendercv_model.settings, 

214 **kwargs, 

215 )