# Copyright (c) 2016-2022 Martin Donath # 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. import logging import os 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.config.config_options import Type 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 dependencies = True except ImportError: dependencies = False # ----------------------------------------------------------------------------- # Class # ----------------------------------------------------------------------------- # Social plugin class SocialPlugin(BasePlugin): # Configuration scheme config_scheme = ( ("enabled", Type(bool, default = True)), ("cache_dir", Type(str, default = ".cache/plugin/social")), # Options for social cards ("cards", Type(bool, default = True)), ("cards_dir", Type(str, default = "assets/images/social")), ("cards_color", Type(dict, default = {})), ("cards_font", Type(str, default = None)), ) # Retrieve configuration def on_config(self, config): self.color = colors.get("indigo") if not self.config["cards"]: return # Check if required dependencies are installed if not dependencies: log.error( "Required dependencies of \"social\" plugin not found. " "Install with: pip install pillow cairosvg" ) sys.exit() # 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 self.color = { **self.color, **self.config["cards_color"] } # Retrieve logo and font self.logo = self._load_logo(config) self.font = self._load_font(config) # 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.get("site_name") # Compute page title and description title = page.meta.get("title", page.title) description = config.get("site_description") or "" if "description" in page.meta: description = page.meta["description"] # Generate social card if not in cache - TODO: values from mkdocs.yml hash = md5("".join([ site_name, str(title), description ]).encode("utf-8")) file = os.path.join(self.cache, f"{hash.hexdigest()}.png") if not os.path.isfile(file): image = self._render_card(site_name, title, description) image.save(file) # Copy file from cache copyfile(file, path) # Inject meta tags into page meta = page.meta.get("meta", []) page.meta["meta"] = meta + self._generate_meta(page, config) # ------------------------------------------------------------------------- # Render social card def _render_card(self, site_name, title, description): logo = self.logo # Render background and logo image = self._render_card_background((1200, 630), self.color["fill"]) image.alpha_composite( logo.resize((144, int(144 * logo.height / logo.width))), (1200 - 228, 64 - 4) ) # Render site name font = ImageFont.truetype(self.font["Bold"], 36) image.alpha_composite( self._render_text((826, 48), font, site_name, 1, 20), (64 + 4, 64) ) # Render page title font = ImageFont.truetype(self.font["Bold"], 92) image.alpha_composite( self._render_text((826, 328), font, title, 3, 30), (64, 160) ) # Render page description font = ImageFont.truetype(self.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) # Render social card text def _render_text(self, size, font, text, lmax, spacing = 0): lines, words = [], [] # Remove remnant HTML tags text = re.sub(r"(<[^>]+>)", "", text) # Create temporary image image = Image.new(mode = "RGBA", size = size) # Retrieve y-offset of textbox to correct for spacing yoffset = 0 # Create drawing context and split text into lines context = ImageDraw.Draw(image) for word in text.split(" "): combine = " ".join(words + [word]) textbox = context.textbbox((0, 0), combine, font = font) yoffset = textbox[1] if not words or textbox[2] <= image.width: words.append(word) else: lines.append(words) words = [word] # # Balance words on last line - TODO: overflows when broken word is too long # if len(lines) > 0: # prev = len(" ".join(lines[-1])) # last = len(" ".join(words))# # print(last, prev) # # Heuristic: try to find a good ratio # if last / prev < 0.6: # words.insert(0, lines[-1].pop()) # 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_path) # Compute page title title = page.meta.get("title", page.title) if not page.is_homepage: title = f"{title} - {config.get('site_name')}" # Compute page description description = config.get("site_description") if "description" in page.meta: description = page.meta["description"] # Resolve image URL url = "{}.png".format(os.path.join( config.get("site_url"), 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 } ] # Retrieve logo image or icon def _load_logo(self, config): theme = config.get("theme") # Handle images (precedence over icons) if "logo" in theme: _, extension = os.path.splitext(theme["logo"]) # Load SVG and convert to PNG path = os.path.join(config["docs_dir"], theme["logo"]) if extension == ".svg": return self._load_logo_svg(path) # Load PNG, JPEG, etc. return Image.open(path).convert("RGBA") # Handle icons logo = "material/library" icon = theme["icon"] or {} if "logo" in icon and icon["logo"]: logo = icon["logo"] # Resolve path of package base = os.path.abspath(os.path.join( os.path.dirname(__file__), "../.." )) # Load icon data and fill with color path = f"{base}/.icons/{logo}.svg" 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("