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,72 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Final, override
3
+
4
+ from ..helpers.parenting import attach
5
+ from ..interface.control_sequence import Parameters, ParameterType
6
+ from ..interface.package import PackageProtocol
7
+ from ..interface.tex import TeX
8
+ from ..registry import Registry
9
+ from .raw import Raw
10
+
11
+ __all__ = ["ControlSequence", "Parameter"]
12
+
13
+
14
+ @Registry.add
15
+ @dataclass(frozen=True, slots=True)
16
+ class Parameter[T: ParameterType = ParameterType](TeX):
17
+ value: Final[T]
18
+ optional: Final[bool] = False
19
+ _parent: "TeX | None" = field(default=None, init=False, compare=False, repr=False)
20
+
21
+ def __post_init__(self) -> None:
22
+ attach(self, self.value)
23
+
24
+ @property
25
+ def _braces(self) -> tuple[str, str]:
26
+ return ("[", "]") if self.optional else ("{", "}")
27
+
28
+ @property
29
+ @override
30
+ def children(self) -> tuple[TeX]:
31
+ if isinstance(self.value, dict):
32
+ return tuple[TeX]()
33
+
34
+ return (self.value if isinstance(self.value, TeX) else Raw(self.value),)
35
+
36
+ @property
37
+ @override
38
+ def rendered(self) -> str:
39
+ """Render this Node to a valid LaTeX-String"""
40
+
41
+ content: str | TeX = ""
42
+
43
+ if isinstance(self.value, (TeX, str)):
44
+ content = self.value
45
+ else:
46
+ content = ",".join(f"{key}={value}" for key, value in self.value.items())
47
+
48
+ return f"{self._braces[0]}{content}{self._braces[1]}"
49
+
50
+
51
+ @Registry.add
52
+ @dataclass(frozen=True, slots=True)
53
+ class ControlSequence[P: Parameters](TeX):
54
+ name: Final[str]
55
+ params: Final[P]
56
+ required_packages: frozenset[PackageProtocol] = field(default_factory=frozenset)
57
+ _parent: "TeX | None" = field(default=None, init=False, compare=False, repr=False)
58
+
59
+ def __post_init__(self) -> None:
60
+ if self.params is not None:
61
+ attach(self, *self.params)
62
+
63
+ @property
64
+ @override
65
+ def requires(self) -> frozenset[PackageProtocol]:
66
+ return self.required_packages
67
+
68
+ @property
69
+ @override
70
+ def rendered(self) -> str:
71
+ body = "".join(p.rendered for p in (self.params or ()))
72
+ return f"\\{self.name}{body}"
@@ -0,0 +1,120 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import override
3
+
4
+ from ..helpers.coerce import coerce_tex
5
+ from ..helpers.parenting import attach
6
+ from ..interface.package import PackageOption, PackageProtocol
7
+ from ..interface.tex import TeX
8
+ from ..registry import Registry
9
+ from .concat import Concat
10
+ from .document_class import DocumentClass
11
+ from .empty import Empty
12
+ from .environment import Environment
13
+ from .image import IncludeImage, collect_inline_images, filecontents_b64_block
14
+ from .raw import Raw
15
+
16
+ __all__ = ["Document"]
17
+
18
+
19
+ @Registry.add
20
+ @dataclass
21
+ class Document(TeX):
22
+ body: TeX | str
23
+ document_class: str = "article"
24
+ document_class_options: set[PackageOption] = field(default_factory=set)
25
+ preamble: TeX | str = Empty
26
+ extra_packages: frozenset[PackageProtocol] = field(default_factory=frozenset)
27
+ _parent: "TeX | None" = field(default=None, init=False, compare=False, repr=False)
28
+
29
+ def __post_init__(self) -> None:
30
+ attach(self, self.body, self.preamble)
31
+
32
+ @property
33
+ def packages(self) -> frozenset[PackageProtocol]:
34
+ def get_packages(obj: TeX, found: set[PackageProtocol]) -> None:
35
+ found |= {
36
+ after
37
+ for pkg in (obj.requires or set[PackageProtocol]())
38
+ for after in pkg.after | {pkg}
39
+ }
40
+
41
+ for child in obj.children or ():
42
+ get_packages(child, found)
43
+
44
+ found = set[PackageProtocol]()
45
+
46
+ get_packages(coerce_tex(self.body), found)
47
+ get_packages(coerce_tex(self.preamble), found)
48
+
49
+ return frozenset(found | self.extra_packages)
50
+
51
+ def ordered_packages(self) -> tuple[PackageProtocol, ...]:
52
+ """Packages sorted so each is emitted after its `after` dependencies.
53
+
54
+ A frozenset has no stable order, but some packages must be loaded in a
55
+ fixed sequence (e.g. `cleveref` after `hyperref`). Resolve that with a
56
+ depth-first topological sort, breaking ties by name for reproducibility.
57
+ """
58
+ packages = self.packages
59
+ by_name = {p.name: p for p in packages}
60
+ state: dict[str, bool] = {} # name -> finished?
61
+ out: list[PackageProtocol] = []
62
+
63
+ def visit(pkg: PackageProtocol) -> None:
64
+ if state.get(pkg.name) is not None:
65
+ return # finished, or currently visiting (cycle guard)
66
+ state[pkg.name] = False
67
+ for dep in sorted(pkg.after or (), key=lambda d: d.name):
68
+ present = by_name.get(dep.name)
69
+ if present is not None:
70
+ visit(present)
71
+ state[pkg.name] = True
72
+ out.append(pkg)
73
+
74
+ for pkg in sorted(packages, key=lambda p: p.name):
75
+ visit(pkg)
76
+ return tuple(out)
77
+
78
+ @property
79
+ def inline_images(self) -> tuple[IncludeImage, ...]:
80
+ images: dict[str, IncludeImage] = {}
81
+ for root in (self.body, self.preamble):
82
+ for img in collect_inline_images(coerce_tex(root)):
83
+ key = img.resolved_path.as_posix()
84
+ images.setdefault(key, img)
85
+ return tuple(images.values())
86
+
87
+ def write_inline_images(self, target_dir: str = ".") -> tuple[str, ...]:
88
+ """Materialise inline images to disk relative to `target_dir`. Returns paths."""
89
+ from pathlib import Path
90
+
91
+ written: list[str] = []
92
+ base = Path(target_dir)
93
+ for img in self.inline_images:
94
+ img.ensure_converted()
95
+ resolved = img.resolved_path
96
+ rel = Path(*resolved.parts[1:]) if resolved.is_absolute() else resolved
97
+ dest = base / rel
98
+ dest.parent.mkdir(parents=True, exist_ok=True)
99
+ dest.write_bytes(img.read_bytes())
100
+ written.append(dest.as_posix())
101
+ return tuple(written)
102
+
103
+ @property
104
+ def inline_image_block(self) -> TeX:
105
+ """`\\begin{filecontents*}` for each inline image, in tree order."""
106
+ images = self.inline_images
107
+ if not images:
108
+ return Empty
109
+ return Concat(*(Raw(filecontents_b64_block(img)) for img in images))
110
+
111
+ @property
112
+ @override
113
+ def rendered(self) -> str:
114
+ return Concat(
115
+ DocumentClass(self.document_class, self.document_class_options),
116
+ *self.ordered_packages(),
117
+ self.inline_image_block,
118
+ self.preamble,
119
+ Environment("document", self.body),
120
+ ).rendered
@@ -0,0 +1,29 @@
1
+ from pytex.model.empty import Empty
2
+
3
+ from ..interface.package import PackageOption
4
+ from ..interface.tex import TeX
5
+ from ..registry import Registry
6
+ from .control_sequence import ControlSequence, Parameter
7
+ from .raw import Raw
8
+
9
+ __all__ = ["DocumentClass"]
10
+
11
+
12
+ def _render_options(options: set[PackageOption] | frozenset[PackageOption]) -> str:
13
+ return ",".join(
14
+ item if isinstance(item, str) else f"{item[0]}={item[1]}" for item in options
15
+ )
16
+
17
+
18
+ @Registry.add
19
+ def DocumentClass(
20
+ name: str,
21
+ options: set[PackageOption] | frozenset[PackageOption] | None = None,
22
+ ) -> TeX:
23
+ options = options or set()
24
+ rendered = _render_options(options)
25
+ opt_param = Parameter(Raw(rendered), optional=True) if rendered else Empty
26
+ return ControlSequence(
27
+ "documentclass",
28
+ (opt_param, Parameter(name)),
29
+ )
pytex/model/empty.py ADDED
@@ -0,0 +1,19 @@
1
+ from typing import override
2
+
3
+ from ..interface.tex import TeX
4
+ from ..registry import Registry
5
+
6
+ __all__ = ["EmptyTeX"]
7
+
8
+
9
+ @Registry.add
10
+ class EmptyTeX(TeX):
11
+ _parent: "TeX | None" = None
12
+
13
+ @property
14
+ @override
15
+ def rendered(self) -> str:
16
+ return ""
17
+
18
+
19
+ Empty = EmptyTeX()
@@ -0,0 +1,30 @@
1
+ from ..interface.control_sequence import Parameters
2
+ from ..interface.tex import TeX
3
+ from ..registry import Registry
4
+ from .concat import Concat
5
+ from .control_sequence import ControlSequence, Parameter
6
+ from .raw import Raw
7
+
8
+ __all__ = ["Begin", "End", "Environment"]
9
+
10
+
11
+ @Registry.add
12
+ def Begin(name: str, params: Parameters = None) -> TeX:
13
+ return ControlSequence(
14
+ "begin",
15
+ (Parameter(Raw(name)), *(params or ())),
16
+ )
17
+
18
+
19
+ @Registry.add
20
+ def End(name: str) -> TeX:
21
+ return ControlSequence("end", (Parameter(Raw(name)),))
22
+
23
+
24
+ @Registry.add
25
+ def Environment(name: str, body: TeX | str, params: Parameters = None) -> TeX:
26
+ return Concat(
27
+ Begin(name, params),
28
+ body,
29
+ End(name),
30
+ )
pytex/model/image.py ADDED
@@ -0,0 +1,137 @@
1
+ import base64
2
+ import hashlib
3
+ import subprocess
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Final, override
7
+
8
+ from ..helpers.parenting import attach
9
+ from ..interface.package import PackageProtocol
10
+ from ..interface.tex import TeX
11
+ from ..registry import Registry
12
+
13
+ __all__ = ["IncludeImage", "collect_inline_images", "filecontents_b64_block"]
14
+
15
+ PDF_COMPAT = {".pdf", ".png", ".jpg", ".jpeg", ".eps"}
16
+
17
+
18
+ def _convert_to_pdf(src: Path, dst: Path) -> None:
19
+ """SVG → PDF via inkscape. Raises FileNotFoundError if inkscape absent."""
20
+ if src.suffix.lower() != ".svg":
21
+ raise ValueError(f"only SVG conversion supported, got {src.suffix}")
22
+ dst.parent.mkdir(parents=True, exist_ok=True)
23
+ subprocess.run(
24
+ ["inkscape", str(src), "--export-type=pdf", f"--export-filename={dst}"],
25
+ check=True,
26
+ capture_output=True,
27
+ )
28
+
29
+
30
+ @Registry.add
31
+ @dataclass
32
+ class IncludeImage(TeX):
33
+ """`\\includegraphics{path}` with optional base64 baking.
34
+
35
+ - Accepts any image format. SVG is converted to PDF via `inkscape` lazily
36
+ (only when bytes are accessed and `inline_base64=True`).
37
+ - When `inline_base64=True`, `Document` collects the node and emits a
38
+ `\\begin{filecontents*}[overwrite,nosearch]{<resolved>.b64}` block at the
39
+ document start containing the raw base64. A build helper can decode them
40
+ to disk before the TeX run. The `\\includegraphics` line stays unchanged.
41
+ """
42
+
43
+ path: Final[str | Path]
44
+ inline_base64: Final[bool] = False
45
+ width: Final[str | None] = None
46
+ height: Final[str | None] = None
47
+ scale: Final[str | None] = None
48
+ keepaspectratio: Final[bool] = False
49
+ _parent: "TeX | None" = field(default=None, init=False, compare=False, repr=False)
50
+
51
+ @property
52
+ def source_path(self) -> Path:
53
+ return Path(self.path)
54
+
55
+ @property
56
+ def resolved_path(self) -> Path:
57
+ """Path that `\\includegraphics` references. SVG → PDF in build/."""
58
+ src = self.source_path
59
+ if src.suffix.lower() in PDF_COMPAT:
60
+ return src
61
+ if src.suffix.lower() == ".svg":
62
+ digest = hashlib.sha1(src.resolve().as_posix().encode()).hexdigest()[:10]
63
+ return Path("build") / f"{src.stem}-{digest}.pdf"
64
+ raise ValueError(f"unsupported image extension: {src.suffix}")
65
+
66
+ def ensure_converted(self) -> None:
67
+ """Run SVG→PDF conversion if needed. Idempotent."""
68
+ if self.source_path.suffix.lower() == ".svg":
69
+ target = self.resolved_path
70
+ if not target.exists():
71
+ _convert_to_pdf(self.source_path, target)
72
+
73
+ def read_bytes(self) -> bytes:
74
+ """Return the bytes of the resolved (TeX-compatible) image."""
75
+ self.ensure_converted()
76
+ return self.resolved_path.read_bytes()
77
+
78
+ def base64_payload(self) -> str:
79
+ return base64.b64encode(self.read_bytes()).decode("ascii")
80
+
81
+ @property
82
+ @override
83
+ def rendered(self) -> str:
84
+ opts: list[str] = []
85
+ if self.width is not None:
86
+ opts.append(f"width={self.width}")
87
+ if self.height is not None:
88
+ opts.append(f"height={self.height}")
89
+ if self.scale is not None:
90
+ opts.append(f"scale={self.scale}")
91
+ if self.keepaspectratio:
92
+ opts.append("keepaspectratio")
93
+ opt_str = f"[{','.join(opts)}]" if opts else ""
94
+ return f"\\includegraphics{opt_str}{{{self.resolved_path.as_posix()}}}"
95
+
96
+ @property
97
+ @override
98
+ def requires(self) -> frozenset[PackageProtocol]:
99
+ from ..packages import GRAPHICX
100
+
101
+ return frozenset({GRAPHICX})
102
+
103
+
104
+ def collect_inline_images(root: TeX) -> tuple[IncludeImage, ...]:
105
+ """Walk a TeX tree, return all IncludeImage nodes with `inline_base64=True`."""
106
+ seen: dict[str, IncludeImage] = {}
107
+
108
+ def walk(node: TeX) -> None:
109
+ if isinstance(node, IncludeImage) and node.inline_base64:
110
+ key = node.resolved_path.as_posix()
111
+ if key not in seen:
112
+ seen[key] = node
113
+ for child in node.children or ():
114
+ walk(child)
115
+
116
+ walk(root)
117
+ return tuple(seen.values())
118
+
119
+
120
+ def filecontents_b64_block(image: IncludeImage) -> str:
121
+ """`\\begin{filecontents*}[overwrite,nosearch]{<resolved>.b64}<base64>\\end{filecontents*}`."""
122
+ target = image.resolved_path.as_posix() + ".b64"
123
+ payload = image.base64_payload()
124
+ chunks = [payload[i : i + 76] for i in range(0, len(payload), 76)]
125
+ body = "\n".join(chunks)
126
+ # Trailing newline is required: LaTeX ignores anything after
127
+ # \end{filecontents*} on the same line, so without it the next token
128
+ # (another block, or the preamble) would be silently dropped.
129
+ return (
130
+ f"\\begin{{filecontents*}}[overwrite,nosearch]{{{target}}}\n"
131
+ f"{body}\n"
132
+ "\\end{filecontents*}\n"
133
+ )
134
+
135
+
136
+ # Avoid unused-import warning
137
+ _ = attach
pytex/model/include.py ADDED
@@ -0,0 +1,21 @@
1
+ from os import PathLike
2
+ from pathlib import Path
3
+
4
+ from ..registry import Registry
5
+ from .raw import Raw
6
+
7
+ __all__ = ["IncludeTeX"]
8
+
9
+
10
+ @Registry.add
11
+ def IncludeTeX(
12
+ path: str | PathLike[str],
13
+ namespace: dict[str, object] | None = None,
14
+ allow_replacements: bool = True,
15
+ ) -> Raw:
16
+ content = Path(path).read_text()
17
+ return Raw(
18
+ content,
19
+ namespace=namespace,
20
+ allow_replacements=allow_replacements,
21
+ )
pytex/model/length.py ADDED
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass
2
+ from typing import Final, override
3
+
4
+ from ..interface.tex import TeX
5
+ from ..registry import Registry
6
+
7
+ __all__ = ["Length"]
8
+
9
+
10
+ def _expr(value: "Length | int | float | str") -> str:
11
+ if isinstance(value, Length):
12
+ return value.expr
13
+ return str(value)
14
+
15
+
16
+ @Registry.add
17
+ @dataclass(frozen=True)
18
+ class Length(TeX):
19
+ """LaTeX length expression. Arithmetic uses the calc package syntax.
20
+
21
+ Combine via Python operators: `Linewidth() - "0.5cm"`, `0.5 * Textwidth()`.
22
+ Pass to anything that takes a length spec (Vspace, Setlength, Minipage width).
23
+ """
24
+
25
+ expr: Final[str]
26
+
27
+ @property
28
+ @override
29
+ def rendered(self) -> str:
30
+ return self.expr
31
+
32
+ def __add__(self, other: "Length | int | float | str") -> "Length":
33
+ return Length(f"{self.expr}+{_expr(other)}")
34
+
35
+ def __radd__(self, other: "Length | int | float | str") -> "Length":
36
+ return Length(f"{_expr(other)}+{self.expr}")
37
+
38
+ def __sub__(self, other: "Length | int | float | str") -> "Length":
39
+ return Length(f"{self.expr}-{_expr(other)}")
40
+
41
+ def __rsub__(self, other: "Length | int | float | str") -> "Length":
42
+ return Length(f"{_expr(other)}-{self.expr}")
43
+
44
+ def __mul__(self, factor: int | float) -> "Length":
45
+ return Length(f"{factor}{self.expr}")
46
+
47
+ def __rmul__(self, factor: int | float) -> "Length":
48
+ return Length(f"{factor}{self.expr}")
49
+
50
+ def __truediv__(self, divisor: int | float) -> "Length":
51
+ return Length(f"{self.expr}/{divisor}")
52
+
53
+ def __neg__(self) -> "Length":
54
+ return Length(f"-{self.expr}")