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,122 @@
1
+ """Conversion registry exposing high-level helpers for assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from texsmith.core.exceptions import TransformerExecutionError
9
+
10
+ from .base import ConverterStrategy
11
+ from .strategies import (
12
+ DrawioToPdfStrategy,
13
+ FetchImageStrategy,
14
+ ImageToPdfStrategy,
15
+ MermaidToPdfStrategy,
16
+ PdfMetadataStrategy,
17
+ SvgToPdfStrategy,
18
+ )
19
+
20
+
21
+ class ConverterRegistry:
22
+ """Registry storing converter strategies."""
23
+
24
+ def __init__(self) -> None:
25
+ self._strategies: dict[str, ConverterStrategy] = {}
26
+
27
+ def register(self, name: str, strategy: ConverterStrategy) -> None:
28
+ """Register a converter strategy under a unique name."""
29
+ self._strategies[name] = strategy
30
+
31
+ def get(self, name: str) -> ConverterStrategy:
32
+ """Return a registered converter strategy or raise an execution error."""
33
+ try:
34
+ return self._strategies[name]
35
+ except KeyError as exc: # pragma: no cover - defensive
36
+ raise TransformerExecutionError(f"No converter registered for '{name}'") from exc
37
+
38
+ def is_registered(self, name: str) -> bool:
39
+ """Return True when a converter has been registered under the given name."""
40
+ return name in self._strategies
41
+
42
+ def convert(
43
+ self,
44
+ name: str,
45
+ source: Path | str,
46
+ *,
47
+ output_dir: Path,
48
+ **options: Any,
49
+ ) -> Any:
50
+ """Execute a converter strategy with the provided arguments."""
51
+ strategy = self.get(name)
52
+ return strategy(source, output_dir=output_dir, **options)
53
+
54
+
55
+ registry = ConverterRegistry()
56
+
57
+ # Built-in strategies
58
+ registry.register("svg", SvgToPdfStrategy())
59
+ registry.register("image", ImageToPdfStrategy())
60
+ registry.register("fetch-image", FetchImageStrategy())
61
+ registry.register("pdf-metadata", PdfMetadataStrategy())
62
+ registry.register("drawio", DrawioToPdfStrategy())
63
+ registry.register("mermaid", MermaidToPdfStrategy())
64
+
65
+
66
+ def register_converter(name: str, strategy: ConverterStrategy) -> None:
67
+ """Expose a helper to register external strategies."""
68
+ registry.register(name, strategy)
69
+
70
+
71
+ def has_converter(name: str) -> bool:
72
+ """Return True when a converter strategy is currently registered."""
73
+ return registry.is_registered(name)
74
+
75
+
76
+ def svg2pdf(source: Path | str, output_dir: Path, **options: Any) -> Path:
77
+ """Convert SVG assets to PDF."""
78
+ return registry.convert("svg", source, output_dir=output_dir, **options)
79
+
80
+
81
+ def image2pdf(source: Path | str, output_dir: Path, **options: Any) -> Path:
82
+ """Convert bitmap images to PDF."""
83
+ return registry.convert("image", source, output_dir=output_dir, **options)
84
+
85
+
86
+ def drawio2pdf(source: Path | str, output_dir: Path, **options: Any) -> Path:
87
+ """Convert draw.io diagrams to PDF."""
88
+ return registry.convert("drawio", source, output_dir=output_dir, **options)
89
+
90
+
91
+ def mermaid2pdf(source: Path | str, output_dir: Path, **options: Any) -> Path:
92
+ """Convert Mermaid diagrams to PDF."""
93
+ return registry.convert("mermaid", source, output_dir=output_dir, **options)
94
+
95
+
96
+ def fetch_image(url: str, output_dir: Path, **options: Any) -> Path:
97
+ """Fetch a remote image and normalise it to PDF."""
98
+ return registry.convert("fetch-image", url, output_dir=output_dir, **options)
99
+
100
+
101
+ def get_pdf_page_sizes(source: Path | str, **options: Any) -> dict[str, Any]:
102
+ """Inspect a PDF and return structured metadata."""
103
+ output_dir = options.pop(
104
+ "output_dir", Path(source).parent if isinstance(source, Path) else Path.cwd()
105
+ )
106
+ return registry.convert("pdf-metadata", source, output_dir=output_dir, **options)
107
+
108
+
109
+ __all__ = [
110
+ "ConverterRegistry",
111
+ "ConverterStrategy",
112
+ "DrawioToPdfStrategy",
113
+ "MermaidToPdfStrategy",
114
+ "drawio2pdf",
115
+ "fetch_image",
116
+ "get_pdf_page_sizes",
117
+ "has_converter",
118
+ "image2pdf",
119
+ "mermaid2pdf",
120
+ "register_converter",
121
+ "svg2pdf",
122
+ ]
@@ -0,0 +1,140 @@
1
+ """Primitives used by asset converter strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from hashlib import sha256
7
+ import json
8
+ from pathlib import Path
9
+ import time
10
+ from typing import Any, Protocol
11
+
12
+ from texsmith.core.exceptions import TransformerExecutionError
13
+
14
+
15
+ class ConverterStrategy(Protocol):
16
+ """Protocol implemented by concrete converter strategies."""
17
+
18
+ def __call__(self, source: Path | str, *, output_dir: Path, **options: Any) -> Any: ...
19
+
20
+
21
+ def exponential_backoff(
22
+ base_delay: float = 0.5, factor: float = 2.0, max_delay: float = 5.0
23
+ ) -> Callable[[int], float]:
24
+ """Return a simple exponential backoff policy."""
25
+
26
+ def policy(attempt: int) -> float:
27
+ delay = base_delay * (factor ** (attempt - 1))
28
+ return min(delay, max_delay)
29
+
30
+ return policy
31
+
32
+
33
+ class CachedConversionStrategy:
34
+ """Base class that adds caching and retry/backoff policies."""
35
+
36
+ suffix: str = ".pdf"
37
+
38
+ def __init__(
39
+ self,
40
+ namespace: str,
41
+ *,
42
+ max_attempts: int = 3,
43
+ backoff: Callable[[int], float] | None = None,
44
+ ) -> None:
45
+ self.namespace = namespace
46
+ self.max_attempts = max_attempts
47
+ self.backoff = backoff or exponential_backoff()
48
+
49
+ def __call__(self, source: Path | str, *, output_dir: Path, **options: Any) -> Path:
50
+ output_dir = Path(output_dir)
51
+ output_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ cacheable_options = {key: value for key, value in options.items() if key != "emitter"}
54
+
55
+ cache_key = self._make_cache_key(source, cacheable_options)
56
+ target = self._resolve_target_path(output_dir, cache_key, source, options)
57
+
58
+ if target.exists() and not options.get("force", False):
59
+ return target
60
+
61
+ cache_dir = output_dir / ".cache" / self.namespace
62
+ cache_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ last_error: Exception | None = None
65
+ for attempt in range(1, self.max_attempts + 1):
66
+ try:
67
+ return self._perform_conversion(
68
+ source, target=target, cache_dir=cache_dir, **options
69
+ )
70
+ except Exception as exc: # pragma: no cover - defensive
71
+ last_error = exc
72
+ should_retry = attempt < self.max_attempts and not isinstance(
73
+ exc, TransformerExecutionError
74
+ )
75
+ if not should_retry:
76
+ raise
77
+ delay = self.backoff(attempt)
78
+ if delay > 0:
79
+ time.sleep(delay)
80
+
81
+ if isinstance(last_error, TransformerExecutionError):
82
+ raise last_error
83
+
84
+ message = f"Conversion failed for '{self.namespace}' after {self.max_attempts} attempts"
85
+ raise TransformerExecutionError(message) from last_error
86
+
87
+ # --------------------------------------------------------------------- helpers
88
+
89
+ def _perform_conversion(
90
+ self,
91
+ source: Path | str,
92
+ *,
93
+ target: Path,
94
+ cache_dir: Path,
95
+ **options: Any,
96
+ ) -> Path:
97
+ """Sub-classes must implement actual conversion logic."""
98
+ raise NotImplementedError
99
+
100
+ def _resolve_target_path(
101
+ self,
102
+ output_dir: Path,
103
+ cache_key: str,
104
+ source: Path | str,
105
+ options: dict[str, Any],
106
+ ) -> Path:
107
+ suffix = self.output_suffix(source=source, options=options)
108
+ return output_dir / f"{cache_key}{suffix}"
109
+
110
+ def output_suffix(self, source: Any, options: dict[str, Any]) -> str:
111
+ """Allow subclasses to customise the output suffix."""
112
+ return self.suffix
113
+
114
+ def _make_cache_key(self, source: Path | str, options: dict[str, Any]) -> str:
115
+ digest = sha256()
116
+ digest.update(self._serialise_source(source))
117
+ digest.update(self._serialise_options(options))
118
+ return digest.hexdigest()
119
+
120
+ def _serialise_source(self, source: Path | str) -> bytes:
121
+ if isinstance(source, Path):
122
+ if source.exists():
123
+ return source.read_bytes()
124
+ return str(source.resolve()).encode("utf-8")
125
+ return source.encode("utf-8")
126
+
127
+ def _serialise_options(self, options: dict[str, Any]) -> bytes:
128
+ normalised = {key: self._normalise_option(value) for key, value in options.items()}
129
+ return json.dumps(normalised, sort_keys=True, separators=(",", ":")).encode("utf-8")
130
+
131
+ def _normalise_option(self, value: Any) -> Any:
132
+ match value:
133
+ case Path():
134
+ return value.as_posix()
135
+ case list() | tuple():
136
+ return [self._normalise_option(item) for item in value]
137
+ case dict():
138
+ return {str(k): self._normalise_option(v) for k, v in value.items()}
139
+ case _:
140
+ return value