Daniele Bartolini 2 месяцев назад
Родитель
Сommit
786337ec86

+ 369 - 0
docs/_extensions/sphinxext/opengraph/__init__.py

@@ -0,0 +1,369 @@
+from __future__ import annotations
+
+import os
+import posixpath
+from pathlib import Path
+from typing import TYPE_CHECKING
+from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
+
+from docutils import nodes
+
+from sphinxext.opengraph._description_parser import get_description
+from sphinxext.opengraph._meta_parser import get_meta_description
+from sphinxext.opengraph._title_parser import get_title
+
+try:
+    from types import NoneType
+except ImportError:
+    NoneType = type(None)
+
+if TYPE_CHECKING:
+    from typing import Any
+
+    from sphinx.application import Sphinx
+    from sphinx.builders import Builder
+    from sphinx.config import Config
+    from sphinx.environment import BuildEnvironment
+    from sphinx.util.typing import ExtensionMetadata
+
+try:
+    from sphinxext.opengraph._social_cards import (
+        DEFAULT_SOCIAL_CONFIG,
+        create_social_card,
+    )
+except ImportError:
+    print('matplotlib is not installed, social cards will not be generated')
+    create_social_card = None
+    DEFAULT_SOCIAL_CONFIG = {}
+
+__version__ = '0.13.0'
+version_info = (0, 13, 0)
+
+DEFAULT_DESCRIPTION_LENGTH = 200
+DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160
+DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80
+
+# A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
+IMAGE_MIME_TYPES = {
+    'gif': 'image/gif',
+    'apng': 'image/apng',
+    'webp': 'image/webp',
+    'jpeg': 'image/jpeg',
+    'jpg': 'image/jpeg',
+    'png': 'image/png',
+    'bmp': 'image/bmp',
+    'heic': 'image/heic',
+    'heif': 'image/heif',
+    'tiff': 'image/tiff',
+}
+
+
+def html_page_context(
+    app: Sphinx,
+    pagename: str,
+    templatename: str,
+    context: dict[str, Any],
+    doctree: nodes.document,
+) -> None:
+    if app.builder.name == 'epub':
+        return
+
+    if doctree:
+        context['metatags'] += get_tags(
+            context,
+            doctree,
+            srcdir=app.srcdir,
+            outdir=app.outdir,
+            config=app.config,
+            builder=app.builder,
+            env=app.env,
+        )
+
+
+def get_tags(
+    context: dict[str, Any],
+    doctree: nodes.document,
+    *,
+    srcdir: str | Path,
+    outdir: str | Path,
+    config: Config,
+    builder: Builder,
+    env: BuildEnvironment,
+) -> str:
+    # Get field lists for per-page overrides
+    fields = context['meta']
+    if fields is None:
+        fields = {}
+
+    if 'ogp_disable' in fields:
+        return ''
+
+    tags = {}
+    meta_tags = {}  # For non-og meta tags
+
+    # Set length of description
+    try:
+        desc_len = int(
+            fields.get('ogp_description_length', config.ogp_description_length)
+        )
+    except ValueError:
+        desc_len = DEFAULT_DESCRIPTION_LENGTH
+
+    # Get the title and parse any html in it
+    title, title_excluding_html = get_title(context['title'])
+
+    # Parse/walk doctree for metadata (tag/description)
+    description = get_description(doctree, desc_len, {title, title_excluding_html})
+
+    # title tag
+    tags['og:title'] = title
+
+    # type tag
+    tags['og:type'] = config.ogp_type
+
+    if not config.ogp_site_url and os.getenv('READTHEDOCS'):
+        ogp_site_url = ambient_site_url()
+    else:
+        ogp_site_url = config.ogp_site_url
+
+    # If ogp_canonical_url is not set, default to the value of ogp_site_url
+    ogp_canonical_url = config.ogp_canonical_url or ogp_site_url
+
+    # url tag
+    # Get the URL of the specific page
+    page_url = urljoin(ogp_canonical_url, builder.get_target_uri(context['pagename']))
+    tags['og:url'] = page_url
+
+    # site name tag, False disables, default to project if ogp_site_name not
+    # set.
+    if config.ogp_site_name is False:
+        site_name = None
+    elif config.ogp_site_name is None:
+        site_name = config.project
+    else:
+        site_name = config.ogp_site_name
+    if site_name:
+        tags['og:site_name'] = site_name
+
+    # description tag
+    if description:
+        tags['og:description'] = description
+
+        if config.ogp_enable_meta_description and not get_meta_description(
+            context['metatags']
+        ):
+            meta_tags['description'] = description
+
+    # image tag
+    # Get basic values from config
+    if 'og:image' in fields:
+        image_url = fields['og:image']
+        ogp_use_first_image = False
+        ogp_image_alt = fields.get('og:image:alt')
+        fields.pop('og:image', None)
+    else:
+        image_url = config.ogp_image
+        ogp_use_first_image = config.ogp_use_first_image
+        ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
+
+    # Decide whether to add social media card images for each page.
+    # Only do this as a fallback if the user hasn't given any configuration
+    # to add other images.
+    config_social = DEFAULT_SOCIAL_CONFIG.copy()
+    social_card_user_options = config.ogp_social_cards or {}
+    config_social.update(social_card_user_options)
+    if (
+        not (image_url or ogp_use_first_image)
+        and config_social.get('enable') is not False
+        and create_social_card is not None
+    ):
+        image_url = social_card_for_page(
+            config_social=config_social,
+            site_name=site_name,
+            title=title,
+            description=description,
+            pagename=context['pagename'],
+            ogp_site_url=ogp_site_url,
+            ogp_canonical_url=ogp_canonical_url,
+            srcdir=srcdir,
+            outdir=outdir,
+            config=config,
+            env=env,
+        )
+        ogp_use_first_image = False
+
+        # Alt text is taken from description unless given
+        if 'og:image:alt' in fields:
+            ogp_image_alt = fields.get('og:image:alt')
+        else:
+            ogp_image_alt = description
+
+        # If the social card objects have been added we add special metadata for them
+        # These are the dimensions *in pixels* of the card
+        # They were chosen by looking at the image pixel dimensions on disk
+        tags['og:image:width'] = '1146'
+        tags['og:image:height'] = '600'
+        meta_tags['twitter:card'] = 'summary_large_image'
+
+    fields.pop('og:image:alt', None)
+
+    first_image = None
+    if ogp_use_first_image:
+        # Use the first image that is defined in the current page
+        first_image = doctree.next_node(nodes.image)
+        if (
+            first_image
+            and Path(first_image.get('uri', '')).suffix[1:].lower() in IMAGE_MIME_TYPES
+        ):
+            image_url = first_image['uri']
+            ogp_image_alt = first_image.get('alt', None)
+        else:
+            first_image = None
+
+    if image_url:
+        # temporarily disable relative image paths with field lists
+        if 'og:image' not in fields:
+            image_url_parsed = urlparse(image_url)
+            if not image_url_parsed.scheme:
+                # Relative image path detected, relative to the source. Make absolute.
+                if first_image:  # NoQA: SIM108
+                    root = page_url
+                else:  # ogp_image is set
+                    # ogp_image is defined as being relative to the site root.
+                    # This workaround is to keep that functionality from breaking.
+                    root = ogp_site_url
+
+                image_url = urljoin(root, image_url_parsed.path)
+            tags['og:image'] = image_url
+
+        # Add image alt text (either provided by config or from site_name)
+        if isinstance(ogp_image_alt, str):
+            tags['og:image:alt'] = ogp_image_alt
+        elif ogp_image_alt is None and site_name:
+            tags['og:image:alt'] = site_name
+        elif ogp_image_alt is None and title:
+            tags['og:image:alt'] = title
+
+    # arbitrary tags and overrides
+    tags.update({k: v for k, v in fields.items() if k.startswith('og:')})
+
+    return (
+        '\n'.join(
+            [make_tag(p, c) for p, c in tags.items()]
+            + [make_tag(p, c, 'name') for p, c in meta_tags.items()]
+            + list(config.ogp_custom_meta_tags)
+        )
+        + '\n'
+    )
+
+
+def ambient_site_url() -> str:
+    # readthedocs addons sets the READTHEDOCS_CANONICAL_URL variable
+    if rtd_canonical_url := os.getenv('READTHEDOCS_CANONICAL_URL'):
+        parse_result = urlsplit(rtd_canonical_url)
+    else:
+        msg = 'ReadTheDocs did not provide a valid canonical URL!'
+        raise RuntimeError(msg)
+
+    # Grab root url from canonical url
+    return urlunsplit(
+        (parse_result.scheme, parse_result.netloc, parse_result.path, '', '')
+    )
+
+
+def social_card_for_page(
+    config_social: dict[str, bool | str],
+    site_name: str,
+    title: str,
+    description: str,
+    pagename: str,
+    ogp_site_url: str,
+    ogp_canonical_url: str,
+    *,
+    srcdir: str | Path,
+    outdir: str | Path,
+    config: Config,
+    env: BuildEnvironment,
+) -> str:
+    # Description
+    description_max_length = config_social.get(
+        'description_max_length', DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3
+    )
+    if len(description) > description_max_length:
+        description = description[:description_max_length].strip() + '...'
+
+    # Page title
+    pagetitle = title
+    if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS:
+        pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + '...'
+
+    # Site URL
+    site_url = config_social.get('site_url', True)
+    if site_url is True:
+        url_text = ogp_canonical_url.split('://')[-1]
+    elif isinstance(site_url, str):
+        url_text = site_url
+
+    # Plot an image with the given metadata to the output path
+    image_path = create_social_card(
+        config_social,
+        site_name,
+        pagetitle,
+        description,
+        url_text,
+        pagename,
+        srcdir=srcdir,
+        outdir=outdir,
+        env=env,
+        html_logo=config.html_logo,
+    )
+
+    # Link the image in our page metadata
+    return posixpath.join(ogp_site_url, image_path.as_posix())
+
+
+def make_tag(property: str, content: str, type_: str = 'property') -> str:
+    # Parse quotation, so they won't break html tags if smart quotes are disabled
+    content = content.replace('"', '"')
+    return f'<meta {type_}="{property}" content="{content}" />'
+
+
+def setup(app: Sphinx) -> ExtensionMetadata:
+    # ogp_site_url="" allows relative by default, even though it's not
+    # officially supported by OGP.
+    app.add_config_value('ogp_site_url', '', 'html', types=frozenset({str}))
+    app.add_config_value('ogp_canonical_url', '', 'html', types=frozenset({str}))
+    app.add_config_value(
+        'ogp_description_length',
+        DEFAULT_DESCRIPTION_LENGTH,
+        'html',
+        types=frozenset({int}),
+    )
+    app.add_config_value('ogp_image', None, 'html', types=frozenset({str, NoneType}))
+    app.add_config_value(
+        'ogp_image_alt', None, 'html', types=frozenset({str, bool, NoneType})
+    )
+    app.add_config_value('ogp_use_first_image', False, 'html', types=frozenset({bool}))
+    app.add_config_value('ogp_type', 'website', 'html', types=frozenset({str}))
+    app.add_config_value(
+        'ogp_site_name', None, 'html', types=frozenset({str, bool, NoneType})
+    )
+    app.add_config_value(
+        'ogp_social_cards', None, 'html', types=frozenset({dict, NoneType})
+    )
+    app.add_config_value(
+        'ogp_custom_meta_tags', (), 'html', types=frozenset({list, tuple})
+    )
+    app.add_config_value(
+        'ogp_enable_meta_description', True, 'html', types=frozenset({bool})
+    )
+
+    # Main Sphinx OpenGraph linking
+    app.connect('html-page-context', html_page_context)
+
+    return {
+        'version': __version__,
+        'env_version': 1,
+        'parallel_read_safe': True,
+        'parallel_write_safe': True,
+    }

+ 113 - 0
docs/_extensions/sphinxext/opengraph/_description_parser.py

@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import html
+import string
+from typing import TYPE_CHECKING
+
+from docutils import nodes
+
+if TYPE_CHECKING:
+    from collections.abc import Set
+
+
+def get_description(
+    doctree: nodes.document,
+    description_length: int,
+    known_titles: Set[str] = frozenset(),
+) -> str:
+    mcv = DescriptionParser(
+        doctree, desc_len=description_length, known_titles=known_titles
+    )
+    doctree.walkabout(mcv)
+    return mcv.description
+
+
+class DescriptionParser(nodes.NodeVisitor):
+    """Finds the title and creates a description from a doctree."""
+
+    def __init__(
+        self,
+        document: nodes.document,
+        *,
+        desc_len: int,
+        known_titles: Set[str] = frozenset(),
+    ) -> None:
+        super().__init__(document)
+        self.description = ''
+        self.desc_len = desc_len
+        self.list_level = 0
+        self.known_titles = known_titles
+        self.first_title_found = False
+
+        # Exceptions can't be raised from dispatch_departure()
+        # This is used to loop the stop call back to the next dispatch_visit()
+        self.stop = False
+
+    def dispatch_visit(self, node: nodes.Element) -> None:
+        if self.stop:
+            raise nodes.StopTraversal
+
+        # Skip comments & all admonitions
+        if isinstance(node, (nodes.Admonition, nodes.Invisible)):
+            raise nodes.SkipNode
+
+        # Mark start of nested lists
+        if isinstance(node, nodes.Sequential):
+            self.list_level += 1
+            if self.list_level > 1:
+                self.description += '-'
+
+        # Skip the first title if it's the title of the page
+        if not self.first_title_found and isinstance(node, nodes.title):
+            self.first_title_found = True
+            if node.astext() in self.known_titles:
+                raise nodes.SkipNode
+
+        if isinstance(node, nodes.raw) or isinstance(node.parent, nodes.literal_block):
+            raise nodes.SkipNode
+
+        # Only include leaf nodes in the description
+        if len(node.children) == 0:
+            text = node.astext().replace('\r', '').replace('\n', ' ').strip()
+
+            # Ensure string contains HTML-safe characters
+            text = html.escape(text, quote=True)
+
+            # Remove double spaces
+            while text.find('  ') != -1:
+                text = text.replace('  ', ' ')
+
+            # Put a space between elements if one does not already exist.
+            if (
+                len(self.description) > 0
+                and len(text) > 0
+                and self.description[-1] not in string.whitespace
+                and text[0] not in string.whitespace + string.punctuation
+            ):
+                self.description += ' '
+
+            self.description += text
+
+    def dispatch_departure(self, node: nodes.Element) -> None:
+        # Separate title from text
+        if isinstance(node, nodes.title):
+            self.description += ':'
+
+        # Separate list elements
+        if isinstance(node, nodes.Part):
+            self.description += ','
+
+        # Separate end of list from text
+        if isinstance(node, nodes.Sequential):
+            if self.description and self.description[-1] == ',':
+                self.description = self.description[:-1]
+            self.description += '.'
+            self.list_level -= 1
+
+        # Check for length
+        if len(self.description) > self.desc_len:
+            self.description = self.description[: self.desc_len]
+            if self.desc_len >= 3:
+                self.description = self.description[:-3] + '...'
+
+            self.stop = True

+ 29 - 0
docs/_extensions/sphinxext/opengraph/_meta_parser.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from html.parser import HTMLParser
+
+
+def get_meta_description(meta_tags: str) -> bool:
+    htp = HTMLTextParser()
+    htp.feed(meta_tags)
+    htp.close()
+
+    return htp.meta_description
+
+
+class HTMLTextParser(HTMLParser):
+    """Parse HTML into text."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.meta_description = None
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        # For example:
+        # attrs = [("content", "My manual description"), ("name", "description")]
+        if ('name', 'description') in attrs:
+            self.meta_description = True
+            for name, value in attrs:
+                if name == 'content':
+                    self.meta_description = value
+                    break

+ 320 - 0
docs/_extensions/sphinxext/opengraph/_social_cards.py

@@ -0,0 +1,320 @@
+"""Build a PNG card for each page meant for social media."""
+
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import matplotlib as mpl
+import matplotlib.font_manager
+import matplotlib.image as mpimg
+from matplotlib import pyplot as plt
+from sphinx.util import logging
+
+if TYPE_CHECKING:
+    from typing import TypeAlias
+
+    from matplotlib.figure import Figure
+    from matplotlib.text import Text
+    from sphinx.environment import BuildEnvironment
+
+    PltObjects: TypeAlias = tuple[Figure, Text, Text, Text, Text]
+
+mpl.use('agg')
+
+LOGGER = logging.getLogger(__name__)
+HERE = Path(__file__).parent
+MAX_CHAR_PAGE_TITLE = 75
+MAX_CHAR_DESCRIPTION = 175
+
+# Default configuration for this functionality
+DEFAULT_SOCIAL_CONFIG = {
+    'enable': True,
+    'site_url': True,
+    'site_title': True,
+    'page_title': True,
+    'description': True,
+}
+
+
+# Default configuration for the figure style
+DEFAULT_KWARGS_FIG = {
+    'enable': True,
+    'site_url': True,
+}
+
+
+def create_social_card(
+    config_social: dict[str, bool | str],
+    site_name: str,
+    page_title: str,
+    description: str,
+    url_text: str,
+    page_path: str,
+    *,
+    srcdir: str | Path,
+    outdir: str | Path,
+    env: BuildEnvironment,
+    html_logo: str | None = None,
+) -> Path:
+    """Create a social preview card according to page metadata.
+
+    This uses page metadata and calls a render function to generate the image.
+    It also passes configuration through to the rendering function.
+    If Matplotlib objects are present in the `app` environment, it reuses them.
+    """
+    # Add a hash to the image path based on metadata to bust caches
+    # ref: https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/troubleshooting-cards#refreshing_images
+    hash = hashlib.sha1(
+        (site_name + page_title + description + str(config_social)).encode(),
+        usedforsecurity=False,
+    ).hexdigest()[:8]
+
+    # Define the file path we'll use for this image
+    path_images_relative = Path('_images/social_previews')
+    filename_image = f'summary_{page_path.replace("/", "_")}_{hash}.png'
+
+    # Absolute path used to save the image
+    path_images_absolute = Path(outdir) / path_images_relative
+    path_images_absolute.mkdir(exist_ok=True, parents=True)
+    path_image = path_images_absolute / filename_image
+
+    # If the image already exists then we can just skip creating a new one.
+    # This is because we hash the values of the text + images in the social card.
+    # If the hash doesn't change, it means the output should be the same.
+    if path_image.exists():
+        return path_images_relative / filename_image
+
+    # These kwargs are used to generate the base figure image
+    kwargs_fig: dict[str, str | Path | None] = {}
+
+    # Large image to the top right
+    if cs_image := config_social.get('image'):
+        kwargs_fig['image'] = Path(srcdir) / cs_image
+    elif html_logo:
+        kwargs_fig['image'] = Path(srcdir) / html_logo
+
+    # Mini image to the bottom right
+    if cs_image_mini := config_social.get('image_mini'):
+        kwargs_fig['image_mini'] = Path(srcdir) / cs_image_mini
+    else:
+        kwargs_fig['image_mini'] = (
+            Path(__file__).parent / '_static/sphinx-logo-shadow.png'
+        )
+
+    # Validation on the images
+    for img in ['image_mini', 'image']:
+        impath = kwargs_fig.get(img)
+        if not impath:
+            continue
+
+        # If image is an SVG replace it with None
+        if impath.suffix.lower() == '.svg':
+            LOGGER.warning('[Social card] %s cannot be an SVG image, skipping...', img)
+            kwargs_fig[img] = None
+
+        # If image doesn't exist, throw a warning and replace with none
+        if not impath.exists():
+            LOGGER.warning("[Social card]: %s file doesn't exist, skipping...", img)
+            kwargs_fig[img] = None
+
+    # These are passed directly from the user configuration to our plotting function
+    pass_through_config = ('text_color', 'line_color', 'background_color', 'font')
+    for config in pass_through_config:
+        if cs_config := config_social.get(config):
+            kwargs_fig[config] = cs_config
+
+    # Generate the image and store the matplotlib objects so that we can re-use them
+    try:
+        plt_objects = env.ogp_social_card_plt_objects
+    except AttributeError:
+        # If objects is None it means this is the first time plotting.
+        # Create the figure objects and return them so that we re-use them later.
+        plt_objects = create_social_card_objects(**kwargs_fig)
+    plt_objects = render_social_card(
+        path_image,
+        site_name,
+        page_title,
+        description,
+        url_text,
+        plt_objects,
+    )
+    env.ogp_social_card_plt_objects = plt_objects
+
+    # Path relative to build folder will be what we use for linking the URL
+    return path_images_relative / filename_image
+
+
+def render_social_card(
+    path: Path,
+    site_title: str,
+    page_title: str,
+    description: str,
+    siteurl: str,
+    plt_objects: PltObjects,
+) -> PltObjects:
+    """Render a social preview card with Matplotlib and write to disk."""
+    fig, txt_site_title, txt_page_title, txt_description, txt_url = plt_objects
+
+    # Update the matplotlib text objects with new text from this page
+    txt_site_title.set_text(site_title)
+    txt_page_title.set_text(page_title)
+    txt_description.set_text(description)
+    txt_url.set_text(siteurl)
+
+    # Save the image
+    fig.savefig(path, facecolor=None)
+    return fig, txt_site_title, txt_page_title, txt_description, txt_url
+
+
+def create_social_card_objects(
+    image: Path | None = None,
+    image_mini: Path | None = None,
+    page_title_color: str = '#2f363d',
+    description_color: str = '#585e63',
+    site_title_color: str = '#585e63',
+    site_url_color: str = '#2f363d',
+    background_color: str = 'white',
+    line_color: str = '#5A626B',
+    font: str | None = None,
+) -> PltObjects:
+    """Create the Matplotlib objects for the first time."""
+    # If no font specified, load the Roboto Flex font as a fallback
+    if font is None:
+        path_font = Path(__file__).parent / '_static/Roboto-Flex.ttf'
+        roboto_font = matplotlib.font_manager.FontEntry(
+            fname=str(path_font), name='Roboto Flex'
+        )
+        matplotlib.font_manager.fontManager.addfont(path_font)
+        font = roboto_font.name
+
+    # Because Matplotlib doesn't let you specify figures in pixels, only inches
+    # This `multiple` results in a scale of about 1146px by 600px
+    # Which is roughly the recommended size for OpenGraph images
+    # ref: https://opengraph.xyz
+    ratio = 1200 / 628
+    multiple = 6
+    fig = plt.figure(figsize=(ratio * multiple, multiple))
+    fig.set_facecolor(background_color)
+
+    # Text axis
+    axtext = fig.add_axes((0, 0, 1, 1))
+
+    # Image axis
+    ax_x, ax_y, ax_w, ax_h = (0.65, 0.65, 0.3, 0.3)
+    axim_logo = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor='NE')
+
+    # Image mini axis
+    ax_x, ax_y, ax_w, ax_h = (0.82, 0.1, 0.1, 0.1)
+    axim_mini = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor='NE')
+
+    # Line at the bottom axis
+    axline = fig.add_axes((-0.1, -0.04, 1.2, 0.1))
+
+    # Axes configuration
+    left_margin = 0.05
+    with plt.rc_context({'font.family': font}):
+        # Site title
+        # Smaller font, just above page title
+        site_title_y_offset = 0.87
+        txt_site = axtext.text(
+            left_margin,
+            site_title_y_offset,
+            'Test site title',
+            {'size': 24},
+            ha='left',
+            va='top',
+            wrap=True,
+            c=site_title_color,
+        )
+
+        # Page title
+        # A larger font for more visibility
+        page_title_y_offset = 0.77
+
+        txt_page = axtext.text(
+            left_margin,
+            page_title_y_offset,
+            'Test page title, a bit longer to demo',
+            {'size': 46, 'color': 'k', 'fontweight': 'bold'},
+            ha='left',
+            va='top',
+            wrap=True,
+            c=page_title_color,
+        )
+
+        txt_page._get_wrap_line_width = _set_page_title_line_width  # NoQA: SLF001
+
+        # description
+        # Just below site title, smallest font and many lines.
+        # Our target length is 160 characters, so it should be
+        # two lines at full width with some room to spare at this length.
+        description_y_offset = 0.2
+        txt_description = axtext.text(
+            left_margin,
+            description_y_offset,
+            (
+                'A longer description that we use to ,'
+                'show off what the descriptions look like.'
+            ),
+            {'size': 17},
+            ha='left',
+            va='bottom',
+            wrap=True,
+            c=description_color,
+        )
+        txt_description._get_wrap_line_width = _set_description_line_width  # NoQA: SLF001
+
+        # url
+        # Aligned to the left of the mini image
+        url_y_axis_ofset = 0.12
+        txt_url = axtext.text(
+            left_margin,
+            url_y_axis_ofset,
+            'testurl.org',
+            {'size': 22},
+            ha='left',
+            va='bottom',
+            fontweight='bold',
+            c=site_url_color,
+        )
+
+    if isinstance(image_mini, Path):
+        img = mpimg.imread(image_mini)
+        axim_mini.imshow(img)
+
+    # Put the logo in the top right if it exists
+    if isinstance(image, Path):
+        img = mpimg.imread(image)
+        yw, xw = img.shape[:2]
+
+        # Axis is square and width is longest image axis
+        longest = max([yw, xw])
+        axim_logo.set_xlim([0, longest])
+        axim_logo.set_ylim([longest, 0])
+
+        # Center it on the non-long axis
+        xdiff = (longest - xw) / 2
+        ydiff = (longest - yw) / 2
+        axim_logo.imshow(img, extent=[xdiff, xw + xdiff, yw + ydiff, ydiff])
+
+    # Put a colored line at the bottom of the figure
+    axline.hlines(0, 0, 1, lw=25, color=line_color)
+
+    # Remove the ticks and borders from all axes for a clean look
+    for ax in fig.axes:
+        ax.set_axis_off()
+    return fig, txt_site, txt_page, txt_description, txt_url
+
+
+# These functions are used when creating social card objects to set MPL values.
+# They must be defined here otherwise Sphinx errors when trying to pickle them.
+# They are dependent on the `multiple` variable defined when the figure is created.
+# Because they are depending on the figure size and renderer used to generate them.
+def _set_page_title_line_width() -> int:
+    return 825
+
+
+def _set_description_line_width() -> int:
+    return 1000

BIN
docs/_extensions/sphinxext/opengraph/_static/Roboto-Flex.ttf


BIN
docs/_extensions/sphinxext/opengraph/_static/sphinx-logo-shadow.png


+ 34 - 0
docs/_extensions/sphinxext/opengraph/_title_parser.py

@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from html.parser import HTMLParser
+
+
+def get_title(title: str) -> tuple[str, str]:
+    htp = HTMLTextParser()
+    htp.feed(title)
+    htp.close()
+
+    return htp.text, htp.text_outside_tags
+
+
+class HTMLTextParser(HTMLParser):
+    """Parse HTML into text."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        # All text found
+        self.text = ''
+        # Only text outside of html tags
+        self.text_outside_tags = ''
+        self.level = 0
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        self.level += 1
+
+    def handle_endtag(self, tag: str) -> None:
+        self.level -= 1
+
+    def handle_data(self, data: str) -> None:
+        self.text += data
+        if self.level == 0:
+            self.text_outside_tags += data

+ 369 - 0
docs/_extensions/sphinxext_opengraph/__init__.py

@@ -0,0 +1,369 @@
+from __future__ import annotations
+
+import os
+import posixpath
+from pathlib import Path
+from typing import TYPE_CHECKING
+from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
+
+from docutils import nodes
+
+from sphinxext.opengraph._description_parser import get_description
+from sphinxext.opengraph._meta_parser import get_meta_description
+from sphinxext.opengraph._title_parser import get_title
+
+try:
+    from types import NoneType
+except ImportError:
+    NoneType = type(None)
+
+if TYPE_CHECKING:
+    from typing import Any
+
+    from sphinx.application import Sphinx
+    from sphinx.builders import Builder
+    from sphinx.config import Config
+    from sphinx.environment import BuildEnvironment
+    from sphinx.util.typing import ExtensionMetadata
+
+try:
+    from sphinxext.opengraph._social_cards import (
+        DEFAULT_SOCIAL_CONFIG,
+        create_social_card,
+    )
+except ImportError:
+    print('matplotlib is not installed, social cards will not be generated')
+    create_social_card = None
+    DEFAULT_SOCIAL_CONFIG = {}
+
+__version__ = '0.13.0'
+version_info = (0, 13, 0)
+
+DEFAULT_DESCRIPTION_LENGTH = 200
+DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160
+DEFAULT_PAGE_LENGTH_SOCIAL_CARDS = 80
+
+# A selection from https://www.iana.org/assignments/media-types/media-types.xhtml#image
+IMAGE_MIME_TYPES = {
+    'gif': 'image/gif',
+    'apng': 'image/apng',
+    'webp': 'image/webp',
+    'jpeg': 'image/jpeg',
+    'jpg': 'image/jpeg',
+    'png': 'image/png',
+    'bmp': 'image/bmp',
+    'heic': 'image/heic',
+    'heif': 'image/heif',
+    'tiff': 'image/tiff',
+}
+
+
+def html_page_context(
+    app: Sphinx,
+    pagename: str,
+    templatename: str,
+    context: dict[str, Any],
+    doctree: nodes.document,
+) -> None:
+    if app.builder.name == 'epub':
+        return
+
+    if doctree:
+        context['metatags'] += get_tags(
+            context,
+            doctree,
+            srcdir=app.srcdir,
+            outdir=app.outdir,
+            config=app.config,
+            builder=app.builder,
+            env=app.env,
+        )
+
+
+def get_tags(
+    context: dict[str, Any],
+    doctree: nodes.document,
+    *,
+    srcdir: str | Path,
+    outdir: str | Path,
+    config: Config,
+    builder: Builder,
+    env: BuildEnvironment,
+) -> str:
+    # Get field lists for per-page overrides
+    fields = context['meta']
+    if fields is None:
+        fields = {}
+
+    if 'ogp_disable' in fields:
+        return ''
+
+    tags = {}
+    meta_tags = {}  # For non-og meta tags
+
+    # Set length of description
+    try:
+        desc_len = int(
+            fields.get('ogp_description_length', config.ogp_description_length)
+        )
+    except ValueError:
+        desc_len = DEFAULT_DESCRIPTION_LENGTH
+
+    # Get the title and parse any html in it
+    title, title_excluding_html = get_title(context['title'])
+
+    # Parse/walk doctree for metadata (tag/description)
+    description = get_description(doctree, desc_len, {title, title_excluding_html})
+
+    # title tag
+    tags['og:title'] = title
+
+    # type tag
+    tags['og:type'] = config.ogp_type
+
+    if not config.ogp_site_url and os.getenv('READTHEDOCS'):
+        ogp_site_url = ambient_site_url()
+    else:
+        ogp_site_url = config.ogp_site_url
+
+    # If ogp_canonical_url is not set, default to the value of ogp_site_url
+    ogp_canonical_url = config.ogp_canonical_url or ogp_site_url
+
+    # url tag
+    # Get the URL of the specific page
+    page_url = urljoin(ogp_canonical_url, builder.get_target_uri(context['pagename']))
+    tags['og:url'] = page_url
+
+    # site name tag, False disables, default to project if ogp_site_name not
+    # set.
+    if config.ogp_site_name is False:
+        site_name = None
+    elif config.ogp_site_name is None:
+        site_name = config.project
+    else:
+        site_name = config.ogp_site_name
+    if site_name:
+        tags['og:site_name'] = site_name
+
+    # description tag
+    if description:
+        tags['og:description'] = description
+
+        if config.ogp_enable_meta_description and not get_meta_description(
+            context['metatags']
+        ):
+            meta_tags['description'] = description
+
+    # image tag
+    # Get basic values from config
+    if 'og:image' in fields:
+        image_url = fields['og:image']
+        ogp_use_first_image = False
+        ogp_image_alt = fields.get('og:image:alt')
+        fields.pop('og:image', None)
+    else:
+        image_url = config.ogp_image
+        ogp_use_first_image = config.ogp_use_first_image
+        ogp_image_alt = fields.get('og:image:alt', config.ogp_image_alt)
+
+    # Decide whether to add social media card images for each page.
+    # Only do this as a fallback if the user hasn't given any configuration
+    # to add other images.
+    config_social = DEFAULT_SOCIAL_CONFIG.copy()
+    social_card_user_options = config.ogp_social_cards or {}
+    config_social.update(social_card_user_options)
+    if (
+        not (image_url or ogp_use_first_image)
+        and config_social.get('enable') is not False
+        and create_social_card is not None
+    ):
+        image_url = social_card_for_page(
+            config_social=config_social,
+            site_name=site_name,
+            title=title,
+            description=description,
+            pagename=context['pagename'],
+            ogp_site_url=ogp_site_url,
+            ogp_canonical_url=ogp_canonical_url,
+            srcdir=srcdir,
+            outdir=outdir,
+            config=config,
+            env=env,
+        )
+        ogp_use_first_image = False
+
+        # Alt text is taken from description unless given
+        if 'og:image:alt' in fields:
+            ogp_image_alt = fields.get('og:image:alt')
+        else:
+            ogp_image_alt = description
+
+        # If the social card objects have been added we add special metadata for them
+        # These are the dimensions *in pixels* of the card
+        # They were chosen by looking at the image pixel dimensions on disk
+        tags['og:image:width'] = '1146'
+        tags['og:image:height'] = '600'
+        meta_tags['twitter:card'] = 'summary_large_image'
+
+    fields.pop('og:image:alt', None)
+
+    first_image = None
+    if ogp_use_first_image:
+        # Use the first image that is defined in the current page
+        first_image = doctree.next_node(nodes.image)
+        if (
+            first_image
+            and Path(first_image.get('uri', '')).suffix[1:].lower() in IMAGE_MIME_TYPES
+        ):
+            image_url = first_image['uri']
+            ogp_image_alt = first_image.get('alt', None)
+        else:
+            first_image = None
+
+    if image_url:
+        # temporarily disable relative image paths with field lists
+        if 'og:image' not in fields:
+            image_url_parsed = urlparse(image_url)
+            if not image_url_parsed.scheme:
+                # Relative image path detected, relative to the source. Make absolute.
+                if first_image:  # NoQA: SIM108
+                    root = page_url
+                else:  # ogp_image is set
+                    # ogp_image is defined as being relative to the site root.
+                    # This workaround is to keep that functionality from breaking.
+                    root = ogp_site_url
+
+                image_url = urljoin(root, image_url_parsed.path)
+            tags['og:image'] = image_url
+
+        # Add image alt text (either provided by config or from site_name)
+        if isinstance(ogp_image_alt, str):
+            tags['og:image:alt'] = ogp_image_alt
+        elif ogp_image_alt is None and site_name:
+            tags['og:image:alt'] = site_name
+        elif ogp_image_alt is None and title:
+            tags['og:image:alt'] = title
+
+    # arbitrary tags and overrides
+    tags.update({k: v for k, v in fields.items() if k.startswith('og:')})
+
+    return (
+        '\n'.join(
+            [make_tag(p, c) for p, c in tags.items()]
+            + [make_tag(p, c, 'name') for p, c in meta_tags.items()]
+            + list(config.ogp_custom_meta_tags)
+        )
+        + '\n'
+    )
+
+
+def ambient_site_url() -> str:
+    # readthedocs addons sets the READTHEDOCS_CANONICAL_URL variable
+    if rtd_canonical_url := os.getenv('READTHEDOCS_CANONICAL_URL'):
+        parse_result = urlsplit(rtd_canonical_url)
+    else:
+        msg = 'ReadTheDocs did not provide a valid canonical URL!'
+        raise RuntimeError(msg)
+
+    # Grab root url from canonical url
+    return urlunsplit(
+        (parse_result.scheme, parse_result.netloc, parse_result.path, '', '')
+    )
+
+
+def social_card_for_page(
+    config_social: dict[str, bool | str],
+    site_name: str,
+    title: str,
+    description: str,
+    pagename: str,
+    ogp_site_url: str,
+    ogp_canonical_url: str,
+    *,
+    srcdir: str | Path,
+    outdir: str | Path,
+    config: Config,
+    env: BuildEnvironment,
+) -> str:
+    # Description
+    description_max_length = config_social.get(
+        'description_max_length', DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3
+    )
+    if len(description) > description_max_length:
+        description = description[:description_max_length].strip() + '...'
+
+    # Page title
+    pagetitle = title
+    if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS:
+        pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + '...'
+
+    # Site URL
+    site_url = config_social.get('site_url', True)
+    if site_url is True:
+        url_text = ogp_canonical_url.split('://')[-1]
+    elif isinstance(site_url, str):
+        url_text = site_url
+
+    # Plot an image with the given metadata to the output path
+    image_path = create_social_card(
+        config_social,
+        site_name,
+        pagetitle,
+        description,
+        url_text,
+        pagename,
+        srcdir=srcdir,
+        outdir=outdir,
+        env=env,
+        html_logo=config.html_logo,
+    )
+
+    # Link the image in our page metadata
+    return posixpath.join(ogp_site_url, image_path.as_posix())
+
+
+def make_tag(property: str, content: str, type_: str = 'property') -> str:
+    # Parse quotation, so they won't break html tags if smart quotes are disabled
+    content = content.replace('"', '&quot;')
+    return f'<meta {type_}="{property}" content="{content}" />'
+
+
+def setup(app: Sphinx) -> ExtensionMetadata:
+    # ogp_site_url="" allows relative by default, even though it's not
+    # officially supported by OGP.
+    app.add_config_value('ogp_site_url', '', 'html', types=frozenset({str}))
+    app.add_config_value('ogp_canonical_url', '', 'html', types=frozenset({str}))
+    app.add_config_value(
+        'ogp_description_length',
+        DEFAULT_DESCRIPTION_LENGTH,
+        'html',
+        types=frozenset({int}),
+    )
+    app.add_config_value('ogp_image', None, 'html', types=frozenset({str, NoneType}))
+    app.add_config_value(
+        'ogp_image_alt', None, 'html', types=frozenset({str, bool, NoneType})
+    )
+    app.add_config_value('ogp_use_first_image', False, 'html', types=frozenset({bool}))
+    app.add_config_value('ogp_type', 'website', 'html', types=frozenset({str}))
+    app.add_config_value(
+        'ogp_site_name', None, 'html', types=frozenset({str, bool, NoneType})
+    )
+    app.add_config_value(
+        'ogp_social_cards', None, 'html', types=frozenset({dict, NoneType})
+    )
+    app.add_config_value(
+        'ogp_custom_meta_tags', (), 'html', types=frozenset({list, tuple})
+    )
+    app.add_config_value(
+        'ogp_enable_meta_description', True, 'html', types=frozenset({bool})
+    )
+
+    # Main Sphinx OpenGraph linking
+    app.connect('html-page-context', html_page_context)
+
+    return {
+        'version': __version__,
+        'env_version': 1,
+        'parallel_read_safe': True,
+        'parallel_write_safe': True,
+    }

+ 113 - 0
docs/_extensions/sphinxext_opengraph/_description_parser.py

@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import html
+import string
+from typing import TYPE_CHECKING
+
+from docutils import nodes
+
+if TYPE_CHECKING:
+    from collections.abc import Set
+
+
+def get_description(
+    doctree: nodes.document,
+    description_length: int,
+    known_titles: Set[str] = frozenset(),
+) -> str:
+    mcv = DescriptionParser(
+        doctree, desc_len=description_length, known_titles=known_titles
+    )
+    doctree.walkabout(mcv)
+    return mcv.description
+
+
+class DescriptionParser(nodes.NodeVisitor):
+    """Finds the title and creates a description from a doctree."""
+
+    def __init__(
+        self,
+        document: nodes.document,
+        *,
+        desc_len: int,
+        known_titles: Set[str] = frozenset(),
+    ) -> None:
+        super().__init__(document)
+        self.description = ''
+        self.desc_len = desc_len
+        self.list_level = 0
+        self.known_titles = known_titles
+        self.first_title_found = False
+
+        # Exceptions can't be raised from dispatch_departure()
+        # This is used to loop the stop call back to the next dispatch_visit()
+        self.stop = False
+
+    def dispatch_visit(self, node: nodes.Element) -> None:
+        if self.stop:
+            raise nodes.StopTraversal
+
+        # Skip comments & all admonitions
+        if isinstance(node, (nodes.Admonition, nodes.Invisible)):
+            raise nodes.SkipNode
+
+        # Mark start of nested lists
+        if isinstance(node, nodes.Sequential):
+            self.list_level += 1
+            if self.list_level > 1:
+                self.description += '-'
+
+        # Skip the first title if it's the title of the page
+        if not self.first_title_found and isinstance(node, nodes.title):
+            self.first_title_found = True
+            if node.astext() in self.known_titles:
+                raise nodes.SkipNode
+
+        if isinstance(node, nodes.raw) or isinstance(node.parent, nodes.literal_block):
+            raise nodes.SkipNode
+
+        # Only include leaf nodes in the description
+        if len(node.children) == 0:
+            text = node.astext().replace('\r', '').replace('\n', ' ').strip()
+
+            # Ensure string contains HTML-safe characters
+            text = html.escape(text, quote=True)
+
+            # Remove double spaces
+            while text.find('  ') != -1:
+                text = text.replace('  ', ' ')
+
+            # Put a space between elements if one does not already exist.
+            if (
+                len(self.description) > 0
+                and len(text) > 0
+                and self.description[-1] not in string.whitespace
+                and text[0] not in string.whitespace + string.punctuation
+            ):
+                self.description += ' '
+
+            self.description += text
+
+    def dispatch_departure(self, node: nodes.Element) -> None:
+        # Separate title from text
+        if isinstance(node, nodes.title):
+            self.description += ':'
+
+        # Separate list elements
+        if isinstance(node, nodes.Part):
+            self.description += ','
+
+        # Separate end of list from text
+        if isinstance(node, nodes.Sequential):
+            if self.description and self.description[-1] == ',':
+                self.description = self.description[:-1]
+            self.description += '.'
+            self.list_level -= 1
+
+        # Check for length
+        if len(self.description) > self.desc_len:
+            self.description = self.description[: self.desc_len]
+            if self.desc_len >= 3:
+                self.description = self.description[:-3] + '...'
+
+            self.stop = True

+ 29 - 0
docs/_extensions/sphinxext_opengraph/_meta_parser.py

@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from html.parser import HTMLParser
+
+
+def get_meta_description(meta_tags: str) -> bool:
+    htp = HTMLTextParser()
+    htp.feed(meta_tags)
+    htp.close()
+
+    return htp.meta_description
+
+
+class HTMLTextParser(HTMLParser):
+    """Parse HTML into text."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.meta_description = None
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        # For example:
+        # attrs = [("content", "My manual description"), ("name", "description")]
+        if ('name', 'description') in attrs:
+            self.meta_description = True
+            for name, value in attrs:
+                if name == 'content':
+                    self.meta_description = value
+                    break

+ 320 - 0
docs/_extensions/sphinxext_opengraph/_social_cards.py

@@ -0,0 +1,320 @@
+"""Build a PNG card for each page meant for social media."""
+
+from __future__ import annotations
+
+import hashlib
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import matplotlib as mpl
+import matplotlib.font_manager
+import matplotlib.image as mpimg
+from matplotlib import pyplot as plt
+from sphinx.util import logging
+
+if TYPE_CHECKING:
+    from typing import TypeAlias
+
+    from matplotlib.figure import Figure
+    from matplotlib.text import Text
+    from sphinx.environment import BuildEnvironment
+
+    PltObjects: TypeAlias = tuple[Figure, Text, Text, Text, Text]
+
+mpl.use('agg')
+
+LOGGER = logging.getLogger(__name__)
+HERE = Path(__file__).parent
+MAX_CHAR_PAGE_TITLE = 75
+MAX_CHAR_DESCRIPTION = 175
+
+# Default configuration for this functionality
+DEFAULT_SOCIAL_CONFIG = {
+    'enable': True,
+    'site_url': True,
+    'site_title': True,
+    'page_title': True,
+    'description': True,
+}
+
+
+# Default configuration for the figure style
+DEFAULT_KWARGS_FIG = {
+    'enable': True,
+    'site_url': True,
+}
+
+
+def create_social_card(
+    config_social: dict[str, bool | str],
+    site_name: str,
+    page_title: str,
+    description: str,
+    url_text: str,
+    page_path: str,
+    *,
+    srcdir: str | Path,
+    outdir: str | Path,
+    env: BuildEnvironment,
+    html_logo: str | None = None,
+) -> Path:
+    """Create a social preview card according to page metadata.
+
+    This uses page metadata and calls a render function to generate the image.
+    It also passes configuration through to the rendering function.
+    If Matplotlib objects are present in the `app` environment, it reuses them.
+    """
+    # Add a hash to the image path based on metadata to bust caches
+    # ref: https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/troubleshooting-cards#refreshing_images
+    hash = hashlib.sha1(
+        (site_name + page_title + description + str(config_social)).encode(),
+        usedforsecurity=False,
+    ).hexdigest()[:8]
+
+    # Define the file path we'll use for this image
+    path_images_relative = Path('_images/social_previews')
+    filename_image = f'summary_{page_path.replace("/", "_")}_{hash}.png'
+
+    # Absolute path used to save the image
+    path_images_absolute = Path(outdir) / path_images_relative
+    path_images_absolute.mkdir(exist_ok=True, parents=True)
+    path_image = path_images_absolute / filename_image
+
+    # If the image already exists then we can just skip creating a new one.
+    # This is because we hash the values of the text + images in the social card.
+    # If the hash doesn't change, it means the output should be the same.
+    if path_image.exists():
+        return path_images_relative / filename_image
+
+    # These kwargs are used to generate the base figure image
+    kwargs_fig: dict[str, str | Path | None] = {}
+
+    # Large image to the top right
+    if cs_image := config_social.get('image'):
+        kwargs_fig['image'] = Path(srcdir) / cs_image
+    elif html_logo:
+        kwargs_fig['image'] = Path(srcdir) / html_logo
+
+    # Mini image to the bottom right
+    if cs_image_mini := config_social.get('image_mini'):
+        kwargs_fig['image_mini'] = Path(srcdir) / cs_image_mini
+    else:
+        kwargs_fig['image_mini'] = (
+            Path(__file__).parent / '_static/sphinx-logo-shadow.png'
+        )
+
+    # Validation on the images
+    for img in ['image_mini', 'image']:
+        impath = kwargs_fig.get(img)
+        if not impath:
+            continue
+
+        # If image is an SVG replace it with None
+        if impath.suffix.lower() == '.svg':
+            LOGGER.warning('[Social card] %s cannot be an SVG image, skipping...', img)
+            kwargs_fig[img] = None
+
+        # If image doesn't exist, throw a warning and replace with none
+        if not impath.exists():
+            LOGGER.warning("[Social card]: %s file doesn't exist, skipping...", img)
+            kwargs_fig[img] = None
+
+    # These are passed directly from the user configuration to our plotting function
+    pass_through_config = ('text_color', 'line_color', 'background_color', 'font')
+    for config in pass_through_config:
+        if cs_config := config_social.get(config):
+            kwargs_fig[config] = cs_config
+
+    # Generate the image and store the matplotlib objects so that we can re-use them
+    try:
+        plt_objects = env.ogp_social_card_plt_objects
+    except AttributeError:
+        # If objects is None it means this is the first time plotting.
+        # Create the figure objects and return them so that we re-use them later.
+        plt_objects = create_social_card_objects(**kwargs_fig)
+    plt_objects = render_social_card(
+        path_image,
+        site_name,
+        page_title,
+        description,
+        url_text,
+        plt_objects,
+    )
+    env.ogp_social_card_plt_objects = plt_objects
+
+    # Path relative to build folder will be what we use for linking the URL
+    return path_images_relative / filename_image
+
+
+def render_social_card(
+    path: Path,
+    site_title: str,
+    page_title: str,
+    description: str,
+    siteurl: str,
+    plt_objects: PltObjects,
+) -> PltObjects:
+    """Render a social preview card with Matplotlib and write to disk."""
+    fig, txt_site_title, txt_page_title, txt_description, txt_url = plt_objects
+
+    # Update the matplotlib text objects with new text from this page
+    txt_site_title.set_text(site_title)
+    txt_page_title.set_text(page_title)
+    txt_description.set_text(description)
+    txt_url.set_text(siteurl)
+
+    # Save the image
+    fig.savefig(path, facecolor=None)
+    return fig, txt_site_title, txt_page_title, txt_description, txt_url
+
+
+def create_social_card_objects(
+    image: Path | None = None,
+    image_mini: Path | None = None,
+    page_title_color: str = '#2f363d',
+    description_color: str = '#585e63',
+    site_title_color: str = '#585e63',
+    site_url_color: str = '#2f363d',
+    background_color: str = 'white',
+    line_color: str = '#5A626B',
+    font: str | None = None,
+) -> PltObjects:
+    """Create the Matplotlib objects for the first time."""
+    # If no font specified, load the Roboto Flex font as a fallback
+    if font is None:
+        path_font = Path(__file__).parent / '_static/Roboto-Flex.ttf'
+        roboto_font = matplotlib.font_manager.FontEntry(
+            fname=str(path_font), name='Roboto Flex'
+        )
+        matplotlib.font_manager.fontManager.addfont(path_font)
+        font = roboto_font.name
+
+    # Because Matplotlib doesn't let you specify figures in pixels, only inches
+    # This `multiple` results in a scale of about 1146px by 600px
+    # Which is roughly the recommended size for OpenGraph images
+    # ref: https://opengraph.xyz
+    ratio = 1200 / 628
+    multiple = 6
+    fig = plt.figure(figsize=(ratio * multiple, multiple))
+    fig.set_facecolor(background_color)
+
+    # Text axis
+    axtext = fig.add_axes((0, 0, 1, 1))
+
+    # Image axis
+    ax_x, ax_y, ax_w, ax_h = (0.65, 0.65, 0.3, 0.3)
+    axim_logo = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor='NE')
+
+    # Image mini axis
+    ax_x, ax_y, ax_w, ax_h = (0.82, 0.1, 0.1, 0.1)
+    axim_mini = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor='NE')
+
+    # Line at the bottom axis
+    axline = fig.add_axes((-0.1, -0.04, 1.2, 0.1))
+
+    # Axes configuration
+    left_margin = 0.05
+    with plt.rc_context({'font.family': font}):
+        # Site title
+        # Smaller font, just above page title
+        site_title_y_offset = 0.87
+        txt_site = axtext.text(
+            left_margin,
+            site_title_y_offset,
+            'Test site title',
+            {'size': 24},
+            ha='left',
+            va='top',
+            wrap=True,
+            c=site_title_color,
+        )
+
+        # Page title
+        # A larger font for more visibility
+        page_title_y_offset = 0.77
+
+        txt_page = axtext.text(
+            left_margin,
+            page_title_y_offset,
+            'Test page title, a bit longer to demo',
+            {'size': 46, 'color': 'k', 'fontweight': 'bold'},
+            ha='left',
+            va='top',
+            wrap=True,
+            c=page_title_color,
+        )
+
+        txt_page._get_wrap_line_width = _set_page_title_line_width  # NoQA: SLF001
+
+        # description
+        # Just below site title, smallest font and many lines.
+        # Our target length is 160 characters, so it should be
+        # two lines at full width with some room to spare at this length.
+        description_y_offset = 0.2
+        txt_description = axtext.text(
+            left_margin,
+            description_y_offset,
+            (
+                'A longer description that we use to ,'
+                'show off what the descriptions look like.'
+            ),
+            {'size': 17},
+            ha='left',
+            va='bottom',
+            wrap=True,
+            c=description_color,
+        )
+        txt_description._get_wrap_line_width = _set_description_line_width  # NoQA: SLF001
+
+        # url
+        # Aligned to the left of the mini image
+        url_y_axis_ofset = 0.12
+        txt_url = axtext.text(
+            left_margin,
+            url_y_axis_ofset,
+            'testurl.org',
+            {'size': 22},
+            ha='left',
+            va='bottom',
+            fontweight='bold',
+            c=site_url_color,
+        )
+
+    if isinstance(image_mini, Path):
+        img = mpimg.imread(image_mini)
+        axim_mini.imshow(img)
+
+    # Put the logo in the top right if it exists
+    if isinstance(image, Path):
+        img = mpimg.imread(image)
+        yw, xw = img.shape[:2]
+
+        # Axis is square and width is longest image axis
+        longest = max([yw, xw])
+        axim_logo.set_xlim([0, longest])
+        axim_logo.set_ylim([longest, 0])
+
+        # Center it on the non-long axis
+        xdiff = (longest - xw) / 2
+        ydiff = (longest - yw) / 2
+        axim_logo.imshow(img, extent=[xdiff, xw + xdiff, yw + ydiff, ydiff])
+
+    # Put a colored line at the bottom of the figure
+    axline.hlines(0, 0, 1, lw=25, color=line_color)
+
+    # Remove the ticks and borders from all axes for a clean look
+    for ax in fig.axes:
+        ax.set_axis_off()
+    return fig, txt_site, txt_page, txt_description, txt_url
+
+
+# These functions are used when creating social card objects to set MPL values.
+# They must be defined here otherwise Sphinx errors when trying to pickle them.
+# They are dependent on the `multiple` variable defined when the figure is created.
+# Because they are depending on the figure size and renderer used to generate them.
+def _set_page_title_line_width() -> int:
+    return 825
+
+
+def _set_description_line_width() -> int:
+    return 1000

BIN
docs/_extensions/sphinxext_opengraph/_static/Roboto-Flex.ttf


BIN
docs/_extensions/sphinxext_opengraph/_static/sphinx-logo-shadow.png


+ 34 - 0
docs/_extensions/sphinxext_opengraph/_title_parser.py

@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from html.parser import HTMLParser
+
+
+def get_title(title: str) -> tuple[str, str]:
+    htp = HTMLTextParser()
+    htp.feed(title)
+    htp.close()
+
+    return htp.text, htp.text_outside_tags
+
+
+class HTMLTextParser(HTMLParser):
+    """Parse HTML into text."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        # All text found
+        self.text = ''
+        # Only text outside of html tags
+        self.text_outside_tags = ''
+        self.level = 0
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        self.level += 1
+
+    def handle_endtag(self, tag: str) -> None:
+        self.level -= 1
+
+    def handle_data(self, data: str) -> None:
+        self.text += data
+        if self.level == 0:
+            self.text_outside_tags += data

+ 5 - 0
docs/conf.py

@@ -38,6 +38,7 @@ extensions = [
 	'sphinx.ext.autosectionlabel',
 	'sphinx_sitemap',
 	'sphinx_last_updated_by_git',
+	'sphinxext.opengraph',
 ]
 
 # Add any paths that contain templates here, relative to this directory.
@@ -178,3 +179,7 @@ sitemap_excludes = [
     "search.html",
     "genindex.html",
 ]
+
+ogp_site_url = html_baseurl
+ogp_description_length = 280
+ogp_use_first_image = True

+ 24 - 2
scripts/upgrade.sh

@@ -451,7 +451,7 @@ update_sphinx_sitemap () {
 
 	# Add changes and commit.
 	git add -f "${DEST}"
-	git commit -m "docs: update sphinx_sitemap"
+	git commit -m "docs: update sphinx-sitemap"
 }
 
 update_sphinx_lubg () {
@@ -469,7 +469,25 @@ update_sphinx_lubg () {
 
 	# Add changes and commit.
 	git add -f "${DEST}"
-	git commit -m "docs: update sphinx_lubg"
+	git commit -m "docs: update sphinx-last-updated-by-git"
+}
+
+update_sphinx_opengraph () {
+	local REPO=https://github.com/sphinx-doc/sphinxext-opengraph
+	local DEST=docs/_extensions/sphinxext/opengraph
+	local BRANCH=master
+
+	local OPENGRAPH=$(mktemp -d)
+
+	# Download latest sphinx_rtd_theme.
+	git_clone "${OPENGRAPH}" "${REPO}" "${BRANCH}"
+
+	rm -rf "${DEST}"
+	mv "${OPENGRAPH}"/sphinxext/opengraph "${DEST}"
+
+	# Add changes and commit.
+	git add -f "${DEST}"
+	git commit -m "docs: update sphinxext-opengraph"
 }
 
 update_gtk_theme () {
@@ -627,6 +645,10 @@ while true; do
 		update_sphinx_lubg
 		exit $?
 		;;
+	sphinx_opengraph)
+		update_sphinx_opengraph
+		exit $?
+		;;
 	tinyexpr)
 		update_tinyexpr
 		exit $?