580 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			580 lines
		
	
	
		
			21 KiB
		
	
	
	
		
			Python
		
	
	
	
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
 | 
						|
 | 
						|
# Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
						|
# of this software and associated documentation files (the "Software"), to
 | 
						|
# deal in the Software without restriction, including without limitation the
 | 
						|
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
 | 
						|
# sell copies of the Software, and to permit persons to whom the Software is
 | 
						|
# furnished to do so, subject to the following conditions:
 | 
						|
 | 
						|
# The above copyright notice and this permission notice shall be included in
 | 
						|
# all copies or substantial portions of the Software.
 | 
						|
 | 
						|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
						|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
						|
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
 | 
						|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
						|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 | 
						|
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 | 
						|
# IN THE SOFTWARE.
 | 
						|
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
# Disclaimer
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
# Please note: this version of the social plugin is not actively development
 | 
						|
# anymore. Instead, Material for MkDocs Insiders ships a complete rewrite of
 | 
						|
# the plugin which is much more powerful and addresses all shortcomings of
 | 
						|
# this implementation. Additionally, the new social plugin allows to create
 | 
						|
# entirely custom social cards. You can probably imagine, that this was a lot
 | 
						|
# of work to pull off. If you run into problems, or want to have additional
 | 
						|
# functionality, please consider sponsoring the project. You can then use the
 | 
						|
# new version of the plugin immediately.
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
 | 
						|
import concurrent.futures
 | 
						|
import functools
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import posixpath
 | 
						|
import re
 | 
						|
import requests
 | 
						|
import sys
 | 
						|
 | 
						|
from collections import defaultdict
 | 
						|
from hashlib import md5
 | 
						|
from html import unescape
 | 
						|
from io import BytesIO
 | 
						|
from mkdocs.commands.build import DuplicateFilter
 | 
						|
from mkdocs.exceptions import PluginError
 | 
						|
from mkdocs.plugins import BasePlugin
 | 
						|
from mkdocs.utils import write_file
 | 
						|
from shutil import copyfile
 | 
						|
 | 
						|
from .config import SocialConfig
 | 
						|
 | 
						|
try:
 | 
						|
    from PIL import Image, ImageDraw, ImageFont
 | 
						|
except ImportError as e:
 | 
						|
    import_errors = {repr(e)}
 | 
						|
else:
 | 
						|
    import_errors = set()
 | 
						|
 | 
						|
cairosvg_error: str = ""
 | 
						|
 | 
						|
try:
 | 
						|
    from cairosvg import svg2png
 | 
						|
except ImportError as e:
 | 
						|
    import_errors.add(repr(e))
 | 
						|
except OSError as e:
 | 
						|
    cairosvg_error = str(e)
 | 
						|
 | 
						|
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
# Classes
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
 | 
						|
# Social plugin
 | 
						|
class SocialPlugin(BasePlugin[SocialConfig]):
 | 
						|
 | 
						|
    def __init__(self):
 | 
						|
        self._executor = concurrent.futures.ThreadPoolExecutor(4)
 | 
						|
 | 
						|
    # Retrieve configuration
 | 
						|
    def on_config(self, config):
 | 
						|
        self.color = colors.get("indigo")
 | 
						|
        if not self.config.enabled:
 | 
						|
            self.config.cards = False
 | 
						|
        if not self.config.cards:
 | 
						|
            return
 | 
						|
 | 
						|
        # Check dependencies
 | 
						|
        if import_errors:
 | 
						|
            raise PluginError(
 | 
						|
                "Required dependencies of \"social\" plugin not found:\n"
 | 
						|
                + str("\n".join(map(lambda x: "- " + x, import_errors)))
 | 
						|
                + "\n\n--> Install with: pip install \"mkdocs-material[imaging]\""
 | 
						|
            )
 | 
						|
 | 
						|
        if cairosvg_error:
 | 
						|
            raise PluginError(
 | 
						|
                "\"cairosvg\" Python module is installed, but it crashed with:\n"
 | 
						|
                + cairosvg_error
 | 
						|
                + "\n\n--> Check out the troubleshooting guide: https://t.ly/MfX6u"
 | 
						|
            )
 | 
						|
 | 
						|
        # Move color options
 | 
						|
        if self.config.cards_color:
 | 
						|
 | 
						|
            # Move background color to new option
 | 
						|
            value = self.config.cards_color.get("fill")
 | 
						|
            if value:
 | 
						|
                self.config.cards_layout_options["background_color"] = value
 | 
						|
 | 
						|
            # Move color to new option
 | 
						|
            value = self.config.cards_color.get("text")
 | 
						|
            if value:
 | 
						|
                self.config.cards_layout_options["color"] = value
 | 
						|
 | 
						|
        # Move font family to new option
 | 
						|
        if self.config.cards_font:
 | 
						|
            value = self.config.cards_font
 | 
						|
            self.config.cards_layout_options["font_family"] = value
 | 
						|
 | 
						|
        # Check if site URL is defined
 | 
						|
        if not config.site_url:
 | 
						|
            log.warning(
 | 
						|
                "The \"site_url\" option is not set. The cards are generated, "
 | 
						|
                "but not linked, so they won't be visible on social media."
 | 
						|
            )
 | 
						|
 | 
						|
        # Ensure presence of cache directory
 | 
						|
        self.cache = self.config.cache_dir
 | 
						|
        if not os.path.isdir(self.cache):
 | 
						|
            os.makedirs(self.cache)
 | 
						|
 | 
						|
        # Retrieve palette from theme configuration
 | 
						|
        theme = config.theme
 | 
						|
        if "palette" in theme:
 | 
						|
            palette = theme["palette"]
 | 
						|
 | 
						|
            # Find first palette that includes primary color definition
 | 
						|
            if isinstance(palette, list):
 | 
						|
                for p in palette:
 | 
						|
                    if "primary" in p and p["primary"]:
 | 
						|
                        palette = p
 | 
						|
                        break
 | 
						|
 | 
						|
            # Set colors according to palette
 | 
						|
            if "primary" in palette and palette["primary"]:
 | 
						|
                primary = palette["primary"].replace(" ", "-")
 | 
						|
                self.color = colors.get(primary, self.color)
 | 
						|
 | 
						|
        # Retrieve color overrides
 | 
						|
        options = self.config.cards_layout_options
 | 
						|
        self.color = {
 | 
						|
            "fill": options.get("background_color", self.color["fill"]),
 | 
						|
            "text": options.get("color", self.color["text"])
 | 
						|
        }
 | 
						|
 | 
						|
        # Retrieve logo and font
 | 
						|
        self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config)
 | 
						|
        self.font = self._load_font(config)
 | 
						|
 | 
						|
        self._image_promises = []
 | 
						|
 | 
						|
    # Create social cards
 | 
						|
    def on_page_markdown(self, markdown, page, config, files):
 | 
						|
        if not self.config.cards:
 | 
						|
            return
 | 
						|
 | 
						|
        # Resolve image directory
 | 
						|
        directory = self.config.cards_dir
 | 
						|
        file, _ = os.path.splitext(page.file.src_path)
 | 
						|
 | 
						|
        # Resolve path of image
 | 
						|
        path = "{}.png".format(os.path.join(
 | 
						|
            config.site_dir,
 | 
						|
            directory,
 | 
						|
            file
 | 
						|
        ))
 | 
						|
 | 
						|
        # Resolve path of image directory
 | 
						|
        directory = os.path.dirname(path)
 | 
						|
        if not os.path.isdir(directory):
 | 
						|
            os.makedirs(directory)
 | 
						|
 | 
						|
        # Compute site name
 | 
						|
        site_name = config.site_name
 | 
						|
 | 
						|
        # Compute page title and description
 | 
						|
        title = page.meta.get("title", page.title)
 | 
						|
        description = config.site_description or ""
 | 
						|
        if "description" in page.meta:
 | 
						|
            description = page.meta["description"]
 | 
						|
 | 
						|
        # Check type of meta title - see https://t.ly/m1Us
 | 
						|
        if not isinstance(title, str):
 | 
						|
            log.error(
 | 
						|
                f"Page meta title of page '{page.file.src_uri}' must be a "
 | 
						|
                f"string, but is of type \"{type(title)}\"."
 | 
						|
            )
 | 
						|
            sys.exit(1)
 | 
						|
 | 
						|
        # Check type of meta description - see https://t.ly/m1Us
 | 
						|
        if not isinstance(description, str):
 | 
						|
            log.error(
 | 
						|
                f"Page meta description of '{page.file.src_uri}' must be a "
 | 
						|
                f"string, but is of type \"{type(description)}\"."
 | 
						|
            )
 | 
						|
            sys.exit(1)
 | 
						|
 | 
						|
        # Generate social card if not in cache
 | 
						|
        hash = md5("".join([
 | 
						|
            site_name,
 | 
						|
            str(title),
 | 
						|
            description
 | 
						|
        ]).encode("utf-8"))
 | 
						|
        file = os.path.join(self.cache, f"{hash.hexdigest()}.png")
 | 
						|
        self._image_promises.append(self._executor.submit(
 | 
						|
            self._cache_image,
 | 
						|
            cache_path = file, dest_path = path,
 | 
						|
            render_function = lambda: self._render_card(site_name, title, description)
 | 
						|
        ))
 | 
						|
 | 
						|
        # Inject meta tags into page
 | 
						|
        meta = page.meta.get("meta", [])
 | 
						|
        page.meta["meta"] = meta + self._generate_meta(page, config)
 | 
						|
 | 
						|
    def on_post_build(self, config):
 | 
						|
        if not self.config.cards:
 | 
						|
            return
 | 
						|
 | 
						|
        # Check for exceptions
 | 
						|
        for promise in self._image_promises:
 | 
						|
            promise.result()
 | 
						|
 | 
						|
    # -------------------------------------------------------------------------
 | 
						|
 | 
						|
    # Render image to cache (if not present), then copy from cache to site
 | 
						|
    def _cache_image(self, cache_path, dest_path, render_function):
 | 
						|
        if not os.path.isfile(cache_path):
 | 
						|
            image = render_function()
 | 
						|
            image.save(cache_path)
 | 
						|
 | 
						|
        # Copy file from cache
 | 
						|
        copyfile(cache_path, dest_path)
 | 
						|
 | 
						|
    @functools.lru_cache(maxsize=None)
 | 
						|
    def _get_font(self, kind, size):
 | 
						|
        return ImageFont.truetype(self.font[kind], size)
 | 
						|
 | 
						|
    # Render social card
 | 
						|
    def _render_card(self, site_name, title, description):
 | 
						|
        # Render background and logo
 | 
						|
        image = self._render_card_background((1200, 630), self.color["fill"])
 | 
						|
        image.alpha_composite(
 | 
						|
            self._resized_logo_promise.result(),
 | 
						|
            (1200 - 228, 64 - 4)
 | 
						|
        )
 | 
						|
 | 
						|
        # Render site name
 | 
						|
        font = self._get_font("Bold", 36)
 | 
						|
        image.alpha_composite(
 | 
						|
            self._render_text((826, 48), font, site_name, 1, 20),
 | 
						|
            (64 + 4, 64)
 | 
						|
        )
 | 
						|
 | 
						|
        # Render page title
 | 
						|
        font = self._get_font("Bold", 92)
 | 
						|
        image.alpha_composite(
 | 
						|
            self._render_text((826, 328), font, title, 3, 30),
 | 
						|
            (64, 160)
 | 
						|
        )
 | 
						|
 | 
						|
        # Render page description
 | 
						|
        font = self._get_font("Regular", 28)
 | 
						|
        image.alpha_composite(
 | 
						|
            self._render_text((826, 80), font, description, 2, 14),
 | 
						|
            (64 + 4, 512)
 | 
						|
        )
 | 
						|
 | 
						|
        # Return social card image
 | 
						|
        return image
 | 
						|
 | 
						|
    # Render social card background
 | 
						|
    def _render_card_background(self, size, fill):
 | 
						|
        return Image.new(mode = "RGBA", size = size, color = fill)
 | 
						|
 | 
						|
    @functools.lru_cache(maxsize=None)
 | 
						|
    def _tmp_context(self):
 | 
						|
        image = Image.new(mode = "RGBA", size = (50, 50))
 | 
						|
        return ImageDraw.Draw(image)
 | 
						|
 | 
						|
    @functools.lru_cache(maxsize=None)
 | 
						|
    def _text_bounding_box(self, text, font):
 | 
						|
        return self._tmp_context().textbbox((0, 0), text, font = font)
 | 
						|
 | 
						|
    # Render social card text
 | 
						|
    def _render_text(self, size, font, text, lmax, spacing = 0):
 | 
						|
        width = size[0]
 | 
						|
        lines, words = [], []
 | 
						|
 | 
						|
        # Remove remnant HTML tags and convert HTML entities
 | 
						|
        text = re.sub(r"(<[^>]+>)", "", text)
 | 
						|
        text = unescape(text)
 | 
						|
 | 
						|
        # Retrieve y-offset of textbox to correct for spacing
 | 
						|
        yoffset = 0
 | 
						|
 | 
						|
        # Create drawing context and split text into lines
 | 
						|
        for word in text.split(" "):
 | 
						|
            combine = " ".join(words + [word])
 | 
						|
            textbox = self._text_bounding_box(combine, font = font)
 | 
						|
            yoffset = textbox[1]
 | 
						|
            if not words or textbox[2] <= width:
 | 
						|
                words.append(word)
 | 
						|
            else:
 | 
						|
                lines.append(words)
 | 
						|
                words = [word]
 | 
						|
 | 
						|
        # Join words for each line and create image
 | 
						|
        lines.append(words)
 | 
						|
        lines = [" ".join(line) for line in lines]
 | 
						|
        image = Image.new(mode = "RGBA", size = size)
 | 
						|
 | 
						|
        # Create drawing context and split text into lines
 | 
						|
        context = ImageDraw.Draw(image)
 | 
						|
        context.text(
 | 
						|
            (0, spacing / 2 - yoffset), "\n".join(lines[:lmax]),
 | 
						|
            font = font, fill = self.color["text"], spacing = spacing - yoffset
 | 
						|
        )
 | 
						|
 | 
						|
        # Return text image
 | 
						|
        return image
 | 
						|
 | 
						|
    # -------------------------------------------------------------------------
 | 
						|
 | 
						|
    # Generate meta tags
 | 
						|
    def _generate_meta(self, page, config):
 | 
						|
        directory = self.config.cards_dir
 | 
						|
        file, _ = os.path.splitext(page.file.src_uri)
 | 
						|
 | 
						|
        # Compute page title
 | 
						|
        if page.is_homepage:
 | 
						|
            title = config.site_name
 | 
						|
        else:
 | 
						|
            page_title = page.meta.get("title", page.title)
 | 
						|
            title = f"{page_title} - {config.site_name}"
 | 
						|
 | 
						|
        # Compute page description
 | 
						|
        description = config.site_description
 | 
						|
        if "description" in page.meta:
 | 
						|
            description = page.meta["description"]
 | 
						|
 | 
						|
        # Resolve image URL
 | 
						|
        url = "{}.png".format(posixpath.join(
 | 
						|
            config.site_url or ".",
 | 
						|
            directory,
 | 
						|
            file
 | 
						|
        ))
 | 
						|
 | 
						|
        # Ensure forward slashes
 | 
						|
        url = url.replace(os.path.sep, "/")
 | 
						|
 | 
						|
        # Return meta tags
 | 
						|
        return [
 | 
						|
 | 
						|
            # Meta tags for Open Graph
 | 
						|
            { "property": "og:type", "content": "website" },
 | 
						|
            { "property": "og:title", "content": title },
 | 
						|
            { "property": "og:description", "content": description },
 | 
						|
            { "property": "og:image", "content": url },
 | 
						|
            { "property": "og:image:type", "content": "image/png" },
 | 
						|
            { "property": "og:image:width", "content": "1200" },
 | 
						|
            { "property": "og:image:height", "content": "630" },
 | 
						|
            { "property": "og:url", "content": page.canonical_url },
 | 
						|
 | 
						|
            # Meta tags for Twitter
 | 
						|
            { "name": "twitter:card", "content": "summary_large_image" },
 | 
						|
            # { "name": "twitter:site", "content": user },
 | 
						|
            # { "name": "twitter:creator", "content": user },
 | 
						|
            { "name": "twitter:title", "content": title },
 | 
						|
            { "name": "twitter:description", "content": description },
 | 
						|
            { "name": "twitter:image", "content": url }
 | 
						|
        ]
 | 
						|
 | 
						|
    def _load_resized_logo(self, config, width = 144):
 | 
						|
        logo = self._load_logo(config)
 | 
						|
        height = int(width * logo.height / logo.width)
 | 
						|
        return logo.resize((width, height))
 | 
						|
 | 
						|
    # Retrieve logo image or icon
 | 
						|
    def _load_logo(self, config):
 | 
						|
        theme = config.theme
 | 
						|
 | 
						|
        # Handle images (precedence over icons)
 | 
						|
        if "logo" in theme:
 | 
						|
            _, extension = os.path.splitext(theme["logo"])
 | 
						|
 | 
						|
            path = os.path.join(config.docs_dir, theme["logo"])
 | 
						|
 | 
						|
            # Allow users to put the logo inside their custom_dir (theme["logo"] case)
 | 
						|
            if theme.custom_dir:
 | 
						|
                custom_dir_logo = os.path.join(theme.custom_dir, theme["logo"])
 | 
						|
                if os.path.exists(custom_dir_logo):
 | 
						|
                    path = custom_dir_logo
 | 
						|
 | 
						|
            # Load SVG and convert to PNG
 | 
						|
            if extension == ".svg":
 | 
						|
                return self._load_logo_svg(path)
 | 
						|
 | 
						|
            # Load PNG, JPEG, etc.
 | 
						|
            return Image.open(path).convert("RGBA")
 | 
						|
 | 
						|
        # Handle icons
 | 
						|
        icon = theme.get("icon") or {}
 | 
						|
        if "logo" in icon and icon["logo"]:
 | 
						|
            logo = icon["logo"]
 | 
						|
        else:
 | 
						|
            logo = "material/library"
 | 
						|
 | 
						|
        # Resolve path of package
 | 
						|
        base = os.path.abspath(os.path.join(
 | 
						|
            os.path.dirname(__file__),
 | 
						|
            "../.."
 | 
						|
        ))
 | 
						|
 | 
						|
        path = f"{base}/templates/.icons/{logo}.svg"
 | 
						|
 | 
						|
        # Allow users to put the logo inside their custom_dir (theme["icon"]["logo"] case)
 | 
						|
        if theme.custom_dir:
 | 
						|
            custom_dir_logo = os.path.join(theme.custom_dir, ".icons", f"{logo}.svg")
 | 
						|
            if os.path.exists(custom_dir_logo):
 | 
						|
                path = custom_dir_logo
 | 
						|
 | 
						|
        # Load icon data and fill with color
 | 
						|
        return self._load_logo_svg(path, self.color["text"])
 | 
						|
 | 
						|
    # Load SVG file and convert to PNG
 | 
						|
    def _load_logo_svg(self, path, fill = None):
 | 
						|
        file = BytesIO()
 | 
						|
        data = open(path).read()
 | 
						|
 | 
						|
        # Fill with color, if given
 | 
						|
        if fill:
 | 
						|
            data = data.replace("<svg", f"<svg fill=\"{fill}\"")
 | 
						|
 | 
						|
        # Convert to PNG and return image
 | 
						|
        svg2png(bytestring = data, write_to = file, scale = 10)
 | 
						|
        return Image.open(file)
 | 
						|
 | 
						|
    # Retrieve font either from the card layout option or from the Material
 | 
						|
    # font defintion. If no font is defined for Material or font is False
 | 
						|
    # then choose a default.
 | 
						|
    def _load_font(self, config):
 | 
						|
        name = self.config.cards_layout_options.get("font_family")
 | 
						|
        if not name:
 | 
						|
            material_name = config.theme.get("font", False)
 | 
						|
            if material_name is False:
 | 
						|
                name = "Roboto"
 | 
						|
            else:
 | 
						|
                name = material_name.get("text", "Roboto")
 | 
						|
 | 
						|
        # Resolve relevant fonts
 | 
						|
        font = {}
 | 
						|
        for style in ["Regular", "Bold"]:
 | 
						|
            font[style] = self._resolve_font(name, style)
 | 
						|
 | 
						|
        # Return available font weights with fallback
 | 
						|
        return defaultdict(lambda: font["Regular"], font)
 | 
						|
 | 
						|
    # 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
 | 
						|
 | 
						|
        # 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()
 | 
						|
 | 
						|
                # Extract font family name and style using the content in the
 | 
						|
                # response via ByteIO to avoid writing a temp file. Done to fix
 | 
						|
                # problems with passing a NamedTemporaryFile to
 | 
						|
                # ImageFont.truetype() on Windows, see https://t.ly/LiF_k
 | 
						|
                with BytesIO(res.content) as fontdata:
 | 
						|
                    font = ImageFont.truetype(fontdata)
 | 
						|
                    name, style = font.getname()
 | 
						|
                    name = " ".join([name.replace(family, ""), style]).strip()
 | 
						|
                    target = os.path.join(path, family, f"{name}.ttf")
 | 
						|
 | 
						|
                # write file to cache
 | 
						|
                write_file(res.content, target)
 | 
						|
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
# Data
 | 
						|
# -----------------------------------------------------------------------------
 | 
						|
 | 
						|
# Set up logging
 | 
						|
log = logging.getLogger("mkdocs")
 | 
						|
log.addFilter(DuplicateFilter())
 | 
						|
 | 
						|
# Color palette
 | 
						|
colors = {
 | 
						|
    "red":         { "fill": "#ef5552", "text": "#ffffff" },
 | 
						|
    "pink":        { "fill": "#e92063", "text": "#ffffff" },
 | 
						|
    "purple":      { "fill": "#ab47bd", "text": "#ffffff" },
 | 
						|
    "deep-purple": { "fill": "#7e56c2", "text": "#ffffff" },
 | 
						|
    "indigo":      { "fill": "#4051b5", "text": "#ffffff" },
 | 
						|
    "blue":        { "fill": "#2094f3", "text": "#ffffff" },
 | 
						|
    "light-blue":  { "fill": "#02a6f2", "text": "#ffffff" },
 | 
						|
    "cyan":        { "fill": "#00bdd6", "text": "#ffffff" },
 | 
						|
    "teal":        { "fill": "#009485", "text": "#ffffff" },
 | 
						|
    "green":       { "fill": "#4cae4f", "text": "#ffffff" },
 | 
						|
    "light-green": { "fill": "#8bc34b", "text": "#ffffff" },
 | 
						|
    "lime":        { "fill": "#cbdc38", "text": "#000000" },
 | 
						|
    "yellow":      { "fill": "#ffec3d", "text": "#000000" },
 | 
						|
    "amber":       { "fill": "#ffc105", "text": "#000000" },
 | 
						|
    "orange":      { "fill": "#ffa724", "text": "#000000" },
 | 
						|
    "deep-orange": { "fill": "#ff6e42", "text": "#ffffff" },
 | 
						|
    "brown":       { "fill": "#795649", "text": "#ffffff" },
 | 
						|
    "grey":        { "fill": "#757575", "text": "#ffffff" },
 | 
						|
    "blue-grey":   { "fill": "#546d78", "text": "#ffffff" },
 | 
						|
    "black":       { "fill": "#000000", "text": "#ffffff" },
 | 
						|
    "white":       { "fill": "#ffffff", "text": "#000000" }
 | 
						|
}
 |