texsmith 0.0.2.dev0__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 (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,28 @@
1
+ """Runtime helpers for latexmk builds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from .log import LatexStreamResult, stream_latexmk_output
11
+
12
+
13
+ def run_latex_engine(
14
+ argv: Sequence[str],
15
+ *,
16
+ workdir: Path,
17
+ env: Mapping[str, str],
18
+ console: Console,
19
+ verbosity: int = 0,
20
+ ) -> LatexStreamResult:
21
+ """Execute a latexmk command and stream structured output."""
22
+ return stream_latexmk_output(
23
+ argv,
24
+ cwd=str(workdir),
25
+ env=env,
26
+ console=console,
27
+ verbosity=verbosity,
28
+ )
@@ -0,0 +1,38 @@
1
+ """Runtime helpers for invoking the Tectonic engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from pathlib import Path
7
+ import subprocess
8
+
9
+ from rich.console import Console
10
+
11
+ from ..latex import LatexStreamResult
12
+
13
+
14
+ def run_tectonic_engine(
15
+ argv: Sequence[str],
16
+ *,
17
+ workdir: Path,
18
+ env: Mapping[str, str],
19
+ console: Console,
20
+ ) -> LatexStreamResult:
21
+ """Execute a tectonic command, streaming plain output."""
22
+ with subprocess.Popen(
23
+ argv,
24
+ cwd=str(workdir),
25
+ env=dict(env),
26
+ stdout=subprocess.PIPE,
27
+ stderr=subprocess.STDOUT,
28
+ text=True,
29
+ bufsize=1,
30
+ encoding="utf-8",
31
+ errors="replace",
32
+ ) as process:
33
+ assert process.stdout is not None
34
+ for line in process.stdout:
35
+ console.print(line.rstrip())
36
+ returncode = process.wait()
37
+
38
+ return LatexStreamResult(returncode=returncode, messages=[])
@@ -0,0 +1,297 @@
1
+ """Utilities for rendering LaTeX partials (snippets)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from jinja2 import Environment, FileSystemLoader, Template
10
+ from requests.utils import requote_uri as requote_url
11
+
12
+ from .pygments import PygmentsLatexHighlighter
13
+ from .utils import escape_latex_chars
14
+
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - typing only
17
+ from texsmith.core.context import DocumentState
18
+
19
+
20
+ TEMPLATE_DIR = Path(__file__).resolve().parent / "partials"
21
+
22
+
23
+ def optimize_list(numbers: Iterable[int]) -> list[str]:
24
+ """Merge consecutive integers into human-readable ranges."""
25
+ values = sorted(numbers)
26
+ if not values:
27
+ return []
28
+
29
+ optimized: list[str] = []
30
+ start = end = values[0]
31
+
32
+ for num in values[1:]:
33
+ if num == end + 1:
34
+ end = num
35
+ else:
36
+ optimized.append(f"{start}-{end}" if start != end else str(start))
37
+ start = end = num
38
+
39
+ optimized.append(f"{start}-{end}" if start != end else str(start))
40
+ return optimized
41
+
42
+
43
+ class LaTeXFormatter:
44
+ """Render LaTeX templates using Jinja2 with custom delimiters."""
45
+
46
+ def __init__(self, template_dir: Path = TEMPLATE_DIR) -> None:
47
+ self.env = Environment(
48
+ block_start_string=r"\BLOCK{",
49
+ block_end_string=r"}",
50
+ variable_start_string=r"\VAR{",
51
+ variable_end_string=r"}",
52
+ comment_start_string=r"\COMMENT{",
53
+ comment_end_string=r"}",
54
+ loader=FileSystemLoader(template_dir),
55
+ )
56
+ self.legacy_latex_accents: bool = False
57
+ self.env.filters.setdefault("latex_escape", self._escape_latex)
58
+ self.env.filters.setdefault("escape_latex", self._escape_latex)
59
+
60
+ template_paths: list[Path] = []
61
+ for ext in (".tex", ".cls"):
62
+ template_paths.extend(template_dir.glob(f"**/*{ext}"))
63
+
64
+ self._template_names: dict[str, str] = {}
65
+ for path in template_paths:
66
+ relative = path.relative_to(template_dir)
67
+ key = self._normalise_key(relative.with_suffix("").as_posix())
68
+ self._template_names[key] = relative.as_posix()
69
+
70
+ self.templates: dict[str, Template] = {}
71
+ self.default_code_engine = "pygments"
72
+ self.default_code_style = "bw"
73
+ self._pygments: PygmentsLatexHighlighter | None = None
74
+
75
+ @staticmethod
76
+ def _normalise_key(name: str) -> str:
77
+ """Normalise template identifiers to align with loader expectations."""
78
+ return name.replace("/", "_")
79
+
80
+ @classmethod
81
+ def normalise_key(cls, name: str) -> str:
82
+ """Public wrapper for normalising template identifiers."""
83
+ return cls._normalise_key(name)
84
+
85
+ @property
86
+ def template_names(self) -> set[str]:
87
+ """Return the set of available template identifiers."""
88
+ return set(self._template_names)
89
+
90
+ def _get_template(self, key: str) -> Template:
91
+ """Return a cached template instance loading it on demand."""
92
+ normalised = self._normalise_key(key)
93
+ template = self.templates.get(normalised)
94
+ if template is not None:
95
+ return template
96
+
97
+ template_name = self._template_names.get(normalised)
98
+ if template_name is None:
99
+ raise KeyError(key)
100
+
101
+ template = self.env.get_template(template_name)
102
+ self.templates[normalised] = template
103
+ return template
104
+
105
+ def __getattr__(self, method: str) -> Callable[..., str]:
106
+ """Proxy calls to templates or custom handlers."""
107
+ mangled = f"handle_{method}"
108
+ try:
109
+ handler = object.__getattribute__(self, mangled)
110
+ except AttributeError:
111
+ handler = None
112
+ if handler is not None:
113
+ return handler # type: ignore[return-value]
114
+
115
+ try:
116
+ template = self._get_template(method)
117
+ except KeyError:
118
+ raise AttributeError(f"Object has no template for '{method}'") from None
119
+
120
+ def render_template(*args: Any, **kwargs: Any) -> str:
121
+ """Render the template with optional positional shorthand."""
122
+ if len(args) > 1:
123
+ msg = f"Expected at most 1 argument, got {len(args)}, use keyword arguments instead"
124
+ raise ValueError(msg)
125
+ if args:
126
+ kwargs["text"] = args[0]
127
+ return template.render(**kwargs)
128
+
129
+ return render_template
130
+
131
+ def __getitem__(self, key: str) -> Callable[..., str]:
132
+ return self._get_template(key).render
133
+
134
+ def _escape_url(self, url: str) -> str:
135
+ """Escape a URL for safe use in LaTeX commands."""
136
+ return escape_latex_chars(requote_url(url), legacy_accents=self.legacy_latex_accents)
137
+
138
+ def handle_codeinlinett(self, text: str) -> str:
139
+ """Render plain inline code inside \\texttt."""
140
+ escaped = escape_latex_chars(text, legacy_accents=self.legacy_latex_accents)
141
+ escaped = escaped.replace("-", "-\\allowbreak{}")
142
+ return self._get_template("codeinlinett").render(text=escaped)
143
+
144
+ def handle_codeblock(
145
+ self,
146
+ code: str,
147
+ language: str = "text",
148
+ filename: str | None = None,
149
+ lineno: bool = False,
150
+ highlight: Iterable[int] | None = None,
151
+ baselinestretch: float | None = None,
152
+ engine: str | None = None,
153
+ state: DocumentState | None = None,
154
+ **_: Any,
155
+ ) -> str:
156
+ """Render code blocks with optional line numbers and highlights."""
157
+ highlight = list(highlight or [])
158
+ optimized_highlight = optimize_list(highlight)
159
+ normalized_engine = (engine or self.default_code_engine or "pygments").lower()
160
+ if normalized_engine not in {"minted", "listings", "verbatim", "pygments"}:
161
+ normalized_engine = "pygments"
162
+
163
+ if normalized_engine == "pygments":
164
+ style_name = str(self.default_code_style or "bw").strip() or "bw"
165
+ if self._pygments is None or self._pygments.style != style_name:
166
+ self._pygments = PygmentsLatexHighlighter(style=style_name)
167
+ latex_code, style_defs = self._pygments.render(
168
+ code,
169
+ language,
170
+ linenos=lineno,
171
+ highlight_lines=highlight,
172
+ )
173
+ if state is not None and style_defs:
174
+ state.pygments_styles.setdefault(self._pygments.style_key, style_defs)
175
+ return self._get_template("codeblock_pygments").render(
176
+ code=latex_code,
177
+ language=language,
178
+ linenos=lineno,
179
+ filename=filename,
180
+ baselinestretch=baselinestretch,
181
+ highlight=optimized_highlight,
182
+ )
183
+
184
+ if normalized_engine == "listings":
185
+ return self._get_template("codeblock_listings").render(
186
+ code=code,
187
+ language=language,
188
+ linenos=lineno,
189
+ filename=filename,
190
+ baselinestretch=baselinestretch,
191
+ highlight=optimized_highlight,
192
+ )
193
+
194
+ if normalized_engine == "verbatim":
195
+ return self._get_template("codeblock_verbatim").render(
196
+ code=code,
197
+ language=language,
198
+ linenos=lineno,
199
+ filename=filename,
200
+ baselinestretch=baselinestretch,
201
+ highlight=optimized_highlight,
202
+ )
203
+
204
+ return self._get_template("codeblock").render(
205
+ code=code,
206
+ language=language,
207
+ linenos=lineno,
208
+ filename=filename,
209
+ baselinestretch=baselinestretch,
210
+ highlight=optimized_highlight,
211
+ )
212
+
213
+ def url(self, text: str, url: str) -> str:
214
+ """Render a URL, escaping special LaTeX characters."""
215
+ safe_url = self._escape_url(url)
216
+ return self._get_template("url").render(text=text, url=safe_url)
217
+
218
+ def handle_href(self, text: str, url: str) -> str:
219
+ """Render \\href links with escaped URLs."""
220
+ return self._get_template("href").render(text=text, url=self._escape_url(url))
221
+
222
+ def handle_regex(self, text: str, url: str) -> str:
223
+ """Render regex helper links with escaped URLs."""
224
+ return self._get_template("regex").render(text=text, url=self._escape_url(url))
225
+
226
+ def handle_codeinline(
227
+ self,
228
+ *,
229
+ language: str = "text",
230
+ text: str,
231
+ engine: str | None = None,
232
+ state: DocumentState | None = None,
233
+ delimiter: str | None = None,
234
+ ) -> str:
235
+ """Render inline code with engine-specific highlighting."""
236
+ normalized_engine = (engine or self.default_code_engine or "pygments").lower()
237
+ if normalized_engine == "minted":
238
+ delimiter = delimiter or "|"
239
+ return self._get_template("codeinline").render(
240
+ language=language or "text",
241
+ text=text,
242
+ delimiter=delimiter,
243
+ )
244
+
245
+ if normalized_engine == "pygments":
246
+ style_name = str(self.default_code_style or "bw").strip() or "bw"
247
+ if self._pygments is None or self._pygments.style != style_name:
248
+ self._pygments = PygmentsLatexHighlighter(style=style_name)
249
+ latex_code, style_defs = self._pygments.render_inline(text, language)
250
+ if state is not None and style_defs:
251
+ state.pygments_styles.setdefault(self._pygments.style_key, style_defs)
252
+ return r"{\ttfamily " + latex_code + "}"
253
+
254
+ # listings/verbatim fallback to plain typewriter
255
+ return self.handle_codeinlinett(text)
256
+
257
+ def _escape_latex(self, value: str) -> str:
258
+ """Escape helper that honours the formatter legacy accent setting."""
259
+ return escape_latex_chars(value, legacy_accents=self.legacy_latex_accents)
260
+
261
+ def svg(self, svg: str | Path) -> str:
262
+ """Render an SVG image by converting it to PDF first."""
263
+ from ..transformers import svg2pdf
264
+
265
+ pdfpath = svg2pdf(svg, self.output_path) # type: ignore[attr-defined]
266
+ return f"\\includegraphics[width=1em]{{{pdfpath}}}"
267
+
268
+ def get_cover(self, name: str, **kwargs: Any) -> str:
269
+ """Render a named cover template populated with book metadata."""
270
+ template = self._get_template(f"cover/{name}")
271
+ return template.render(
272
+ title=self.config.title, # type: ignore[attr-defined]
273
+ author=self.config.author, # type: ignore[attr-defined]
274
+ subtitle=self.config.subtitle, # type: ignore[attr-defined]
275
+ email=self.config.email, # type: ignore[attr-defined]
276
+ year=self.config.year, # type: ignore[attr-defined]
277
+ **self.config.cover.model_dump(), # type: ignore[attr-defined]
278
+ **kwargs,
279
+ )
280
+
281
+ def override_template(self, name: str, source: str | Path) -> None:
282
+ """Override a built-in template snippet using an external payload."""
283
+ if isinstance(source, Path):
284
+ template_source = source.read_text(encoding="utf-8")
285
+ template_name = source.as_posix()
286
+ else:
287
+ template_source = source
288
+ template_name = name
289
+
290
+ template = self.env.from_string(template_source)
291
+ template.name = template_name
292
+ normalised = self._normalise_key(name)
293
+ self.templates[normalised] = template
294
+ self._template_names[normalised] = template_name
295
+
296
+
297
+ __all__ = ["LaTeXFormatter", "optimize_list"]
@@ -0,0 +1,148 @@
1
+ """Helpers for configuring latexmk commands and RC files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import shlex
8
+ import shutil
9
+ from typing import Any
10
+
11
+ from texsmith.adapters.latex import pyxindy
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class LatexmkEngine:
16
+ """Normalised latexmk engine invocation."""
17
+
18
+ command: list[str]
19
+ pdf_mode: int
20
+
21
+
22
+ def _perl_escape(value: str) -> str:
23
+ return value.replace("\\", "\\\\").replace("'", "\\'")
24
+
25
+
26
+ def _pdf_mode_from_command(command: str) -> int:
27
+ lowered = Path(command).name.lower()
28
+ if lowered.endswith("xelatex"):
29
+ return 5
30
+ if lowered.endswith("lualatex"):
31
+ return 4
32
+ return 1
33
+
34
+
35
+ def normalise_engine_command(engine: str | None, *, shell_escape: bool) -> LatexmkEngine:
36
+ """Return latexmk engine tokens and pdf mode derived from the provided string."""
37
+ tokens = shlex.split(engine.strip()) if engine else []
38
+ if not tokens:
39
+ tokens = ["pdflatex"]
40
+
41
+ if shell_escape and not any(token in {"-shell-escape", "--shell-escape"} for token in tokens):
42
+ tokens.append("--shell-escape")
43
+
44
+ pdf_mode = _pdf_mode_from_command(tokens[0])
45
+ return LatexmkEngine(command=tokens, pdf_mode=pdf_mode)
46
+
47
+
48
+ def latexmk_pdf_flag(pdf_mode: int) -> str:
49
+ """Return the latexmk flag required for the requested pdf mode."""
50
+ if pdf_mode == 4:
51
+ return "-pdflua"
52
+ if pdf_mode == 5:
53
+ return "-pdfxe"
54
+ return "-pdf"
55
+
56
+
57
+ def build_engine_command(engine: LatexmkEngine) -> str:
58
+ """Construct the pdflatex command string used by latexmk."""
59
+ return shlex.join([*engine.command, "%O", "%S"])
60
+
61
+
62
+ def normalise_index_engine(candidate: Any) -> str:
63
+ """Resolve the effective index engine from template context or availability."""
64
+ if isinstance(candidate, str):
65
+ stripped = candidate.strip().lower()
66
+ if stripped in {"pyxindy", "texindy", "makeindex"}:
67
+ if stripped == "pyxindy" and not pyxindy.is_available():
68
+ stripped = "texindy" if shutil.which("texindy") else "makeindex"
69
+ return stripped
70
+ if pyxindy.is_available():
71
+ return "pyxindy"
72
+ return "texindy" if shutil.which("texindy") else "makeindex"
73
+
74
+
75
+ def _engine_variable(pdf_mode: int) -> str:
76
+ if pdf_mode == 5:
77
+ return "xelatex"
78
+ if pdf_mode == 4:
79
+ return "lualatex"
80
+ return "pdflatex"
81
+
82
+
83
+ def build_latexmkrc_content(
84
+ *,
85
+ root_filename: str,
86
+ engine: str | None,
87
+ requires_shell_escape: bool,
88
+ bibliography: bool,
89
+ index_engine: str | None = None,
90
+ has_index: bool = False,
91
+ has_glossary: bool = False,
92
+ ) -> str:
93
+ """Produce a latexmkrc payload matching the selected template features."""
94
+ engine_config = normalise_engine_command(engine, shell_escape=requires_shell_escape)
95
+ command_string = build_engine_command(engine_config)
96
+ engine_var = _engine_variable(engine_config.pdf_mode)
97
+ safe_root = _perl_escape(root_filename or "texsmith-output")
98
+
99
+ lines = [
100
+ "# Auto-generated by texsmith\n",
101
+ f"$root_filename = '{safe_root}';\n",
102
+ f"$pdf_mode = {engine_config.pdf_mode};\n",
103
+ f"${engine_var} = '{_perl_escape(command_string)}';\n",
104
+ ]
105
+
106
+ if bibliography:
107
+ lines.extend(
108
+ [
109
+ "$bibtex_use = 2;\n",
110
+ "$biber = 'biber %O %B';\n",
111
+ "$clean_ext .= ' %R.run.xml %R.blg';\n",
112
+ ]
113
+ )
114
+
115
+ if has_index:
116
+ index_var = normalise_index_engine(index_engine)
117
+ if index_var == "pyxindy":
118
+ makeindex = pyxindy.latexmk_makeindex_command()
119
+ else:
120
+ makeindex = "texindy %O -o %D %S" if index_var == "texindy" else "makeindex %O -o %D %S"
121
+ lines.append(f"$makeindex = '{_perl_escape(makeindex)}';\n")
122
+
123
+ if has_glossary:
124
+ glossaries_cmd = pyxindy.latexmk_makeglossaries_command()
125
+ lines.extend(
126
+ [
127
+ "add_cus_dep('glo', 'gls', 0, 'run_makeglossaries');\n",
128
+ "add_cus_dep('acn', 'acr', 0, 'run_makeglossaries');\n",
129
+ "\n",
130
+ "sub run_makeglossaries {\n",
131
+ " my ($base) = @_;\n",
132
+ f' my $cmd = "{_perl_escape(glossaries_cmd)}";\n',
133
+ " return system($cmd);\n",
134
+ "}\n",
135
+ ]
136
+ )
137
+
138
+ return "".join(lines)
139
+
140
+
141
+ __all__ = [
142
+ "LatexmkEngine",
143
+ "build_engine_command",
144
+ "build_latexmkrc_content",
145
+ "latexmk_pdf_flag",
146
+ "normalise_engine_command",
147
+ "normalise_index_engine",
148
+ ]
@@ -0,0 +1 @@
1
+ \gls{\VAR{text}}
@@ -0,0 +1 @@
1
+ \begin{goodbox}\uline{\VAR{text}}\end{goodbox}
@@ -0,0 +1 @@
1
+ \begin{goodbox}\uline{\VAR{text}}\end{goodbox}
@@ -0,0 +1,3 @@
1
+ \begin{displayquote}
2
+ \VAR{text|safe}
3
+ \end{displayquote}
@@ -0,0 +1,3 @@
1
+ \begin{callout}[callout \VAR{type}]{\VAR{title}}
2
+ \VAR{text}
3
+ \end{callout}
@@ -0,0 +1,5 @@
1
+ \begin{todolist}
2
+ \BLOCK{for checked, item in items -}
3
+ \item\BLOCK{if checked}[\done]\BLOCK{endif} \VAR{item}
4
+ \BLOCK{endfor}
5
+ \end{todolist}
@@ -0,0 +1 @@
1
+ \cite{\VAR{key}}
@@ -0,0 +1,7 @@
1
+ %\leavevmode % Required to avoid a bug in the tcolorbox package
2
+ \begin{code}{\VAR{language|default('text')}}{\BLOCK{if filename}\VAR{filename}\BLOCK{endif}}{
3
+ \BLOCK{- if baselinestretch}baselinestretch=\VAR{baselinestretch}, \BLOCK{endif}
4
+ \BLOCK{- if linenos}linenos, \BLOCK{endif -}
5
+ \BLOCK{- if highlight }, highlightlines={\VAR{','.join(highlight)}}\BLOCK{endif -}
6
+ }
7
+ \VAR{code}\end{code}
@@ -0,0 +1,5 @@
1
+ \begin{code}{\VAR{language|default('text')}}{\BLOCK{if filename}\VAR{filename}\BLOCK{endif}}{
2
+ \BLOCK{- if linenos},numbers=left, firstnumber=1, stepnumber=1\BLOCK{endif -}
3
+ \BLOCK{- if highlight }, highlightlines={\VAR{','.join(highlight)}}\BLOCK{endif -}
4
+ }
5
+ \VAR{code}\end{code}
@@ -0,0 +1,5 @@
1
+ \begin{code}{\VAR{language|default('text')}}{\BLOCK{if filename}\VAR{filename}\BLOCK{endif}}{
2
+ \BLOCK{- if baselinestretch}baselinestretch=\VAR{baselinestretch}, \BLOCK{endif}
3
+ \BLOCK{- if linenos}linenos, \BLOCK{endif -}
4
+ }
5
+ \VAR{code}\end{code}
@@ -0,0 +1,12 @@
1
+ \begin{code}{\VAR{language|default('text')}}{\BLOCK{if filename}\VAR{filename}\BLOCK{endif}}{
2
+ \BLOCK{- if baselinestretch}baselinestretch=\VAR{baselinestretch}, \BLOCK{endif}
3
+ }
4
+ \begin{Verbatim}[
5
+ breaklines,
6
+ breakanywhere,
7
+ commandchars=\\\{\},
8
+ \BLOCK{- if linenos}numbers=left, firstnumber=1, stepnumber=1,\BLOCK{endif -}
9
+ \BLOCK{- if highlight }highlightlines={\VAR{','.join(highlight)}}\BLOCK{endif}
10
+ ]
11
+ \VAR{code}\end{Verbatim}
12
+ \end{code}
@@ -0,0 +1 @@
1
+ \mintinline[breaklines=true]{\BLOCK{if language}\VAR{language}\BLOCK{else}text\BLOCK{endif}}\VAR{delimiter}\VAR{text}\VAR{delimiter}
@@ -0,0 +1 @@
1
+ \texttt{\VAR{text}}
@@ -0,0 +1 @@
1
+ \begin{commentbox}/* \VAR{text} */\end{commentbox}
@@ -0,0 +1 @@
1
+ \xout{\VAR{text}}
@@ -0,0 +1 @@
1
+ \xout{\VAR{text}}
@@ -0,0 +1,5 @@
1
+ \begin{description}
2
+ \BLOCK{for title, item in items -}
3
+ \item[{ \VAR{title} }] \VAR{item}
4
+ \BLOCK{endfor}
5
+ \end{description}
@@ -0,0 +1 @@
1
+ \enquote{\VAR{text}}
@@ -0,0 +1 @@
1
+ \epigraph{\itshape ``\VAR{text}''}{--- \textit{\VAR{source}}}
@@ -0,0 +1,7 @@
1
+ \chapter{Solution des exercices}
2
+
3
+ \BLOCK{for id, title, label, solution in text}
4
+ \par
5
+ \textbf{Exercice \VAR{id}. \hyperref[\VAR{label}]{\VAR{title}}}\par
6
+ \VAR{solution}
7
+ \BLOCK{endfor}
@@ -0,0 +1,33 @@
1
+ \begin{figure}[H]
2
+ \centering
3
+ \BLOCK{-if label}\label{\VAR{label}}\BLOCK{endif}
4
+ \BLOCK{if width}\BLOCK{set width_value = width.rstrip('%') if width.endswith('%') else width}\BLOCK{endif}
5
+ \BLOCK{if adjustbox}
6
+ \ifdefined\adjustbox
7
+ \BLOCK{if link}\href{\VAR{link}}{%
8
+ \adjustbox{max width=\textwidth}{%
9
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
10
+ }
11
+ }\BLOCK{else}
12
+ \adjustbox{max width=\textwidth}{%
13
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
14
+ }
15
+ \BLOCK{endif}
16
+ \else
17
+ \BLOCK{if link}\href{\VAR{link}}{%
18
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
19
+ }\BLOCK{else}
20
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
21
+ \BLOCK{endif}
22
+ \fi
23
+ \BLOCK{else}
24
+ \BLOCK{if link}\href{\VAR{link}}{%
25
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
26
+ }\BLOCK{else}
27
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
28
+ \BLOCK{endif}
29
+ \BLOCK{endif}
30
+ \BLOCK{-if caption}
31
+ \caption\BLOCK{if shortcaption}[\VAR{shortcaption}]\BLOCK{endif}{\VAR{caption}}
32
+ \BLOCK{-endif}
33
+ \end{figure}
@@ -0,0 +1,15 @@
1
+ \begin{center}
2
+ \BLOCK{-if label}\label{\VAR{label}}
3
+ \BLOCK{endif-}
4
+ \BLOCK{if width}\BLOCK{set width_value = width.rstrip('%') if width.endswith('%') else width}\BLOCK{endif}
5
+ \BLOCK{-if caption}\captionsetup{type=figure}\BLOCK{endif}
6
+ \BLOCK{if link}\href{\VAR{link}}{%
7
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
8
+ }
9
+ \BLOCK{else}
10
+ \includegraphics[width=\BLOCK{if width}\BLOCK{if width.endswith('%')}\VAR{(width_value|float) / 100}\linewidth\BLOCK{else}\VAR{width}\BLOCK{endif}\BLOCK{else}\linewidth\BLOCK{endif}]{\VAR{path}}
11
+ \BLOCK{endif}
12
+ \BLOCK{-if caption}
13
+ \captionof{figure}\BLOCK{if shortcaption}[\VAR{shortcaption}]\BLOCK{endif}{\VAR{caption}}
14
+ \BLOCK{-endif}
15
+ \end{center}
@@ -0,0 +1,3 @@
1
+ \footnote\BLOCK{if id}[\VAR{id}]\BLOCK{endif}{\BLOCK{if "\n" in text}%
2
+ \VAR{text}
3
+ \BLOCK{else}\VAR{text}\BLOCK{endif}}%