Skip to content

Commit ad72336

Browse files
committed
Fixed social plugin Google Fonts integration
1 parent f27b93e commit ad72336

File tree

2 files changed

+180
-82
lines changed

2 files changed

+180
-82
lines changed

Diff for: material/plugins/social/plugin.py

+90-41
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@
4646
from mkdocs.commands.build import DuplicateFilter
4747
from mkdocs.exceptions import PluginError
4848
from mkdocs.plugins import BasePlugin
49+
from mkdocs.utils import copy_file
4950
from shutil import copyfile
50-
from tempfile import TemporaryFile
51-
from zipfile import ZipFile
51+
from tempfile import NamedTemporaryFile
5252
try:
5353
from cairosvg import svg2png
5454
from PIL import Image, ImageDraw, ImageFont
@@ -437,53 +437,102 @@ def _load_font(self, config):
437437
else:
438438
name = "Roboto"
439439

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
445441
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)
465444

466445
# Return available font weights with fallback
467446
return defaultdict(lambda: font["Regular"], font)
468447

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)
473497

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")
478501

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+
)
483515

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)
487536

488537
# -----------------------------------------------------------------------------
489538
# Data

Diff for: src/plugins/social/plugin.py

+90-41
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@
4646
from mkdocs.commands.build import DuplicateFilter
4747
from mkdocs.exceptions import PluginError
4848
from mkdocs.plugins import BasePlugin
49+
from mkdocs.utils import copy_file
4950
from shutil import copyfile
50-
from tempfile import TemporaryFile
51-
from zipfile import ZipFile
51+
from tempfile import NamedTemporaryFile
5252
try:
5353
from cairosvg import svg2png
5454
from PIL import Image, ImageDraw, ImageFont
@@ -437,53 +437,102 @@ def _load_font(self, config):
437437
else:
438438
name = "Roboto"
439439

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
445441
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)
465444

466445
# Return available font weights with fallback
467446
return defaultdict(lambda: font["Regular"], font)
468447

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)
473497

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")
478501

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+
)
483515

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)
487536

488537
# -----------------------------------------------------------------------------
489538
# Data

0 commit comments

Comments
 (0)