UnicodeTable.py

Summary

Lines:

178 of 184 covered (96.7%)

Branches:

44 of 46 taken (95.7%), 2 lines partially

Coverage:

96.5%

Directory:

Sphinx

Missed lines:

160, 162-164, 198, 298

Source

pyTooling/Sphinx/UnicodeTable.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 Sphinx directive listing Unicode characters with their names and code points.
33
34The characters are icons, e.g. the ones a ``tree`` draws. An icon written as a character is hard to type, and written
35as a code point hard to recognize. The ``unicode-table`` directive takes the icons as content, one per line, and
36computes a row per icon: the icon, its Unicode name and its code points, in the form the icon options of the ``tree``
37directive take them:
38
39.. code-block:: ReST
40
41   .. unicode-table::
42
43      📦       | a package
44      U+1F4C1  | a directory
45      ⚙️       | settings
46
47**An entry is a line holding an icon**, written as characters or as code points ``U+<hex>``, several words forming
48one icon - e.g. ``U+2699 U+FE0F``. A ``|`` between spaces separates the icon from its description, which is inline
49ReST.
50
51.. rubric:: Names and code points
52
53The names are Python's :mod:`unicodedata`'s, so a row can't contradict its icon. A sequence is named by its characters,
54joined by ``+``; a variation selector and the zero width joiner select how the characters are drawn, and don't count:
55``⚙️`` - ``U+2699 U+FE0F`` - is named ``GEAR``. A character without a name gets the label the Unicode standard gives
56it, e.g. ``<private-use-E000>``. A code point not assigned in the Unicode version of :mod:`unicodedata` is warned about,
57as its name is unknown.
58
59.. rubric:: HTML and other formats
60
61HTML writes a :class:`~pyTooling.Sphinx.Node.CopyButton` beside an icon and beside its code points, which copies them
62into the clipboard; the script :file:`pyTooling.js` gives the buttons their function. Every other format finds no
63visitor for :class:`~pyTooling.Sphinx.Node.CopyButton` and writes its base-class, an empty inline element. LaTeX
64writes no icons, as pdfLaTeX stops at a character it has no definition for - which an emoji is - and LuaLaTeX needs
65an emoji font: :class:`RemoveUnicodeGlyphs` removes the icons' column.
66
67.. seealso::
68
69   :ref:`DIR/UnicodeTable`
70      |rarr| The directive's options, with rendered examples.
71   :ref:`DIR/Tree/CommonIcons`
72      |rarr| Icons commonly used in a tree, listed by this directive.
73"""
74from typing                            import Any, Iterable, NamedTuple, Optional as Nullable, Self
75from unicodedata                       import category, name as unicodeName, unidata_version
76
77from docutils                          import nodes
78from sphinx.transforms.post_transforms import SphinxPostTransform
79
80from pyTooling.Common                  import StringEnum, getFullyQualifiedName
81from pyTooling.Decorators              import export, readonly
82from pyTooling.MetaClasses             import ExtendedType
83from pyTooling.Sphinx                  import BaseDirective, SphinxExtensionError, strip
84from pyTooling.Sphinx.Node             import CopyButton
85from pyTooling.Sphinx.Tree             import SEPARATOR_PATTERN, icon
86
87
88__all__ = ["TABLE_CLASS"]
89
90#: The CSS class of every table the directive writes, by which :class:`RemoveUnicodeGlyphs` finds them.
91TABLE_CLASS = "pytooling-unicode-table"
92
93
94@export
95class CodePointType(StringEnum):
96	"""
97	The type of a code point, as the Unicode standard (section 2.4, table 2-3) defines it.
98
99	A code point without a name is labelled by its type (section 4.8): ``<control-0009>``. A letter, a symbol or a format
100	character has a name.
101	"""
102
103	Graphic =      "graphic"       #: A letter, mark, number, punctuation, symbol or space.
104	Format =       "format"        #: An invisible character affecting its neighbours, or a line or paragraph separator.
105	Control =      "control"       #: A control character, e.g. a tab.
106	PrivateUse =   "private-use"   #: A character assigned by a private agreement, not by Unicode.
107	Surrogate =    "surrogate"     #: Half of a UTF-16 surrogate pair, never a character of its own.
108	Noncharacter = "noncharacter"  #: Reserved for a program's internal use; one of the 66 noncharacters.
109	Reserved =     "reserved"      #: Not assigned in the Unicode version of :mod:`unicodedata`.
110
111
112@export
113class CodePoint(metaclass=ExtendedType, slots=True):
114	"""
115	A Unicode code point: its value, and what :mod:`unicodedata` knows about it - its general category, its type and its
116	name.
117
118	A code point is written in the standard's notation ``U+<hex>`` with at least four digits, e.g. ``U+2699``
119	(:attr:`Notation`). :func:`str` returns its character.
120	"""
121
122	_value:     int  #: The code point's value, in range 0 to 0x10FFFF.
123	_character: str  #: The code point's character.
124
125	def __init__(self, value: int) -> None:
126		"""
127		Initializes a code point by its value.
128
129		:param value:       The code point's value.
130		:raises ValueError: If parameter ``value`` is ``None``.
131		:raises TypeError:  If parameter ``value`` isn't of type :class:`int`.
132		:raises ValueError: If parameter ``value`` is out of range 0 to 0x10FFFF.
133		"""
134		if value is None:
135			raise ValueError("Parameter 'value' is None.")
136		elif not isinstance(value, int):
137			ex = TypeError("Parameter 'value' is not of type 'int'.")
138			ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
139			raise ex
140		elif not 0 <= value <= 0x10ffff:
141			ex = ValueError("Parameter 'value' is out of range 0 to 0x10FFFF.")
142			ex.add_note(f"Got value '{value:#x}'.")
143			raise ex
144
145		self._value =     value
146		self._character = chr(value)
147
148	@classmethod
149	def FromCharacter(cls, character: str) -> Self:
150		"""
151		Create the code point of a character.
152
153		:param character:   The character.
154		:returns:           The character's code point.
155		:raises ValueError: If parameter ``character`` is ``None``.
156		:raises TypeError:  If parameter ``character`` isn't of type :class:`str`.
157		:raises ValueError: If parameter ``character`` isn't a single character.
158		"""
159		if character is None:
160			raise ValueError("Parameter 'character' is None.")
161		elif not isinstance(character, str):
162			ex = TypeError("Parameter 'character' is not of type 'str'.")
163			ex.add_note(f"Got type '{getFullyQualifiedName(character)}'.")
164			raise ex
165		elif len(character) != 1:
166			ex = ValueError("Parameter 'character' isn't a single character.")
167			ex.add_note(f"Got {len(character)} characters.")
168			raise ex
169
170		return cls(ord(character))
171
172	@readonly
173	def Value(self) -> int:
174		"""
175		Read-only property to access the code point's value (:attr:`_value`).
176
177		:returns: The value.
178		"""
179		return self._value
180
181	@readonly
182	def Character(self) -> str:
183		"""
184		Read-only property to access the code point's character (:attr:`_character`).
185
186		:returns: The character.
187		"""
188		return self._character
189
190	@readonly
191	def Category(self) -> str:
192		"""
193		Read-only property to return the code point's general category, as :mod:`unicodedata` knows it.
194
195		:returns: The general category's abbreviation, e.g. ``Lu`` for an uppercase letter or ``Cn`` for an unassigned
196		          code point.
197		"""
198		return category(self._character)
199
200	@readonly
201	def Type(self) -> CodePointType:
202		"""
203		Read-only property to return the code point's type, derived from its general category.
204
205		A code point of category ``Cn`` is a noncharacter or reserved.
206
207		:returns: The type.
208		"""
209		match category(self._character):
210			case "Cn":
211				return CodePointType.Noncharacter if self.IsNoncharacter else CodePointType.Reserved
212			case "Cc":
213				return CodePointType.Control
214			case "Co":
215				return CodePointType.PrivateUse
216			case "Cs":
217				return CodePointType.Surrogate
218			case "Cf" | "Zl" | "Zp":
219				return CodePointType.Format
220			case _:
221				return CodePointType.Graphic
222
223	@readonly
224	def Name(self) -> str:
225		"""
226		Read-only property to return the code point's name, or the label the Unicode standard gives a code point without
227		a name.
228
229		A label is ``<type-XXXX>``, e.g. ``<control-0009>``, ``<private-use-E000>``, ``<reserved-0378>`` or
230		``<noncharacter-FFFF>``: the code point's :attr:`Type`, and its hexadecimal value with at least four digits.
231
232		:returns: The name, or the label.
233		"""
234		if (name := unicodeName(self._character, "")) != "":
235			return name
236
237		return f"<{self.Type}-{self._value:04X}>"
238
239	@readonly
240	def IsAssigned(self) -> bool:
241		"""
242		Read-only property to return whether the code point is assigned in the Unicode version of :mod:`unicodedata`.
243
244		A noncharacter is assigned: Unicode reserves it permanently.
245
246		:returns: ``True``, if the code point isn't reserved.
247		"""
248		return self.Type is not CodePointType.Reserved
249
250	@readonly
251	def IsNoncharacter(self) -> bool:
252		"""
253		Read-only property to return whether the code point is one of the 66 noncharacters, which Unicode reserves for a
254		program's internal use.
255
256		The noncharacters are U+FDD0 to U+FDEF, and the last two code points of every plane, e.g. U+FFFE and U+FFFF.
257
258		:returns: ``True``, if the code point is a noncharacter.
259		"""
260		return 0xfdd0 <= self._value <= 0xfdef or self._value & 0xfffe == 0xfffe
261
262	@readonly
263	def IsSelector(self) -> bool:
264		"""
265		Read-only property to return whether the code point selects how its neighbours are drawn, instead of being drawn
266		itself.
267
268		Such a code point is a variation selector, e.g. U+FE0F selecting an emoji's colourful presentation, or the zero
269		width joiner U+200D, which joins emoji into one, e.g. a man and a computer into a technologist.
270
271		:returns: ``True``, if the code point is a variation selector or the zero width joiner.
272		"""
273		return 0xfe00 <= self._value <= 0xfe0f or 0xe0100 <= self._value <= 0xe01ef or self._value == 0x200d
274
275	@readonly
276	def Notation(self) -> str:
277		"""
278		Read-only property to return the code point in the standard's notation: ``U+<hex>`` with at least four digits.
279
280		:returns: The code point, e.g. ``U+2699``.
281		"""
282		return f"U+{self._value:04X}"
283
284	def __str__(self) -> str:
285		"""
286		Return the code point's character.
287
288		:returns: The character, e.g. ``⚙``.
289		"""
290		return self._character
291
292	def __repr__(self) -> str:
293		"""
294		Return a representation of the code point for debugging.
295
296		:returns: The code point and its name, e.g. ``<CodePoint U+2699 GEAR>``.
297		"""
298		return f"<CodePoint {self.Notation} {self.Name}>"
299
300
301@export
302class UnicodeEntry(NamedTuple):
303	"""
304	An entry of a ``unicode-table``: an icon's characters, and the description written behind it.
305
306	:attr:`Name` and :attr:`CodePointText` are computed from the characters, so they can't contradict the icon.
307	"""
308
309	characters:  str  #: The icon's characters, e.g. a gear and variation selector 16.
310	description: str  #: The description as inline ReST; empty if the entry has none.
311	lineIndex:   int  #: Index of the entry's line in the directive's content.
312
313	@classmethod
314	def Parse(cls, line: str, lineIndex: int) -> Self:
315		"""
316		Read an entry from a line of the directive's content: an icon, and optionally a description behind a separator.
317
318		The icon is read as the icon options of the ``tree`` directive read theirs - see
319		:func:`~pyTooling.Sphinx.Tree.icon`: characters as written, and a word ``U+<hex>`` as the code point it names. The
320		separator is a ``|`` between spaces - see :data:`~pyTooling.Sphinx.Tree.SEPARATOR_PATTERN`.
321
322		:param line:                  The line, not blank.
323		:param lineIndex:             Index of the line in the directive's content.
324		:returns:                     The entry.
325		:raises SphinxExtensionError: If the line has more than one separator ``|``.
326		:raises SphinxExtensionError: If the line has a description, but no icon.
327		:raises SphinxExtensionError: If a word names a code point beyond U+10FFFF, or a surrogate.
328		"""
329		text = f" {line.strip()}"  # a separator at the start follows a space too
330		separators = [match.start() for match in SEPARATOR_PATTERN.finditer(text) if match["separator"] is not None]
331		if len(separators) > 1:
332			raise SphinxExtensionError(
333				f"'{line.strip()}' has more than one separator ' | '; a '|' in a text is escaped as '\\|'."
334			)
335		elif len(separators) == 1:
336			iconText =    text[:separators[0]]
337			description = text[separators[0] + 1:].strip()
338		else:
339			iconText =    text
340			description = ""
341
342		try:
343			characters = icon(iconText)
344		except ValueError as ex:
345			raise SphinxExtensionError(f"'{line.strip()}' has an invalid icon: {ex}") from ex
346
347		if characters == "":
348			raise SphinxExtensionError(f"'{line.strip()}' has a description, but no icon.")
349
350		return cls(characters, description, lineIndex)
351
352	@readonly
353	def CodePoints(self) -> list[CodePoint]:
354		"""
355		Read-only property to return the code points of the icon's characters.
356
357		:returns: The code points, in the order written.
358		"""
359		return [CodePoint.FromCharacter(character) for character in self.characters]
360
361	@readonly
362	def Name(self) -> str:
363		"""
364		Read-only property to return the icon's name: the names of its characters, joined by ``+``.
365
366		Variation selectors and the zero width joiner select how the characters are drawn and aren't named, unless the
367		icon consists of nothing else: ``U+2699 U+FE0F`` is named ``GEAR``. A character without a name contributes its
368		label - see :attr:`CodePoint.Name`.
369
370		:returns: The name, e.g. ``GEAR`` or ``MAN + PERSONAL COMPUTER``.
371		"""
372		codePoints = self.CodePoints
373		named = [codePoint for codePoint in codePoints if not codePoint.IsSelector]
374		return " + ".join(codePoint.Name for codePoint in (named if len(named) > 0 else codePoints))
375
376	@readonly
377	def CodePointText(self) -> str:
378		"""
379		Read-only property to return the icon's code points, as the icon options of the ``tree`` directive take them.
380
381		:returns: The code points ``U+<hex>`` with at least four digits, separated by spaces, e.g. ``U+2699 U+FE0F``.
382		"""
383		return " ".join(codePoint.Notation for codePoint in self.CodePoints)
384
385	@readonly
386	def UnassignedCodePoints(self) -> list[CodePoint]:
387		"""
388		Read-only property to return the code points, which aren't assigned in the Unicode version of :mod:`unicodedata`.
389
390		Such a code point is a typo - or newer than the Python running the build: Python 3.12 knows Unicode 15.0.
391
392		:returns: The unassigned code points, in the order written.
393		"""
394		return [codePoint for codePoint in self.CodePoints if not codePoint.IsAssigned]
395
396
397@export
398class UnicodeTable(BaseDirective):
399	"""
400	The ``unicode-table`` directive: a table of icons, each with its Unicode name and its code points.
401
402	:meth:`_ParseContent` reads the content into :class:`UnicodeEntry` instances, and :meth:`run` turns them into a
403	table: the icon, its name, its code points and - if an entry has one - its description. The icon and the code
404	points each get a :class:`~pyTooling.Sphinx.Node.CopyButton`. ``:class:`` puts additional CSS classes on the table.
405	"""
406
407	directiveName: str = "unicode-table"  #: Name the directive is invoked by.
408
409	has_content =               True   #: A boolean; ``True`` if content is allowed.
410	required_arguments =        0      #: Number of required directive arguments.
411	optional_arguments =        0      #: Number of optional arguments after the required ones.
412	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
413	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
414	# spelling of this override a conflict with one of them
415	#: Mapping of option names to validator functions.
416	option_spec: dict[str, Any] = {  # type: ignore[misc]
417		"class": strip,
418	}
419
420	def run(self) -> list[nodes.Node]:
421		"""
422		Write the table of the icons listed in the content.
423
424		An icon with a character, which isn't assigned in the Unicode version of :mod:`unicodedata`, is warned about at
425		its line, and still gets its row.
426
427		:returns: The table, followed by the messages about the descriptions and the unassigned characters, or the message
428		          of whatever the content got wrong.
429		"""
430		try:
431			entries = self._ParseContent(self.content)
432		except SphinxExtensionError as ex:
433			return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
434
435		withDescriptions = any(entry.description != "" for entry in entries)
436		columns: list[tuple[str, Nullable[int]]] = [("Icon", 10), ("Name", 45), ("Code points", 25)]
437		if withDescriptions:
438			columns.append(("Description", 40))
439
440		classes = [TABLE_CLASS] + self.options.get("class", "").split()
441		tableGroup = self._CreateSingleRowTableHeader(columns, TABLE_CLASS, classes)
442		tableGroup += (tableBody := nodes.tbody())
443
444		messages: list[nodes.system_message] = []
445		for entry in entries:
446			line = self.content_offset + entry.lineIndex + 1
447			if len(unassigned := entry.UnassignedCodePoints) > 0:
448				codePoints = ", ".join(codePoint.Notation for codePoint in unassigned)
449				messages.append(self.state.document.reporter.warning(
450					f"{self.directiveName}: {codePoints} isn't assigned in Unicode {unidata_version}, which Python's "
451					f"'unicodedata' knows; its name is unknown.",
452					line=line
453				))
454
455			name =          entry.Name
456			codePointText = entry.CodePointText
457			tableBody += (row := nodes.row())
458			row += nodes.entry("", nodes.paragraph(
459				"", "",
460				nodes.inline(entry.characters, entry.characters, classes=["unicode-glyph"]),
461				CopyButton("", copy=entry.characters, label=f"Copy the icon {name}")
462			))
463			row += nodes.entry("", nodes.paragraph(name, name))
464			row += nodes.entry("", nodes.paragraph(
465				"", "",
466				nodes.literal(codePointText, codePointText),
467				CopyButton("", copy=codePointText, label=f"Copy the code points of {name}")
468			))
469			if withDescriptions:
470				descriptionNodes, descriptionMessages = self.state.inline_text(entry.description, line)
471				row += nodes.entry("", nodes.paragraph(entry.description, "", *descriptionNodes))
472				messages.extend(descriptionMessages)
473
474		return [tableGroup.parent, *messages]
475
476	@staticmethod
477	def _ParseContent(content: Iterable[str]) -> list[UnicodeEntry]:
478		"""
479		Read the content into entries: an entry per line, blank lines skipped.
480
481		:param content:               The directive's content, line by line.
482		:returns:                     The entries, in the order written.
483		:raises SphinxExtensionError: If the content holds no entry.
484		:raises SphinxExtensionError: If a line isn't an entry - see :meth:`UnicodeEntry.Parse`.
485		"""
486		entries = [UnicodeEntry.Parse(line, index) for index, line in enumerate(content) if line.strip() != ""]
487		if len(entries) == 0:
488			raise SphinxExtensionError("The directive's content holds no entry.")
489
490		return entries
491
492
493@export
494class RemoveUnicodeGlyphs(SphinxPostTransform):
495	"""
496	A post-transform removing the icons' column from the tables of the ``unicode-table`` directive in LaTeX.
497
498	pdfLaTeX stops at a character it has no definition for - which an emoji is -, and LuaLaTeX needs an emoji font to
499	draw one. The names and the code points identify the icons without it.
500	"""
501
502	default_priority = 200          #: Priority among the post-transforms.
503	formats =          ("latex", )  #: Output formats the transform runs for.
504
505	def run(self, **kwargs: Any) -> None:
506		"""
507		Remove the first column - the icons' - of every table of the ``unicode-table`` directive.
508
509		:param kwargs: Keyword arguments Sphinx passes to a post-transform; none is used.
510		"""
511		for table in list(self.document.findall(nodes.table)):
512			if TABLE_CLASS not in table["classes"]:
513				continue
514
515			tableGroup = table.next_node(nodes.tgroup)
516			tableGroup["cols"] -= 1
517			tableGroup.remove(tableGroup.next_node(nodes.colspec))
518			for row in list(tableGroup.findall(nodes.row)):
519				row.remove(row[0])