Coverage for rendercv/renderer/renderer.py: 89%

108 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 00:25 +0000

1""" 

2The `rendercv.renderer.renderer` module contains the necessary functions for rendering 

3Typst, PDF, Markdown, HTML, and PNG files from the `RenderCVDataModel` object. 

4""" 

5 

6import importlib.resources 

7import pathlib 

8import re 

9import shutil 

10import sys 

11from typing import Any, Literal, Optional 

12 

13from .. import data 

14from . import templater 

15 

16 

17def copy_theme_files_to_output_directory( 

18 theme_name: str, 

19 output_directory_path: pathlib.Path, 

20): 

21 """Copy the auxiliary files (all the files that don't end with `.j2.typ` and `.py`) 

22 of the theme to the output directory. 

23 

24 Args: 

25 theme_name: The name of the theme. 

26 output_directory_path: Path to the output directory. 

27 """ 

28 if theme_name in data.available_themes: 

29 theme_directory_path = importlib.resources.files( 

30 f"rendercv.themes.{theme_name}" 

31 ) 

32 else: 

33 # Then it means the theme is a custom theme. If theme_directory is not given 

34 # as an argument, then look for the theme in the current working directory. 

35 theme_directory_path = pathlib.Path.cwd() / theme_name 

36 

37 if not theme_directory_path.is_dir(): 

38 message = ( 

39 f"The theme {theme_name} doesn't exist in the available themes and" 

40 " the current working directory!" 

41 ) 

42 raise FileNotFoundError(message) 

43 

44 dont_copy_files_with_these_extensions = [".py", ".j2.typ"] 

45 for theme_file in theme_directory_path.iterdir(): 

46 # theme_file.suffix returns the latest part of the file name after the last dot. 

47 # But we need the latest part of the file name after the first dot: 

48 try: 

49 suffix = re.search(r"\..*", theme_file.name)[0] # type: ignore 

50 except TypeError: 

51 suffix = "" 

52 

53 if suffix not in dont_copy_files_with_these_extensions: 

54 if theme_file.is_dir(): 

55 shutil.copytree( 

56 str(theme_file), 

57 output_directory_path / theme_file.name, 

58 dirs_exist_ok=True, 

59 ) 

60 else: 

61 shutil.copyfile( 

62 str(theme_file), output_directory_path / theme_file.name 

63 ) 

64 

65 

66def create_contents_of_a_typst_file( 

67 rendercv_data_model: data.RenderCVDataModel, 

68) -> str: 

69 """Create a Typst file with the given data model and return it as a string. 

70 

71 Args: 

72 rendercv_data_model: The data model. 

73 

74 Returns: 

75 The path to the generated Typst file. 

76 """ 

77 jinja2_environment = templater.Jinja2Environment().environment 

78 

79 file_object = templater.TypstFile( 

80 rendercv_data_model, 

81 jinja2_environment, 

82 ) 

83 

84 return file_object.get_full_code() 

85 

86 

87def create_a_typst_file( 

88 rendercv_data_model: data.RenderCVDataModel, 

89 output_directory: pathlib.Path, 

90) -> pathlib.Path: 

91 """Create a Typst file (depending on the theme) with the given data model and write 

92 it to the output directory. 

93 

94 Args: 

95 rendercv_data_model: The data model. 

96 output_directory: Path to the output directory. If not given, the Typst file 

97 will be returned as a string. 

98 

99 Returns: 

100 The path to the generated Typst file. 

101 """ 

102 

103 typst_contents = create_contents_of_a_typst_file(rendercv_data_model) 

104 

105 # Create output directory if it doesn't exist: 

106 if not output_directory.is_dir(): 

107 output_directory.mkdir(parents=True) 

108 

109 file_name = f"{str(rendercv_data_model.cv.name).replace(' ', '_')}_CV.typ" 

110 file_path = output_directory / file_name 

111 file_path.write_text(typst_contents, encoding="utf-8") 

112 

113 return file_path 

114 

115 

116def create_a_markdown_file( 

117 rendercv_data_model: data.RenderCVDataModel, output_directory: pathlib.Path 

118) -> pathlib.Path: 

119 """Render the Markdown file with the given data model and write it to the output 

120 directory. 

121 

122 Args: 

123 rendercv_data_model: The data model. 

124 output_directory: Path to the output directory. 

125 

126 Returns: 

127 The path to the rendered Markdown file. 

128 """ 

129 # create output directory if it doesn't exist: 

130 if not output_directory.is_dir(): 

131 output_directory.mkdir(parents=True) 

132 

133 jinja2_environment = templater.Jinja2Environment().environment 

134 markdown_file_object = templater.MarkdownFile( 

135 rendercv_data_model, 

136 jinja2_environment, 

137 ) 

138 

139 markdown_file_name = f"{str(rendercv_data_model.cv.name).replace(' ', '_')}_CV.md" 

140 markdown_file_path = output_directory / markdown_file_name 

141 markdown_file_object.create_file(markdown_file_path) 

142 

143 return markdown_file_path 

144 

145 

146def create_a_typst_file_and_copy_theme_files( 

147 rendercv_data_model: data.RenderCVDataModel, output_directory: pathlib.Path 

148) -> pathlib.Path: 

149 """Render the Typst file with the given data model in the output directory and 

150 copy the auxiliary theme files to the output directory. 

151 

152 Args: 

153 rendercv_data_model: The data model. 

154 output_directory: Path to the output directory. 

155 

156 Returns: 

157 The path to the rendered Typst file. 

158 """ 

159 file_path = create_a_typst_file(rendercv_data_model, output_directory) 

160 copy_theme_files_to_output_directory( 

161 rendercv_data_model.design.theme, output_directory 

162 ) 

163 

164 # Copy the profile picture to the output directory, if it exists: 

165 if rendercv_data_model.cv.photo: 

166 shutil.copyfile( 

167 rendercv_data_model.cv.photo, 

168 output_directory / rendercv_data_model.cv.photo.name, 

169 ) 

170 

171 return file_path 

172 

173 

174class TypstCompiler: 

175 """A singleton class for the Typst compiler.""" 

176 

177 instance: "TypstCompiler" 

178 compiler: Any 

179 file_path: pathlib.Path 

180 

181 def __new__(cls, file_path: pathlib.Path): 

182 if not hasattr(cls, "instance") or cls.instance.file_path != file_path: 

183 try: 

184 import rendercv_fonts 

185 import typst 

186 except Exception as e: 

187 from .. import _parial_install_error_message 

188 

189 raise ImportError(_parial_install_error_message) from e 

190 

191 cls.instance = super().__new__(cls) 

192 cls.instance.file_path = file_path 

193 cls.instance.compiler = typst.Compiler( 

194 file_path, font_paths=rendercv_fonts.paths_to_font_folders 

195 ) 

196 

197 return cls.instance 

198 

199 def run( 

200 self, 

201 output: pathlib.Path, 

202 format: Literal["png", "pdf"], 

203 ppi: Optional[float] = None, 

204 ) -> pathlib.Path | list[pathlib.Path]: 

205 return self.instance.compiler.compile(format=format, output=output, ppi=ppi) 

206 

207 

208def render_a_pdf_from_typst(file_path: pathlib.Path) -> pathlib.Path: 

209 """Run TinyTeX with the given Typst file to render the PDF. 

210 

211 Args: 

212 file_path: The path to the Typst file. 

213 

214 Returns: 

215 The path to the rendered PDF file. 

216 """ 

217 typst_compiler = TypstCompiler(file_path) 

218 

219 # Before running Typst, make sure the PDF file is not open in another program, 

220 # that wouldn't allow Typst to write to it. Remove the PDF file if it exists, 

221 # if it's not removable, then raise an error: 

222 pdf_output_path = file_path.with_suffix(".pdf") 

223 

224 if sys.platform == "win32": 

225 if pdf_output_path.is_file(): 

226 try: 

227 pdf_output_path.unlink() 

228 except PermissionError as e: 

229 message = ( 

230 f"The PDF file {pdf_output_path} is open in another program and" 

231 " doesn't allow RenderCV to rewrite it. Please close the PDF file." 

232 ) 

233 raise RuntimeError(message) from e 

234 

235 typst_compiler.run(output=pdf_output_path, format="pdf") 

236 

237 return pdf_output_path 

238 

239 

240def render_pngs_from_typst( 

241 file_path: pathlib.Path, ppi: float = 150 

242) -> list[pathlib.Path]: 

243 """Run Typst with the given Typst file to render the PNG files. 

244 

245 Args: 

246 file_path: The path to the Typst file. 

247 ppi: Pixels per inch for PNG output, defaults to 150. 

248 

249 Returns: 

250 Paths to the rendered PNG files. 

251 """ 

252 typst_compiler = TypstCompiler(file_path) 

253 output_path = file_path.parent / (file_path.stem + "_{p}.png") 

254 output = typst_compiler.run(format="png", ppi=ppi, output=output_path) 

255 

256 if isinstance(output, list): 

257 return [ 

258 output_path.parent / output_path.name.format(p=i) 

259 for i in range(len(output)) 

260 ] 

261 

262 return [output_path.parent / output_path.name.format(p=1)] 

263 

264 

265def render_an_html_from_markdown(markdown_file_path: pathlib.Path) -> pathlib.Path: 

266 """Render an HTML file from a Markdown file with the same name and in the same 

267 directory. It uses `rendercv/themes/main.j2.html` as the Jinja2 template. 

268 

269 Args: 

270 markdown_file_path: The path to the Markdown file. 

271 

272 Returns: 

273 The path to the rendered HTML file. 

274 """ 

275 try: 

276 import markdown 

277 except Exception as e: 

278 from .. import _parial_install_error_message 

279 

280 raise ImportError(_parial_install_error_message) from e 

281 

282 # check if the file exists: 

283 if not markdown_file_path.is_file(): 

284 message = f"The file {markdown_file_path} doesn't exist!" 

285 raise FileNotFoundError(message) 

286 

287 # Convert the markdown file to HTML: 

288 markdown_text = markdown_file_path.read_text(encoding="utf-8") 

289 html_body = markdown.markdown(markdown_text) 

290 

291 # Get the title of the markdown content: 

292 title = re.search(r"# (.*)\n", markdown_text) 

293 title = title.group(1) if title else None 

294 

295 jinja2_environment = templater.Jinja2Environment().environment 

296 html_template = jinja2_environment.get_template("main.j2.html") 

297 html = html_template.render(html_body=html_body, title=title) 

298 

299 # Write html into a file: 

300 html_file_path = markdown_file_path.parent / f"{markdown_file_path.stem}.html" 

301 html_file_path.write_text(html, encoding="utf-8") 

302 

303 return html_file_path