_social_cards.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. """Build a PNG card for each page meant for social media."""
  2. from __future__ import annotations
  3. import hashlib
  4. from pathlib import Path
  5. from typing import TYPE_CHECKING
  6. import matplotlib as mpl
  7. import matplotlib.font_manager
  8. import matplotlib.image as mpimg
  9. from matplotlib import pyplot as plt
  10. from sphinx.util import logging
  11. if TYPE_CHECKING:
  12. from typing import TypeAlias
  13. from matplotlib.figure import Figure
  14. from matplotlib.text import Text
  15. from sphinx.environment import BuildEnvironment
  16. PltObjects: TypeAlias = tuple[Figure, Text, Text, Text, Text]
  17. mpl.use('agg')
  18. LOGGER = logging.getLogger(__name__)
  19. HERE = Path(__file__).parent
  20. MAX_CHAR_PAGE_TITLE = 75
  21. MAX_CHAR_DESCRIPTION = 175
  22. # Default configuration for this functionality
  23. DEFAULT_SOCIAL_CONFIG = {
  24. 'enable': True,
  25. 'site_url': True,
  26. 'site_title': True,
  27. 'page_title': True,
  28. 'description': True,
  29. }
  30. # Default configuration for the figure style
  31. DEFAULT_KWARGS_FIG = {
  32. 'enable': True,
  33. 'site_url': True,
  34. }
  35. def create_social_card(
  36. config_social: dict[str, bool | str],
  37. site_name: str,
  38. page_title: str,
  39. description: str,
  40. url_text: str,
  41. page_path: str,
  42. *,
  43. srcdir: str | Path,
  44. outdir: str | Path,
  45. env: BuildEnvironment,
  46. html_logo: str | None = None,
  47. ) -> Path:
  48. """Create a social preview card according to page metadata.
  49. This uses page metadata and calls a render function to generate the image.
  50. It also passes configuration through to the rendering function.
  51. If Matplotlib objects are present in the `app` environment, it reuses them.
  52. """
  53. # Add a hash to the image path based on metadata to bust caches
  54. # ref: https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/troubleshooting-cards#refreshing_images
  55. hash = hashlib.sha1(
  56. (site_name + page_title + description + str(config_social)).encode(),
  57. usedforsecurity=False,
  58. ).hexdigest()[:8]
  59. # Define the file path we'll use for this image
  60. path_images_relative = Path('_images/social_previews')
  61. filename_image = f'summary_{page_path.replace("/", "_")}_{hash}.png'
  62. # Absolute path used to save the image
  63. path_images_absolute = Path(outdir) / path_images_relative
  64. path_images_absolute.mkdir(exist_ok=True, parents=True)
  65. path_image = path_images_absolute / filename_image
  66. # If the image already exists then we can just skip creating a new one.
  67. # This is because we hash the values of the text + images in the social card.
  68. # If the hash doesn't change, it means the output should be the same.
  69. if path_image.exists():
  70. return path_images_relative / filename_image
  71. # These kwargs are used to generate the base figure image
  72. kwargs_fig: dict[str, str | Path | None] = {}
  73. # Large image to the top right
  74. if cs_image := config_social.get('image'):
  75. kwargs_fig['image'] = Path(srcdir) / cs_image
  76. elif html_logo:
  77. kwargs_fig['image'] = Path(srcdir) / html_logo
  78. # Mini image to the bottom right
  79. if cs_image_mini := config_social.get('image_mini'):
  80. kwargs_fig['image_mini'] = Path(srcdir) / cs_image_mini
  81. else:
  82. kwargs_fig['image_mini'] = (
  83. Path(__file__).parent / '_static/sphinx-logo-shadow.png'
  84. )
  85. # Validation on the images
  86. for img in ['image_mini', 'image']:
  87. impath = kwargs_fig.get(img)
  88. if not impath:
  89. continue
  90. # If image is an SVG replace it with None
  91. if impath.suffix.lower() == '.svg':
  92. LOGGER.warning('[Social card] %s cannot be an SVG image, skipping...', img)
  93. kwargs_fig[img] = None
  94. # If image doesn't exist, throw a warning and replace with none
  95. if not impath.exists():
  96. LOGGER.warning("[Social card]: %s file doesn't exist, skipping...", img)
  97. kwargs_fig[img] = None
  98. # These are passed directly from the user configuration to our plotting function
  99. pass_through_config = ('text_color', 'line_color', 'background_color', 'font')
  100. for config in pass_through_config:
  101. if cs_config := config_social.get(config):
  102. kwargs_fig[config] = cs_config
  103. # Generate the image and store the matplotlib objects so that we can re-use them
  104. try:
  105. plt_objects = env.ogp_social_card_plt_objects
  106. except AttributeError:
  107. # If objects is None it means this is the first time plotting.
  108. # Create the figure objects and return them so that we re-use them later.
  109. plt_objects = create_social_card_objects(**kwargs_fig)
  110. plt_objects = render_social_card(
  111. path_image,
  112. site_name,
  113. page_title,
  114. description,
  115. url_text,
  116. plt_objects,
  117. )
  118. env.ogp_social_card_plt_objects = plt_objects
  119. # Path relative to build folder will be what we use for linking the URL
  120. return path_images_relative / filename_image
  121. def render_social_card(
  122. path: Path,
  123. site_title: str,
  124. page_title: str,
  125. description: str,
  126. siteurl: str,
  127. plt_objects: PltObjects,
  128. ) -> PltObjects:
  129. """Render a social preview card with Matplotlib and write to disk."""
  130. fig, txt_site_title, txt_page_title, txt_description, txt_url = plt_objects
  131. # Update the matplotlib text objects with new text from this page
  132. txt_site_title.set_text(site_title)
  133. txt_page_title.set_text(page_title)
  134. txt_description.set_text(description)
  135. txt_url.set_text(siteurl)
  136. # Save the image
  137. fig.savefig(path, facecolor=None)
  138. return fig, txt_site_title, txt_page_title, txt_description, txt_url
  139. def create_social_card_objects(
  140. image: Path | None = None,
  141. image_mini: Path | None = None,
  142. page_title_color: str = '#2f363d',
  143. description_color: str = '#585e63',
  144. site_title_color: str = '#585e63',
  145. site_url_color: str = '#2f363d',
  146. background_color: str = 'white',
  147. line_color: str = '#5A626B',
  148. font: str | None = None,
  149. ) -> PltObjects:
  150. """Create the Matplotlib objects for the first time."""
  151. # If no font specified, load the Roboto Flex font as a fallback
  152. if font is None:
  153. path_font = Path(__file__).parent / '_static/Roboto-Flex.ttf'
  154. roboto_font = matplotlib.font_manager.FontEntry(
  155. fname=str(path_font), name='Roboto Flex'
  156. )
  157. matplotlib.font_manager.fontManager.addfont(path_font)
  158. font = roboto_font.name
  159. # Because Matplotlib doesn't let you specify figures in pixels, only inches
  160. # This `multiple` results in a scale of about 1146px by 600px
  161. # Which is roughly the recommended size for OpenGraph images
  162. # ref: https://opengraph.xyz
  163. ratio = 1200 / 628
  164. multiple = 6
  165. fig = plt.figure(figsize=(ratio * multiple, multiple))
  166. fig.set_facecolor(background_color)
  167. # Text axis
  168. axtext = fig.add_axes((0, 0, 1, 1))
  169. # Image axis
  170. ax_x, ax_y, ax_w, ax_h = (0.65, 0.65, 0.3, 0.3)
  171. axim_logo = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor='NE')
  172. # Image mini axis
  173. ax_x, ax_y, ax_w, ax_h = (0.82, 0.1, 0.1, 0.1)
  174. axim_mini = fig.add_axes((ax_x, ax_y, ax_w, ax_h), anchor='NE')
  175. # Line at the bottom axis
  176. axline = fig.add_axes((-0.1, -0.04, 1.2, 0.1))
  177. # Axes configuration
  178. left_margin = 0.05
  179. with plt.rc_context({'font.family': font}):
  180. # Site title
  181. # Smaller font, just above page title
  182. site_title_y_offset = 0.87
  183. txt_site = axtext.text(
  184. left_margin,
  185. site_title_y_offset,
  186. 'Test site title',
  187. {'size': 24},
  188. ha='left',
  189. va='top',
  190. wrap=True,
  191. c=site_title_color,
  192. )
  193. # Page title
  194. # A larger font for more visibility
  195. page_title_y_offset = 0.77
  196. txt_page = axtext.text(
  197. left_margin,
  198. page_title_y_offset,
  199. 'Test page title, a bit longer to demo',
  200. {'size': 46, 'color': 'k', 'fontweight': 'bold'},
  201. ha='left',
  202. va='top',
  203. wrap=True,
  204. c=page_title_color,
  205. )
  206. txt_page._get_wrap_line_width = _set_page_title_line_width # NoQA: SLF001
  207. # description
  208. # Just below site title, smallest font and many lines.
  209. # Our target length is 160 characters, so it should be
  210. # two lines at full width with some room to spare at this length.
  211. description_y_offset = 0.2
  212. txt_description = axtext.text(
  213. left_margin,
  214. description_y_offset,
  215. (
  216. 'A longer description that we use to ,'
  217. 'show off what the descriptions look like.'
  218. ),
  219. {'size': 17},
  220. ha='left',
  221. va='bottom',
  222. wrap=True,
  223. c=description_color,
  224. )
  225. txt_description._get_wrap_line_width = _set_description_line_width # NoQA: SLF001
  226. # url
  227. # Aligned to the left of the mini image
  228. url_y_axis_ofset = 0.12
  229. txt_url = axtext.text(
  230. left_margin,
  231. url_y_axis_ofset,
  232. 'testurl.org',
  233. {'size': 22},
  234. ha='left',
  235. va='bottom',
  236. fontweight='bold',
  237. c=site_url_color,
  238. )
  239. if isinstance(image_mini, Path):
  240. img = mpimg.imread(image_mini)
  241. axim_mini.imshow(img)
  242. # Put the logo in the top right if it exists
  243. if isinstance(image, Path):
  244. img = mpimg.imread(image)
  245. yw, xw = img.shape[:2]
  246. # Axis is square and width is longest image axis
  247. longest = max([yw, xw])
  248. axim_logo.set_xlim([0, longest])
  249. axim_logo.set_ylim([longest, 0])
  250. # Center it on the non-long axis
  251. xdiff = (longest - xw) / 2
  252. ydiff = (longest - yw) / 2
  253. axim_logo.imshow(img, extent=[xdiff, xw + xdiff, yw + ydiff, ydiff])
  254. # Put a colored line at the bottom of the figure
  255. axline.hlines(0, 0, 1, lw=25, color=line_color)
  256. # Remove the ticks and borders from all axes for a clean look
  257. for ax in fig.axes:
  258. ax.set_axis_off()
  259. return fig, txt_site, txt_page, txt_description, txt_url
  260. # These functions are used when creating social card objects to set MPL values.
  261. # They must be defined here otherwise Sphinx errors when trying to pickle them.
  262. # They are dependent on the `multiple` variable defined when the figure is created.
  263. # Because they are depending on the figure size and renderer used to generate them.
  264. def _set_page_title_line_width() -> int:
  265. return 825
  266. def _set_description_line_width() -> int:
  267. return 1000