Coverage for src/svglib/fonts.py: 90%

155 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-06-16 15:14 +0200

1"""Font management utilities for converting SVG to ReportLab graphics. 

2 

3This module provides font mapping and registration functionality for converting 

4SVG fonts to ReportLab-compatible fonts. It handles font discovery, registration, 

5and mapping between SVG font specifications and ReportLab font names. 

6 

7The module includes: 

8- FontMap class for managing font mappings 

9- Font discovery using system fontconfig 

10- Support for standard PDF fonts 

11- Automatic font file detection and registration 

12""" 

13 

14import os 

15import shutil 

16import subprocess 

17import sys 

18from typing import Dict, Optional, Tuple, Union 

19 

20from reportlab.pdfbase.pdfmetrics import registerFont 

21from reportlab.pdfbase.ttfonts import TTFError, TTFont 

22 

23STANDARD_FONT_NAMES = ( 

24 "Times-Roman", 

25 "Times-Italic", 

26 "Times-Bold", 

27 "Times-BoldItalic", 

28 "Helvetica", 

29 "Helvetica-Oblique", 

30 "Helvetica-Bold", 

31 "Helvetica-BoldOblique", 

32 "Courier", 

33 "Courier-Oblique", 

34 "Courier-Bold", 

35 "Courier-BoldOblique", 

36 "Symbol", 

37 "ZapfDingbats", 

38) 

39DEFAULT_FONT_NAME = "Helvetica" 

40DEFAULT_FONT_WEIGHT = "normal" 

41DEFAULT_FONT_STYLE = "normal" 

42DEFAULT_FONT_SIZE = 12 # points (CSS/SVG default: 12 pt = 16 px) 

43 

44 

45class FontMap: 

46 """Manages mapping of SVG font names to ReportLab fonts and handles registration. 

47 

48 This class provides a centralized way to map SVG font specifications (family, 

49 weight, style) to ReportLab-compatible font names. It supports automatic font 

50 discovery, registration of custom fonts, and fallback to standard PDF fonts. 

51 

52 The internal font map uses normalized font names as keys for efficient lookup 

53 and supports both exact and approximate font matching. 

54 """ 

55 

56 def __init__(self) -> None: 

57 """Initialize the FontMap with an empty font registry. 

58 

59 Creates an empty internal font map and registers all default font mappings 

60 for standard PDF fonts and common font family aliases. 

61 

62 The internal font map structure: 

63 'internal_name': { 

64 'svg_family': 'family_name', 

65 'svg_weight': 'font_weight', 

66 'svg_style': 'font_style', 

67 'rlgFont': 'reportlab_font_name', 

68 'exact': True/False 

69 } 

70 

71 Internal names are normalized for efficient lookup and follow the pattern: 

72 'Family-WeightStyle' (e.g., 'Arial-BoldItalic'). 

73 """ 

74 self._map: Dict[str, Dict[str, Union[str, bool, int]]] = {} 

75 self._family_index: Dict[str, str] = {} # lowercase family → canonical family 

76 

77 self.register_default_fonts() 

78 

79 @staticmethod 

80 def build_internal_name( 

81 family: str, weight: str = "normal", style: str = "normal" 

82 ) -> str: 

83 """Build normalized internal font name from family, weight, and style. 

84 

85 Creates a standardized font name for internal mapping by combining the 

86 font family with capitalized weight and style variants. This follows 

87 the standard naming convention used by most font systems. 

88 

89 Args: 

90 family: Font family name (e.g., "Arial", "Times New Roman"). 

91 weight: Font weight ("normal", "bold", or numeric value). 

92 style: Font style ("normal" or "italic"). 

93 

94 Returns: 

95 Normalized font name string (e.g., "Arial-BoldItalic"). 

96 

97 Examples: 

98 >>> FontMap.build_internal_name("Arial", "bold", "italic") 

99 'Arial-BoldItalic' 

100 >>> FontMap.build_internal_name("Times", "normal", "normal") 

101 'Times' 

102 """ 

103 result_name = family 

104 if weight != "normal" or style != "normal": 

105 result_name += "-" 

106 if weight != "normal": 

107 if isinstance(weight, int): 

108 result_name += f"{weight}" 

109 else: 

110 result_name += weight.lower().capitalize() 

111 if style != "normal": 

112 result_name += style.lower().capitalize() 

113 return result_name 

114 

115 @staticmethod 

116 def guess_font_filename( 

117 basename: str, 

118 weight: str = "normal", 

119 style: str = "normal", 

120 extension: str = "ttf", 

121 ) -> str: 

122 """Guess font filename based on family, weight, and style parameters. 

123 

124 Attempts to construct a likely font filename using common naming conventions 

125 for TrueType fonts. This works well for standard system fonts on Windows and 

126 many Unix-like systems. 

127 

128 Args: 

129 basename: Base font family name (e.g., "arial", "times"). 

130 weight: Font weight ("normal" or "bold"). 

131 style: Font style ("normal" or "italic"). 

132 extension: File extension (default "ttf"). 

133 

134 Returns: 

135 Guessed filename with appropriate weight/style suffix. 

136 

137 Examples: 

138 >>> FontMap.guess_font_filename("arial", "bold", "italic") 

139 'arialbi.ttf' 

140 >>> FontMap.guess_font_filename("times", "normal", "normal") 

141 'times.ttf' 

142 """ 

143 prefix = "" 

144 is_bold = weight.lower() == "bold" 

145 is_italic = style.lower() == "italic" 

146 if is_bold and not is_italic: 

147 prefix = "bd" 

148 elif is_bold and is_italic: 

149 prefix = "bi" 

150 elif not is_bold and is_italic: 

151 prefix = "i" 

152 filename = f"{basename}{prefix}.{extension}" 

153 return filename 

154 

155 def use_fontconfig( 

156 self, font_name: str, weight: str = "normal", style: str = "normal" 

157 ) -> Tuple[Optional[str], bool]: 

158 """Find and register a font using system fontconfig. 

159 

160 Uses the system's fontconfig utility to locate and register fonts that 

161 match the given specifications. This provides access to system-installed 

162 fonts that aren't part of the standard PDF font set. 

163 

164 Args: 

165 font_name: Name of the font family to search for. 

166 weight: Font weight specification ("normal" or "bold"). 

167 style: Font style specification ("normal" or "italic"). 

168 

169 Returns: 

170 Tuple of (font_name, is_exact_match). Returns (None, False) if 

171 fontconfig is unavailable or no suitable font is found. 

172 

173 Raises: 

174 OSError: If fontconfig command is not available on the system. 

175 

176 Note: 

177 Fontconfig may return a default fallback font if the exact font 

178 is not found. The exact_match flag indicates whether the returned 

179 font is an exact match for the requested font. 

180 """ 

181 NOT_FOUND = (None, False) 

182 # Searching with Fontconfig 

183 font_name_expr = font_name 

184 if weight != "normal": 

185 font_name_expr = f"{font_name_expr}:{weight}" 

186 if style != "normal": 

187 font_name_expr = f"{font_name_expr}:{style}" 

188 if font_name_expr != font_name and font_name not in self._map: 

189 # Ensure the "normal" version of the font is registered first 

190 self.use_fontconfig(font_name) 

191 fc_match = shutil.which("fc-match") 

192 if fc_match is None: 

193 return NOT_FOUND 

194 try: 

195 pipe = subprocess.Popen( 

196 [fc_match, "-s", "--format=%{file}\\n", "--", font_name_expr], 

197 stdout=subprocess.PIPE, 

198 stderr=subprocess.PIPE, 

199 ) 

200 output = pipe.communicate()[0].decode(sys.getfilesystemencoding()) 

201 except OSError: 

202 return NOT_FOUND 

203 internal_name = FontMap.build_internal_name(font_name, weight, style) 

204 font_paths = output.split("\n") 

205 for font_path in font_paths: 

206 try: 

207 registerFont(TTFont(internal_name, font_path)) 

208 except TTFError: 

209 continue 

210 else: 

211 success_font_path = font_path 

212 break 

213 else: 

214 return NOT_FOUND 

215 # Fontconfig may return a default font totally unrelated with font_name 

216 exact = font_name.lower() in os.path.basename(success_font_path).lower() 

217 self._map[internal_name] = { 

218 "svg_family": font_name, 

219 "svg_weight": weight, 

220 "svg_style": style, 

221 "rlgFont": internal_name, 

222 "exact": exact, 

223 } 

224 self._family_index.setdefault(font_name.lower(), font_name) 

225 return internal_name, exact 

226 

227 def register_default_fonts(self) -> None: 

228 """Register mappings for standard PDF fonts and common font families. 

229 

230 Sets up the default font mappings that are always available in ReportLab. 

231 This includes the 14 standard PDF fonts and common font family aliases 

232 like "serif", "sans-serif", and "monospace" that map to appropriate 

233 standard fonts. 

234 

235 This method is called automatically during FontMap initialization and 

236 establishes the baseline font support for SVG to PDF conversion. 

237 """ 

238 self.register_font("Times New Roman", rlgFontName="Times-Roman") 

239 self.register_font("Times New Roman", weight="bold", rlgFontName="Times-Bold") 

240 self.register_font( 

241 "Times New Roman", style="italic", rlgFontName="Times-Italic" 

242 ) 

243 self.register_font( 

244 "Times New Roman", 

245 weight="bold", 

246 style="italic", 

247 rlgFontName="Times-BoldItalic", 

248 ) 

249 

250 self.register_font("Helvetica", rlgFontName="Helvetica") 

251 self.register_font("Helvetica", weight="bold", rlgFontName="Helvetica-Bold") 

252 self.register_font("Helvetica", style="italic", rlgFontName="Helvetica-Oblique") 

253 self.register_font( 

254 "Helvetica", 

255 weight="bold", 

256 style="italic", 

257 rlgFontName="Helvetica-BoldOblique", 

258 ) 

259 

260 self.register_font("Courier New", rlgFontName="Courier") 

261 self.register_font("Courier New", weight="bold", rlgFontName="Courier-Bold") 

262 self.register_font("Courier New", style="italic", rlgFontName="Courier-Oblique") 

263 self.register_font( 

264 "Courier New", 

265 weight="bold", 

266 style="italic", 

267 rlgFontName="Courier-BoldOblique", 

268 ) 

269 self.register_font("Courier", style="italic", rlgFontName="Courier-Oblique") 

270 self.register_font( 

271 "Courier", weight="bold", style="italic", rlgFontName="Courier-BoldOblique" 

272 ) 

273 

274 self.register_font("sans-serif", rlgFontName="Helvetica") 

275 self.register_font("sans-serif", weight="bold", rlgFontName="Helvetica-Bold") 

276 self.register_font( 

277 "sans-serif", style="italic", rlgFontName="Helvetica-Oblique" 

278 ) 

279 self.register_font( 

280 "sans-serif", 

281 weight="bold", 

282 style="italic", 

283 rlgFontName="Helvetica-BoldOblique", 

284 ) 

285 

286 self.register_font("serif", rlgFontName="Times-Roman") 

287 self.register_font("serif", weight="bold", rlgFontName="Times-Bold") 

288 self.register_font("serif", style="italic", rlgFontName="Times-Italic") 

289 self.register_font( 

290 "serif", weight="bold", style="italic", rlgFontName="Times-BoldItalic" 

291 ) 

292 

293 self.register_font("times", rlgFontName="Times-Roman") 

294 self.register_font("times", weight="bold", rlgFontName="Times-Bold") 

295 self.register_font("times", style="italic", rlgFontName="Times-Italic") 

296 self.register_font( 

297 "times", weight="bold", style="italic", rlgFontName="Times-BoldItalic" 

298 ) 

299 

300 self.register_font("monospace", rlgFontName="Courier") 

301 self.register_font("monospace", weight="bold", rlgFontName="Courier-Bold") 

302 self.register_font("monospace", style="italic", rlgFontName="Courier-Oblique") 

303 self.register_font( 

304 "monospace", 

305 weight="bold", 

306 style="italic", 

307 rlgFontName="Courier-BoldOblique", 

308 ) 

309 

310 def register_font_family( 

311 self, 

312 family: str, 

313 normal: str, 

314 bold: Optional[str] = None, 

315 italic: Optional[str] = None, 

316 bolditalic: Optional[str] = None, 

317 ) -> None: 

318 """Register a complete font family with all style variants. 

319 

320 Convenience method to register an entire font family at once by providing 

321 the font paths for different style combinations. This automatically creates 

322 mappings for normal, bold, italic, and bold-italic variants. 

323 

324 Args: 

325 family: Font family name (e.g., "MyCustomFont"). 

326 normal: Path or name for the normal weight/style variant. 

327 bold: Optional path or name for bold variant. 

328 italic: Optional path or name for italic variant. 

329 bolditalic: Optional path or name for bold-italic variant. 

330 

331 Example: 

332 >>> font_map.register_font_family( 

333 ... "MyFont", 

334 ... "/path/to/myfont-regular.ttf", 

335 ... "/path/to/myfont-bold.ttf", 

336 ... "/path/to/myfont-italic.ttf", 

337 ... "/path/to/myfont-bolditalic.ttf" 

338 ... ) 

339 """ 

340 self.register_font(family, normal) 

341 if bold is not None: 

342 self.register_font(family, bold, weight="bold") 

343 if italic is not None: 

344 self.register_font(family, italic, style="italic") 

345 if bolditalic is not None: 

346 self.register_font(family, bolditalic, weight="bold", style="italic") 

347 

348 def register_font( 

349 self, 

350 font_family: str, 

351 font_path: Optional[str] = None, 

352 weight: str = "normal", 

353 style: str = "normal", 

354 rlgFontName: Optional[str] = None, 

355 ) -> Tuple[Optional[str], bool]: 

356 """Register a font or create a mapping to a ReportLab font name. 

357 

358 This method handles two scenarios: 

359 1. Registering a custom TrueType font file with ReportLab 

360 2. Creating a mapping from SVG font specifications to existing ReportLab fonts 

361 

362 For standard PDF fonts, only the mapping is created. For custom fonts, 

363 the font file is registered with ReportLab and then mapped. 

364 

365 Args: 

366 font_family: SVG font family name (e.g., "Arial", "Times New Roman"). 

367 font_path: Path to TrueType font file (.ttf). If None, assumes this 

368 is a mapping to an existing ReportLab font. 

369 weight: Font weight ("normal" or "bold"). 

370 style: Font style ("normal" or "italic"). 

371 rlgFontName: ReportLab font name to map to. If None, uses the 

372 normalized internal name. 

373 

374 Returns: 

375 Tuple of (internal_font_name, success_flag). Returns (None, False) 

376 if registration fails. 

377 

378 Raises: 

379 TTFError: If the font file cannot be loaded or registered. 

380 

381 Examples: 

382 >>> # Map to existing ReportLab font 

383 >>> font_map.register_font("MyArial", rlgFontName="Helvetica") 

384 ('MyArial', True) 

385 

386 >>> # Register custom font file 

387 >>> font_map.register_font("MyFont", "/path/to/font.ttf") 

388 ('MyFont', True) 

389 """ 

390 NOT_FOUND = (None, False) 

391 internal_name = FontMap.build_internal_name(font_family, weight, style) 

392 if rlgFontName is None: 

393 # if no reportlabs font name is given, use the internal fontname to 

394 # register the reportlab font 

395 rlgFontName = internal_name 

396 

397 if rlgFontName in STANDARD_FONT_NAMES: 

398 # mapping to one of the standard fonts, no need to register 

399 self._map[internal_name] = { 

400 "svg_family": font_family, 

401 "svg_weight": weight, 

402 "svg_style": style, 

403 "rlgFont": rlgFontName, 

404 "exact": True, 

405 } 

406 self._family_index.setdefault(font_family.lower(), font_family) 

407 return internal_name, True 

408 

409 if internal_name not in STANDARD_FONT_NAMES and font_path is not None: 

410 try: 

411 registerFont(TTFont(rlgFontName, font_path)) 

412 self._map[internal_name] = { 

413 "svg_family": font_family, 

414 "svg_weight": weight, 

415 "svg_style": style, 

416 "rlgFont": rlgFontName, 

417 "exact": True, 

418 } 

419 self._family_index.setdefault(font_family.lower(), font_family) 

420 return internal_name, True 

421 except TTFError: 

422 return NOT_FOUND 

423 

424 # If we reach here, no registration was possible 

425 return NOT_FOUND 

426 

427 def find_font( 

428 self, font_name: str, weight: str = "normal", style: str = "normal" 

429 ) -> Tuple[str, bool]: 

430 """Find the best matching ReportLab font for given specifications. 

431 

432 Searches through the font registry to find the most appropriate font match. 

433 Uses a multi-step fallback strategy: exact match, standard fonts, file-based 

434 registration, fontconfig discovery, and finally default fallback. 

435 

436 Args: 

437 font_name: SVG font family name to search for. 

438 weight: Font weight ("normal" or "bold"). 

439 style: Font style ("normal" or "italic"). 

440 

441 Returns: 

442 Tuple of (reportlab_font_name, is_exact_match). The exact_match flag 

443 indicates whether the returned font is an exact match for the request. 

444 

445 Note: 

446 If no suitable font is found, falls back to DEFAULT_FONT_NAME (Helvetica). 

447 The search prioritizes exact matches over approximate ones. 

448 """ 

449 font_name = self._family_index.get(font_name.lower(), font_name) 

450 internal_name = FontMap.build_internal_name(font_name, weight, style) 

451 # Step 1 check if the font is one of the buildin standard fonts 

452 if internal_name in STANDARD_FONT_NAMES: 

453 return internal_name, True 

454 # Step 2 Check if font is already registered 

455 if internal_name in self._map: 

456 font_entry = self._map[internal_name] 

457 return ( 

458 str(font_entry["rlgFont"]), 

459 bool(font_entry["exact"]), 

460 ) 

461 # Step 3 Try to auto register the font 

462 # Try first to register the font if it exists as ttf 

463 guessed_filename = FontMap.guess_font_filename(font_name, weight, style) 

464 reg_name, exact = self.register_font(font_name, guessed_filename) 

465 if reg_name is not None: 

466 return reg_name, exact 

467 fontconfig_result = self.use_fontconfig(font_name, weight, style) 

468 if fontconfig_result[0] is not None: 

469 return fontconfig_result[0], fontconfig_result[1] 

470 # Fallback to default font if nothing found 

471 return DEFAULT_FONT_NAME, False 

472 

473 

474_font_map = FontMap() # the global font map 

475 

476 

477def register_font( 

478 font_name: str, 

479 font_path: Optional[str] = None, 

480 weight: str = "normal", 

481 style: str = "normal", 

482 rlgFontName: Optional[str] = None, 

483) -> Tuple[Optional[str], bool]: 

484 """Register a font with the global font map. 

485 

486 Convenience function that delegates to the global FontMap instance. 

487 Registers a custom font or creates a mapping to an existing ReportLab font. 

488 

489 Args: 

490 font_name: SVG font family name (e.g., "Arial", "Times New Roman"). 

491 font_path: Path to TrueType font file (.ttf). Optional for mappings 

492 to existing fonts. 

493 weight: Font weight ("normal" or "bold"). 

494 style: Font style ("normal" or "italic"). 

495 rlgFontName: ReportLab font name to map to. 

496 

497 Returns: 

498 Tuple of (internal_font_name, success_flag). 

499 

500 See Also: 

501 FontMap.register_font: The underlying implementation method. 

502 """ 

503 return _font_map.register_font(font_name, font_path, weight, style, rlgFontName) 

504 

505 

506def find_font( 

507 font_name: str, weight: str = "normal", style: str = "normal" 

508) -> Tuple[str, bool]: 

509 """Find the best matching font from the global font registry. 

510 

511 Convenience function that delegates to the global FontMap instance. 

512 Searches for fonts using a multi-step fallback strategy. 

513 

514 Args: 

515 font_name: SVG font family name to search for. 

516 weight: Font weight ("normal" or "bold"). 

517 style: Font style ("normal" or "italic"). 

518 

519 Returns: 

520 Tuple of (reportlab_font_name, is_exact_match). 

521 

522 See Also: 

523 FontMap.find_font: The underlying implementation method. 

524 """ 

525 return _font_map.find_font(font_name, weight, style) 

526 

527 

528def register_font_family( 

529 family: str, 

530 normal: str, 

531 bold: Optional[str] = None, 

532 italic: Optional[str] = None, 

533 bolditalic: Optional[str] = None, 

534) -> None: 

535 """Register a complete font family with the global font map. 

536 

537 Convenience function that delegates to the global FontMap instance. 

538 Registers an entire font family with all style variants at once. 

539 

540 Args: 

541 family: Font family name (e.g., "MyCustomFont"). 

542 normal: Path or name for the normal weight/style variant. 

543 bold: Optional path or name for bold variant. 

544 italic: Optional path or name for italic variant. 

545 bolditalic: Optional path or name for bold-italic variant. 

546 

547 See Also: 

548 FontMap.register_font_family: The underlying implementation method. 

549 """ 

550 _font_map.register_font_family(family, normal, bold, italic, bolditalic) 

551 

552 

553def get_global_font_map() -> FontMap: 

554 """Get the global FontMap instance used by the module. 

555 

556 Returns the singleton FontMap instance that manages all font registrations 

557 and mappings for the svglib module. This is the same instance used by all 

558 module-level font functions. 

559 

560 Returns: 

561 The global FontMap instance. 

562 

563 Note: 

564 Direct access to the FontMap allows for advanced font management 

565 operations not available through the convenience functions. 

566 """ 

567 return _font_map