UnicodeTable.py
Summary
- Lines:
178 of 184 covered (96.7%)
- Branches:
44 of 46 taken (95.7%), 2 lines partially
- Coverage:
96.5%
- Directory:
- 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])