|
46 | 46 | from mkdocs.commands.build import DuplicateFilter
|
47 | 47 | from mkdocs.exceptions import PluginError
|
48 | 48 | from mkdocs.plugins import BasePlugin
|
| 49 | +from mkdocs.utils import copy_file |
49 | 50 | from shutil import copyfile
|
50 |
| -from tempfile import TemporaryFile |
51 |
| -from zipfile import ZipFile |
| 51 | +from tempfile import NamedTemporaryFile |
52 | 52 | try:
|
53 | 53 | from cairosvg import svg2png
|
54 | 54 | from PIL import Image, ImageDraw, ImageFont
|
@@ -437,53 +437,102 @@ def _load_font(self, config):
|
437 | 437 | else:
|
438 | 438 | name = "Roboto"
|
439 | 439 |
|
440 |
| - # Google fonts can return varients like OpenSans_Condensed-Regular.ttf so |
441 |
| - # we only use the font requested e.g. OpenSans-Regular.ttf |
442 |
| - font_filename_base = name.replace(' ', '') |
443 |
| - filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$" |
444 |
| - |
| 440 | + # Resolve relevant fonts |
445 | 441 | font = {}
|
446 |
| - # Check for cached files - note these may be in subfolders |
447 |
| - for currentpath, folders, files in os.walk(self.cache): |
448 |
| - for file in files: |
449 |
| - # Map available font weights to file paths |
450 |
| - fname = os.path.join(currentpath, file) |
451 |
| - match = re.search(filename_regex, fname) |
452 |
| - if match: |
453 |
| - font[match.group(1)] = fname |
454 |
| - |
455 |
| - # If none found, fetch from Google and try again |
456 |
| - if len(font) == 0: |
457 |
| - self._load_font_from_google(name) |
458 |
| - for currentpath, folders, files in os.walk(self.cache): |
459 |
| - for file in files: |
460 |
| - # Map available font weights to file paths |
461 |
| - fname = os.path.join(currentpath, file) |
462 |
| - match = re.search(filename_regex, fname) |
463 |
| - if match: |
464 |
| - font[match.group(1)] = fname |
| 442 | + for style in ["Regular", "Bold"]: |
| 443 | + font[style] = self._resolve_font(name, style) |
465 | 444 |
|
466 | 445 | # Return available font weights with fallback
|
467 | 446 | return defaultdict(lambda: font["Regular"], font)
|
468 | 447 |
|
469 |
| - # Retrieve font from Google Fonts |
470 |
| - def _load_font_from_google(self, name): |
471 |
| - url = "https://fonts.google.com/download?family={}" |
472 |
| - res = requests.get(url.format(name.replace(" ", "+")), stream = True) |
| 448 | + # Resolve font family with specific style - if we haven't already done it, |
| 449 | + # the font family is first downloaded from Google Fonts and the styles are |
| 450 | + # saved to the cache directory. If the font cannot be resolved, the plugin |
| 451 | + # must abort with an error. |
| 452 | + def _resolve_font(self, family: str, style: str): |
| 453 | + path = os.path.join(self.config.cache_dir, "fonts", family) |
| 454 | + |
| 455 | + # Fetch font family, if it hasn't been fetched yet |
| 456 | + if not os.path.isdir(path): |
| 457 | + self._fetch_font_from_google_fonts(family) |
| 458 | + |
| 459 | + # Check for availability of font style |
| 460 | + list = sorted(os.listdir(path)) |
| 461 | + for file in list: |
| 462 | + name, _ = os.path.splitext(file) |
| 463 | + if name == style: |
| 464 | + return os.path.join(path, file) |
| 465 | + |
| 466 | + # Find regular variant of font family - we cannot rely on the fact that |
| 467 | + # fonts always have a single regular variant - some of them have several |
| 468 | + # of them, potentially prefixed with "Condensed" etc. For this reason we |
| 469 | + # use the first font we find if we find no regular one. |
| 470 | + fallback = "" |
| 471 | + for file in list: |
| 472 | + name, _ = os.path.splitext(file) |
| 473 | + |
| 474 | + # 1. Fallback: use first font |
| 475 | + if not fallback: |
| 476 | + fallback = name |
| 477 | + |
| 478 | + # 2. Fallback: use regular font - use the shortest one, i.e., prefer |
| 479 | + # "10pt Regular" over "10pt Condensed Regular". This is a heuristic. |
| 480 | + if "Regular" in name: |
| 481 | + if not fallback or len(name) < len(fallback): |
| 482 | + fallback = name |
| 483 | + |
| 484 | + # Print warning in debug mode, since the font could not be resolved |
| 485 | + if self.config.debug: |
| 486 | + log.warning( |
| 487 | + f"Couldn't find style '{style}' for font family '{family}'. " + |
| 488 | + f"Styles available:\n\n" + |
| 489 | + f"\n".join([os.path.splitext(file)[0] for file in list]) + |
| 490 | + f"\n\n" |
| 491 | + f"Falling back to: {fallback}\n" |
| 492 | + f"\n" |
| 493 | + ) |
| 494 | + |
| 495 | + # Fall back to regular font (guess if there are multiple) |
| 496 | + return self._resolve_font(family, fallback) |
473 | 497 |
|
474 |
| - # Write archive to temporary file |
475 |
| - tmp = TemporaryFile() |
476 |
| - for chunk in res.iter_content(chunk_size = 32768): |
477 |
| - tmp.write(chunk) |
| 498 | + # Fetch font family from Google Fonts |
| 499 | + def _fetch_font_from_google_fonts(self, family: str): |
| 500 | + path = os.path.join(self.config.cache_dir, "fonts") |
478 | 501 |
|
479 |
| - # Unzip fonts from temporary file |
480 |
| - zip = ZipFile(tmp) |
481 |
| - files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")] |
482 |
| - zip.extractall(self.cache, files) |
| 502 | + # Download manifest from Google Fonts - Google returns JSON with syntax |
| 503 | + # errors, so we just treat the response as plain text and parse out all |
| 504 | + # URLs to font files, as we're going to rename them anyway. This should |
| 505 | + # be more resilient than trying to correct the JSON syntax. |
| 506 | + url = f"https://fonts.google.com/download/list?family={family}" |
| 507 | + res = requests.get(url) |
| 508 | + |
| 509 | + # Ensure that the download succeeded |
| 510 | + if res.status_code != 200: |
| 511 | + raise PluginError( |
| 512 | + f"Couldn't find font family '{family}' on Google Fonts " |
| 513 | + f"({res.status_code}: {res.reason})" |
| 514 | + ) |
483 | 515 |
|
484 |
| - # Close and delete temporary file |
485 |
| - tmp.close() |
486 |
| - return files |
| 516 | + # Extract font URLs from manifest |
| 517 | + for match in re.findall( |
| 518 | + r"\"(https:(?:.*?)\.[ot]tf)\"", str(res.content) |
| 519 | + ): |
| 520 | + with requests.get(match) as res: |
| 521 | + res.raise_for_status() |
| 522 | + |
| 523 | + # Create a temporary file to download the font |
| 524 | + with NamedTemporaryFile() as temp: |
| 525 | + temp.write(res.content) |
| 526 | + temp.flush() |
| 527 | + |
| 528 | + # Extract font family name and style |
| 529 | + font = ImageFont.truetype(temp.name) |
| 530 | + name, style = font.getname() |
| 531 | + name = " ".join([name.replace(family, ""), style]).strip() |
| 532 | + |
| 533 | + # Move fonts to cache directory |
| 534 | + target = os.path.join(path, family, f"{name}.ttf") |
| 535 | + copy_file(temp.name, target) |
487 | 536 |
|
488 | 537 | # -----------------------------------------------------------------------------
|
489 | 538 | # Data
|
|
0 commit comments