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
« 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.
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.
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"""
14import os
15import shutil
16import subprocess
17import sys
18from typing import Dict, Optional, Tuple, Union
20from reportlab.pdfbase.pdfmetrics import registerFont
21from reportlab.pdfbase.ttfonts import TTFError, TTFont
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)
45class FontMap:
46 """Manages mapping of SVG font names to ReportLab fonts and handles registration.
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.
52 The internal font map uses normalized font names as keys for efficient lookup
53 and supports both exact and approximate font matching.
54 """
56 def __init__(self) -> None:
57 """Initialize the FontMap with an empty font registry.
59 Creates an empty internal font map and registers all default font mappings
60 for standard PDF fonts and common font family aliases.
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 }
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
77 self.register_default_fonts()
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.
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.
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").
94 Returns:
95 Normalized font name string (e.g., "Arial-BoldItalic").
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
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.
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.
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").
134 Returns:
135 Guessed filename with appropriate weight/style suffix.
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
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.
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.
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").
169 Returns:
170 Tuple of (font_name, is_exact_match). Returns (None, False) if
171 fontconfig is unavailable or no suitable font is found.
173 Raises:
174 OSError: If fontconfig command is not available on the system.
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
227 def register_default_fonts(self) -> None:
228 """Register mappings for standard PDF fonts and common font families.
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.
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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.
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.
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.
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")
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.
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
362 For standard PDF fonts, only the mapping is created. For custom fonts,
363 the font file is registered with ReportLab and then mapped.
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.
374 Returns:
375 Tuple of (internal_font_name, success_flag). Returns (None, False)
376 if registration fails.
378 Raises:
379 TTFError: If the font file cannot be loaded or registered.
381 Examples:
382 >>> # Map to existing ReportLab font
383 >>> font_map.register_font("MyArial", rlgFontName="Helvetica")
384 ('MyArial', True)
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
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
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
424 # If we reach here, no registration was possible
425 return NOT_FOUND
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.
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.
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").
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.
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
474_font_map = FontMap() # the global font map
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.
486 Convenience function that delegates to the global FontMap instance.
487 Registers a custom font or creates a mapping to an existing ReportLab font.
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.
497 Returns:
498 Tuple of (internal_font_name, success_flag).
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)
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.
511 Convenience function that delegates to the global FontMap instance.
512 Searches for fonts using a multi-step fallback strategy.
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").
519 Returns:
520 Tuple of (reportlab_font_name, is_exact_match).
522 See Also:
523 FontMap.find_font: The underlying implementation method.
524 """
525 return _font_map.find_font(font_name, weight, style)
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.
537 Convenience function that delegates to the global FontMap instance.
538 Registers an entire font family with all style variants at once.
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.
547 See Also:
548 FontMap.register_font_family: The underlying implementation method.
549 """
550 _font_map.register_font_family(family, normal, bold, italic, bolditalic)
553def get_global_font_map() -> FontMap:
554 """Get the global FontMap instance used by the module.
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.
560 Returns:
561 The global FontMap instance.
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