Files

1128 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
from __future__ import annotations
import functools
import html
import json
import logging
import os
import pickle
import posixpath
import re
import requests
import sys
import yaml
from concurrent.futures import Future
from concurrent.futures.thread import ThreadPoolExecutor
from copy import copy
from fnmatch import fnmatch
from hashlib import sha1
from html import unescape
from io import BytesIO
from jinja2.sandbox import SandboxedEnvironment
from jinja2.meta import find_undeclared_variables
from mkdocs.config.base import Config
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.files import File, InclusionLevel
from mkdocs.structure.pages import Page
from mkdocs.utils import write_file
from statistics import stdev
from threading import Lock
from yaml import SafeLoader
from .config import SocialConfig
from .layout import Layer, Layout, Line, get_offset, get_size
from .templates import x_filter
try:
from PIL import Image, ImageColor, ImageDraw, ImageFont
from PIL.Image import Image as _Image
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]):
supports_multiple_instances = True
# Manifest
manifest: dict[str, str] = {}
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
# Determine whether we're serving the site, and thus doing an incremental
# build, and initialize two thread pools for card generation, because it's
# split into two stages: rendering of layers and composition. We use two
# thread pools, one for each stage, as we need to make sure that all layers
# of a card are rendered before we compose the card itself. At the same time
# we want to off-load as much as possible onto worker threads, as card
# generation is a problem that can be perfectly solved in parallel. Thus,
# we leverage the file system to cache the generated images, so we don't
# re-generate the exact same images again and again, making successive
# builds of large sites much faster.
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
# Initialize thread pool for cards
self.card_pool = ThreadPoolExecutor(self.config.concurrency)
self.card_pool_jobs: dict[str, Future] = {}
# Initialize thread pool for card layers
self.card_layer_pool = ThreadPoolExecutor(self.config.concurrency)
self.card_layer_pool_jobs: dict[str, Future] = {}
# Resolve and load manifest and initialize environment
def on_config(self, config):
if not self.config.enabled:
return
# Resolve cache directory (once) - this is necessary, so the cache is
# always relative to the configuration file, and thus project, and not
# relative to the current working directory, or it would not work with
# the projects plugin.
path = os.path.abspath(self.config.cache_dir)
if path != self.config.cache_dir:
self.config.cache_dir = os.path.join(
os.path.dirname(config.config_file_path),
os.path.normpath(self.config.cache_dir)
)
# Ensure cache directory exists
os.makedirs(self.config.cache_dir, exist_ok = True)
# Initialize manifest
self.manifest_file = os.path.join(
self.config.cache_dir, "manifest.json"
)
# Load manifest if it exists and the cache should be used
if os.path.isfile(self.manifest_file) and self.config.cache:
try:
with open(self.manifest_file) as f:
self.manifest = json.load(f)
except:
pass
# Initialize lock for synchronizing downloading of fonts
self.lock = Lock()
# Initialize card layouts and variables
self.card_layouts: dict[str, Layout] = {}
self.card_variables: dict[str, list[list[str]]] = {}
# Initialize card environment
self.card_env = SandboxedEnvironment()
self.card_env.filters["x"] = x_filter
# Always print a warning when debug mode is active
if self.config.debug:
log.warning("Debug mode is enabled for \"social\" plugin.")
# By default, debug mode is disabled when the documentation is
# built, but not when it is served, for a better user experience
if not self.is_serve and not self.config.debug_on_build:
self.config.debug = False
# 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 card layouts are not copied to the site directory
def on_files(self, files, *, config):
if not self.config.enabled:
return
# We must exclude all files related to layouts from here on, so MkDocs
# doesn't copy them to the site directory when the project is built
for file in files:
# As of MkDocs 1.6, abs_src_path is optional for generated files,
# so we need to exlude them - see https://t.ly/zRYj7
if not file.abs_src_path:
continue
# Exclude files from layout directory
if file.abs_src_path.startswith(_templates_dirpath()):
file.inclusion = InclusionLevel.EXCLUDED
# Generate card as soon as metadata is available (run latest) - run this
# after all other plugins, so they can alter the card configuration
@event_priority(-100)
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Skip if cards should not be generated
if self._is_excluded(page):
return
# Resolve card layout - we also preload the layout here, so we're not
# triggering multiple concurrent loads in the worker threads
name = self._config("cards_layout", page)
self._resolve_layout(name, config)
# Spawn concurrent job to generate card for page and add future to
# job dictionary, as it returns the file we need to copy later
self.card_pool_jobs[page.file.src_uri] = self.card_pool.submit(
self._generate, name, page, config
)
# Generate card metadata (run earlier) - don't run this too late, as we
# want plugins like the minify plugin to pick up the HTML we inject
@event_priority(50)
def on_post_page(self, output, *, page, config):
if not self.config.enabled:
return
# Skip if cards should not be generated
if self._is_excluded(page):
return
# Reconcile concurrent jobs - we need to wait for the card job to finish
# before we can copy the generated files to the output directory. If an
# exception occurred in one of the jobs, we either log it as configured
# by the user, or raise it, so the build fails.
future = self.card_pool_jobs[page.file.src_uri]
if future.exception():
e = future.exception()
if self.config.log and isinstance(e, PluginError):
log.log(self.config.log_level, e)
return
# Otherwise throw error
raise e
else:
file: File = future.result()
file.copy_file()
# Resolve card layout
name = self._config("cards_layout", page)
layout, _ = self._resolve_layout(name, config)
# Stop if no tags are present or site URL is not set
if not layout.tags or not config.site_url:
return
# Resolve image dimensions and curate image metadata
width, height = get_size(layout)
image = {
"url": posixpath.join(config.site_url, file.url),
"type": "image/png",
"width": width,
"height": height
}
# Find offset of closing head tag, so we can insert meta tags before
# it - a bit hacky, but much faster than regular expressions
at = output.find("</head>")
return "\n".join([
output[:at],
"\n".join([
f"<meta property=\"{property}\" content=\"{content}\" />"
for property, content in _replace(
layout.tags, self.card_env, config,
page = page, image = image,
layout = self._config("cards_layout_options", page),
).items() if content
]),
output[at:]
])
# Save manifest after build
def on_post_build(self, *, config):
if not self.config.enabled:
return
# Save manifest if cache should be used
if self.config.cache:
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# Add custom layout directory to watched files
def on_serve(self, server, *, config, builder):
path = os.path.abspath(self.config.cards_layout_dir)
if os.path.isdir(path):
server.watch(path, recursive = True)
# Reconcile jobs (run latest) - all other plugins do not depend on the
# generated cards, so we can run this after all of them
@event_priority(-100)
def on_shutdown(self):
if not self.config.enabled:
return
# Shutdown thread pools - if we're on Python 3.9 and above, cancel all
# pending futures that have not yet been scheduled
for pool in [self.card_layer_pool, self.card_pool]:
if sys.version_info >= (3, 9):
pool.shutdown(cancel_futures = True)
else:
pool.shutdown()
# Save manifest if cache should be used
if self.manifest and self.config.cache:
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# -------------------------------------------------------------------------
# Check if the given page is excluded - giving the author the option to
# include and exclude specific pages is important, as it allows to control
# which pages should generate social cards, and which shouldn't. Different
# cards can be built by using multiple instances of the plugin.
def _is_excluded(self, page: Page):
path = page.file.src_path
# Check if card generation is disabled for the given page
if not self._config("cards", page):
return True
# Check if page matches one of the inclusion patterns
if self.config.cards_include:
for pattern in self.config.cards_include:
if fnmatch(page.file.src_uri, pattern):
return False
# Page is not included
log.debug(f"Excluding page '{path}' due to inclusion patterns")
return True
# Check if page matches one of the exclusion patterns
for pattern in self.config.cards_exclude:
if fnmatch(page.file.src_uri, pattern):
log.debug(f"Excluding page '{path}' due to exclusion patterns")
return True
# Page is not excluded
return False
# -------------------------------------------------------------------------
# Generate card for the given page - generation of cards does not depend on
# anything else than the page content (incl. metadata) and configuration,
# which is why it is an embarrassingly parallel problem and can be solved
# by delegating the generation of each card to a thread pool
def _generate(self, name: str, page: Page, config: MkDocsConfig):
layout, variables = self._resolve_layout(name, config)
# Each card can consist of multiple layers, many of which are likely
# the same across cards (like background or logo layers). Some of the
# input values to generate a card may be dependent on author-provided
# data, e.g., the site description or card title that is sourced from
# front matter. Additionally, layouts may allow to define arbitrary
# text boxes with author-provided metadata like tags or categories.
# Thus, we generate a hash for each card, which is based on the layers
# and the values of all variables that are used to generate the card.
layers: dict[str, Layer] = {}
for layer, templates in zip(layout.layers, variables):
fingerprints = [self.config, layer]
# Compute fingerprints for each layer
for template in templates:
template = _compile(template, self.card_env)
fingerprints.append(template.render(
config = config, page = page,
layout = self._config("cards_layout_options", page)
))
# Compute digest of fingerprints
layers[_digest(fingerprints)] = layer
# Compute digest of all fingerprints - we use this value to check if
# the exact same card was already generated and cached
hash = _digest([layout, *list(layers)])
# Determine part of path we need to replace - this depends on whether
# we're using directory URLs and if the page is an index page or not
suffix = ".html"
if config.use_directory_urls and not page.is_index:
suffix = "/index.html"
# Compute path to card, which is sourced from the cache directory, and
# generate file to register it with MkDocs as soon as it was generated
path = page.file.dest_uri.replace(suffix, ".png")
file = self._path_to_file(path, config)
# Check if file hash changed, so we need to re-generate the card - if
# the hash didn't change, we can just return the existing file
prev = self.manifest.get(file.url, "")
if hash == prev and os.path.isfile(file.abs_src_path):
return file
# Check if the required dependencies for rendering are available, which
# is, at the absolute minimum, the 'pillow' package, and raise an error
# to the caller, so he can decide what to do with the error. The caller
# can treat this as a warning or an error to abort the build.
if import_errors:
# docs = os.path.relpath(config.docs_dir)
# path = os.path.relpath(page.file.abs_src_path, docs)
# raise PluginError(
# f"Couldn't render card for '{path}' in '{docs}': install "
# f"required dependencies pip install 'mkdocs-material[imaging]'"
# )
# @todo improve formatting of error handling
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:
# @todo improve formatting of error handling
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"
)
# Spawn concurrent jobs to render layers - we only need to render layers
# that we haven't already dispatched, reducing work by deduplication
for h, layer in layers.items():
sentinel = Future()
# We need to use a hack here to avoid locking the thread pool while
# we check if the layer was already dispatched. If we don't do this,
# layers might be dispatched multiple times. The trick is to use a
# sentinel value to check if the layer was already dispatched.
if sentinel == self.card_layer_pool_jobs.setdefault(h, sentinel):
self.card_layer_pool_jobs[h] = self.card_layer_pool.submit(
self._render, layer, page, config
)
# Reconcile concurrent jobs to render layers and compose card - since
# layers are rendered in parallel, we can compose the card as soon as
# all layers have been rendered. For this, we await each future to
# resolve with the image of the rendered layer.
image = Image.new(mode = "RGBA", size = get_size(layout))
for h, layer in layers.items():
image.alpha_composite(
self.card_layer_pool_jobs[h].result(),
get_offset(layer, image)
)
# If debug mode is enabled, render overlay
if self.config.debug:
image = self._render_overlay(layout, image)
# Save composed image to cache - the caller must copy the image from
# the cache, so we don't need to worry about concurrent access
os.makedirs(os.path.dirname(file.abs_src_path), exist_ok = True)
image.save(file.abs_src_path)
# Update manifest by associating file with hash
self.manifest[file.url] = hash
# Return file for generated card
return file
# Render layer - this is the core of the plugin, which renders a single
# layer of a card. Order is: background, icon, and typography.
def _render(self, layer: Layer, page: Page, config: MkDocsConfig):
image = Image.new(mode = "RGBA", size = get_size(layer))
layer = _replace(
layer, self.card_env, config,
page = page, layout = self._config("cards_layout_options", page)
)
# Render background, icon, and typography
image = self._render_background(layer, image)
image = self._render_icon(layer, image, config)
image = self._render_typography(layer, image)
# Return image with layer
return image
# Render layer background
def _render_background(self, layer: Layer, input: _Image):
background = layer.background
# If given, load background image and resize it proportionally to cover
# the entire area while retaining the aspect ratio of the input image
if background.image:
if not os.path.isfile(background.image):
raise PluginError(f"Couldn't find image '{background.image}'")
# Open file and convert SVGs to PNGs
with open(background.image, "rb") as f:
data = f.read()
if background.image.endswith(".svg"):
data = svg2png(data, output_width = input.width)
# Resize image to cover entire area
image = Image.open(BytesIO(data)).convert("RGBA")
input.alpha_composite(_resize_cover(image, input))
# If given, fill background color - this is done after the image is
# loaded to allow for transparent tints. How awesome is that?
if background.color:
color = background.color
if color == "transparent":
return input
# Create image filled with background color
image = Image.new(mode = "RGBA", size = input.size, color = color)
input.alpha_composite(image)
# Return image with background
return input
# Render layer icon
def _render_icon(self, layer: Layer, input: _Image, config: MkDocsConfig):
icon = layer.icon
if not icon.value:
return input
# Resolve icon by searching all configured theme directories and apply
# the fill color before rendering, if given. Note that the fill color
# must be converted to rgba() function syntax, or opacity will not work
# correctly. This way, we don't need to use the fill-opacity property.
data = self._resolve_icon(icon.value, config)
if icon.color:
(r, g, b, *a) = ImageColor.getrgb(icon.color)
opacity = a[0] / 255 if a else 1
# Compute and replace fill color
fill = f"rgba({r}, {g}, {b}, {opacity})"
data = data.replace("<svg", f"<svg fill=\"{fill}\"")
# Rasterize vector image given by icon to match the size of the
# input image, resize it and render it on top of the input image
image = Image.open(BytesIO(
svg2png(data.encode("utf-8"), output_width = input.width)
))
input.alpha_composite(_resize_contain(image.convert("RGBA"), input))
# Return image with icon
return input
# Render layer typography
def _render_typography(self, layer: Layer, input: _Image):
typography = layer.typography
if not typography.content:
return input
# Retrieve font family and font style
family = typography.font.family
variant = typography.font.variant
style = typography.font.style
# Resolve and load font and compute metrics
path = self._resolve_font(family, style, variant)
current, spacing = _metrics(path, typography.line, input)
font = ImageFont.truetype(path, current)
# Create image and initialize drawing context
image = Image.new(mode = "RGBA", size = input.size)
context = ImageDraw.Draw(image)
# Compute length of whitespace and ellipsis - in the next step, we will
# distribute the words across the lines we have available, which means
# we need to compute the length of each word and intersperse it with
# whitespace. Note that lengths of words are perfectly additive, so we
# can compute the length of a line by adding the lengths of all words
# and the whitespace between them.
space = context.textlength(" ", font = font)
ellipsis = context.textlength("...", font = font)
# Initialize lists to hold the lengths of words and indexes of lines.
# Tracking line indexes allows us to improve splitting using heuristics.
lengths: list[int] = []
indexes, current = [0], 0
# Split words at whitespace, and successively add words to the current
# line. For every other than the first word, account for the whitespace
# between words. If the next word would exceed the width of the input
# image, and thus overflow the line, start a new one.
words = re.split(r"\s+", unescape(typography.content))
for word in words:
length = context.textlength(word, font = font)
lengths.append(length)
# Start new line if current line overflows
whitespace = space if current else 0
if current + whitespace + length > input.width:
indexes.append(len(lengths) - 1)
current = length
# Add word to current line
else:
current += whitespace + length
# Add terminating index, if not already present
if len(lengths) != indexes[-1]:
indexes.append(len(lengths))
# If the number of lines exceeds the maximum amount we are able to
# render, either shrink or truncate the text and add an ellipsis
amount = typography.line.amount
if amount < len(indexes) - 1:
# If overflow mode is set to 'shrink', decrease the font size and
# try to render the typography again to see if it fits
overflow = typography.overflow
if overflow == "shrink":
typography.line.amount += 1
# Render layer with new typography metrics by calling this
# function recursively and returning immediately from it
return self._render_typography(layer, input)
# Determine last and penultimate line indexes
indexes = indexes[:amount + 1]
p, q = indexes[-2:]
# Compute the length of the last line, and check whether we can add
# the ellipsis after the last word. If not, replace the last word.
current = sum(lengths[p:q]) + (q - p) * space
if current + ellipsis < input.width:
q += 1
# Update line indexes and replace word with ellipsis
indexes[-1] = q
words[q - 1] = "..."
# If there are exactly two lines, check if we can improve splitting by
# moving the last word of the first line to the last line
elif len(indexes) == 3:
p, q, r = indexes[-3:]
# Create two configurations of lines, one with the last word of the
# first line moved to the last line, and one without the change
a = [len(" ".join(l)) for l in [words[p:q], words[q:r]]]
b = [len(" ".join(l)) for l in [words[p:q - 1], words[q - 1:r]]]
# Compute standard deviation of line lengths before and after the
# change, and if the standard deviation decreases, move the word
if stdev(b) < stdev(a):
indexes[-2] -= 1
# Compute anchor and deduce alignment, as well as offset. The anchor
# is computed as a string of two characters, where the first character
# denotes the horizontal alignment and the second character denotes
# the vertical alignment.
anchor = _anchor(typography.align)
# Compute horizontal alignment
if anchor[0] == "l": align, x = "left", 0
elif anchor[0] == "m": align, x = "center", input.width >> 1
else: align, x = "right", input.width >> 0
# Compute vertical alignment
if anchor[1] == "a": y = 0
elif anchor[1] == "m": y = input.height >> 1
else: y = input.height >> 0
# Join words with whitespace and lines with line breaks
text = "\n".join([
" ".join(words[p:q])
for p, q in zip(indexes, indexes[1:])
])
# Draw text onto image
context.text(
(x, y), text,
font = font,
anchor = anchor,
spacing = spacing,
fill = typography.color,
align = align
)
# Return image with typography
input.alpha_composite(image)
return input
# Render overlay for debugging
def _render_overlay(self, layout: Layout, input: _Image):
path = self._resolve_font("Roboto", "Regular")
font = ImageFont.truetype(path, 12)
# Create image and initialize drawing context
image = Image.new(mode = "RGBA", size = input.size)
context = ImageDraw.Draw(image)
# Draw overlay grid
fill = self.config.debug_color
if self.config.debug_grid:
step = self.config.debug_grid_step
for i in range(0, input.width, step):
for j in range(0, input.height, step):
context.ellipse(
((i - 1, j - 1), (i + 1, j + 1)),
fill = fill
)
# Compute luminosity of debug color and use it to determine the color
# of the text that will be drawn on top of the debug color
(r, g, b, *_) = ImageColor.getrgb(fill)
color = "black" if r * 0.299 + g * 0.587 + b * 0.114 > 150 else "white"
# Draw overlay outline for each layer
for i, layer in enumerate(layout.layers):
x, y = get_offset(layer, image)
w, h = get_size(layer)
# Draw overlay outline
context.rectangle(outline = fill, xy = (x, y,
min(x + w, input.width - 1),
min(y + h, input.height - 1)
))
# Assemble text and compute its width and height - we only use the
# coordinates denoting the width and height of the text, as we need
# to compute the coordinates of the text box manually in order to
# have the rectangle align perfectly with the outline
text = f"{i} {x}, {y}"
(_, _, x1, y1) = context.textbbox((x, y), text, font = font)
# Draw text on a small rectangle in the top left corner of the
# layer denoting the number of the layer and its offset
context.rectangle(fill = fill, xy = (x, y, x1 + 8, y1 + 4))
context.text((x + 4, y + 2), text, font = font, fill = color)
# Return image with overlay
input.alpha_composite(image)
return input
# -------------------------------------------------------------------------
# Resolve layout - authors can specify a custom directory for layouts in
# the configuration, which is checked prior to the layout directory shipped
# with this plugin. If the layout cannot be resolved in any of the known
# directories, the plugin must abort with an error.
def _resolve_layout(self, name: str, config: MkDocsConfig):
name, _ = os.path.splitext(name)
if name in self.card_layouts:
return self.card_layouts[name], self.card_variables[name]
# If the author specified a custom directory, try to resolve the layout
# from this directory first, otherwise fall back to the default
for base in [
os.path.relpath(self.config.cards_layout_dir),
_templates_dirpath()
]:
path = os.path.join(base, f"{name}.yml")
path = os.path.normpath(path)
# Skip if layout does not exist and try next directory
if not os.path.isfile(path):
continue
# Open file and parse as YAML
with open(path, encoding = "utf-8-sig") as f:
layout: Layout = Layout(config_file_path = path)
try:
layout.load_dict(yaml.load(f, SafeLoader) or {})
# The layout could not be loaded because of a syntax error,
# which we display to the author with a nice error message
except Exception as e:
path = os.path.relpath(path, base)
raise PluginError(
f"Error reading layout file '{path}' in '{base}':\n"
f"{e}"
)
# Validate layout and abort if errors occurred
errors, warnings = layout.validate()
for _, w in warnings:
log.warning(w)
for _, e in errors:
path = os.path.relpath(path, base)
raise PluginError(
f"Error reading layout file '{path}' in '{base}':\n"
f"{e}"
)
# Store layout and variables
self.card_layouts[name] = layout
self.card_variables[name] = []
# Extract variables for each layer from layout
for layer in layout.layers:
variables = _extract(layer, self.card_env, config)
self.card_variables[name].append(variables)
# Set default values for for layer size, if not given
for key, value in layer.size.items():
if value == 0:
layer.size[key] = layout.size[key]
# Abort, since we're done
break
# Abort if the layout could not be resolved
if name not in self.card_layouts:
raise PluginError(f"Couldn't find layout '{name}'")
# Return layout and variables
return self.card_layouts[name], self.card_variables[name]
# Resolve icon with given name - this function searches for the icon in all
# known theme directories, including custom directories specified by the
# author, which allows for using custom icons in cards. If the icon cannot
# be resolved, the plugin must abort with an error.
def _resolve_icon(self, name: str, config: MkDocsConfig):
for base in config.theme.dirs:
path = os.path.join(base, ".icons", f"{name}.svg")
path = os.path.normpath(path)
# Skip if icon does not exist and try next directory
if not os.path.isfile(path):
continue
# Open and return icon
with open(path, encoding = "utf-8") as f:
return f.read()
# Abort if the icon could not be resolved
raise PluginError(f"Couldn't find icon '{name}'")
# 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, variant = ""):
path = os.path.join(self.config.cache_dir, "fonts", family)
# Fetch font family, if it hasn't been fetched yet - we use a lock to
# synchronize access, so the font is not downloaded multiple times, but
# all other threads wait for the font being available. This is also why
# we need the double path check, which makes sure that we only use the
# lock when we actually need to download a font that doesn't exist. If
# we already downloaded it, we don't want to block at all.
if not os.path.isdir(path):
with self.lock:
if not os.path.isdir(path):
self._fetch_font_from_google_fonts(family)
# Assemble fully qualified style - see https://t.ly/soDF0
if variant:
style = f"{variant} {style}"
# 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()
# Construct image font for analysis by directly reading the
# contents from the response without priorily writing to a
# temporary file (like we did before), as this might lead to
# problems on Windows machines, see https://t.ly/LiF_k
with BytesIO(res.content) as f:
font = ImageFont.truetype(f)
# Extract font family name and style
name, style = font.getname()
name = " ".join([name.replace(family, ""), style]).strip()
# Write file to cache directory
target = os.path.join(path, family, f"{name}.ttf")
write_file(res.content, target)
# -------------------------------------------------------------------------
# Retrieve configuration value - each page can override certain parts of
# the site configuration, depending on the type and structure of the value
def _config(self, name: str, page: Page):
meta = page.meta.get("social", {})
# Primitive values: choose page- over site-level configuration
if isinstance(self.config[name], (bool, str, int, float)):
return meta.get(name, self.config[name])
# Dictionary values: merge site- with page-level configuration
if isinstance(self.config[name], (dict)):
return { **self.config[name], **meta.get(name, {}) }
# Create a file for the given path
def _path_to_file(self, path: str, config: MkDocsConfig):
assert path.endswith(".png")
return File(
posixpath.join(self.config.cards_dir, path),
self.config.cache_dir,
config.site_dir,
False
)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Compute a stable hash from an object - since we're doing compositing, we can
# leverage caching to omit re-generating layers when their parameters stay the
# same. Additionally, we can identify identical layers between images, e.g.,
# background, logos, or avatars, but also unchanged text. Note that we need to
# convert the data to a string prior to hashing, because configuration objects
# are inherently unstable, always resulting in new hashes.
def _digest(data: object):
return sha1(pickle.dumps(str(data))).hexdigest()
# -----------------------------------------------------------------------------
# Extract all variables recursively
def _extract(data: any, env: SandboxedEnvironment, config: MkDocsConfig):
# Traverse configuration or dictionary
if isinstance(data, (Config, dict)):
return [
variable for value in data.values()
for variable in _extract(value, env, config)
]
# Traverse list
elif isinstance(data, list):
return [
variable for value in data
for variable in _extract(value, env, config)
]
# Retrieve variables from string
elif isinstance(data, str):
if find_undeclared_variables(env.parse(data)):
return [data]
# Return nothing
return []
# Replace all variables recursively and return a copy of the given data
def _replace(
data: any, env: SandboxedEnvironment, config: MkDocsConfig, **kwargs
):
# Traverse configuration or dictionary
if isinstance(data, (Config, dict)):
data = copy(data)
for key, value in data.items():
data[key] = _replace(value, env, config, **kwargs)
# Traverse list
elif isinstance(data, list):
return [
_replace(value, env, config, **kwargs)
for value in data
]
# Retrieve variables from string
elif isinstance(data, str):
return _compile(data, env).render(
config = config, **kwargs
) or None
# Return data
return data
# Compile template and cache it indefinitely
@functools.lru_cache(maxsize = None)
def _compile(data: str, env: SandboxedEnvironment):
return env.from_string(html.unescape(data))
# Compute absolute path to internal templates directory,
# we need to do it this way to assure compatibility with Python 3.8,
# and also to allow users to install their Python site-packages
# to a different mount root than their documentation - see https://t.ly/GMeYP
def _templates_dirpath():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
# -----------------------------------------------------------------------------
# Resize image to match the size of the reference image and align it to the
# center of the reference image so that it is fully covered
def _resize_cover(image: _Image, ref: _Image):
ratio = max(
ref.width / image.width,
ref.height / image.height
)
# Compute aspect ratios of both images and choose the larger one, then
# resize the image so that it covers the entire reference image
image = image.resize((
int(image.width * ratio),
int(image.height * ratio)
))
# Align image to the center of the reference image - we also need to crop
# the image if it's larger than the given reference image
return image.crop((
image.width - ref.width >> 1,
image.height - ref.height >> 1,
image.width + ref.width >> 1,
image.height + ref.height >> 1
))
# Resize image to match the size of the reference image and align it to the
# center of the reference image so that it is fully contained
def _resize_contain(image: _Image, ref: _Image):
ratio = min(
ref.width / image.width,
ref.height / image.height
)
# Resize image according to minimum ratio
image = image.resize((
int(image.width * ratio),
int(image.height * ratio)
))
# Create a blank image and paste the resized image into it
blank = Image.new(mode = "RGBA", size = ref.size)
blank.paste(image, (
ref.width - image.width >> 1,
ref.height - image.height >> 1
))
# Return resized image
return blank
# -----------------------------------------------------------------------------
# Resolve font metrics for given truetype font - this function computes the
# font size and spacing between lines based on the number of lines and height.
# In order to omit rounding errors, we compute the ascender and descender based
# on a font size of 1,000.
def _metrics(path: str, line: Line, ref: _Image):
font = ImageFont.truetype(path, 1000)
ascender, descender = font.getmetrics()
# It would be too complex to let the author define the font size, since this
# would involve a lot of fiddling to find the right value. Instead, we let
# the author define the number of lines and the line height, and we compute
# the font size from that. This is much more intuitive. As a basis, we use
# the ascender as the actual line height and also add the descender to
# account for the last line. It's no secret that correctly handling font
# metrics is super tricky - see https://bit.ly/31u9bh6
extent = line.amount * ascender + 1 * descender
# Now, we still need to account for spacing between lines, which is why we
# take the number of lines - 1, and multiply that with the line height we
# computed from the ascender. We add this to the extent we computed before,
# which we use as a basis for the final font size.
extent += (line.amount - 1) * (line.height - 1) * ascender
size = (1000 * ref.height) / extent
# From this, we can compute the spacing between lines, and we're done. We
# then return both, the font size and spacing between lines.
spacing = (line.height - 1) * ascender * size / 1000
return int(size), spacing
# Compute anchor, determining the alignment of text relative to the given
# coordinates, with the default being "top left" - see https://bit.ly/3NEfr07
def _anchor(data: str):
axis = re.split(r"\s+", data)
# Determine anchor on x-axis
if "start" in axis: anchor = "l"
elif "end" in axis: anchor = "r"
elif "center" in axis: anchor = "m"
else: anchor = "l"
# Determine anchor on y-axis
if "top" in axis: anchor += "a"
elif "bottom" in axis: anchor += "d"
elif "center" in axis: anchor += "m"
else: anchor += "a"
# Return anchor
return anchor
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.social")