pytex-preprocessor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. pytex/__init__.py +87 -0
  2. pytex/commands/__init__.py +51 -0
  3. pytex/commands/biblatex.py +98 -0
  4. pytex/commands/builtin.py +598 -0
  5. pytex/commands/captions.py +56 -0
  6. pytex/commands/cleveref.py +43 -0
  7. pytex/commands/colors.py +60 -0
  8. pytex/commands/conditionals.py +62 -0
  9. pytex/commands/counters.py +85 -0
  10. pytex/commands/definitions.py +109 -0
  11. pytex/commands/floats.py +93 -0
  12. pytex/commands/font.py +138 -0
  13. pytex/commands/fontawesome.py +88 -0
  14. pytex/commands/fontspec.py +75 -0
  15. pytex/commands/geometry.py +25 -0
  16. pytex/commands/glossaries.py +126 -0
  17. pytex/commands/graphics.py +68 -0
  18. pytex/commands/hooks.py +58 -0
  19. pytex/commands/hyperref.py +57 -0
  20. pytex/commands/lengths.py +200 -0
  21. pytex/commands/listings.py +63 -0
  22. pytex/commands/mdframed.py +43 -0
  23. pytex/commands/picture.py +32 -0
  24. pytex/commands/setspace.py +38 -0
  25. pytex/commands/tables.py +123 -0
  26. pytex/helpers/__init__.py +3 -0
  27. pytex/helpers/coerce.py +13 -0
  28. pytex/helpers/parenting.py +13 -0
  29. pytex/helpers/sanitize.py +54 -0
  30. pytex/helpers/with_package.py +61 -0
  31. pytex/interface/__init__.py +3 -0
  32. pytex/interface/control_sequence.py +29 -0
  33. pytex/interface/package.py +52 -0
  34. pytex/interface/tex.py +41 -0
  35. pytex/model/__init__.py +25 -0
  36. pytex/model/color.py +203 -0
  37. pytex/model/concat.py +31 -0
  38. pytex/model/control_sequence.py +72 -0
  39. pytex/model/document.py +120 -0
  40. pytex/model/document_class.py +29 -0
  41. pytex/model/empty.py +19 -0
  42. pytex/model/environment.py +30 -0
  43. pytex/model/image.py +137 -0
  44. pytex/model/include.py +21 -0
  45. pytex/model/length.py +54 -0
  46. pytex/model/math.py +401 -0
  47. pytex/model/package.py +132 -0
  48. pytex/model/raw.py +61 -0
  49. pytex/packages.py +221 -0
  50. pytex/registry.py +49 -0
  51. pytex_builder/__init__.py +8 -0
  52. pytex_builder/build.py +175 -0
  53. pytex_builder/console.py +77 -0
  54. pytex_builder/render.py +90 -0
  55. pytex_builder/tectonic.py +370 -0
  56. pytex_hsrtreport/__init__.py +116 -0
  57. pytex_hsrtreport/assets/fonts/Blender/Blender-Bold.ttf +0 -0
  58. pytex_hsrtreport/assets/fonts/Blender/Blender-BoldItalic.ttf +0 -0
  59. pytex_hsrtreport/assets/fonts/Blender/Blender-Book.ttf +0 -0
  60. pytex_hsrtreport/assets/fonts/Blender/Blender-BookItalic.ttf +0 -0
  61. pytex_hsrtreport/assets/fonts/Blender/Blender-Medium.ttf +0 -0
  62. pytex_hsrtreport/assets/fonts/Blender/Blender-MediumItalic.ttf +0 -0
  63. pytex_hsrtreport/assets/fonts/Blender/Blender-Strong.ttf +0 -0
  64. pytex_hsrtreport/assets/fonts/Blender/Blender-Thin.ttf +0 -0
  65. pytex_hsrtreport/assets/fonts/Blender/Blender-ThinItalic.ttf +0 -0
  66. pytex_hsrtreport/assets/fonts/DIN/DIN-Black.ttf +0 -0
  67. pytex_hsrtreport/assets/fonts/DIN/DIN-Bold.ttf +0 -0
  68. pytex_hsrtreport/assets/fonts/DIN/DIN-BoldItalic.ttf +0 -0
  69. pytex_hsrtreport/assets/fonts/DIN/DIN-Italic.ttf +0 -0
  70. pytex_hsrtreport/assets/fonts/DIN/DIN-Medium.ttf +0 -0
  71. pytex_hsrtreport/assets/fonts/DIN/DIN-Regular.ttf +0 -0
  72. pytex_hsrtreport/assets/fonts/Times New Roman.ttf +0 -0
  73. pytex_hsrtreport/assets/logos/ASTA.svg +79 -0
  74. pytex_hsrtreport/assets/logos/DUMMY.png +0 -0
  75. pytex_hsrtreport/assets/logos/DUMMY_FOOT.png +0 -0
  76. pytex_hsrtreport/assets/logos/ECHO.svg +226 -0
  77. pytex_hsrtreport/assets/logos/HSRT.pdf +0 -0
  78. pytex_hsrtreport/assets/logos/INF.pdf +0 -0
  79. pytex_hsrtreport/assets/logos/STUPA.pdf +0 -0
  80. pytex_hsrtreport/assets/logos/Skyline.pdf +0 -0
  81. pytex_hsrtreport/boxes.py +215 -0
  82. pytex_hsrtreport/citations.py +21 -0
  83. pytex_hsrtreport/cleveref_names.py +47 -0
  84. pytex_hsrtreport/colors.py +30 -0
  85. pytex_hsrtreport/document.py +307 -0
  86. pytex_hsrtreport/fonts.py +66 -0
  87. pytex_hsrtreport/glossary.py +61 -0
  88. pytex_hsrtreport/hyperref_config.py +49 -0
  89. pytex_hsrtreport/listings.py +90 -0
  90. pytex_hsrtreport/logos.py +234 -0
  91. pytex_hsrtreport/pagebreak.py +67 -0
  92. pytex_hsrtreport/pagesetup.py +33 -0
  93. pytex_hsrtreport/tex/pagesetup.tex +76 -0
  94. pytex_hsrtreport/titlepage.py +136 -0
  95. pytex_hsrtreport/variants.py +24 -0
  96. pytex_hsrtreport/voting.py +96 -0
  97. pytex_hsrtreport/watermark.py +63 -0
  98. pytex_hsrtreport/wordcount.py +33 -0
  99. pytex_koma/__init__.py +90 -0
  100. pytex_koma/commands.py +296 -0
  101. pytex_koma/document.py +138 -0
  102. pytex_markdown/__init__.py +62 -0
  103. pytex_markdown/convert.py +271 -0
  104. pytex_markdown/escape.py +11 -0
  105. pytex_preprocessor-0.1.0.dist-info/METADATA +82 -0
  106. pytex_preprocessor-0.1.0.dist-info/RECORD +119 -0
  107. pytex_preprocessor-0.1.0.dist-info/WHEEL +5 -0
  108. pytex_preprocessor-0.1.0.dist-info/entry_points.txt +2 -0
  109. pytex_preprocessor-0.1.0.dist-info/top_level.txt +7 -0
  110. pytex_protocol/__init__.py +37 -0
  111. pytex_protocol/convert.py +202 -0
  112. pytex_protocol/document.py +91 -0
  113. pytex_protocol/entries.py +96 -0
  114. pytex_protocol/frontmatter.py +80 -0
  115. pytex_protocol/header.py +139 -0
  116. pytex_protocol/shortcodes.py +130 -0
  117. pytex_protocol/signatures.py +84 -0
  118. pytex_tikz/__init__.py +25 -0
  119. pytex_tikz/tikz.py +272 -0
@@ -0,0 +1,38 @@
1
+ from ..helpers.with_package import with_package
2
+ from ..interface.tex import TeX
3
+ from ..model.control_sequence import ControlSequence, Parameter
4
+ from ..model.environment import Environment
5
+ from ..packages import SETSPACE
6
+ from ..registry import Registry
7
+
8
+ __all__ = ["Doublespacing", "Onehalfspacing", "Setstretch", "Singlespacing", "Spacing"]
9
+
10
+
11
+ @Registry.add
12
+ @with_package(SETSPACE)
13
+ def Setstretch(factor: str) -> TeX:
14
+ return ControlSequence("setstretch", (Parameter(factor),))
15
+
16
+
17
+ @Registry.add
18
+ @with_package(SETSPACE)
19
+ def Singlespacing() -> TeX:
20
+ return ControlSequence("singlespacing", ())
21
+
22
+
23
+ @Registry.add
24
+ @with_package(SETSPACE)
25
+ def Onehalfspacing() -> TeX:
26
+ return ControlSequence("onehalfspacing", ())
27
+
28
+
29
+ @Registry.add
30
+ @with_package(SETSPACE)
31
+ def Doublespacing() -> TeX:
32
+ return ControlSequence("doublespacing", ())
33
+
34
+
35
+ @Registry.add
36
+ @with_package(SETSPACE)
37
+ def Spacing(factor: str, body: TeX | str) -> TeX:
38
+ return Environment("spacing", body, (Parameter(factor),))
@@ -0,0 +1,123 @@
1
+ from ..helpers.with_package import with_package
2
+ from ..interface.tex import TeX
3
+ from ..model.control_sequence import ControlSequence, Parameter
4
+ from ..model.environment import Environment
5
+ from ..packages import BOOKTABS, LONGTABLE, MULTIROW, TABULARX
6
+ from ..registry import Registry
7
+
8
+ __all__ = [
9
+ "Arraybackslash",
10
+ "Arraystretch",
11
+ "Bottomrule",
12
+ "Cline",
13
+ "Cmidrule",
14
+ "Hline",
15
+ "Longtable",
16
+ "Midrule",
17
+ "Multicolumn",
18
+ "Multirow",
19
+ "Newcolumntype",
20
+ "Tabular",
21
+ "Tabularx",
22
+ "Toprule",
23
+ ]
24
+
25
+
26
+ @Registry.add
27
+ def Tabular(spec: str, body: TeX | str) -> TeX:
28
+ return Environment("tabular", body, (Parameter(spec),))
29
+
30
+
31
+ @Registry.add
32
+ @with_package(TABULARX)
33
+ def Tabularx(width: str, spec: str, body: TeX | str) -> TeX:
34
+ return Environment("tabularx", body, (Parameter(width), Parameter(spec)))
35
+
36
+
37
+ @Registry.add
38
+ @with_package(LONGTABLE)
39
+ def Longtable(spec: str, body: TeX | str) -> TeX:
40
+ return Environment("longtable", body, (Parameter(spec),))
41
+
42
+
43
+ @Registry.add
44
+ def Multicolumn(cols: int, align: str, body: TeX | str) -> TeX:
45
+ return ControlSequence(
46
+ "multicolumn",
47
+ (Parameter(str(cols)), Parameter(align), Parameter(body)),
48
+ )
49
+
50
+
51
+ @Registry.add
52
+ @with_package(MULTIROW)
53
+ def Multirow(rows: int, width: str, body: TeX | str) -> TeX:
54
+ return ControlSequence(
55
+ "multirow",
56
+ (Parameter(str(rows)), Parameter(width), Parameter(body)),
57
+ )
58
+
59
+
60
+ @Registry.add
61
+ def Hline() -> TeX:
62
+ return ControlSequence("hline", ())
63
+
64
+
65
+ @Registry.add
66
+ def Cline(spec: str) -> TeX:
67
+ return ControlSequence("cline", (Parameter(spec),))
68
+
69
+
70
+ @Registry.add
71
+ @with_package(BOOKTABS)
72
+ def Toprule() -> TeX:
73
+ return ControlSequence("toprule", ())
74
+
75
+
76
+ @Registry.add
77
+ @with_package(BOOKTABS)
78
+ def Midrule() -> TeX:
79
+ return ControlSequence("midrule", ())
80
+
81
+
82
+ @Registry.add
83
+ @with_package(BOOKTABS)
84
+ def Bottomrule() -> TeX:
85
+ return ControlSequence("bottomrule", ())
86
+
87
+
88
+ @Registry.add
89
+ @with_package(BOOKTABS)
90
+ def Cmidrule(spec: str, trim: str | None = None) -> TeX:
91
+ if trim is None:
92
+ return ControlSequence("cmidrule", (Parameter(spec),))
93
+ return ControlSequence(
94
+ "cmidrule",
95
+ (Parameter(trim, optional=True), Parameter(spec)),
96
+ )
97
+
98
+
99
+ @Registry.add
100
+ def Arraybackslash() -> TeX:
101
+ return ControlSequence("arraybackslash", ())
102
+
103
+
104
+ @Registry.add
105
+ def Arraystretch(factor: str) -> TeX:
106
+ return ControlSequence("arraystretch", (Parameter(factor),))
107
+
108
+
109
+ @Registry.add
110
+ def Newcolumntype(name: str, arity: int | None, spec: str) -> TeX:
111
+ if arity is None:
112
+ return ControlSequence(
113
+ "newcolumntype",
114
+ (Parameter(name), Parameter(spec)),
115
+ )
116
+ return ControlSequence(
117
+ "newcolumntype",
118
+ (
119
+ Parameter(name),
120
+ Parameter(str(arity), optional=True),
121
+ Parameter(spec),
122
+ ),
123
+ )
@@ -0,0 +1,3 @@
1
+ from . import coerce, parenting, sanitize, with_package
2
+
3
+ __all__ = ["coerce", "parenting", "sanitize", "with_package"]
@@ -0,0 +1,13 @@
1
+ from ..interface.tex import TeX
2
+ from ..model.raw import Raw
3
+ from ..registry import Registry
4
+
5
+ __all__ = ["coerce_tex"]
6
+
7
+
8
+ @Registry.add
9
+ def coerce_tex(value: TeX | str) -> TeX:
10
+ if isinstance(value, TeX):
11
+ return value
12
+
13
+ return Raw(value)
@@ -0,0 +1,13 @@
1
+ from contextlib import suppress
2
+
3
+ from ..interface.tex import TeX
4
+
5
+ __all__ = ["attach"]
6
+
7
+
8
+ def attach(parent: TeX, *children: object) -> None:
9
+ """Set `_parent` on each TeX child to `parent`. Non-TeX children are skipped."""
10
+ for child in children:
11
+ if isinstance(child, TeX):
12
+ with suppress(AttributeError, TypeError):
13
+ object.__setattr__(child, "_parent", parent)
@@ -0,0 +1,54 @@
1
+ """Sanitize untrusted text before it enters a document.
2
+
3
+ Two independent concerns:
4
+
5
+ * ``tex`` - escape LaTeX-special characters so the text renders literally
6
+ (pytex itself does no escaping; raw strings pass straight through).
7
+ * ``pytex`` - disable PyTeX replacement so an embedded
8
+ ``\\iffalse{pytex(...)}\\fi`` marker is never evaluated. The marker can run
9
+ arbitrary Python, so this matters for content from outside the program.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Final
15
+
16
+ from ..model.raw import Raw
17
+ from ..registry import Registry
18
+
19
+ __all__ = ["Sanitize", "escape_latex"]
20
+
21
+ if TYPE_CHECKING:
22
+ from ..interface.tex import TeX
23
+
24
+ # Build the result char-by-char so braces introduced by a replacement are
25
+ # never themselves re-escaped.
26
+ ESCAPES: Final[dict[str, str]] = {
27
+ "\\": r"\textbackslash{}",
28
+ "&": r"\&",
29
+ "%": r"\%",
30
+ "$": r"\$",
31
+ "#": r"\#",
32
+ "_": r"\_",
33
+ "{": r"\{",
34
+ "}": r"\}",
35
+ "~": r"\textasciitilde{}",
36
+ "^": r"\textasciicircum{}",
37
+ }
38
+
39
+
40
+ def escape_latex(text: str) -> str:
41
+ """Return ``text`` with LaTeX-special characters escaped."""
42
+ return "".join(ESCAPES.get(ch, ch) for ch in text)
43
+
44
+
45
+ @Registry.add
46
+ def Sanitize(content: str, pytex: bool = True, tex: bool = True) -> TeX:
47
+ """Wrap ``content`` as a ``TeX`` node, neutralising unsafe input.
48
+
49
+ * ``tex=True`` escapes LaTeX-special characters.
50
+ * ``pytex=True`` prevents any ``\\iffalse{pytex(...)}\\fi`` marker in the
51
+ content from being evaluated.
52
+ """
53
+ text = escape_latex(content) if tex else content
54
+ return Raw(text, allow_replacements=not pytex)
@@ -0,0 +1,61 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from functools import wraps
4
+ from typing import override
5
+
6
+ from pytex.interface.package import PackageProtocol
7
+
8
+ from ..interface.tex import TeX
9
+ from ..model.package import Package
10
+ from ..registry import Registry
11
+ from .parenting import attach
12
+
13
+ __all__ = ["WithPackage", "coerce_package", "with_package"]
14
+
15
+
16
+ @Registry.add
17
+ def coerce_package(pkg: Package | str) -> Package:
18
+ if isinstance(pkg, Package):
19
+ return pkg
20
+
21
+ return Package(pkg)
22
+
23
+
24
+ @Registry.add
25
+ @dataclass
26
+ class WithPackage[T: TeX](TeX):
27
+ child: T
28
+ package: Package | str
29
+ _parent: "TeX | None" = field(default=None, init=False, compare=False, repr=False)
30
+
31
+ def __post_init__(self) -> None:
32
+ attach(self, self.child)
33
+
34
+ @property
35
+ @override
36
+ def rendered(self) -> str:
37
+ return self.child.rendered
38
+
39
+ @property
40
+ @override
41
+ def children(self) -> tuple[TeX, ...]:
42
+ return (self.child,)
43
+
44
+ @property
45
+ @override
46
+ def requires(self) -> frozenset[PackageProtocol] | None:
47
+ return frozenset(
48
+ {coerce_package(self.package)}
49
+ | (self.child.requires or set[PackageProtocol]())
50
+ )
51
+
52
+
53
+ def with_package[C: Callable[..., TeX]](pkg: Package | str) -> Callable[[C], C]:
54
+ def decorator(func: C) -> C:
55
+ @wraps(func)
56
+ def wrapper(*args: object, **kwargs: object) -> TeX:
57
+ return WithPackage(func(*args, **kwargs), pkg)
58
+
59
+ return wrapper # pyright: ignore[reportReturnType]
60
+
61
+ return decorator
@@ -0,0 +1,3 @@
1
+ from . import control_sequence, package, tex
2
+
3
+ __all__ = ["control_sequence", "package", "tex"]
@@ -0,0 +1,29 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+ from ..model.empty import EmptyTeX
4
+ from .tex import TeX
5
+
6
+ __all__ = ["ControlSequenceProtocol", "ParameterProtocol"]
7
+
8
+ type ParameterType = TeX | str | dict[str, str]
9
+
10
+
11
+ @runtime_checkable
12
+ class ParameterProtocol[T: ParameterType = ParameterType](TeX, Protocol):
13
+ @property
14
+ def optional(self) -> bool: ...
15
+
16
+ @property
17
+ def value(self) -> T: ...
18
+
19
+
20
+ type Parameters = tuple[ParameterProtocol | EmptyTeX, ...] | None
21
+
22
+
23
+ @runtime_checkable
24
+ class ControlSequenceProtocol[P: Parameters](TeX, Protocol):
25
+ @property
26
+ def name(self) -> str: ...
27
+
28
+ @property
29
+ def params(self) -> P: ...
@@ -0,0 +1,52 @@
1
+ from typing import Protocol, Self, override, runtime_checkable
2
+
3
+ __all__ = ["PackageProtocol"]
4
+
5
+ type PackageOption = str | tuple[str, str]
6
+
7
+
8
+ @runtime_checkable
9
+ class PackageProtocol(Protocol):
10
+ @property
11
+ def name(self) -> str: ...
12
+
13
+ @property
14
+ def after(self) -> frozenset[Self]: ...
15
+
16
+ @property
17
+ def incompatible(self) -> frozenset[Self]: ...
18
+
19
+ @property
20
+ def options(self) -> frozenset[PackageOption]: ...
21
+
22
+ @property
23
+ def rendered(self) -> str:
24
+ """Render this object to a valid LaTeX-String"""
25
+ ...
26
+
27
+ @property
28
+ def children(self) -> tuple[Self, ...]:
29
+ return ()
30
+
31
+ @property
32
+ def requires(self) -> frozenset[Self]:
33
+ return self.after
34
+
35
+ @property
36
+ def parent(self) -> Self | None:
37
+ """Parent node in the document tree, or None if root/detached."""
38
+ return getattr(self, "_parent", None)
39
+
40
+ @property
41
+ def parents(self) -> tuple[Self, ...]:
42
+ """Chain of ancestors from immediate parent up to root."""
43
+ out: list[Self] = []
44
+ cur = self.parent
45
+ while cur is not None:
46
+ out.append(cur)
47
+ cur = cur.parent
48
+ return tuple(out)
49
+
50
+ @override
51
+ def __str__(self) -> str:
52
+ return self.rendered
pytex/interface/tex.py ADDED
@@ -0,0 +1,41 @@
1
+ from typing import Protocol, override, runtime_checkable
2
+
3
+ from .package import PackageProtocol
4
+
5
+ __all__ = ["TeX"]
6
+
7
+
8
+ @runtime_checkable
9
+ class TeX(Protocol):
10
+ @property
11
+ def rendered(self) -> str:
12
+ """Render this Node to a valid LaTeX-String"""
13
+ ...
14
+
15
+ @property
16
+ def children(self) -> tuple["TeX", ...]:
17
+ """Children of the Node"""
18
+ return ()
19
+
20
+ @property
21
+ def requires(self) -> frozenset[PackageProtocol] | None:
22
+ return None
23
+
24
+ @property
25
+ def parent(self) -> "TeX | None":
26
+ """Parent node in the document tree, or None if root/detached."""
27
+ return getattr(self, "_parent", None)
28
+
29
+ @property
30
+ def parents(self) -> tuple["TeX", ...]:
31
+ """Chain of ancestors from immediate parent up to root."""
32
+ out: list[TeX] = []
33
+ cur = self.parent
34
+ while cur is not None:
35
+ out.append(cur)
36
+ cur = cur.parent
37
+ return tuple(out)
38
+
39
+ @override
40
+ def __str__(self) -> str:
41
+ return self.rendered
@@ -0,0 +1,25 @@
1
+ # pyright: reportUnsupportedDunderAll=false
2
+ """Model subpackage.
3
+
4
+ Modules are not eager-imported here to avoid a circular import:
5
+ `pytex/__init__.py` → `pytex.packages` → `pytex.model.package` →
6
+ `pytex.model.__init__` → `pytex.model.math` → `pytex.packages` (still loading).
7
+
8
+ Import submodules directly: `from pytex.model.math import Frac`.
9
+ """
10
+
11
+ __all__ = [
12
+ "color",
13
+ "concat",
14
+ "control_sequence",
15
+ "document",
16
+ "document_class",
17
+ "empty",
18
+ "environment",
19
+ "image",
20
+ "include",
21
+ "length",
22
+ "math",
23
+ "package",
24
+ "raw",
25
+ ]
pytex/model/color.py ADDED
@@ -0,0 +1,203 @@
1
+ from dataclasses import dataclass
2
+ from typing import override
3
+
4
+ from ..interface.package import PackageProtocol
5
+ from ..interface.tex import TeX
6
+ from ..registry import Registry
7
+
8
+ __all__ = [
9
+ "Color",
10
+ "ColorSpec",
11
+ "collect_colors",
12
+ "is_known_color_name",
13
+ "register_named_color",
14
+ ]
15
+
16
+ NAMED_COLORS: set[str] = {
17
+ "black",
18
+ "white",
19
+ "red",
20
+ "green",
21
+ "blue",
22
+ "cyan",
23
+ "magenta",
24
+ "yellow",
25
+ "gray",
26
+ "lightgray",
27
+ "darkgray",
28
+ "orange",
29
+ "violet",
30
+ "purple",
31
+ "brown",
32
+ "pink",
33
+ "olive",
34
+ "lime",
35
+ "teal",
36
+ }
37
+
38
+
39
+ def register_named_color(name: str) -> None:
40
+ """Whitelist a name so `Color.named(name)` accepts it."""
41
+ NAMED_COLORS.add(name)
42
+
43
+
44
+ def is_known_color_name(name: str) -> bool:
45
+ return name in NAMED_COLORS
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ColorSpec:
50
+ """Frozen colour identity: `model` + `value` for `\\definecolor`."""
51
+
52
+ model: str
53
+ value: str
54
+
55
+
56
+ @Registry.add
57
+ class Color(TeX):
58
+ """Type-safe colour reference.
59
+
60
+ Constructors:
61
+ Color("blue") # registered name
62
+ Color("#FF0000") # hex
63
+ Color((255, 0, 0)) # rgb 0..255
64
+ Color((1.0, 0.0, 0.0)) # rgb 0..1
65
+ Color.hex("FF0000")
66
+ Color.rgb255(255, 0, 0)
67
+ Color.rgb(1.0, 0.0, 0.0)
68
+ Color.named("blue")
69
+ """
70
+
71
+ name: str
72
+ spec: ColorSpec | None
73
+ _parent: "TeX | None"
74
+
75
+ def __init__(
76
+ self,
77
+ value: "str | tuple[int, int, int] | tuple[float, float, float] | None" = None,
78
+ *,
79
+ name: str | None = None,
80
+ spec: ColorSpec | None = None,
81
+ ) -> None:
82
+ if value is not None:
83
+ resolved_name, resolved_spec = _from_overload(value)
84
+ self.name = resolved_name
85
+ self.spec = resolved_spec
86
+ elif name is not None:
87
+ self.name = name
88
+ self.spec = spec
89
+ else:
90
+ raise TypeError("Color() requires `value` or `name`")
91
+ self._parent = None
92
+
93
+ @classmethod
94
+ def hex(cls, value: str, name: str | None = None) -> "Color":
95
+ clean = value.lstrip("#").upper()
96
+ if len(clean) != 6 or any(c not in "0123456789ABCDEF" for c in clean):
97
+ raise ValueError(f"invalid hex colour: {value!r}")
98
+ return cls(name=name or f"c{clean}", spec=ColorSpec("HTML", clean))
99
+
100
+ @classmethod
101
+ def rgb255(cls, r: int, g: int, b: int, name: str | None = None) -> "Color":
102
+ for v in (r, g, b):
103
+ if not 0 <= v <= 255:
104
+ raise ValueError(f"rgb255 component out of range: {v}")
105
+ return cls(
106
+ name=name or f"c{r:03d}{g:03d}{b:03d}",
107
+ spec=ColorSpec("RGB", f"{r},{g},{b}"),
108
+ )
109
+
110
+ @classmethod
111
+ def rgb(cls, r: float, g: float, b: float, name: str | None = None) -> "Color":
112
+ for v in (r, g, b):
113
+ if not 0.0 <= float(v) <= 1.0:
114
+ raise ValueError(f"rgb component out of range [0,1]: {v}")
115
+ return cls(
116
+ name=name or f"crgb{int(r * 255):03d}{int(g * 255):03d}{int(b * 255):03d}",
117
+ spec=ColorSpec("rgb", f"{r},{g},{b}"),
118
+ )
119
+
120
+ @classmethod
121
+ def named(cls, name: str) -> "Color":
122
+ if not is_known_color_name(name):
123
+ raise ValueError(
124
+ f"unknown colour name {name!r}; register via register_named_color"
125
+ )
126
+ return cls(name=name, spec=None)
127
+
128
+ def tint(self, percent: int) -> "Color":
129
+ """xcolor tint: `<self>!<percent>` (e.g. `blue!20`)."""
130
+ return Color(name=f"{self.name}!{percent}", spec=None)
131
+
132
+ def mix(self, other: "Color", percent: int = 50) -> "Color":
133
+ return Color(
134
+ name=f"{self.name}!{percent}!{other.name}",
135
+ spec=None,
136
+ )
137
+
138
+ def __or__(self, other: "Color") -> "Color":
139
+ return self.mix(other)
140
+
141
+ @override
142
+ def __eq__(self, other: object) -> bool:
143
+ return (
144
+ isinstance(other, Color)
145
+ and self.name == other.name
146
+ and self.spec == other.spec
147
+ )
148
+
149
+ @override
150
+ def __hash__(self) -> int:
151
+ return hash((self.name, self.spec))
152
+
153
+ @override
154
+ def __repr__(self) -> str:
155
+ return f"Color(name={self.name!r}, spec={self.spec!r})"
156
+
157
+ @property
158
+ @override
159
+ def rendered(self) -> str:
160
+ return self.name
161
+
162
+ @property
163
+ @override
164
+ def requires(self) -> frozenset[PackageProtocol]:
165
+ from ..packages import XCOLOR
166
+
167
+ return frozenset({XCOLOR})
168
+
169
+
170
+ def _from_overload(
171
+ value: "str | tuple[int, int, int] | tuple[float, float, float]",
172
+ ) -> tuple[str, ColorSpec | None]:
173
+ if isinstance(value, str):
174
+ if value.startswith("#"):
175
+ c = Color.hex(value)
176
+ return c.name, c.spec
177
+ if not is_known_color_name(value):
178
+ raise ValueError(f"unknown colour name {value!r}")
179
+ return value, None
180
+ if len(value) == 3:
181
+ if all(type(v) is int for v in value):
182
+ r, g, b = value
183
+ c = Color.rgb255(int(r), int(g), int(b))
184
+ return c.name, c.spec
185
+ if all(isinstance(v, float) for v in value):
186
+ r, g, b = value
187
+ c = Color.rgb(float(r), float(g), float(b))
188
+ return c.name, c.spec
189
+ raise TypeError(f"cannot construct Color from {value!r}")
190
+
191
+
192
+ def collect_colors(root: TeX) -> tuple[Color, ...]:
193
+ """Walk a TeX tree, return all unique `Color` instances with a `spec`."""
194
+ seen: dict[str, Color] = {}
195
+
196
+ def walk(node: TeX) -> None:
197
+ if isinstance(node, Color) and node.spec is not None and node.name not in seen:
198
+ seen[node.name] = node
199
+ for child in node.children or ():
200
+ walk(child)
201
+
202
+ walk(root)
203
+ return tuple(seen.values())
pytex/model/concat.py ADDED
@@ -0,0 +1,31 @@
1
+ from dataclasses import dataclass
2
+ from typing import Final, override
3
+
4
+ from ..helpers.coerce import coerce_tex
5
+ from ..helpers.parenting import attach
6
+ from ..interface.tex import TeX
7
+ from ..registry import Registry
8
+
9
+ __all__ = ["Concat"]
10
+
11
+
12
+ @Registry.add
13
+ @dataclass(frozen=True, init=False)
14
+ class Concat(TeX):
15
+ elements: Final[tuple[TeX]]
16
+
17
+ def __init__(self, *elements: TeX | str) -> None:
18
+ coerced = tuple(coerce_tex(e) for e in elements)
19
+ object.__setattr__(self, "elements", coerced)
20
+ object.__setattr__(self, "_parent", None)
21
+ attach(self, *coerced)
22
+
23
+ @property
24
+ @override
25
+ def children(self) -> tuple[TeX, ...]:
26
+ return self.elements
27
+
28
+ @property
29
+ @override
30
+ def rendered(self) -> str:
31
+ return "".join(str(e) for e in self.elements)