GitHubMarkdown.py

Summary

Lines:

208 of 221 covered (94.1%)

Excluded lines:

2

Branches:

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

Coverage:

93.4%

Directory:

Sphinx

Missed lines:

205-207, 214, 381-383, 416, 418-420, 473, 511

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
103#: Name of the emoji table in :mod:`pyTooling.Sphinx.Resources`: a line per emoji, its code points and its shortcodes,
104#: generated from github/gemoji (MIT license, Copyright (c) 2019 GitHub, Inc.).
105EMOJI_FILE = "GitHubEmoji.txt"
106
107#: A GitHub owner - a user or an organization: ASCII letters, digits and single hyphens, not starting with a hyphen.
108OWNER_PATTERN = re_compile(r"[A-Za-z0-9](?:-?[A-Za-z0-9])*")
109
110#: A GitHub repository's name: ASCII letters, digits, ``.``, ``_`` and ``-``.
111REPOSITORY_NAME_PATTERN = re_compile(r"[A-Za-z0-9._-]+")
112
113#: A GitHub repository with its owner, as a reference writes it: ``<owner>/<name>``.
114REPOSITORY_PATTERN = re_compile(rf"{OWNER_PATTERN.pattern}/{REPOSITORY_NAME_PATTERN.pattern}")
115
116_BEFORE = r"(?<![\w/@#.-])"
117"""What can't precede a reference: a word character, or a character continuing a path, a reference or a URL."""
118
119#: An emoji's shortcode, or one of GitHub's references: an issue or pull request, a commit, or a user.
120REFERENCE_PATTERN = re_compile(
121	r":(?P<shortcode>[a-z0-9_+-]+):"
122	rf"|{_BEFORE}(?:(?P<issueRepository>{REPOSITORY_PATTERN.pattern})#|GH-|#)(?P<issue>[0-9]+)(?![\w-])"
123	rf"|{_BEFORE}(?:(?P<commitRepository>{REPOSITORY_PATTERN.pattern})@)?(?P<sha>[0-9a-f]{{7,40}})(?![\w-])"
124	rf"|{_BEFORE}@(?P<user>{OWNER_PATTERN.pattern})(?![\w/-])"
125)
126
127
128@export
129class GitHubMarkdown(metaclass=ExtendedType, slots=True):
130	"""
131	A converter of GitHub-flavoured Markdown into docutils nodes, which links GitHub's references in a repository's
132	context.
133
134	The owner and the repository are the ones ``#123`` refers to, and the server URL the one every link starts with.
135	:meth:`Convert` converts a Markdown document into docutils nodes, :meth:`Parse` parses a text without Markdown for
136	GitHub's references and emoji.
137
138	GitHub writes a line break of an issue, a comment or a release's notes as a line break, not as a space:
139	:attr:`LineBreaks` selects which.
140	"""
141
142	_emojis:       ClassVar[Nullable[dict[str, str]]] = None  #: The emoji's characters by shortcode, once read.
143	_shortcodes:   ClassVar[Nullable[dict[str, str]]] = None  #: A shortcode per emoji, by its characters without U+FE0F.
144	_emojiPattern: ClassVar[Nullable[Pattern[str]]]   = None  #: The pattern finding emoji written as characters.
145
146	_owner:      str         #: The owner of the repository references refer to, a user or an organization.
147	_repository: str         #: The name of the repository references refer to.
148	_serverURL:  str         #: The URL of the GitHub server, without a trailing ``/``.
149	_lineBreaks: bool        #: Whether a line break in a paragraph is written as a line break.
150	_parser:     MarkdownIt  #: The Markdown parser.
151
152	def __init__(
153		self,
154		owner: str,
155		repository: str,
156		serverURL: str = "https://github.com",
157		lineBreaks: bool = True
158	) -> None:
159		"""
160		Initializes a converter for the Markdown of a repository.
161
162		:param owner:                   The owner of the repository references refer to, a user or an organization.
163		:param repository:              The name of the repository references refer to.
164		:param serverURL:               Optional, URL of the GitHub server. Default: ``"https://github.com"``.
165		:param lineBreaks:              Optional, whether a line break in a paragraph is written as a line break, as
166		                                GitHub does in a release's notes. Default: ``True``.
167		:raises ValueError:             If parameter ``owner`` is ``None``.
168		:raises TypeError:              If parameter ``owner`` isn't of type :class:`str`.
169		:raises ValueError:             If parameter ``owner`` isn't a GitHub user's or organization's name.
170		:raises ValueError:             If parameter ``repository`` is ``None``.
171		:raises TypeError:              If parameter ``repository`` isn't of type :class:`str`.
172		:raises ValueError:             If parameter ``repository`` isn't a GitHub repository's name.
173		:raises ValueError:             If parameter ``serverURL`` is ``None``.
174		:raises TypeError:              If parameter ``serverURL`` isn't of type :class:`str`.
175		:raises ValueError:             If parameter ``serverURL`` isn't an HTTP or HTTPS URL.
176		:raises ValueError:             If parameter ``lineBreaks`` is ``None``.
177		:raises TypeError:              If parameter ``lineBreaks`` isn't of type :class:`bool`.
178		:raises MissingDependencyError: If the extra ``markdown`` isn't installed.
179		"""
180		if owner is None:
181			raise ValueError("Parameter 'owner' is None.")
182		elif not isinstance(owner, str):
183			ex = TypeError("Parameter 'owner' is not of type 'str'.")
184			ex.add_note(f"Got type '{getFullyQualifiedName(owner)}'.")
185			raise ex
186		elif OWNER_PATTERN.fullmatch(owner) is None:
187			ex = ValueError("Parameter 'owner' is not a GitHub user's or organization's name.")
188			ex.add_note(f"Got value '{owner}'.")
189			raise ex
190
191		if repository is None:
192			raise ValueError("Parameter 'repository' is None.")
193		elif not isinstance(repository, str):
194			ex = TypeError("Parameter 'repository' is not of type 'str'.")
195			ex.add_note(f"Got type '{getFullyQualifiedName(repository)}'.")
196			raise ex
197		elif REPOSITORY_NAME_PATTERN.fullmatch(repository) is None:
198			ex = ValueError("Parameter 'repository' is not a GitHub repository's name.")
199			ex.add_note(f"Got value '{repository}'.")
200			raise ex
201
202		if serverURL is None:
203			raise ValueError("Parameter 'serverURL' is None.")
204		elif not isinstance(serverURL, str):
205			ex = TypeError("Parameter 'serverURL' is not of type 'str'.")
206			ex.add_note(f"Got type '{getFullyQualifiedName(serverURL)}'.")
207			raise ex
208		elif urlsplit(serverURL).scheme not in ("https", "http") or urlsplit(serverURL).netloc == "":
209			ex = ValueError("Parameter 'serverURL' is not an HTTP or HTTPS URL.")
210			ex.add_note(f"Got value '{serverURL}'.")
211			raise ex
212
213		if lineBreaks is None:
214			raise ValueError("Parameter 'lineBreaks' is None.")
215		elif not isinstance(lineBreaks, bool):
216			ex = TypeError("Parameter 'lineBreaks' is not of type 'bool'.")
217			ex.add_note(f"Got type '{getFullyQualifiedName(lineBreaks)}'.")
218			raise ex
219
220		try:
221			from markdown_it         import MarkdownIt
222		except ImportError as ex:
223			raise MissingDependencyError(dependency="markdown-it-py", extra="markdown") from ex
224
225		try:
226			from mdit_py_plugins.gfm import gfm_plugin
227		except ImportError as ex:
228			raise MissingDependencyError(dependency="mdit-py-plugins", extra="markdown") from ex
229
230		self._owner =      owner
231		self._repository = repository
232		self._serverURL =  serverURL.rstrip("/")
233		self._lineBreaks = lineBreaks
234		# 'text_join' would merge an escaped character into the text around it, e.g. '\#12' into a reference
235		self._parser =     MarkdownIt("commonmark", {"html": True}).use(gfm_plugin).disable("text_join")
236
237		if GitHubMarkdown._emojis is None:
238			emojis: dict[str, str] =     {}
239			shortcodes: dict[str, str] = {}
240			for line in readResourceFile(SphinxResources, EMOJI_FILE).splitlines():
241				if line.startswith("#") or line.strip() == "":
242					continue
243
244				codePoints, *names = line.split()
245				characters = "".join(chr(int(codePoint, 16)) for codePoint in codePoints.split("-"))
246				shortcodes[characters.replace("\ufe0f", "")] = names[0]
247				for name in names:
248					emojis[name] = characters
249
250			GitHubMarkdown._emojiPattern = self._EmojiPattern(shortcodes)
251			GitHubMarkdown._shortcodes =   shortcodes
252			GitHubMarkdown._emojis =       emojis
253
254	@staticmethod
255	def _EmojiPattern(shortcodes: dict[str, str]) -> Pattern[str]:
256		"""
257		Build the pattern finding the emoji written as characters in a text: a trie of their characters, each optionally
258		followed by variation selector 16 (U+FE0F).
259
260		A single character below U+2190 - e.g. the copyright sign - is an emoji only if followed by U+FE0F; else it is
261		a character of the text font.
262
263		:param shortcodes: The emoji's characters without U+FE0F.
264		:returns:          The pattern.
265		"""
266		trie: dict[str, Any] = {}
267		for characters in shortcodes:
268			level = trie
269			for character in characters:
270				level = level.setdefault(character, {})
271			level[""] = {}
272
273		def alternatives(level: dict[str, Any], depth: int) -> str:
274			"""
275			Nested function for recursion.
276
277			:param level: A level of the trie, by character; key ``""`` marks the end of an emoji.
278			:param depth: The level's depth, ``0`` for the first character.
279			:returns:     The level's pattern.
280			"""
281			branches = []
282			for character, nextLevel in sorted(level.items()):
283				if character == "":
284					continue
285
286				selector = "\ufe0f" if depth == 0 and ord(character) < 0x2190 and list(nextLevel) == [""] else "\ufe0f?"
287				branches.append(f"{re_escape(character)}{selector}{alternatives(nextLevel, depth + 1)}")
288
289			if len(branches) == 0:
290				return ""
291
292			optional = "?" if "" in level else ""
293			return f"(?:{'|'.join(branches)}){optional}"
294
295		return re_compile(alternatives(trie, 0))
296
297	@readonly
298	def Owner(self) -> str:
299		"""
300		Read-only property to access the owner of the repository references refer to (:attr:`_owner`).
301
302		:returns: The owner, a user or an organization.
303		"""
304		return self._owner
305
306	@readonly
307	def Repository(self) -> str:
308		"""
309		Read-only property to access the name of the repository references refer to (:attr:`_repository`).
310
311		:returns: The repository's name.
312		"""
313		return self._repository
314
315	@readonly
316	def ServerURL(self) -> str:
317		"""
318		Read-only property to access the URL of the GitHub server (:attr:`_serverURL`).
319
320		:returns: The URL, without a trailing ``/``.
321		"""
322		return self._serverURL
323
324	@readonly
325	def LineBreaks(self) -> bool:
326		"""
327		Read-only property to access whether a line break in a paragraph is written as a line break (:attr:`_lineBreaks`).
328
329		:returns: ``True``, if a line break is written as a line break; ``False``, if as a space.
330		"""
331		return self._lineBreaks
332
333	def Convert(
334		self,
335		markdown: str,
336		document: nodes.document,
337		repeatedTitle: str = "",
338		location: Nullable[str] = None
339	) -> list[nodes.Node]:
340		"""
341		Convert a Markdown document into docutils nodes.
342
343		The nodes are body elements a directive returns; headings become topics and rubrics (see the module's
344		description). A footnote's ID is unique in the document.
345
346		The first heading of the Markdown can repeat the title, which the nodes are put under, e.g. ``# GHDL 5.0.1`` in
347		the notes of a release titled so. Given as ``repeatedTitle``, such a heading is skipped, so the headings after it
348		become the topics.
349
350		:param markdown:      The Markdown.
351		:param document:      The document the nodes are put into.
352		:param repeatedTitle: Optional, the title the first heading is skipped for; compared without surrounding and
353		                      repeated whitespace. Default: ``""``, no heading is skipped.
354		:param location:      Optional, location the warnings are reported at, e.g. ``index.rst:12``. Default: ``None``.
355		:returns:             The nodes.
356		:raises ValueError:   If parameter ``markdown`` is ``None``.
357		:raises TypeError:    If parameter ``markdown`` isn't of type :class:`str`.
358		:raises ValueError:   If parameter ``document`` is ``None``.
359		:raises TypeError:    If parameter ``document`` isn't of type :class:`~docutils.nodes.document`.
360		:raises ValueError:   If parameter ``repeatedTitle`` is ``None``.
361		:raises TypeError:    If parameter ``repeatedTitle`` isn't of type :class:`str`.
362		:raises TypeError:    If parameter ``location`` isn't of type :class:`str`.
363		"""
364		if markdown is None:
365			raise ValueError("Parameter 'markdown' is None.")
366		elif not isinstance(markdown, str):
367			ex = TypeError("Parameter 'markdown' is not of type 'str'.")
368			ex.add_note(f"Got type '{getFullyQualifiedName(markdown)}'.")
369			raise ex
370
371		if document is None:
372			raise ValueError("Parameter 'document' is None.")
373		elif not isinstance(document, nodes.document):
374			ex = TypeError("Parameter 'document' is not of type 'docutils.nodes.document'.")
375			ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.")
376			raise ex
377
378		if repeatedTitle is None:
379			raise ValueError("Parameter 'repeatedTitle' is None.")
380		elif not isinstance(repeatedTitle, str):
381			ex = TypeError("Parameter 'repeatedTitle' is not of type 'str'.")
382			ex.add_note(f"Got type '{getFullyQualifiedName(repeatedTitle)}'.")
383			raise ex
384
385		if location is not None and not isinstance(location, str):
386			ex = TypeError("Parameter 'location' is not of type 'str'.")
387			ex.add_note(f"Got type '{getFullyQualifiedName(location)}'.")
388			raise ex
389
390		tokens = self._parser.parse(markdown)
391		if repeatedTitle != "":
392			for index, token in enumerate(tokens):
393				if token.type == "heading_open" and token.level == 0:
394					if " ".join(tokens[index + 1].content.split()) == " ".join(repeatedTitle.split()):
395						del tokens[index:index + 3]
396					break
397
398		return MarkdownTranslator(self, document, location).Translate(tokens)
399
400	def Parse(self, text: str, references: bool = True) -> list[nodes.Node]:
401		"""
402		Parse a text for GitHub's references and emoji into docutils nodes, linking the references and writing emoji as
403		:class:`~pyTooling.Sphinx.Node.Emoji` nodes.
404
405		An emoji is written as its shortcode, e.g. ``:tada:``, or as its characters. A shortcode GitHub doesn't know
406		stays text, as does a custom GitHub emoji, which has no characters, e.g. ``:octocat:``.
407
408		:param text:        The text, without Markdown.
409		:param references:  Optional, whether GitHub's references are linked; ``False`` e.g. for a link's text. Default:
410		                    ``True``.
411		:returns:           The text, references and emoji.
412		:raises ValueError: If parameter ``text`` is ``None``.
413		:raises TypeError:  If parameter ``text`` isn't of type :class:`str`.
414		"""
415		if text is None:
416			raise ValueError("Parameter 'text' is None.")
417		elif not isinstance(text, str):
418			ex = TypeError("Parameter 'text' is not of type 'str'.")
419			ex.add_note(f"Got type '{getFullyQualifiedName(text)}'.")
420			raise ex
421
422		result: list[nodes.Node] = []
423		ownRepository = f"{self._owner}/{self._repository}"
424		position = 0
425		for match in REFERENCE_PATTERN.finditer(text):
426			written = match[0]
427			if (shortcode := match["shortcode"]) is not None:
428				if (characters := self._emojis.get(shortcode)) is None:
429					continue
430
431				node: nodes.Node = Emoji(characters, characters, shortcode=shortcode)
432			elif not references:
433				continue
434			elif (issue := match["issue"]) is not None:
435				repository = ownRepository if match["issueRepository"] is None else match["issueRepository"]
436				node = nodes.reference(
437					written, written, refuri=f"{self._serverURL}/{repository}/issues/{issue}", classes=["github-issue"]
438				)
439			elif (sha := match["sha"]) is not None:
440				if sha.isdigit() or not any(character.isdigit() for character in sha):
441					continue
442
443				repository = ownRepository if match["commitRepository"] is None else match["commitRepository"]
444				node = nodes.reference(
445					written, "", refuri=f"{self._serverURL}/{repository}/commit/{sha}", classes=["github-commit"]
446				)
447				if match["commitRepository"] is not None:
448					node += nodes.Text(f"{repository}@")
449				node += nodes.literal(sha[:7], sha[:7])
450			else:
451				node = nodes.reference(
452					written, written, refuri=f"{self._serverURL}/{match['user']}", classes=["github-user"]
453				)
454
455			result.extend(self._ConvertEmojiCharacters(text[position:match.start()]))
456			result.append(node)
457			position = match.end()
458
459		result.extend(self._ConvertEmojiCharacters(text[position:]))
460		return result
461
462	def _ConvertEmojiCharacters(self, text: str) -> list[nodes.Node]:
463		"""
464		Convert a text into text and :class:`~pyTooling.Sphinx.Node.Emoji` nodes for the emoji written as characters.
465
466		:param text: The text.
467		:returns:    The text and the emoji; nothing for an empty text.
468		"""
469		result: list[nodes.Node] = []
470		position = 0
471		for match in self._emojiPattern.finditer(text):
472			if match.end() == match.start():
473				continue
474
475			if match.start() > position:
476				result.append(nodes.Text(text[position:match.start()]))
477
478			characters = match[0]
479			result.append(Emoji(characters, characters, shortcode=self._shortcodes[characters.replace("\ufe0f", "")]))
480			position = match.end()
481
482		if position < len(text):
483			result.append(nodes.Text(text[position:]))
484
485		return result
486
487
488@export
489class RasterizeShieldsBadges(SphinxPostTransform):
490	"""
491	A post-transform fetching the shields.io badges of converted Markdown as PNG in LaTeX, which can't include SVG.
492
493	A badge's URI is moved to :data:`~pyTooling.Sphinx.Shields.SHIELDS_SERVICE_PNG`, without a suffix ``.svg``. Every
494	other SVG image is left to :mod:`sphinx.ext.imgconverter`, which pyTooling.Sphinx loads and which converts it with
495	ImageMagick.
496	"""
497
498	default_priority = 50           #: Priority among the post-transforms: before Sphinx downloads remote images.
499	formats =          ("latex", )  #: Output formats the transform runs for.
500
501	def run(self, **kwargs: Any) -> None:
502		"""
503		Move the shields.io badges of converted Markdown to the service rasterizing them.
504
505		The images of converted Markdown have class :data:`~pyTooling.Sphinx.Markdown.IMAGE_CLASS`.
506
507		:param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
508		"""
509		for image in self.document.findall(nodes.image):
510			if IMAGE_CLASS not in image["classes"]:
511				continue
512
513			uri = urlsplit(image["uri"])
514			if f"{uri.scheme}://{uri.netloc}" == SHIELDS_SERVICE_SVG:
515				raster = urlsplit(SHIELDS_SERVICE_PNG)._replace(path=uri.path.removesuffix(".svg"), query=uri.query)
516				image["uri"] =        raster.geturl()
517				image["candidates"] = {"?": image["uri"]}