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.
- pytex/__init__.py +87 -0
- pytex/commands/__init__.py +51 -0
- pytex/commands/biblatex.py +98 -0
- pytex/commands/builtin.py +598 -0
- pytex/commands/captions.py +56 -0
- pytex/commands/cleveref.py +43 -0
- pytex/commands/colors.py +60 -0
- pytex/commands/conditionals.py +62 -0
- pytex/commands/counters.py +85 -0
- pytex/commands/definitions.py +109 -0
- pytex/commands/floats.py +93 -0
- pytex/commands/font.py +138 -0
- pytex/commands/fontawesome.py +88 -0
- pytex/commands/fontspec.py +75 -0
- pytex/commands/geometry.py +25 -0
- pytex/commands/glossaries.py +126 -0
- pytex/commands/graphics.py +68 -0
- pytex/commands/hooks.py +58 -0
- pytex/commands/hyperref.py +57 -0
- pytex/commands/lengths.py +200 -0
- pytex/commands/listings.py +63 -0
- pytex/commands/mdframed.py +43 -0
- pytex/commands/picture.py +32 -0
- pytex/commands/setspace.py +38 -0
- pytex/commands/tables.py +123 -0
- pytex/helpers/__init__.py +3 -0
- pytex/helpers/coerce.py +13 -0
- pytex/helpers/parenting.py +13 -0
- pytex/helpers/sanitize.py +54 -0
- pytex/helpers/with_package.py +61 -0
- pytex/interface/__init__.py +3 -0
- pytex/interface/control_sequence.py +29 -0
- pytex/interface/package.py +52 -0
- pytex/interface/tex.py +41 -0
- pytex/model/__init__.py +25 -0
- pytex/model/color.py +203 -0
- pytex/model/concat.py +31 -0
- pytex/model/control_sequence.py +72 -0
- pytex/model/document.py +120 -0
- pytex/model/document_class.py +29 -0
- pytex/model/empty.py +19 -0
- pytex/model/environment.py +30 -0
- pytex/model/image.py +137 -0
- pytex/model/include.py +21 -0
- pytex/model/length.py +54 -0
- pytex/model/math.py +401 -0
- pytex/model/package.py +132 -0
- pytex/model/raw.py +61 -0
- pytex/packages.py +221 -0
- pytex/registry.py +49 -0
- pytex_builder/__init__.py +8 -0
- pytex_builder/build.py +175 -0
- pytex_builder/console.py +77 -0
- pytex_builder/render.py +90 -0
- pytex_builder/tectonic.py +370 -0
- pytex_hsrtreport/__init__.py +116 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Bold.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-BoldItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Book.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-BookItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Medium.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-MediumItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Strong.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Thin.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-ThinItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Black.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Bold.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-BoldItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Italic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Medium.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Regular.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Times New Roman.ttf +0 -0
- pytex_hsrtreport/assets/logos/ASTA.svg +79 -0
- pytex_hsrtreport/assets/logos/DUMMY.png +0 -0
- pytex_hsrtreport/assets/logos/DUMMY_FOOT.png +0 -0
- pytex_hsrtreport/assets/logos/ECHO.svg +226 -0
- pytex_hsrtreport/assets/logos/HSRT.pdf +0 -0
- pytex_hsrtreport/assets/logos/INF.pdf +0 -0
- pytex_hsrtreport/assets/logos/STUPA.pdf +0 -0
- pytex_hsrtreport/assets/logos/Skyline.pdf +0 -0
- pytex_hsrtreport/boxes.py +215 -0
- pytex_hsrtreport/citations.py +21 -0
- pytex_hsrtreport/cleveref_names.py +47 -0
- pytex_hsrtreport/colors.py +30 -0
- pytex_hsrtreport/document.py +307 -0
- pytex_hsrtreport/fonts.py +66 -0
- pytex_hsrtreport/glossary.py +61 -0
- pytex_hsrtreport/hyperref_config.py +49 -0
- pytex_hsrtreport/listings.py +90 -0
- pytex_hsrtreport/logos.py +234 -0
- pytex_hsrtreport/pagebreak.py +67 -0
- pytex_hsrtreport/pagesetup.py +33 -0
- pytex_hsrtreport/tex/pagesetup.tex +76 -0
- pytex_hsrtreport/titlepage.py +136 -0
- pytex_hsrtreport/variants.py +24 -0
- pytex_hsrtreport/voting.py +96 -0
- pytex_hsrtreport/watermark.py +63 -0
- pytex_hsrtreport/wordcount.py +33 -0
- pytex_koma/__init__.py +90 -0
- pytex_koma/commands.py +296 -0
- pytex_koma/document.py +138 -0
- pytex_markdown/__init__.py +62 -0
- pytex_markdown/convert.py +271 -0
- pytex_markdown/escape.py +11 -0
- pytex_preprocessor-0.1.0.dist-info/METADATA +82 -0
- pytex_preprocessor-0.1.0.dist-info/RECORD +119 -0
- pytex_preprocessor-0.1.0.dist-info/WHEEL +5 -0
- pytex_preprocessor-0.1.0.dist-info/entry_points.txt +2 -0
- pytex_preprocessor-0.1.0.dist-info/top_level.txt +7 -0
- pytex_protocol/__init__.py +37 -0
- pytex_protocol/convert.py +202 -0
- pytex_protocol/document.py +91 -0
- pytex_protocol/entries.py +96 -0
- pytex_protocol/frontmatter.py +80 -0
- pytex_protocol/header.py +139 -0
- pytex_protocol/shortcodes.py +130 -0
- pytex_protocol/signatures.py +84 -0
- pytex_tikz/__init__.py +25 -0
- 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}"
|
pytex/model/document.py
ADDED
|
@@ -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}")
|