diff --git a/material/plugins/social/plugin.py b/material/plugins/social/plugin.py index 917454a62..b1be351a6 100644 --- a/material/plugins/social/plugin.py +++ b/material/plugins/social/plugin.py @@ -46,9 +46,9 @@ from io import BytesIO 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 @@ -437,53 +437,102 @@ class SocialPlugin(BasePlugin[SocialConfig]): 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) - # Write archive to temporary file - tmp = TemporaryFile() - for chunk in res.iter_content(chunk_size = 32768): - tmp.write(chunk) + # Fetch font family, if it hasn't been fetched yet + if not os.path.isdir(path): + self._fetch_font_from_google_fonts(family) - # 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) + # 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) - # Close and delete temporary file - tmp.close() - return files + # 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) + + # 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 diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py index 917454a62..b1be351a6 100644 --- a/src/plugins/social/plugin.py +++ b/src/plugins/social/plugin.py @@ -46,9 +46,9 @@ from io import BytesIO 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 @@ -437,53 +437,102 @@ class SocialPlugin(BasePlugin[SocialConfig]): 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) - # Write archive to temporary file - tmp = TemporaryFile() - for chunk in res.iter_content(chunk_size = 32768): - tmp.write(chunk) + # Fetch font family, if it hasn't been fetched yet + if not os.path.isdir(path): + self._fetch_font_from_google_fonts(family) - # 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) + # 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) - # Close and delete temporary file - tmp.close() - return files + # 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) + + # 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