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

71 statements  

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

1import atexit 

2import functools 

3import pathlib 

4import shutil 

5import tempfile 

6import tomllib 

7 

8import rendercv_fonts 

9import typst 

10 

11from rendercv.exception import RenderCVInternalError 

12from rendercv.schema.models.rendercv_model import RenderCVModel 

13 

14from .path_resolver import resolve_rendercv_file_path 

15 

16 

17def generate_pdf( 

18 rendercv_model: RenderCVModel, typst_path: pathlib.Path | None 

19) -> pathlib.Path | None: 

20 """Compile Typst source to PDF using typst-py compiler. 

21 

22 Why: 

23 PDF is the primary output format for CVs. Typst compilation produces 

24 high-quality PDFs with proper fonts, layout, and typography from the 

25 intermediate Typst markup. 

26 

27 Args: 

28 rendercv_model: CV model for path resolution and photo handling. 

29 typst_path: Path to Typst source file to compile. 

30 

31 Returns: 

32 Path to generated PDF file, or None if generation disabled. 

33 """ 

34 if rendercv_model.settings.render_command.dont_generate_pdf or typst_path is None: 

35 return None 

36 pdf_path = resolve_rendercv_file_path( 

37 rendercv_model, rendercv_model.settings.render_command.pdf_path 

38 ) 

39 typst_compiler = get_typst_compiler( 

40 rendercv_model._input_file_path, typst_path.parent 

41 ) 

42 copy_photo_next_to_typst_file(rendercv_model, typst_path) 

43 typst_compiler.compile(input=typst_path, format="pdf", output=pdf_path) 

44 

45 return pdf_path 

46 

47 

48def generate_png( 

49 rendercv_model: RenderCVModel, typst_path: pathlib.Path | None 

50) -> list[pathlib.Path] | None: 

51 """Compile Typst source to PNG images using typst-py compiler. 

52 

53 Why: 

54 PNG format enables CV preview in web applications and README files. 

55 Multi-page CVs produce multiple PNG files with sequential numbering. 

56 

57 Args: 

58 rendercv_model: CV model for path resolution and photo handling. 

59 typst_path: Path to Typst source file to compile. 

60 

61 Returns: 

62 List of paths to generated PNG files, or None if generation disabled. 

63 """ 

64 if rendercv_model.settings.render_command.dont_generate_png or typst_path is None: 

65 return None 

66 png_path = resolve_rendercv_file_path( 

67 rendercv_model, rendercv_model.settings.render_command.png_path 

68 ) 

69 

70 pattern = f"{png_path.stem}_*.png" 

71 for existing_png_file in png_path.parent.glob(pattern): 

72 if existing_png_file.is_file(): 

73 existing_png_file.unlink() 

74 

75 typst_compiler = get_typst_compiler( 

76 rendercv_model._input_file_path, typst_path.parent 

77 ) 

78 copy_photo_next_to_typst_file(rendercv_model, typst_path) 

79 png_files_bytes = typst_compiler.compile(input=typst_path, format="png") 

80 

81 if not isinstance(png_files_bytes, list): 

82 png_files_bytes = [png_files_bytes] 

83 

84 png_files = [] 

85 for i, png_file_bytes in enumerate(png_files_bytes): 

86 if png_file_bytes is None: 

87 raise RenderCVInternalError("Typst compiler returned None for PNG bytes") 

88 png_file = png_path.parent / (png_path.stem + f"_{i + 1}.png") 

89 png_file.write_bytes(png_file_bytes) 

90 png_files.append(png_file) 

91 

92 return png_files if png_files else None 

93 

94 

95def copy_photo_next_to_typst_file( 

96 rendercv_model: RenderCVModel, typst_path: pathlib.Path 

97) -> None: 

98 """Copy CV photo to Typst file directory for compilation. 

99 

100 Why: 

101 Typst compiler resolves image paths relative to source file location. 

102 Copying photo ensures compilation succeeds regardless of original 

103 photo location. 

104 

105 Args: 

106 rendercv_model: CV model containing photo path. 

107 typst_path: Path to Typst source file. 

108 """ 

109 photo_path = rendercv_model.cv.photo 

110 if isinstance(photo_path, pathlib.Path): 

111 copy_to = typst_path.parent / photo_path.name 

112 if photo_path != copy_to: 

113 shutil.copy(photo_path, copy_to) 

114 

115 

116def read_version_from_typst_toml(typst_toml_path: pathlib.Path) -> str: 

117 """Read the version field from a typst.toml file. 

118 

119 Why: 

120 Multiple bundled Typst packages need their version extracted for 

121 directory layout. Centralizes the parsing logic. 

122 

123 Args: 

124 typst_toml_path: Path to the typst.toml file. 

125 

126 Returns: 

127 The version string. 

128 """ 

129 data = tomllib.loads(typst_toml_path.read_text(encoding="utf-8")) 

130 try: 

131 return data["package"]["version"] 

132 except KeyError as e: 

133 message = f"Could not find version in {typst_toml_path}" 

134 raise RenderCVInternalError(message) from e 

135 

136 

137def install_bundled_typst_package( 

138 bundled_path: pathlib.Path, 

139 package_name: str, 

140 temp_dir: pathlib.Path, 

141 typ_files: list[str], 

142) -> None: 

143 """Copy a bundled Typst package into the temporary package cache. 

144 

145 Why: 

146 The Typst compiler expects packages in a directory structure of 

147 preview/{name}/{version}/. This copies the required files from 

148 a bundled package into that layout. 

149 

150 Args: 

151 bundled_path: Path to the bundled package directory. 

152 package_name: Name of the Typst package (used in directory structure). 

153 temp_dir: Root of the temporary package cache. 

154 typ_files: List of .typ filenames to copy alongside typst.toml. 

155 """ 

156 version = read_version_from_typst_toml(bundled_path / "typst.toml") 

157 package_directory = temp_dir / "preview" / package_name / version 

158 package_directory.mkdir(parents=True) 

159 shutil.copy2(bundled_path / "typst.toml", package_directory / "typst.toml") 

160 for typ_file in typ_files: 

161 shutil.copy2(bundled_path / typ_file, package_directory / typ_file) 

162 

163 

164@functools.lru_cache(maxsize=1) 

165def get_package_path() -> pathlib.Path: 

166 """Set up local Typst package resolution from bundled Typst packages. 

167 

168 Why: 

169 Bundled Typst packages (rendercv, fontawesome) are shipped inside the 

170 Python package so that PDF compilation works without downloading from 

171 Typst Universe. The Typst compiler expects packages in a directory 

172 structure of preview/{name}/{version}/, so this creates a temporary 

173 directory with that layout. 

174 

175 Returns: 

176 Path to temporary package cache directory. 

177 """ 

178 temp_dir = pathlib.Path(tempfile.mkdtemp(prefix="rendercv-pkg-")) 

179 atexit.register(shutil.rmtree, str(temp_dir), True) 

180 

181 renderer_dir = pathlib.Path(__file__).parent 

182 

183 install_bundled_typst_package( 

184 bundled_path=renderer_dir / "rendercv_typst", 

185 package_name="rendercv", 

186 temp_dir=temp_dir, 

187 typ_files=["lib.typ"], 

188 ) 

189 

190 install_bundled_typst_package( 

191 bundled_path=renderer_dir / "typst_fontawesome", 

192 package_name="fontawesome", 

193 temp_dir=temp_dir, 

194 typ_files=[ 

195 "lib.typ", 

196 "lib-impl.typ", 

197 "lib-gen-func.typ", 

198 "lib-gen-map.typ", 

199 ], 

200 ) 

201 

202 return temp_dir 

203 

204 

205@functools.lru_cache(maxsize=1) 

206def get_typst_compiler( 

207 input_file_path: pathlib.Path | None, 

208 root: pathlib.Path, 

209) -> typst.Compiler: 

210 """Create cached Typst compiler with font paths configured. 

211 

212 Why: 

213 Compiler initialization is expensive. Caching enables reuse across 

214 all compilations. The source file is passed per compile() call, so 

215 the compiler survives output filename changes (e.g., when cv.name 

216 changes). Font paths include package fonts and optional user fonts 

217 from input file directory. 

218 

219 Args: 

220 input_file_path: Original input file path for relative font resolution. 

221 root: Root directory for Typst project. Must contain the input file. 

222 

223 Returns: 

224 Configured Typst compiler instance. 

225 """ 

226 return typst.Compiler( 

227 root=root, 

228 font_paths=[ 

229 *rendercv_fonts.paths_to_font_folders, 

230 ( 

231 input_file_path.parent / "fonts" 

232 if input_file_path 

233 else pathlib.Path.cwd() / "fonts" 

234 ), 

235 ], 

236 package_path=get_package_path(), 

237 )