GitHubMarkdown.py

Summary

Lines:

213 of 226 covered (94.2%)

Excluded lines:

2

Branches:

88 of 96 taken (91.7%), 8 lines partially

Coverage:

93.5%

Directory:

Sphinx

Missed lines:

207-209, 216, 383-385, 418, 420-422, 475, 513

Source

pyTooling/Sphinx/GitHubMarkdown.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2#             _____           _ _               ____        _     _                                                    #
3#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
7# |_|    |___/                          |___/        |_|                                                               #
8# ==================================================================================================================== #
9# Authors:                                                                                                             #
10#   Patrick Lehmann                                                                                                    #
11#                                                                                                                      #
12# License:                                                                                                             #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany                                                             #
15#                                                                                                                      #
16# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
17# you may not use this file except in compliance with the License.                                                     #
18# You may obtain a copy of the License at                                                                              #
19#                                                                                                                      #
20#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
21#                                                                                                                      #
22# Unless required by applicable law or agreed to in writing, software                                                  #
23# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
25# See the License for the specific language governing permissions and                                                  #
26# limitations under the License.                                                                                       #
27#                                                                                                                      #
28# SPDX-License-Identifier: Apache-2.0                                                                                  #
29# ==================================================================================================================== #
30#
31"""
32A converter of GitHub-flavoured Markdown into docutils nodes.
33
34The Markdown is e.g. the notes of a GitHub release. :class:`GitHubMarkdown` parses it with markdown-it-py and the
35GFM plugin of mdit-py-plugins - CommonMark plus tables, strikethrough, task lists, alerts, autolinks and footnotes -,
36and :class:`~pyTooling.Sphinx.Markdown.MarkdownTranslator` translates the tokens into the nodes a directive
37returns:
38
39.. code-block:: Python
40
41   converter = GitHubMarkdown("pyTooling", "pyTooling")
42   return converter.Convert(markdown, self.state.document, location=self.get_location())
43
44.. rubric:: GitHub's references
45
46GitHub links what Markdown leaves as text: ``#123``, ``GH-123`` and ``owner/name#123`` to an issue or a pull request,
47``@user`` to a user, a commit's hash (7 to 40 hexadecimal digits) to the commit, and ``:tada:`` is an emoji. The
48converter does the same in text outside of code and links (see :meth:`GitHubMarkdown.Parse`). GitHub links a
49hash only if the commit exists; the converter can't ask, so it links a word of hexadecimal digits holding a digit and a
50letter.
51
52.. rubric:: Emoji
53
54The emoji, their characters and shortcodes, are GitHub's own list: the table :data:`EMOJI_FILE` is generated from
55github/gemoji's :file:`db/emoji.json`, which GitHub, Inc. distributes under the MIT license; the table's header names
56the version and holds the license text. A custom GitHub emoji, e.g. ``:octocat:``, is an image without characters, and
57isn't in the list.
58
59.. rubric:: Headings and raw HTML
60
61A heading becomes a topic or a rubric, never a section, and raw HTML is translated element by element, as
62:mod:`~pyTooling.Sphinx.Markdown` describes.
63
64.. rubric:: LaTeX
65
66An emoji is a node of its own (:class:`~pyTooling.Sphinx.Node.Emoji`), whose characters LaTeX writes as they are, for
67LuaLaTeX and an emoji font.
68
69LaTeX can't include an SVG image, e.g. a badge. :class:`RasterizeShieldsBadges` fetches a shields.io badge as PNG;
70every other SVG image is converted by Sphinx' ImageMagick converter, :mod:`sphinx.ext.imgconverter`, which
71pyTooling.Sphinx loads. Without ImageMagick, Sphinx warns, and LaTeX stops at the image with "Unknown graphics
72extension: .svg".
73
74.. attention::
75
76   Packages ``markdown_it`` and ``mdit_py_plugins`` are imported when a :class:`GitHubMarkdown` is created, not when
77   this module is imported. They are installed with the extra ``markdown``.
78"""
79from __future__                        import annotations
80
81from re                                import compile as re_compile, escape as re_escape
82from typing                            import TYPE_CHECKING, Any, ClassVar, Optional as Nullable, Pattern
83from urllib.parse                      import urlsplit
84
85from docutils                          import nodes
86from sphinx.transforms.post_transforms import SphinxPostTransform
87
88from pyTooling.Common                  import getFullyQualifiedName, readResourceFile
89from pyTooling.Decorators              import export, readonly
90from pyTooling.Exceptions              import MissingDependencyError
91from pyTooling.MetaClasses             import ExtendedType
92from pyTooling.Sphinx                  import Resources as SphinxResources
93from pyTooling.Sphinx.Markdown         import IMAGE_CLASS, MarkdownTranslator
94from pyTooling.Sphinx.Node             import Emoji
95from pyTooling.Sphinx.Shields          import SHIELDS_SERVICE_PNG, SHIELDS_SERVICE_SVG
96
97if TYPE_CHECKING:  # pragma: no cover
98	from markdown_it                     import MarkdownIt
99
100
101__all__ = ["EMOJI_FILE", "OWNER_PATTERN", "REPOSITORY_NAME_PATTERN", "REPOSITORY_PATTERN", "REFERENCE_PATTERN"]
102
103EMOJI_FILE = "GitHubEmoji.txt"
104"""
105Name of the emoji table in :mod:`pyTooling.Sphinx.Resources`: a line per emoji, its code points and its shortcodes,
106generated from github/gemoji (MIT license, Copyright (c) 2019 GitHub, Inc.).
107"""
108
109OWNER_PATTERN = re_compile(r"[A-Za-z0-9](?:-?[A-Za-z0-9])*")
110"""A GitHub owner - a user or an organization: ASCII letters, digits and single hyphens, not starting with a hyphen."""
111
112REPOSITORY_NAME_PATTERN = re_compile(r"[A-Za-z0-9._-]+")
113"""A GitHub repository's name: ASCII letters, digits, ``.``, ``_`` and ``-``."""
114
115REPOSITORY_PATTERN = re_compile(rf"{OWNER_PATTERN.pattern}/{REPOSITORY_NAME_PATTERN.pattern}")
116"""A GitHub repository with its owner, as a reference writes it: ``<owner>/<name>``."""
117
118_BEFORE = r"(?<![\w/@#.-])"
119"""What can't precede a reference: a word character, or a character continuing a path, a reference or a URL."""
120
121REFERENCE_PATTERN = re_compile(
122	r":(?P<shortcode>[a-z0-9_+-]+):"
123	rf"|{_BEFORE}(?:(?P<issueRepository>{REPOSITORY_PATTERN.pattern})#|GH-|#)(?P<issue>[0-9]+)(?![\w-])"
124	rf"|{_BEFORE}(?:(?P<commitRepository>{REPOSITORY_PATTERN.pattern})@)?(?P<sha>[0-9a-f]{{7,40}})(?![\w-])"
125	rf"|{_BEFORE}@(?P<user>{OWNER_PATTERN.pattern})(?![\w/-])"
126)
127"""An emoji's shortcode, or one of GitHub's references: an issue or pull request, a commit, or a user."""
128
129
130@export
131class GitHubMarkdown(metaclass=ExtendedType, slots=True):
132	"""
133	A converter of GitHub-flavoured Markdown into docutils nodes, which links GitHub's references in a repository's
134	context.
135
136	The owner and the repository are the ones ``#123`` refers to, and the server URL the one every link starts with.
137	:meth:`Convert` converts a Markdown document into docutils nodes, :meth:`Parse` parses a text without Markdown for
138	GitHub's references and emoji.
139
140	GitHub writes a line break of an issue, a comment or a release's notes as a line break, not as a space:
141	:attr:`LineBreaks` selects which.
142	"""
143
144	_emojis:       ClassVar[Nullable[dict[str, str]]] = None  #: The emoji's characters by shortcode, once read.
145	_shortcodes:   ClassVar[Nullable[dict[str, str]]] = None  #: A shortcode per emoji, by its characters without U+FE0F.
146	_emojiPattern: ClassVar[Nullable[Pattern[str]]]   = None  #: The pattern finding emoji written as characters.
147
148	_owner:      str         #: The owner of the repository references refer to, a user or an organization.
149	_repository: str         #: The name of the repository references refer to.
150	_serverURL:  str         #: The URL of the GitHub server, without a trailing ``/``.
151	_lineBreaks: bool        #: Whether a line break in a paragraph is written as a line break.
152	_parser:     MarkdownIt  #: The Markdown parser.
153
154	def __init__(
155		self,
156		owner: str,
157		repository: str,
158		serverURL: str = "https://github.com",
159		lineBreaks: bool = True
160	) -> None:
161		"""
162		Initializes a converter for the Markdown of a repository.
163
164		:param owner:                   The owner of the repository references refer to, a user or an organization.
165		:param repository:              The name of the repository references refer to.
166		:param serverURL:               Optional, URL of the GitHub server. Default: ``"https://github.com"``.
167		:param lineBreaks:              Optional, whether a line break in a paragraph is written as a line break, as
168		                                GitHub does in a release's notes. Default: ``True``.
169		:raises ValueError:             If parameter ``owner`` is ``None``.
170		:raises TypeError:              If parameter ``owner`` isn't of type :class:`str`.
171		:raises ValueError:             If parameter ``owner`` isn't a GitHub user's or organization's name.
172		:raises ValueError:             If parameter ``repository`` is ``None``.
173		:raises TypeError:              If parameter ``repository`` isn't of type :class:`str`.
174		:raises ValueError:             If parameter ``repository`` isn't a GitHub repository's name.
175		:raises ValueError:             If parameter ``serverURL`` is ``None``.
176		:raises TypeError:              If parameter ``serverURL`` isn't of type :class:`str`.
177		:raises ValueError:             If parameter ``serverURL`` isn't an HTTP or HTTPS URL.
178		:raises ValueError:             If parameter ``lineBreaks`` is ``None``.
179		:raises TypeError:              If parameter ``lineBreaks`` isn't of type :class:`bool`.
180		:raises MissingDependencyError: If the extra ``markdown`` isn't installed.
181		"""
182		if owner is None:
183			raise ValueError("Parameter 'owner' is None.")
184		elif not isinstance(owner, str):
185			ex = TypeError("Parameter 'owner' is not of type 'str'.")
186			ex.add_note(f"Got type '{getFullyQualifiedName(owner)}'.")
187			raise ex
188		elif OWNER_PATTERN.fullmatch(owner) is None:
189			ex = ValueError("Parameter 'owner' is not a GitHub user's or organization's name.")
190			ex.add_note(f"Got value '{owner}'.")
191			raise ex
192
193		if repository is None:
194			raise ValueError("Parameter 'repository' is None.")
195		elif not isinstance(repository, str):
196			ex = TypeError("Parameter 'repository' is not of type 'str'.")
197			ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
198			raise ex
199		elif REPOSITORY_NAME_PATTERN.fullmatch(repository) is None:
200			ex = ValueError("Parameter 'repository' is not a GitHub repository's name.")
201			ex.add_note(f"Got value '{repository}'.")
202			raise ex
203
204		if serverURL is None:
205			raise ValueError("Parameter 'serverURL' is None.")
206		elif not isinstance(serverURL, str):
207			ex = TypeError("Parameter 'serverURL' is not of type 'str'.")
208			ex.add_note(f"Got type '{getFullyQualifiedName(serverURL)}'.")
209			raise ex
210		elif urlsplit(serverURL).scheme not in ("https", "http") or urlsplit(serverURL).netloc == "":
211			ex = ValueError("Parameter 'serverURL' is not an HTTP or HTTPS URL.")
212			ex.add_note(f"Got value '{serverURL}'.")
213			raise ex
214
215		if lineBreaks is None:
216			raise ValueError("Parameter 'lineBreaks' is None.")
217		elif not isinstance(lineBreaks, bool):
218			ex = TypeError("Parameter 'lineBreaks' is not of type 'bool'.")
219			ex.add_note(f"Got type '{getFullyQualifiedName(lineBreaks)}'.")
220			raise ex
221
222		try:
223			from markdown_it         import MarkdownIt
224		except ImportError as ex:
225			raise MissingDependencyError(dependency="markdown-it-py", extra="markdown") from ex
226
227		try:
228			from mdit_py_plugins.gfm import gfm_plugin
229		except ImportError as ex:
230			raise MissingDependencyError(dependency="mdit-py-plugins", extra="markdown") from ex
231
232		self._owner =      owner
233		self._repository = repository
234		self._serverURL =  serverURL.rstrip("/")
235		self._lineBreaks = lineBreaks
236		# 'text_join' would merge an escaped character into the text around it, e.g. '\#12' into a reference
237		self._parser =     MarkdownIt("commonmark", {"html": True}).use(gfm_plugin).disable("text_join")
238
239		if GitHubMarkdown._emojis is None:
240			emojis: dict[str, str] =     {}
241			shortcodes: dict[str, str] = {}
242			for line in readResourceFile(SphinxResources, EMOJI_FILE).splitlines():
243				if line.startswith("#") or line.strip() == "":
244					continue
245
246				codePoints, *names = line.split()
247				characters = "".join(chr(int(codePoint, 16)) for codePoint in codePoints.split("-"))
248				shortcodes[characters.replace("\ufe0f", "")] = names[0]
249				for name in names:
250					emojis[name] = characters
251
252			GitHubMarkdown._emojiPattern = self._EmojiPattern(shortcodes)
253			GitHubMarkdown._shortcodes =   shortcodes
254			GitHubMarkdown._emojis =       emojis
255
256	@staticmethod
257	def _EmojiPattern(shortcodes: dict[str, str]) -> Pattern[str]:
258		"""
259		Build the pattern finding the emoji written as characters in a text: a trie of their characters, each optionally
260		followed by variation selector 16 (U+FE0F).
261
262		A single character below U+2190 - e.g. the copyright sign - is an emoji only if followed by U+FE0F; else it is
263		a character of the text font.
264
265		:param shortcodes: The emoji's characters without U+FE0F.
266		:returns:          The pattern.
267		"""
268		trie: dict[str, Any] = {}
269		for characters in shortcodes:
270			level = trie
271			for character in characters:
272				level = level.setdefault(character, {})
273			level[""] = {}
274
275		def alternatives(level: dict[str, Any], depth: int) -> str:
276			"""
277			Nested function for recursion.
278
279			:param level: A level of the trie, by character; key ``""`` marks the end of an emoji.
280			:param depth: The level's depth, ``0`` for the first character.
281			:returns:     The level's pattern.
282			"""
283			branches = []
284			for character, nextLevel in sorted(level.items()):
285				if character == "":
286					continue
287
288				selector = "\ufe0f" if depth == 0 and ord(character) < 0x2190 and list(nextLevel) == [""] else "\ufe0f?"
289				branches.append(f"{re_escape(character)}{selector}{alternatives(nextLevel, depth + 1)}")
290
291			if len(branches) == 0:
292				return ""
293
294			optional = "?" if "" in level else ""
295			return f"(?:{'|'.join(branches)}){optional}"
296
297		return re_compile(alternatives(trie, 0))
298
299	@readonly
300	def Owner(self) -> str:
301		"""
302		Read-only property to access the owner of the repository references refer to (:attr:`_owner`).
303
304		:returns: The owner, a user or an organization.
305		"""
306		return self._owner
307
308	@readonly
309	def Repository(self) -> str:
310		"""
311		Read-only property to access the name of the repository references refer to (:attr:`_repository`).
312
313		:returns: The repository's name.
314		"""
315		return self._repository
316
317	@readonly
318	def ServerURL(self) -> str:
319		"""
320		Read-only property to access the URL of the GitHub server (:attr:`_serverURL`).
321
322		:returns: The URL, without a trailing ``/``.
323		"""
324		return self._serverURL
325
326	@readonly
327	def LineBreaks(self) -> bool:
328		"""
329		Read-only property to access whether a line break in a paragraph is written as a line break (:attr:`_lineBreaks`).
330
331		:returns: ``True``, if a line break is written as a line break; ``False``, if as a space.
332		"""
333		return self._lineBreaks
334
335	def Convert(
336		self,
337		markdown: str,
338		document: nodes.document,
339		repeatedTitle: str = "",
340		location: Nullable[str] = None
341	) -> list[nodes.Node]:
342		"""
343		Convert a Markdown document into docutils nodes.
344
345		The nodes are body elements a directive returns; headings become topics and rubrics (see the module's
346		description). A footnote's ID is unique in the document.
347
348		The first heading of the Markdown can repeat the title, which the nodes are put under, e.g. ``# GHDL 5.0.1`` in
349		the notes of a release titled so. Given as ``repeatedTitle``, such a heading is skipped, so the headings after it
350		become the topics.
351
352		:param markdown:      The Markdown.
353		:param document:      The document the nodes are put into.
354		:param repeatedTitle: Optional, the title the first heading is skipped for; compared without surrounding and
355		                      repeated whitespace. Default: ``""``, no heading is skipped.
356		:param location:      Optional, location the warnings are reported at, e.g. ``index.rst:12``. Default: ``None``.
357		:returns:             The nodes.
358		:raises ValueError:   If parameter ``markdown`` is ``None``.
359		:raises TypeError:    If parameter ``markdown`` isn't of type :class:`str`.
360		:raises ValueError:   If parameter ``document`` is ``None``.
361		:raises TypeError:    If parameter ``document`` isn't of type :class:`~docutils.nodes.document`.
362		:raises ValueError:   If parameter ``repeatedTitle`` is ``None``.
363		:raises TypeError:    If parameter ``repeatedTitle`` isn't of type :class:`str`.
364		:raises TypeError:    If parameter ``location`` isn't of type :class:`str`.
365		"""
366		if markdown is None:
367			raise ValueError("Parameter 'markdown' is None.")
368		elif not isinstance(markdown, str):
369			ex = TypeError("Parameter 'markdown' is not of type 'str'.")
370			ex.add_note(f"Got type '{getFullyQualifiedName(markdown)}'.")
371			raise ex
372
373		if document is None:
374			raise ValueError("Parameter 'document' is None.")
375		elif not isinstance(document, nodes.document):
376			ex = TypeError("Parameter 'document' is not of type 'docutils.nodes.document'.")
377			ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
378			raise ex
379
380		if repeatedTitle is None:
381			raise ValueError("Parameter 'repeatedTitle' is None.")
382		elif not isinstance(repeatedTitle, str):
383			ex = TypeError("Parameter 'repeatedTitle' is not of type 'str'.")
384			ex.add_note(f"Got type '{getFullyQualifiedName(repeatedTitle)}'.")
385			raise ex
386
387		if location is not None and not isinstance(location, str):
388			ex = TypeError("Parameter 'location' is not of type 'str'.")
389			ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
390			raise ex
391
392		tokens = self._parser.parse(markdown)
393		if repeatedTitle != "":
394			for index, token in enumerate(tokens):
395				if token.type == "heading_open" and token.level == 0:
396					if " ".join(tokens[index + 1].content.split()) == " ".join(repeatedTitle.split()):
397						del tokens[index:index + 3]
398					break
399
400		return MarkdownTranslator(self, document, location).Translate(tokens)
401
402	def Parse(self, text: str, references: bool = True) -> list[nodes.Node]:
403		"""
404		Parse a text for GitHub's references and emoji into docutils nodes, linking the references and writing emoji as
405		:class:`~pyTooling.Sphinx.Node.Emoji` nodes.
406
407		An emoji is written as its shortcode, e.g. ``:tada:``, or as its characters. A shortcode GitHub doesn't know
408		stays text, as does a custom GitHub emoji, which has no characters, e.g. ``:octocat:``.
409
410		:param text:        The text, without Markdown.
411		:param references:  Optional, whether GitHub's references are linked; ``False`` e.g. for a link's text. Default:
412		                    ``True``.
413		:returns:           The text, references and emoji.
414		:raises ValueError: If parameter ``text`` is ``None``.
415		:raises TypeError:  If parameter ``text`` isn't of type :class:`str`.
416		"""
417		if text is None:
418			raise ValueError("Parameter 'text' is None.")
419		elif not isinstance(text, str):
420			ex = TypeError("Parameter 'text' is not of type 'str'.")
421			ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
422			raise ex
423
424		result: list[nodes.Node] = []
425		ownRepository = f"{self._owner}/{self._repository}"
426		position = 0
427		for match in REFERENCE_PATTERN.finditer(text):
428			written = match[0]
429			if (shortcode := match["shortcode"]) is not None:
430				if (characters := self._emojis.get(shortcode)) is None:
431					continue
432
433				node: nodes.Node = Emoji(characters, characters, shortcode=shortcode)
434			elif not references:
435				continue
436			elif (issue := match["issue"]) is not None:
437				repository = ownRepository if match["issueRepository"] is None else match["issueRepository"]
438				node = nodes.reference(
439					written, written, refuri=f"{self._serverURL}/{repository}/issues/{issue}", classes=["github-issue"]
440				)
441			elif (sha := match["sha"]) is not None:
442				if sha.isdigit() or not any(character.isdigit() for character in sha):
443					continue
444
445				repository = ownRepository if match["commitRepository"] is None else match["commitRepository"]
446				node = nodes.reference(
447					written, "", refuri=f"{self._serverURL}/{repository}/commit/{sha}", classes=["github-commit"]
448				)
449				if match["commitRepository"] is not None:
450					node += nodes.Text(f"{repository}@")
451				node += nodes.literal(sha[:7], sha[:7])
452			else:
453				node = nodes.reference(
454					written, written, refuri=f"{self._serverURL}/{match['user']}", classes=["github-user"]
455				)
456
457			result.extend(self._ConvertEmojiCharacters(text[position:match.start()]))
458			result.append(node)
459			position = match.end()
460
461		result.extend(self._ConvertEmojiCharacters(text[position:]))
462		return result
463
464	def _ConvertEmojiCharacters(self, text: str) -> list[nodes.Node]:
465		"""
466		Convert a text into text and :class:`~pyTooling.Sphinx.Node.Emoji` nodes for the emoji written as characters.
467
468		:param text: The text.
469		:returns:    The text and the emoji; nothing for an empty text.
470		"""
471		result: list[nodes.Node] = []
472		position = 0
473		for match in self._emojiPattern.finditer(text):
474			if match.end() == match.start():
475				continue
476
477			if match.start() > position:
478				result.append(nodes.Text(text[position:match.start()]))
479
480			characters = match[0]
481			result.append(Emoji(characters, characters, shortcode=self._shortcodes[characters.replace("\ufe0f", "")]))
482			position = match.end()
483
484		if position < len(text):
485			result.append(nodes.Text(text[position:]))
486
487		return result
488
489
490@export
491class RasterizeShieldsBadges(SphinxPostTransform):
492	"""
493	A post-transform fetching the shields.io badges of converted Markdown as PNG in LaTeX, which can't include SVG.
494
495	A badge's URI is moved to :data:`~pyTooling.Sphinx.Shields.SHIELDS_SERVICE_PNG`, without a suffix ``.svg``. Every
496	other SVG image is left to :mod:`sphinx.ext.imgconverter`, which pyTooling.Sphinx loads and which converts it with
497	ImageMagick.
498	"""
499
500	default_priority = 50           #: Priority among the post-transforms: before Sphinx downloads remote images.
501	formats =          ("latex", )  #: Output formats the transform runs for.
502
503	def run(self, **kwargs: Any) -> None:
504		"""
505		Move the shields.io badges of converted Markdown to the service rasterizing them.
506
507		The images of converted Markdown have class :data:`~pyTooling.Sphinx.Markdown.IMAGE_CLASS`.
508
509		:param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
510		"""
511		for image in self.document.findall(nodes.image):
512			if IMAGE_CLASS not in image["classes"]:
513				continue
514
515			uri = urlsplit(image["uri"])
516			if f"{uri.scheme}://{uri.netloc}" == SHIELDS_SERVICE_SVG:
517				raster = urlsplit(SHIELDS_SERVICE_PNG)._replace(path=uri.path.removesuffix(".svg"), query=uri.query)
518				image["uri"] =        raster.geturl()
519				image["candidates"] = {"?": image["uri"]}