Coverage for fastblocks / adapters / fonts / squirrel.py: 85%

176 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-19 12:49 -0800

1"""Font Squirrel adapter implementation for self-hosted fonts.""" 

2 

3import typing as t 

4from contextlib import suppress 

5from pathlib import Path 

6from uuid import UUID 

7 

8from acb.depends import depends 

9 

10from ._base import FontsBase, FontsBaseSettings 

11 

12 

13class FontSquirrelFontsSettings(FontsBaseSettings): 

14 """Font Squirrel-specific settings.""" 

15 

16 fonts_dir: str = "/static/fonts" 

17 fonts: list[dict[str, t.Any]] = [] 

18 preload_critical: bool = True 

19 display: str = "swap" 

20 

21 

22class FontSquirrelFonts(FontsBase): 

23 """Font Squirrel adapter for self-hosted fonts.""" 

24 

25 # Required ACB 0.19.0+ metadata 

26 MODULE_ID: UUID = UUID("01937d86-4f2a-7b3c-8d9e-f3b4d3c2e1a2") # Static UUID7 

27 MODULE_STATUS = "stable" 

28 

29 # Common font format priorities (most modern first) 

30 FORMAT_PRIORITIES = ["woff2", "woff", "ttf", "otf", "eot"] 

31 

32 def __init__(self) -> None: 

33 """Initialize Font Squirrel adapter.""" 

34 super().__init__() 

35 self.settings = FontSquirrelFontsSettings() 

36 

37 # Register with ACB dependency system 

38 with suppress(Exception): 

39 depends.set(self) 

40 

41 async def get_font_import(self) -> str: 

42 """Generate @font-face declarations for self-hosted fonts.""" 

43 if not self.settings.fonts: 

44 return "<!-- No self-hosted fonts configured -->" 

45 

46 font_faces = [] 

47 

48 for font_config in self.settings.fonts: 

49 font_face = self._generate_font_face(font_config) 

50 if font_face: 

51 font_faces.append(font_face) 

52 

53 if font_faces: 

54 return f"<style>\n{chr(10).join(font_faces)}\n</style>" 

55 return "" 

56 

57 def get_font_family(self, font_type: str) -> str: 

58 """Get font family CSS values for configured fonts.""" 

59 # Look for a font with the specified type 

60 for font_config in self.settings.fonts: 

61 if font_config.get("type") == font_type: 

62 family_name = font_config.get("family", font_config.get("name", "")) 

63 fallback = font_config.get( 

64 "fallback", self._get_default_fallback(font_type) 

65 ) 

66 return f"'{family_name}', {fallback}" if family_name else fallback 

67 

68 # Return default fallbacks if no specific font found 

69 return self._get_default_fallback(font_type) 

70 

71 def _generate_font_face(self, font_config: dict[str, t.Any]) -> str: 

72 """Generate a single @font-face declaration.""" 

73 family = font_config.get("family") or font_config.get("name") 

74 if not family: 

75 return "" 

76 

77 # Build font-face properties 

78 properties = [ 

79 f" font-family: '{family}';", 

80 f" font-display: {self.settings.display};", 

81 ] 

82 

83 # Add font style 

84 style = font_config.get("style", "normal") 

85 properties.append(f" font-style: {style};") 

86 

87 # Add font weight 

88 weight = font_config.get("weight", "400") 

89 properties.append(f" font-weight: {weight};") 

90 

91 # Build src declaration 

92 src_parts = self._build_src_declaration(font_config) 

93 if not src_parts: 

94 return "" # No valid sources found 

95 

96 properties.append(f" src: {src_parts};") 

97 

98 # Add unicode-range if specified 

99 if "unicode_range" in font_config: 

100 properties.append(f" unicode-range: {font_config['unicode_range']};") 

101 

102 return f"@font-face {{\n{chr(10).join(properties)}\n}}" 

103 

104 def _handle_single_file_path(self, font_config: dict[str, t.Any]) -> list[str]: 

105 """Handle single file path configuration.""" 

106 src_parts = [] 

107 file_path = font_config["path"] 

108 format_hint = self._get_format_from_path(file_path) 

109 url = self._normalize_font_url(file_path) 

110 src_parts.append(f"url('{url}') format('{format_hint}')") 

111 return src_parts 

112 

113 def _handle_multiple_file_paths(self, font_config: dict[str, t.Any]) -> list[str]: 

114 """Handle multiple file paths with formats.""" 

115 src_parts = [] 

116 files = font_config["files"] 

117 

118 # Sort files by format priority 

119 sorted_files = sorted( 

120 files, 

121 key=lambda f: self.FORMAT_PRIORITIES.index(f.get("format", "ttf")) 

122 if f.get("format") in self.FORMAT_PRIORITIES 

123 else 999, 

124 ) 

125 

126 for file_info in sorted_files: 

127 file_path = file_info.get("path") 

128 format_hint = file_info.get("format") or self._get_format_from_path( 

129 file_path 

130 ) 

131 

132 if file_path and format_hint: 

133 url = self._normalize_font_url(file_path) 

134 src_parts.append(f"url('{url}') format('{format_hint}')") 

135 

136 return src_parts 

137 

138 def _handle_directory_discovery(self, font_config: dict[str, t.Any]) -> list[str]: 

139 """Handle directory-based font discovery.""" 

140 src_parts = [] 

141 directory = font_config["directory"] 

142 family = font_config.get("family") or font_config.get("name", "") 

143 weight = font_config.get("weight", "400") 

144 style = font_config.get("style", "normal") 

145 

146 # Look for font files in directory 

147 if family: # Only proceed if family name is available 

148 discovered_files = self._discover_font_files( 

149 directory, family, weight, style 

150 ) 

151 for file_path, format_hint in discovered_files: 

152 url = self._normalize_font_url(file_path) 

153 src_parts.append(f"url('{url}') format('{format_hint}')") 

154 

155 return src_parts 

156 

157 def _build_src_declaration(self, font_config: dict[str, t.Any]) -> str: 

158 """Build the src property for @font-face.""" 

159 src_parts = [] 

160 

161 # Handle single file path 

162 if "path" in font_config: 

163 src_parts.extend(self._handle_single_file_path(font_config)) 

164 

165 # Handle multiple file paths with formats 

166 elif "files" in font_config: 

167 src_parts.extend(self._handle_multiple_file_paths(font_config)) 

168 

169 # Handle directory-based discovery 

170 elif "directory" in font_config: 

171 src_parts.extend(self._handle_directory_discovery(font_config)) 

172 

173 return ", ".join(src_parts) 

174 

175 def _get_format_from_path(self, file_path: str) -> str: 

176 """Determine font format from file extension.""" 

177 path = Path(file_path) 

178 extension = path.suffix.lower() 

179 

180 format_map = { 

181 ".woff2": "woff2", 

182 ".woff": "woff", 

183 ".ttf": "truetype", 

184 ".otf": "opentype", 

185 ".eot": "embedded-opentype", 

186 ".svg": "svg", 

187 } 

188 

189 return format_map.get(extension, "truetype") 

190 

191 def _normalize_font_url(self, file_path: str) -> str: 

192 """Normalize font file path to URL.""" 

193 # If already a full URL, return as-is 

194 if file_path.startswith(("http://", "https://", "//")): 

195 return file_path 

196 

197 # If relative path, prepend fonts directory 

198 if not file_path.startswith("/"): 

199 return f"{self.settings.fonts_dir.rstrip('/')}/{file_path}" 

200 

201 return file_path 

202 

203 def _discover_font_files( 

204 self, directory: str, family: str, weight: str, style: str 

205 ) -> list[tuple[str, str]]: 

206 """Discover font files in a directory based on naming patterns.""" 

207 discovered = [] 

208 

209 # Common naming patterns for font files 

210 patterns = [ 

211 f"{family.lower().replace(' ', '-')}-{weight}-{style}", 

212 f"{family.lower().replace(' ', '')}{weight}{style}", 

213 f"{family.replace(' ', '')}-{weight}", 

214 f"{family.lower()}-{style}", 

215 family.lower().replace(" ", "-"), 

216 ] 

217 

218 for pattern in patterns: 

219 for ext in (".woff2", ".woff", ".ttf", ".otf"): 

220 file_path = f"{directory.rstrip('/')}/{pattern}{ext}" 

221 format_hint = self._get_format_from_path(file_path) 

222 discovered.append((file_path, format_hint)) 

223 

224 return discovered 

225 

226 def _get_default_fallback(self, font_type: str) -> str: 

227 """Get default fallback fonts for different types.""" 

228 fallbacks = { 

229 "primary": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

230 "secondary": "Georgia, 'Times New Roman', serif", 

231 "heading": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

232 "body": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

233 "monospace": "'Courier New', monospace", 

234 "display": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

235 "sans-serif": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

236 "serif": "Georgia, 'Times New Roman', serif", 

237 } 

238 return fallbacks.get(font_type, "inherit") 

239 

240 def _get_default_critical_fonts(self) -> list[str]: 

241 """Get default critical fonts (first font of each type). 

242 

243 Returns: 

244 List of font family names to preload 

245 """ 

246 fonts_to_preload = [] 

247 seen_types = set() 

248 

249 for font_config in self.settings.fonts: 

250 font_type = font_config.get("type") 

251 if font_type and font_type not in seen_types: 

252 font_family = font_config.get("family") or font_config.get("name") 

253 if font_family: 

254 fonts_to_preload.append(font_family) 

255 seen_types.add(font_type) 

256 

257 return fonts_to_preload 

258 

259 def _generate_preload_links_for_fonts(self, font_families: list[str]) -> list[str]: 

260 """Generate preload links for specified font families. 

261 

262 Args: 

263 font_families: List of font family names 

264 

265 Returns: 

266 List of preload link HTML strings 

267 """ 

268 preload_links: list[str] = [] 

269 

270 for font_family in font_families: 

271 for font_config in self.settings.fonts: 

272 config_family = font_config.get("family") or font_config.get("name") 

273 if config_family == font_family: 

274 preload_link = self._generate_preload_link(font_config) 

275 if preload_link: 

276 preload_links.append(preload_link) 

277 break 

278 

279 return preload_links 

280 

281 def get_preload_links(self, critical_fonts: list[str] | None = None) -> str: 

282 """Generate preload links for critical fonts.""" 

283 if not self.settings.preload_critical: 

284 return "" 

285 

286 # Determine which fonts to preload 

287 fonts_to_preload = critical_fonts or self._get_default_critical_fonts() 

288 

289 # Generate preload links 

290 preload_links = self._generate_preload_links_for_fonts(fonts_to_preload) 

291 

292 return "\n".join(preload_links) 

293 

294 def _find_best_font_file(self, font_config: dict[str, t.Any]) -> str | None: 

295 """Find the best format file (woff2 preferred, then woff). 

296 

297 Args: 

298 font_config: Font configuration dictionary 

299 

300 Returns: 

301 Path to best font file or None 

302 """ 

303 if "path" in font_config: 

304 # Dictionary access returns Any, so we cast to the expected type 

305 return t.cast(str | None, font_config["path"]) 

306 

307 if "files" not in font_config: 

308 return None 

309 

310 # Search for woff2 first 

311 for file_info in font_config["files"]: 

312 if file_info.get("format") == "woff2": 

313 # Dictionary.get() returns Any, so we cast to the expected type 

314 return t.cast(str | None, file_info.get("path")) 

315 

316 # Fall back to woff 

317 for file_info in font_config["files"]: 

318 if file_info.get("format") == "woff": 

319 # Dictionary.get() returns Any, so we cast to the expected type 

320 return t.cast(str | None, file_info.get("path")) 

321 

322 return None 

323 

324 def _generate_preload_link(self, font_config: dict[str, t.Any]) -> str: 

325 """Generate a preload link for a specific font.""" 

326 best_file = self._find_best_font_file(font_config) 

327 

328 if best_file: 

329 url = self._normalize_font_url(best_file) 

330 return f'<link rel="preload" as="font" type="font/woff2" href="{url}" crossorigin>' 

331 

332 return "" 

333 

334 def validate_font_files(self) -> dict[str, list[str]]: 

335 """Validate that configured font files exist and are accessible.""" 

336 validation_results: dict[str, list[str]] = { 

337 "valid": [], 

338 "invalid": [], 

339 "warnings": [], 

340 } 

341 

342 for font_config in self.settings.fonts: 

343 family = font_config.get("family") or font_config.get("name", "Unknown") 

344 

345 if "path" in font_config: 

346 # Single file validation would go here 

347 validation_results["valid"].append(f"{family}: {font_config['path']}") 

348 elif "files" in font_config: 

349 # Multiple files validation would go here 

350 for file_info in font_config["files"]: 

351 validation_results["valid"].append( 

352 f"{family}: {file_info.get('path', 'Unknown path')}" 

353 ) 

354 else: 

355 validation_results["warnings"].append( 

356 f"{family}: No font files specified" 

357 ) 

358 

359 return validation_results 

360 

361 

362FontsSettings = FontSquirrelFontsSettings 

363Fonts = FontSquirrelFonts 

364 

365depends.set(Fonts, "squirrel")