diff --git a/docs/script/generate_social_card_previews.py b/docs/script/generate_social_card_previews.py index 2376c2c..5cf404e 100644 --- a/docs/script/generate_social_card_previews.py +++ b/docs/script/generate_social_card_previews.py @@ -8,12 +8,14 @@ from __future__ import annotations +import io import random from pathlib import Path -from sphinxext.opengraph._social_cards import ( - MAX_CHAR_DESCRIPTION, - MAX_CHAR_PAGE_TITLE, +from sphinxext.opengraph._social_cards_matplotlib import ( + DEFAULT_DESCRIPTION_LENGTH, + PAGE_TITLE_LENGTH, + MatplotlibSocialCardSettings, create_social_card_objects, render_social_card, ) @@ -28,36 +30,42 @@ cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum""".split() # NoQA: SIM905 -kwargs_fig = { - 'image': PROJECT_ROOT / 'docs/_static/og-logo.png', - 'image_mini': PROJECT_ROOT / 'sphinxext/opengraph/_static/sphinx-logo-shadow.png', -} +settings = MatplotlibSocialCardSettings.from_values( + { + 'image': PROJECT_ROOT / 'docs/_static/og-logo.png', + 'image_mini': PROJECT_ROOT + / 'sphinxext/opengraph/_static/sphinx-logo-shadow.png', + } +) print('Generating previews of social media cards...') -plt_objects = create_social_card_objects(**kwargs_fig) +plt_objects = create_social_card_objects(settings) grid_items = [] for perm in range(20): # Create dummy text description and pagetitle for this iteration random.shuffle(lorem) title = ' '.join(lorem[:100]) - title = title[: MAX_CHAR_PAGE_TITLE - 3] + '...' + title = title[: PAGE_TITLE_LENGTH - 3] + '...' random.shuffle(lorem) desc = ' '.join(lorem[:100]) - desc = desc[: MAX_CHAR_DESCRIPTION - 3] + '...' + desc = desc[: DEFAULT_DESCRIPTION_LENGTH - 3] + '...' path_tmp = Path(PROJECT_ROOT / 'docs/tmp') path_tmp.mkdir(exist_ok=True) path_out = Path(path_tmp / f'num_{perm}.png') + bytes_obj = io.BytesIO() + plt_objects = render_social_card( - path=path_out, + bytes_obj=bytes_obj, site_title='Sphinx Social Card Demo', page_title=title, description=desc, - siteurl='sphinxext-opengraph.readthedocs.io', + site_url='sphinxext-opengraph.readthedocs.io', plt_objects=plt_objects, ) + path_out.write_bytes(bytes_obj.getvalue()) path_examples_page_folder = PROJECT_ROOT / 'docs' / 'tmp' grid_items.append(f"""\ diff --git a/docs/socialcards.rst b/docs/socialcards.rst index 414a72f..1970088 100644 --- a/docs/socialcards.rst +++ b/docs/socialcards.rst @@ -1,8 +1,94 @@ Social media card images ======================== -This extension will automatically generate a PNG meant for sharing documentation links on social media platforms. -These cards display metadata about the page that you link to, and are meant to catch the attention of readers. +This extension may create and reference a PNG file on the HTML pages, meant for sharing +documentation links on social media platforms (both "feed-based" and "chat-based" social +networks tend to use this). These cards display metadata about the page that you link +to. + +This extension will automatically setup such social media card images for each page of +your documentation, using one of 3 possible setups: + +- If you have set up an image manually using ``ogp_use_first_image`` or ``ogp_image``, + that image will be used as the social media card image. +- Otherwise, you may define a callback function to generate a custom PNG image for each + page (see below). +- If neither of the above are set, and matplotlib is installed, a default social media card + image will be generated for each page, using the page title and other metadata. + +Custom image callback +--------------------- + +In conf.py, connect a custom function to the ``ogp_social_card_callback`` event: + +.. code-block:: python + :caption: conf.py + + def setup(app): + app.connect("generate-social-card", my_custom_generate_card_function) + + +Then implement the function as follows: + +.. code-block:: python + + from sphinxext.opengraph import SocialCardContents + + def generate_card( + app: Sphinx, + contents: SocialCardContents, + check_if_signature_exists: typing.Callable[[str], None], + ) -> None | tuple[io.BytesIO, str]: + """Generate a social media card image for the current page. + + Parameters + ---------- + app : Sphinx + The Sphinx application object. + contents : SocialCardContents + An object containing metadata about the current page. + Contains the following attributes: + - site_name : str - the name of the site + - site_url : str - the base URL of the site + - page_title : str - the title of the page + - description : str - the description of the page + - html_logo : Path | None - the path to the logo image, if set + - page_path : Path - the path to the current page file + check_if_signature_exists : Callable[[str], None] + A callable to check if a social card image has already been generated for + the current page. This is useful to avoid regenerating an image that + already exists. + Returns + ------- + None or tuple[io.BytesIO, str] + If None is returned, the default image generation will be used. + Otherwise, return a tuple containing: + - An io.BytesIO object containing the image PNG bytes. + - A string whose hash will be included in the image filename to ensure + the image is updated when the content changes (because social media platforms + often cache images aggressively). + """ + + signature = f"{contents.page_title}-{contents.description}" + check_if_signature_exists(signature) + + # Generate image bytes here + image_bytes = io.BytesIO(...) + # ... generate image and write to image_bytes ... + return image_bytes, signature + +You may want to explore different libraries to generate images, such as Pillow, Matplotlib, +html2image, or others. + +Default social media card images (with matplotlib) +-------------------------------------------------- + +Default image generation uses additional third-party libraries, so you need to install +the extension with the **social_cards** extra: + +.. code-block:: console + + pip install 'sphinxext-opengraph[social_cards]' See `the opengraph.xyz website`__ for a way to preview what your social media cards look like. Here's an example of what the card for this page looks like: @@ -12,40 +98,48 @@ Here's an example of what the card for this page looks like: __ https://www.opengraph.xyz/ -Disable card images -------------------- +Default image generation also uses specific configuration options, which you can set in +your ``conf.py`` file as a dictionnary assigned to the ``ogp_social_cards`` variable. -To disable social media card images, use the following configuration: +Here are the available options and their default values: .. code-block:: python :caption: conf.py ogp_social_cards = { - "enable": False - } - -Update the top-right image --------------------------- - -By default the top-right image will use the image specified by ``html_logo`` if it exists. -To update it, specify another path in the **image** key like so: + # If False, disable social card images + "enable": True, + # If set, use this URL as the site URL instead of the one inferred from the config + "override_site_url": None, + + # Color overides, in hex format or named colors + "page_title_color": "#2f363d", + "description_color": "#585e63", + "site_title_color": "#585e63", + "site_url_color": "#2f363d", + "line_color": "#5a626b", + "background_color": "white", + + # Font override. If not set, use Roboto Flex (vendored in _static) + "font": None, + + # Image override, if not set, use html_logo + # You may set it as a string path or a Path object + # Note: neither this image not the image_mini may be an SVG file + "image": None, + + # Mini-image appears at the bottom-left of the card + # If not set, use the Sphinx logo + # You may set it as a string path or a Path object + "image_mini": "...", + + # Maximum length of the description text (in characters) before truncation + "description_max_length": 140, -.. code-block:: python - :caption: conf.py - - ogp_social_cards = { - "image": "path/to/image.png", } -.. warning:: - - The image cannot be an SVG - - Matplotlib does not support easy plotting of SVG images, - so ensure that your image is a PNG or JPEG file, not SVG. - Customize the text font ------------------------ +^^^^^^^^^^^^^^^^^^^^^^^ By default, the Roboto Flex font is used to render the card text. @@ -58,25 +152,30 @@ You can specify the other font name via ``font`` key: "font": "Noto Sans CJK JP", } -You might need to install an additional font package on your environment. Also, note that the font name needs to be -discoverable by Matplotlib FontManager. -See `Matplotlib documentation`__ -for the information about FontManager. +You can also use a font from a file by specifying the full path to the font file: -__ https://matplotlib.org/stable/tutorials/text/text_props.html#default-font +.. code-block:: python + :caption: conf.py -Customize the card ------------------- + from matplotlib import font_manager -There are several customization options to change the text and look of the social media preview card. -Below is a summary of these options. + font_path = 'font-file.ttf' # Your font path goes here + font_manager.fontManager.addfont(font_path) -- **site_url**: Set a custom site URL. -- **line_color**: Colour of the border line at the bottom of the card, in hex format. + ogp_social_cards = { + "font": "font name", + } + +You might need to install an additional font package on your environment. Also, note +that the font name needs to be discoverable by Matplotlib FontManager. See `Matplotlib +documentation`__ for the information about FontManager. + +__ https://matplotlib.org/stable/tutorials/text/text_props.html#default-font Example social cards --------------------- +^^^^^^^^^^^^^^^^^^^^ -Below are several social cards to give an idea for how this extension behaves with different length and size of text. +Below are several social cards to give an idea for how this extension behaves with +different length and size of text. .. include:: ./tmp/embed.txt diff --git a/sphinxext/opengraph/__init__.py b/sphinxext/opengraph/__init__.py index 6a43ab1..0585e01 100644 --- a/sphinxext/opengraph/__init__.py +++ b/sphinxext/opengraph/__init__.py @@ -1,7 +1,12 @@ from __future__ import annotations +import dataclasses +import functools +import hashlib +import logging import os import posixpath +import struct from pathlib import Path from typing import TYPE_CHECKING from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit @@ -18,7 +23,8 @@ NoneType = type(None) if TYPE_CHECKING: - from typing import Any + import io + from typing import Any, Callable from sphinx.application import Sphinx from sphinx.builders import Builder @@ -26,22 +32,13 @@ 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) +LOGGER = logging.getLogger(__name__) 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 = { @@ -58,6 +55,53 @@ } +@functools.cache +def get_file_contents_hash(file_path: Path) -> str: + """Get a hash of the contents of a file.""" + hasher = hashlib.sha1(usedforsecurity=False) + with file_path.open('rb') as f: + while chunk := f.read(8192): + hasher.update(chunk) + return hasher.hexdigest()[:8] + + +class PNGFormatError(Exception): + """Raised when a PNG file is invalid.""" + + +def get_png_dimensions(png_bytes: bytes) -> tuple[int, int]: + try: + w, h = struct.unpack('>LL', png_bytes[16:24]) + width = int(w) + height = int(h) + except struct.error as exc: + raise PNGFormatError from exc + return width, height + + +@dataclasses.dataclass +class SocialCardContents: + """Parameters for generating a social card. + + Received by the `generate-social-card` event. + """ + + site_name: str + site_url: str + page_title: str + description: str + html_logo: Path | None + page_path: Path + + @property + def signature(self) -> str: + """A string that uniquely identifies the contents of this social card. + + Used to avoid regenerating cards unnecessarily. + """ + return f'{self.site_name}{self.page_title}{self.description}{self.site_url}{get_file_contents_hash(self.html_logo) if self.html_logo else ""}' + + def html_page_context( app: Sphinx, pagename: str, @@ -137,7 +181,7 @@ def get_tags( # site name tag, False disables, default to project if ogp_site_name not # set. if config.ogp_site_name is False: - site_name = None + site_name = '' elif config.ogp_site_name is None: site_name = config.project else: @@ -166,44 +210,40 @@ def get_tags( 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. + # Decide whether to generate a social media card image. # 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, + # to add another image. + + if not (image_url or ogp_use_first_image): + image_path = social_card_for_page( + app=builder.app, 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, + page_title=title, + description=fields.get('og:description', description), + page_path=Path(context['pagename']), + site_url=ogp_canonical_url, 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 image_path: + image_url = posixpath.join(ogp_site_url, image_path.as_posix()) + + ogp_use_first_image = False - # 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' + # 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 + + try: + width, height = get_png_dimensions((outdir / image_path).read_bytes()) + except PNGFormatError as exc: + LOGGER.warning('Could not get dimensions of social card: %s', exc) + else: + tags['og:image:width'] = f'{width}' + tags['og:image:height'] = f'{height}' + meta_tags['twitter:card'] = 'summary_large_image' fields.pop('og:image:alt', None) @@ -271,55 +311,108 @@ def ambient_site_url() -> str: ) +class CardAlreadyExistsError(Exception): + """Raised when a social card already exists.""" + + def __init__(self, path: Path) -> None: + self.path = path + super().__init__(f'Card already exists: {path}') + + def social_card_for_page( - config_social: dict[str, bool | str], + *, + app: Sphinx, site_name: str, - title: str, + page_title: str, description: str, - pagename: str, - ogp_site_url: str, - ogp_canonical_url: str, - *, - srcdir: str | Path, - outdir: str | Path, + page_path: Path, config: Config, - env: BuildEnvironment, -) -> str: - # Description - description_max_length = config_social.get( - 'description_max_length', DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3 + site_url: str, +) -> Path | None: + contents = SocialCardContents( + site_name=site_name, + site_url=site_url.split('://')[-1], + page_title=page_title, + description=description, + page_path=page_path, + html_logo=(app.srcdir / Path(config.html_logo)) if config.html_logo else None, ) - 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] + '...' + image_bytes: io.BytesIO + signature: str - # 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 + outdir = Path(app.outdir) - # 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, - ) + # First callback to return a BytesIO object wins + try: + result = app.emit_firstresult( + 'generate-social-card', + contents, + functools.partial(check_if_signature_exists, outdir, page_path), + allowed_exceptions=(CardAlreadyExistsError,), + ) + except CardAlreadyExistsError as exc: + return exc.path + + if result is None: + return None + + image_bytes, signature = result + + path_to_image = get_path_for_signature(page_path=page_path, signature=signature) + + # Save the image to the output directory + absolute_path = outdir / path_to_image + absolute_path.parent.mkdir(exist_ok=True, parents=True) + absolute_path.write_bytes(image_bytes.getbuffer()) # Link the image in our page metadata - return posixpath.join(ogp_site_url, image_path.as_posix()) + return path_to_image + + +def hash_str(data: str) -> str: + return hashlib.sha1(data.encode(), usedforsecurity=False).hexdigest()[:8] + + +def get_path_for_signature(page_path: Path, signature: str) -> Path: + """Get a path for a social card image based on the page path and hash.""" + return ( + Path('_images') + / 'social_previews' + / f'summary_{str(page_path).replace("/", "_")}_{hash_str(signature)}.png' + ) + + +def check_if_signature_exists(outdir: Path, page_path: Path, signature: str) -> None: + """Check if a file with the given hash already exists. + + This is used to avoid regenerating social cards unnecessarily. + """ + relative_path = get_path_for_signature(page_path=page_path, signature=signature) + path = outdir / relative_path + if path.exists(): + raise CardAlreadyExistsError(path=relative_path) + + +def create_social_card_matplotlib_fallback( + app: Sphinx, + contents: SocialCardContents, + check_if_signature_exists: Callable[[str], None], +) -> None | tuple[io.BytesIO, str]: + try: + from sphinxext.opengraph._social_cards_matplotlib import create_social_card + except ImportError as exc: + # Ideally we should raise and let people who don't want the card explicitly + # disable it, but this would be a breaking change. + LOGGER.warning( + 'matplotlib is not installed, social cards will not be generated: %s', exc + ) + return None + + # Plot an image with the given metadata to the output path + return create_social_card( + app=app, contents=contents, check_if_signature_exists=check_if_signature_exists + ) def make_tag(property: str, content: str, type_: str = 'property') -> str: @@ -361,6 +454,17 @@ def setup(app: Sphinx) -> ExtensionMetadata: # Main Sphinx OpenGraph linking app.connect('html-page-context', html_page_context) + # Register event for customizing social card generation + app.add_event(name='generate-social-card') + # Add our matplotlib fallback, but with a low priority so that other + # extensions can override it. + # (default priority is 500, functions with lower priority numbers are called first). + app.connect( + 'generate-social-card', + create_social_card_matplotlib_fallback, + priority=1000, + ) + return { 'version': __version__, 'env_version': 1, diff --git a/sphinxext/opengraph/_social_cards.py b/sphinxext/opengraph/_social_cards_matplotlib.py similarity index 54% rename from sphinxext/opengraph/_social_cards.py rename to sphinxext/opengraph/_social_cards_matplotlib.py index 34108cf..abb6e64 100644 --- a/sphinxext/opengraph/_social_cards.py +++ b/sphinxext/opengraph/_social_cards_matplotlib.py @@ -1,8 +1,9 @@ -"""Build a PNG card for each page meant for social media.""" +"""Build a PNG card for each page meant for social media using matplotlib.""" from __future__ import annotations -import hashlib +import dataclasses +import io from pathlib import Path from typing import TYPE_CHECKING @@ -12,31 +13,24 @@ from matplotlib import pyplot as plt from sphinx.util import logging +from . import SocialCardContents + if TYPE_CHECKING: - from typing import TypeAlias + from typing import Callable from matplotlib.figure import Figure from matplotlib.text import Text - from sphinx.environment import BuildEnvironment + from sphinx.application import Sphinx - PltObjects: TypeAlias = tuple[Figure, Text, Text, Text, Text] + from . import SocialCardContents 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_DESCRIPTION_LENGTH = 157 +PAGE_TITLE_LENGTH = 80 # Default configuration for the figure style DEFAULT_KWARGS_FIG = { @@ -45,142 +39,156 @@ } +@dataclasses.dataclass +class MatplotlibSocialCardSettings: + """Configuration for social cards. + + These elements can be set in the `ogp_social_cards` config variable. + """ + + # Some elements accept a broader set of types, but we convert them in `from_config` + # below. + enable: bool = True + override_site_url: str | None = None + page_title_color: str = '#2f363d' + description_color: str = '#585e63' + site_title_color: str = '#585e63' + site_url_color: str = '#2f363d' + line_color: str = '#5a626b' + background_color: str = 'white' + font: str | None = None # Defaults to Roboto Flex, vendored in _static + image: Path | None = None + image_mini: Path | None = Path(__file__).parent / '_static/sphinx-logo-shadow.png' + description_max_length: int = DEFAULT_DESCRIPTION_LENGTH + + @classmethod + def from_values(cls, values: dict) -> MatplotlibSocialCardSettings: + """Create settings from a config dictionary.""" + if values.get('image'): + values['image'] = Path(values['image']) + + if values.get('image_mini'): + values['image_mini'] = Path(values['image_mini']) + + if isinstance(values.get('site_url'), str): + values['override_site_url'] = values.pop('site_url') + + return cls(**values) + + +@dataclasses.dataclass +class MatplotlibObjects: + fig: Figure + txt_site_title: Text + txt_page_title: Text + txt_description: Text + txt_url: Text + + +def validate_image(path: Path | None) -> Path | None: + # Validation on the images + if not path: + return None + + # If image is an SVG replace it with None + if path.suffix.lower() == '.svg': + LOGGER.warning('[Social card] %s cannot be an SVG image, skipping...', path) + return None + + # If image doesn't exist, throw a warning and replace with none + if not path.exists(): + LOGGER.warning("[Social card]: %s file doesn't exist, skipping...", path) + return None + + return path + + +def truncate(text: str, max_length: int) -> str: + """Truncate text to a maximum length, adding ellipsis if needed.""" + if len(text) > max_length: + return text[:max_length].rstrip() + '...' + return text + + 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: + app: Sphinx, + contents: SocialCardContents, + check_if_signature_exists: Callable[[str], None], +) -> None | tuple[io.BytesIO, str]: """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' - ) + config_social = app.config.ogp_social_cards or {} + config_social.setdefault('image', contents.html_logo) + settings = MatplotlibSocialCardSettings.from_values(values=config_social) - # 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 + signature = f'{contents.signature}{dataclasses.asdict(settings)}' + check_if_signature_exists(signature) + + if not settings.enable: + return None + + settings.image = validate_image(settings.image) + settings.image_mini = validate_image(settings.image_mini) + + description = truncate( + text=contents.description, max_length=settings.description_max_length + ) + page_title = truncate(text=contents.page_title, max_length=PAGE_TITLE_LENGTH) # Generate the image and store the matplotlib objects so that we can re-use them try: - plt_objects = env.ogp_social_card_plt_objects + plt_objects = app.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 = create_social_card_objects(settings=settings) + + bytes_obj = io.BytesIO() + plt_objects = render_social_card( - path_image, - site_name, - page_title, - description, - url_text, - plt_objects, + bytes_obj=bytes_obj, + site_title=contents.site_name, + page_title=page_title, + description=description, + site_url=settings.override_site_url or contents.site_url, + plt_objects=plt_objects, ) - env.ogp_social_card_plt_objects = plt_objects + app.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 + return bytes_obj, signature def render_social_card( - path: Path, + bytes_obj: io.BytesIO, site_title: str, page_title: str, description: str, - siteurl: str, - plt_objects: PltObjects, -) -> PltObjects: + site_url: str, + plt_objects: MatplotlibObjects, +) -> MatplotlibObjects: """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) + plt_objects.txt_site_title.set_text(site_title) + plt_objects.txt_page_title.set_text(page_title) + plt_objects.txt_description.set_text(description) + plt_objects.txt_url.set_text(site_url) # Save the image - fig.savefig(path, facecolor=None) - return fig, txt_site_title, txt_page_title, txt_description, txt_url + plt_objects.fig.savefig(bytes_obj, facecolor=None) + return plt_objects 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: + settings: MatplotlibSocialCardSettings, +) -> MatplotlibObjects: """Create the Matplotlib objects for the first time.""" # If no font specified, load the Roboto Flex font as a fallback + font = settings.font if font is None: path_font = Path(__file__).parent / '_static/Roboto-Flex.ttf' roboto_font = matplotlib.font_manager.FontEntry( @@ -196,7 +204,7 @@ def create_social_card_objects( ratio = 1200 / 628 multiple = 6 fig = plt.figure(figsize=(ratio * multiple, multiple)) - fig.set_facecolor(background_color) + fig.set_facecolor(settings.background_color) # Text axis axtext = fig.add_axes((0, 0, 1, 1)) @@ -226,7 +234,7 @@ def create_social_card_objects( ha='left', va='top', wrap=True, - c=site_title_color, + c=settings.site_title_color, ) # Page title @@ -241,7 +249,7 @@ def create_social_card_objects( ha='left', va='top', wrap=True, - c=page_title_color, + c=settings.page_title_color, ) txt_page._get_wrap_line_width = _set_page_title_line_width # NoQA: SLF001 @@ -262,7 +270,7 @@ def create_social_card_objects( ha='left', va='bottom', wrap=True, - c=description_color, + c=settings.description_color, ) txt_description._get_wrap_line_width = _set_description_line_width # NoQA: SLF001 @@ -277,16 +285,16 @@ def create_social_card_objects( ha='left', va='bottom', fontweight='bold', - c=site_url_color, + c=settings.site_url_color, ) - if isinstance(image_mini, Path): - img = mpimg.imread(image_mini) + if settings.image_mini: + img = mpimg.imread(settings.image_mini) axim_mini.imshow(img) # Put the logo in the top right if it exists - if isinstance(image, Path): - img = mpimg.imread(image) + if settings.image: + img = mpimg.imread(settings.image) yw, xw = img.shape[:2] # Axis is square and width is longest image axis @@ -300,12 +308,19 @@ def create_social_card_objects( 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) + axline.hlines(0, 0, 1, lw=25, color=settings.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 + + return MatplotlibObjects( + fig=fig, + txt_site_title=txt_site, + txt_page_title=txt_page, + txt_description=txt_description, + txt_url=txt_url, + ) # These functions are used when creating social card objects to set MPL values. diff --git a/tests/roots/test-custom-social-card-generation/conf.py b/tests/roots/test-custom-social-card-generation/conf.py new file mode 100644 index 0000000..b6b1842 --- /dev/null +++ b/tests/roots/test-custom-social-card-generation/conf.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import io +import pathlib + +extensions = ['sphinxext.opengraph'] + +master_doc = 'index' +exclude_patterns = ['_build'] + +html_theme = 'basic' + +ogp_site_url = 'http://example.org/en/latest/' + + +def setup(app): + app.connect('generate-social-card', generate) + + +def generate( + app, + contents, + check_if_signature_exists, +) -> None | tuple[io.BytesIO, str]: + signature = 'custom-signature' + check_if_signature_exists(signature) + + return io.BytesIO(pathlib.Path(app.srcdir / 'pixel.png').read_bytes()), signature diff --git a/tests/roots/test-custom-social-card-generation/index.rst b/tests/roots/test-custom-social-card-generation/index.rst new file mode 100644 index 0000000..bbd23a0 --- /dev/null +++ b/tests/roots/test-custom-social-card-generation/index.rst @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse at lorem ornare, fringilla massa nec, venenatis mi. Donec erat sapien, tincidunt nec rhoncus nec, scelerisque id diam. Orci varius natoque penatibus et magnis dis parturient mauris. diff --git a/tests/roots/test-custom-social-card-generation/pixel.png b/tests/roots/test-custom-social-card-generation/pixel.png new file mode 100644 index 0000000..1914264 Binary files /dev/null and b/tests/roots/test-custom-social-card-generation/pixel.png differ diff --git a/tests/test_options.py b/tests/test_options.py index c780435..e74c601 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pathlib from typing import TYPE_CHECKING import conftest @@ -119,7 +120,7 @@ def test_image_alt(og_meta_tags): @pytest.mark.sphinx('html', testroot='simple') -def test_image_social_cards(meta_tags): +def test_image_social_cards(content, meta_tags): """Social cards should automatically be added if no og:image is given.""" pytest.importorskip('matplotlib') # Asserting `in` instead of `==` because of the hash that is generated @@ -136,6 +137,39 @@ def test_image_social_cards(meta_tags): assert 'summary_large_image' in get_tag_content( meta_tags, 'card', kind='name', prefix='twitter' ) + png_file = ( + pathlib.Path(content.outdir) + / get_tag_content(meta_tags, 'image').split('/', 5)[-1] + ) + assert png_file.is_file() + # https://en.wikipedia.org/wiki/List_of_file_signatures + assert png_file.read_bytes()[:8] == b'\x89PNG\r\n\x1a\n' + assert get_tag_content(meta_tags, 'image:width') == '1146' + assert get_tag_content(meta_tags, 'image:height') == '600' + + +@pytest.mark.sphinx('html', testroot='custom-social-card-generation') +def test_image_social_cards_custom(content, meta_tags): + """Providing a custom generation function for social cards.""" + # Asserting `in` instead of `==` because of the hash that is generated + assert ( + 'http://example.org/en/latest/_images/social_previews/summary_index' + in get_tag_content(meta_tags, 'image') + ) + # Make sure the extra tags are in the HTML + assert 'summary_large_image' in get_tag_content( + meta_tags, 'card', kind='name', prefix='twitter' + ) + assert get_tag_content(meta_tags, 'image:width') == '1' + assert get_tag_content(meta_tags, 'image:height') == '1' + png_file = ( + pathlib.Path(content.outdir) + / get_tag_content(meta_tags, 'image').split('/', 5)[-1] + ) + assert png_file.is_file() + # https://en.wikipedia.org/wiki/List_of_file_signatures + assert png_file.read_bytes()[:8] == b'\x89PNG\r\n\x1a\n' + assert len(png_file.read_bytes()) == 95 # Size of the provided pixel.png @pytest.mark.sphinx('html', testroot='type')