diff options
Diffstat (limited to 'src/plugins/social')
| -rw-r--r-- | src/plugins/social/__init__.py | 19 | ||||
| -rw-r--r-- | src/plugins/social/config.py | 48 | ||||
| -rw-r--r-- | src/plugins/social/plugin.py | 516 |
3 files changed, 583 insertions, 0 deletions
diff --git a/src/plugins/social/__init__.py b/src/plugins/social/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/src/plugins/social/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 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. diff --git a/src/plugins/social/config.py b/src/plugins/social/config.py new file mode 100644 index 00000000..2d87c25e --- /dev/null +++ b/src/plugins/social/config.py @@ -0,0 +1,48 @@ +# Copyright (c) 2016-2023 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 mkdocs.config.base import Config +from mkdocs.config.config_options import Deprecated, Type + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Social plugin configuration +class SocialConfig(Config): + enabled = Type(bool, default = True) + cache_dir = Type(str, default = ".cache/plugin/social") + + # Settings for social cards + cards = Type(bool, default = True) + cards_dir = Type(str, default = "assets/images/social") + cards_layout_options = Type(dict, default = {}) + + # Deprecated settings + cards_color = Deprecated( + option_type = Type(dict, default = {}), + message = + "Deprecated, use 'cards_layout_options.background_color' " + "and 'cards_layout_options.color' with 'default' layout" + ) + cards_font = Deprecated( + option_type = Type(str), + message = "Deprecated, use 'cards_layout_options.font_family'" + ) diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py new file mode 100644 index 00000000..3cdfa3ce --- /dev/null +++ b/src/plugins/social/plugin.py @@ -0,0 +1,516 @@ +# Copyright (c) 2016-2023 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 io import BytesIO +from mkdocs.commands.build import DuplicateFilter +from mkdocs.exceptions import PluginError +from mkdocs.plugins import BasePlugin +from shutil import copyfile +from tempfile import TemporaryFile +from zipfile import ZipFile +try: + from cairosvg import svg2png + from PIL import Image, ImageDraw, ImageFont +except ImportError: + pass + +from .config import SocialConfig + + +# ----------------------------------------------------------------------------- +# 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") + self.config.cards = self.config.enabled + if not self.config.cards: + return + + # Check dependencies + if "Image" not in globals(): + raise PluginError( + "Required dependencies of \"social\" plugin not found. " + "Install with: pip install \"mkdocs-material[imaging]\"" + ) + + # 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"] + + # Use first palette, if multiple are defined + if isinstance(palette, list): + palette = palette[0] + + # 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 + text = re.sub(r"(<[^>]+>)", "", 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 + title = page.meta.get("title", page.title) + if not page.is_homepage: + title = f"{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["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 + def _load_font(self, config): + name = self.config.cards_layout_options.get("font_family") + if not name: + + # Retrieve from theme (default: Roboto) + theme = config.theme + if isinstance(theme["font"], dict) and "text" in theme["font"]: + name = theme["font"]["text"] + else: + name = "Roboto" + + # Google fonts can return varients like OpenSans_Condensed-Regular.ttf so + # we only use the font requested e.g. OpenSans-Regular.ttf + font_filename_base = name.replace(' ', '') + filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$" + + font = {} + # Check for cached files - note these may be in subfolders + for currentpath, folders, files in os.walk(self.cache): + for file in files: + # Map available font weights to file paths + fname = os.path.join(currentpath, file) + match = re.search(filename_regex, fname) + if match: + font[match.group(1)] = fname + + # If none found, fetch from Google and try again + if len(font) == 0: + self._load_font_from_google(name) + for currentpath, folders, files in os.walk(self.cache): + for file in files: + # Map available font weights to file paths + fname = os.path.join(currentpath, file) + match = re.search(filename_regex, fname) + if match: + font[match.group(1)] = fname + + # Return available font weights with fallback + return defaultdict(lambda: font["Regular"], font) + + # Retrieve font from Google Fonts + def _load_font_from_google(self, name): + url = "https://fonts.google.com/download?family={}" + res = requests.get(url.format(name.replace(" ", "+")), stream = True) + + # Write archive to temporary file + tmp = TemporaryFile() + for chunk in res.iter_content(chunk_size = 32768): + tmp.write(chunk) + + # Unzip fonts from temporary file + zip = ZipFile(tmp) + files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")] + zip.extractall(self.cache, files) + + # Close and delete temporary file + tmp.close() + return files + +# ----------------------------------------------------------------------------- +# Data +# ----------------------------------------------------------------------------- + +# Set up logging +log = logging.getLogger("mkdocs") +log.addFilter(DuplicateFilter()) + +# Color palette +colors = dict({ + "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" } +}) |
