Fixed social plugin Google Fonts integration

This commit is contained in:
squidfunk 2024-03-31 15:23:22 +08:00
parent f27b93ece3
commit ad7233640f
No known key found for this signature in database
GPG Key ID: 5ED40BC4F9C436DF
2 changed files with 180 additions and 82 deletions

View File

@ -46,9 +46,9 @@ from io import BytesIO
from mkdocs.commands.build import DuplicateFilter from mkdocs.commands.build import DuplicateFilter
from mkdocs.exceptions import PluginError from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
from mkdocs.utils import copy_file
from shutil import copyfile from shutil import copyfile
from tempfile import TemporaryFile from tempfile import NamedTemporaryFile
from zipfile import ZipFile
try: try:
from cairosvg import svg2png from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@ -437,53 +437,102 @@ class SocialPlugin(BasePlugin[SocialConfig]):
else: else:
name = "Roboto" name = "Roboto"
# Google fonts can return varients like OpenSans_Condensed-Regular.ttf so # Resolve relevant fonts
# 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$"
font = {} font = {}
# Check for cached files - note these may be in subfolders for style in ["Regular", "Bold"]:
for currentpath, folders, files in os.walk(self.cache): font[style] = self._resolve_font(name, style)
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
# Return available font weights with fallback # Return available font weights with fallback
return defaultdict(lambda: font["Regular"], font) return defaultdict(lambda: font["Regular"], font)
# Retrieve font from Google Fonts # Resolve font family with specific style - if we haven't already done it,
def _load_font_from_google(self, name): # the font family is first downloaded from Google Fonts and the styles are
url = "https://fonts.google.com/download?family={}" # saved to the cache directory. If the font cannot be resolved, the plugin
res = requests.get(url.format(name.replace(" ", "+")), stream = True) # must abort with an error.
def _resolve_font(self, family: str, style: str):
path = os.path.join(self.config.cache_dir, "fonts", family)
# Write archive to temporary file # Fetch font family, if it hasn't been fetched yet
tmp = TemporaryFile() if not os.path.isdir(path):
for chunk in res.iter_content(chunk_size = 32768): self._fetch_font_from_google_fonts(family)
tmp.write(chunk)
# Unzip fonts from temporary file # Check for availability of font style
zip = ZipFile(tmp) list = sorted(os.listdir(path))
files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")] for file in list:
zip.extractall(self.cache, files) name, _ = os.path.splitext(file)
if name == style:
return os.path.join(path, file)
# Close and delete temporary file # Find regular variant of font family - we cannot rely on the fact that
tmp.close() # fonts always have a single regular variant - some of them have several
return files # 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)
# Fetch font family from Google Fonts
def _fetch_font_from_google_fonts(self, family: str):
path = os.path.join(self.config.cache_dir, "fonts")
# 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})"
)
# 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 # Data

View File

@ -46,9 +46,9 @@ from io import BytesIO
from mkdocs.commands.build import DuplicateFilter from mkdocs.commands.build import DuplicateFilter
from mkdocs.exceptions import PluginError from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin from mkdocs.plugins import BasePlugin
from mkdocs.utils import copy_file
from shutil import copyfile from shutil import copyfile
from tempfile import TemporaryFile from tempfile import NamedTemporaryFile
from zipfile import ZipFile
try: try:
from cairosvg import svg2png from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@ -437,53 +437,102 @@ class SocialPlugin(BasePlugin[SocialConfig]):
else: else:
name = "Roboto" name = "Roboto"
# Google fonts can return varients like OpenSans_Condensed-Regular.ttf so # Resolve relevant fonts
# 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$"
font = {} font = {}
# Check for cached files - note these may be in subfolders for style in ["Regular", "Bold"]:
for currentpath, folders, files in os.walk(self.cache): font[style] = self._resolve_font(name, style)
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
# Return available font weights with fallback # Return available font weights with fallback
return defaultdict(lambda: font["Regular"], font) return defaultdict(lambda: font["Regular"], font)
# Retrieve font from Google Fonts # Resolve font family with specific style - if we haven't already done it,
def _load_font_from_google(self, name): # the font family is first downloaded from Google Fonts and the styles are
url = "https://fonts.google.com/download?family={}" # saved to the cache directory. If the font cannot be resolved, the plugin
res = requests.get(url.format(name.replace(" ", "+")), stream = True) # must abort with an error.
def _resolve_font(self, family: str, style: str):
path = os.path.join(self.config.cache_dir, "fonts", family)
# Write archive to temporary file # Fetch font family, if it hasn't been fetched yet
tmp = TemporaryFile() if not os.path.isdir(path):
for chunk in res.iter_content(chunk_size = 32768): self._fetch_font_from_google_fonts(family)
tmp.write(chunk)
# Unzip fonts from temporary file # Check for availability of font style
zip = ZipFile(tmp) list = sorted(os.listdir(path))
files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")] for file in list:
zip.extractall(self.cache, files) name, _ = os.path.splitext(file)
if name == style:
return os.path.join(path, file)
# Close and delete temporary file # Find regular variant of font family - we cannot rely on the fact that
tmp.close() # fonts always have a single regular variant - some of them have several
return files # 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)
# Fetch font family from Google Fonts
def _fetch_font_from_google_fonts(self, family: str):
path = os.path.join(self.config.cache_dir, "fonts")
# 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})"
)
# 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 # Data