Skip to content

Commit

Permalink
Fixed social plugin Google Fonts integration
Browse files Browse the repository at this point in the history
  • Loading branch information
squidfunk committed Mar 31, 2024
1 parent f27b93e commit ad72336
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 82 deletions.
131 changes: 90 additions & 41 deletions material/plugins/social/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
from mkdocs.commands.build import DuplicateFilter
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin
from mkdocs.utils import copy_file
from shutil import copyfile
from tempfile import TemporaryFile
from zipfile import ZipFile
from tempfile import NamedTemporaryFile
try:
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
Expand Down Expand Up @@ -437,53 +437,102 @@ def _load_font(self, config):
else:
name = "Roboto"

# Google fonts can return varients like OpenSans_Condensed-Regular.ttf so
# we only use the font requested e.g. OpenSans-Regular.ttf
font_filename_base = name.replace(' ', '')
filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$"

# Resolve relevant fonts
font = {}
# Check for cached files - note these may be in subfolders
for currentpath, folders, files in os.walk(self.cache):
for file in files:
# Map available font weights to file paths
fname = os.path.join(currentpath, file)
match = re.search(filename_regex, fname)
if match:
font[match.group(1)] = fname

# If none found, fetch from Google and try again
if len(font) == 0:
self._load_font_from_google(name)
for currentpath, folders, files in os.walk(self.cache):
for file in files:
# Map available font weights to file paths
fname = os.path.join(currentpath, file)
match = re.search(filename_regex, fname)
if match:
font[match.group(1)] = fname
for style in ["Regular", "Bold"]:
font[style] = self._resolve_font(name, style)

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

# Retrieve font from Google Fonts
def _load_font_from_google(self, name):
url = "https://fonts.google.com/download?family={}"
res = requests.get(url.format(name.replace(" ", "+")), stream = True)
# Resolve font family with specific style - if we haven't already done it,
# the font family is first downloaded from Google Fonts and the styles are
# saved to the cache directory. If the font cannot be resolved, the plugin
# must abort with an error.
def _resolve_font(self, family: str, style: str):
path = os.path.join(self.config.cache_dir, "fonts", family)

# Fetch font family, if it hasn't been fetched yet
if not os.path.isdir(path):
self._fetch_font_from_google_fonts(family)

# Check for availability of font style
list = sorted(os.listdir(path))
for file in list:
name, _ = os.path.splitext(file)
if name == style:
return os.path.join(path, file)

# Find regular variant of font family - we cannot rely on the fact that
# fonts always have a single regular variant - some of them have several
# of them, potentially prefixed with "Condensed" etc. For this reason we
# use the first font we find if we find no regular one.
fallback = ""
for file in list:
name, _ = os.path.splitext(file)

# 1. Fallback: use first font
if not fallback:
fallback = name

# 2. Fallback: use regular font - use the shortest one, i.e., prefer
# "10pt Regular" over "10pt Condensed Regular". This is a heuristic.
if "Regular" in name:
if not fallback or len(name) < len(fallback):
fallback = name

# Print warning in debug mode, since the font could not be resolved
if self.config.debug:
log.warning(
f"Couldn't find style '{style}' for font family '{family}'. " +
f"Styles available:\n\n" +
f"\n".join([os.path.splitext(file)[0] for file in list]) +
f"\n\n"
f"Falling back to: {fallback}\n"
f"\n"
)

# Fall back to regular font (guess if there are multiple)
return self._resolve_font(family, fallback)

# Write archive to temporary file
tmp = TemporaryFile()
for chunk in res.iter_content(chunk_size = 32768):
tmp.write(chunk)
# Fetch font family from Google Fonts
def _fetch_font_from_google_fonts(self, family: str):
path = os.path.join(self.config.cache_dir, "fonts")

# Unzip fonts from temporary file
zip = ZipFile(tmp)
files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")]
zip.extractall(self.cache, files)
# Download manifest from Google Fonts - Google returns JSON with syntax
# errors, so we just treat the response as plain text and parse out all
# URLs to font files, as we're going to rename them anyway. This should
# be more resilient than trying to correct the JSON syntax.
url = f"https://fonts.google.com/download/list?family={family}"
res = requests.get(url)

# Ensure that the download succeeded
if res.status_code != 200:
raise PluginError(
f"Couldn't find font family '{family}' on Google Fonts "
f"({res.status_code}: {res.reason})"
)

# Close and delete temporary file
tmp.close()
return files
# Extract font URLs from manifest
for match in re.findall(
r"\"(https:(?:.*?)\.[ot]tf)\"", str(res.content)
):
with requests.get(match) as res:
res.raise_for_status()

# Create a temporary file to download the font
with NamedTemporaryFile() as temp:
temp.write(res.content)
temp.flush()

# Extract font family name and style
font = ImageFont.truetype(temp.name)
name, style = font.getname()
name = " ".join([name.replace(family, ""), style]).strip()

# Move fonts to cache directory
target = os.path.join(path, family, f"{name}.ttf")
copy_file(temp.name, target)

# -----------------------------------------------------------------------------
# Data
Expand Down
131 changes: 90 additions & 41 deletions src/plugins/social/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
from mkdocs.commands.build import DuplicateFilter
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin
from mkdocs.utils import copy_file
from shutil import copyfile
from tempfile import TemporaryFile
from zipfile import ZipFile
from tempfile import NamedTemporaryFile
try:
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
Expand Down Expand Up @@ -437,53 +437,102 @@ def _load_font(self, config):
else:
name = "Roboto"

# Google fonts can return varients like OpenSans_Condensed-Regular.ttf so
# we only use the font requested e.g. OpenSans-Regular.ttf
font_filename_base = name.replace(' ', '')
filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$"

# Resolve relevant fonts
font = {}
# Check for cached files - note these may be in subfolders
for currentpath, folders, files in os.walk(self.cache):
for file in files:
# Map available font weights to file paths
fname = os.path.join(currentpath, file)
match = re.search(filename_regex, fname)
if match:
font[match.group(1)] = fname

# If none found, fetch from Google and try again
if len(font) == 0:
self._load_font_from_google(name)
for currentpath, folders, files in os.walk(self.cache):
for file in files:
# Map available font weights to file paths
fname = os.path.join(currentpath, file)
match = re.search(filename_regex, fname)
if match:
font[match.group(1)] = fname
for style in ["Regular", "Bold"]:
font[style] = self._resolve_font(name, style)

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

# Retrieve font from Google Fonts
def _load_font_from_google(self, name):
url = "https://fonts.google.com/download?family={}"
res = requests.get(url.format(name.replace(" ", "+")), stream = True)
# Resolve font family with specific style - if we haven't already done it,
# the font family is first downloaded from Google Fonts and the styles are
# saved to the cache directory. If the font cannot be resolved, the plugin
# must abort with an error.
def _resolve_font(self, family: str, style: str):
path = os.path.join(self.config.cache_dir, "fonts", family)

# Fetch font family, if it hasn't been fetched yet
if not os.path.isdir(path):
self._fetch_font_from_google_fonts(family)

# Check for availability of font style
list = sorted(os.listdir(path))
for file in list:
name, _ = os.path.splitext(file)
if name == style:
return os.path.join(path, file)

# Find regular variant of font family - we cannot rely on the fact that
# fonts always have a single regular variant - some of them have several
# of them, potentially prefixed with "Condensed" etc. For this reason we
# use the first font we find if we find no regular one.
fallback = ""
for file in list:
name, _ = os.path.splitext(file)

# 1. Fallback: use first font
if not fallback:
fallback = name

# 2. Fallback: use regular font - use the shortest one, i.e., prefer
# "10pt Regular" over "10pt Condensed Regular". This is a heuristic.
if "Regular" in name:
if not fallback or len(name) < len(fallback):
fallback = name

# Print warning in debug mode, since the font could not be resolved
if self.config.debug:
log.warning(
f"Couldn't find style '{style}' for font family '{family}'. " +
f"Styles available:\n\n" +
f"\n".join([os.path.splitext(file)[0] for file in list]) +
f"\n\n"
f"Falling back to: {fallback}\n"
f"\n"
)

# Fall back to regular font (guess if there are multiple)
return self._resolve_font(family, fallback)

# Write archive to temporary file
tmp = TemporaryFile()
for chunk in res.iter_content(chunk_size = 32768):
tmp.write(chunk)
# Fetch font family from Google Fonts
def _fetch_font_from_google_fonts(self, family: str):
path = os.path.join(self.config.cache_dir, "fonts")

# Unzip fonts from temporary file
zip = ZipFile(tmp)
files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")]
zip.extractall(self.cache, files)
# Download manifest from Google Fonts - Google returns JSON with syntax
# errors, so we just treat the response as plain text and parse out all
# URLs to font files, as we're going to rename them anyway. This should
# be more resilient than trying to correct the JSON syntax.
url = f"https://fonts.google.com/download/list?family={family}"
res = requests.get(url)

# Ensure that the download succeeded
if res.status_code != 200:
raise PluginError(
f"Couldn't find font family '{family}' on Google Fonts "
f"({res.status_code}: {res.reason})"
)

# Close and delete temporary file
tmp.close()
return files
# Extract font URLs from manifest
for match in re.findall(
r"\"(https:(?:.*?)\.[ot]tf)\"", str(res.content)
):
with requests.get(match) as res:
res.raise_for_status()

# Create a temporary file to download the font
with NamedTemporaryFile() as temp:
temp.write(res.content)
temp.flush()

# Extract font family name and style
font = ImageFont.truetype(temp.name)
name, style = font.getname()
name = " ".join([name.replace(family, ""), style]).strip()

# Move fonts to cache directory
target = os.path.join(path, family, f"{name}.ttf")
copy_file(temp.name, target)

# -----------------------------------------------------------------------------
# Data
Expand Down

0 comments on commit ad72336

Please sign in to comment.