1128 lines
46 KiB
Python
1128 lines
46 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.
|
|||
|
|
|
|||
|
|
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")
|