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,1456 @@
1
+ """Concrete converter strategies with caching and error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ import contextlib
8
+ from datetime import datetime, timezone
9
+ from importlib import metadata as importlib_metadata
10
+ from io import BytesIO
11
+ import json
12
+ import math
13
+ import os
14
+ from pathlib import Path
15
+ import shlex
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ from threading import Lock, Thread
21
+ from typing import Any, ClassVar, TypeVar
22
+ from urllib.parse import unquote, urlparse
23
+ from urllib.request import urlopen
24
+ import warnings
25
+
26
+ from texsmith.core.conversion.debug import ensure_emitter, record_event
27
+ from texsmith.core.exceptions import TransformerExecutionError
28
+ from texsmith.core.user_dir import get_user_dir
29
+
30
+ from ..docker import DockerLimits, VolumeMount, run_container
31
+ from .base import CachedConversionStrategy
32
+ from .utils import normalise_pdf_version, points_to_mm
33
+
34
+
35
+ _EXPORT3_URL = "https://app.diagrams.net/export3.html"
36
+ _DEFAULT_MERMAID_CONFIG_PATH = (
37
+ Path(__file__).resolve().parents[4]
38
+ / "texsmith"
39
+ / "templates"
40
+ / "article"
41
+ / "template"
42
+ / "assets"
43
+ / "mermaid-config.json"
44
+ )
45
+ _DEFAULT_MERMAID_CONFIG: dict[str, Any] = {}
46
+ if _DEFAULT_MERMAID_CONFIG_PATH.exists():
47
+ try:
48
+ _DEFAULT_MERMAID_CONFIG = json.loads(_DEFAULT_MERMAID_CONFIG_PATH.read_text("utf-8"))
49
+ except Exception:
50
+ _DEFAULT_MERMAID_CONFIG = {}
51
+
52
+
53
+ MERMAID_CLI_HINT_PATHS: tuple[Path, ...] = (Path("/snap/bin/mmdc"),)
54
+ DRAWIO_CLI_HINT_PATHS: tuple[Path, ...] = (Path("/snap/bin/drawio"),)
55
+
56
+ DPI_CSS = 96
57
+ DPI_TARGET = 72
58
+ SCALE = DPI_CSS / DPI_TARGET
59
+ _PLAYWRIGHT_APT_PACKAGES: tuple[str, ...] = (
60
+ "libglib2.0-0",
61
+ "libnspr4",
62
+ "libnss3",
63
+ "libatk1.0-0",
64
+ "libatk-bridge2.0-0",
65
+ "libcups2",
66
+ "libxkbcommon0",
67
+ "libatspi2.0-0",
68
+ "libxcomposite1",
69
+ "libxdamage1",
70
+ "libxfixes3",
71
+ "libxrandr2",
72
+ "libgbm1",
73
+ "libcairo2",
74
+ "libpango-1.0-0",
75
+ "libasound2",
76
+ )
77
+ _PLAYWRIGHT_BACKEND_HINT = (
78
+ "If Playwright cannot run in this environment, re-run with "
79
+ "--diagrams-backend local or --diagrams-backend docker."
80
+ )
81
+
82
+ _PLACEHOLDER_PDF = b"%PDF-1.4\n1 0 obj<<>>\nendobj\nxref\n0 1\n0000000000 65535 f \ntrailer<<>>\nstartxref\n9\n%%EOF\n"
83
+
84
+
85
+ def _emit_dependency_warning(emitter: Any, message: str) -> None:
86
+ """Send a warning through the emitter or fall back to Python warnings."""
87
+ handled = False
88
+ if emitter is not None:
89
+ try:
90
+ emitter.warning(message)
91
+ handled = True
92
+ except Exception:
93
+ handled = False
94
+ if not handled:
95
+ warnings.warn(message, stacklevel=3)
96
+
97
+
98
+ def _playwright_dependency_hint() -> str:
99
+ packages = " ".join(_PLAYWRIGHT_APT_PACKAGES)
100
+ return (
101
+ "Install Playwright browser dependencies with `playwright install-deps` "
102
+ f"(Debian/Ubuntu: `sudo apt-get install {packages}`)."
103
+ )
104
+
105
+
106
+ def _cairo_dependency_hint() -> str:
107
+ return (
108
+ "CairoSVG requires the system cairo library (package: libcairo2). "
109
+ "Install it via your package manager to enable SVG conversion."
110
+ )
111
+
112
+
113
+ def _write_placeholder_pdf(target: Path) -> None:
114
+ """Write a minimal placeholder PDF when conversion cannot proceed."""
115
+ target.parent.mkdir(parents=True, exist_ok=True)
116
+ target.write_bytes(_PLACEHOLDER_PDF)
117
+
118
+
119
+ def _wrap_playwright_error(exc: Exception, emitter: Any = None) -> TransformerExecutionError:
120
+ """Return a structured error with guidance for missing Playwright deps."""
121
+ base_message = str(exc).strip() or exc.__class__.__name__
122
+ hint = f"{_playwright_dependency_hint()} {_PLAYWRIGHT_BACKEND_HINT}"
123
+ _emit_dependency_warning(emitter, hint)
124
+ message = f"Playwright backend failed: {base_message}. {hint}"
125
+ return TransformerExecutionError(message)
126
+
127
+
128
+ def _resolve_cli(names: Sequence[str], hints: Sequence[Path]) -> tuple[str | None, bool]:
129
+ """Return an executable path and whether it was discovered via $PATH."""
130
+ for name in names:
131
+ resolved = shutil.which(name)
132
+ if resolved:
133
+ return resolved, True
134
+ for candidate in hints:
135
+ if candidate and candidate.exists():
136
+ return str(candidate), False
137
+ return None, False
138
+
139
+
140
+ def _warn_add_to_path(command: str, path: str) -> None:
141
+ """Emit a guidance warning when a CLI was found outside $PATH."""
142
+ message = (
143
+ f"Found '{command}' at '{path}'. Add this directory to PATH so TeXSmith can "
144
+ "detect it automatically."
145
+ )
146
+ warnings.warn(message, stacklevel=3)
147
+
148
+
149
+ def _script_fallback_command(command: Sequence[str]) -> list[str] | None:
150
+ """Return a Windows-friendly command for script files without extensions."""
151
+ if os.name != "nt" or not command:
152
+ return None
153
+ executable = Path(command[0])
154
+ suffix = executable.suffix.lower()
155
+ if suffix in {".exe", ".bat", ".cmd", ".com"}:
156
+ return None
157
+ if not executable.exists():
158
+ return None
159
+ interpreter: str | None = None
160
+ try:
161
+ first_line = executable.read_text(encoding="utf-8", errors="ignore").splitlines()[0]
162
+ except Exception:
163
+ first_line = ""
164
+ if first_line.startswith("#!"):
165
+ shebang = first_line[2:].strip()
166
+ try:
167
+ parts = shlex.split(shebang)
168
+ except ValueError:
169
+ parts = [shebang]
170
+ if parts and parts[0] == "/usr/bin/env" and len(parts) > 1:
171
+ interpreter = parts[1]
172
+ elif parts:
173
+ interpreter = parts[0]
174
+ if interpreter is None:
175
+ interpreter = sys.executable
176
+ if shutil.which(interpreter) is None and interpreter != sys.executable:
177
+ interpreter = sys.executable
178
+ return [interpreter, str(executable), *command[1:]]
179
+
180
+
181
+ def _run_cli(command: list[str], *, cwd: Path, description: str) -> None:
182
+ """Execute a local CLI, raising a transformer error on failure."""
183
+
184
+ def _execute(cmd: list[str]) -> subprocess.CompletedProcess[str]:
185
+ return subprocess.run(
186
+ cmd,
187
+ check=False,
188
+ capture_output=True,
189
+ text=True,
190
+ cwd=cwd,
191
+ )
192
+
193
+ invoked = command
194
+ primary_error: OSError | None = None
195
+ try:
196
+ result = _execute(command)
197
+ except OSError as exc:
198
+ primary_error = exc
199
+ fallback = _script_fallback_command(command)
200
+ if fallback is None:
201
+ raise TransformerExecutionError(f"Failed to execute {description}: {exc}") from exc
202
+ invoked = fallback
203
+ try:
204
+ result = _execute(fallback)
205
+ except OSError as fallback_exc:
206
+ raise TransformerExecutionError(
207
+ f"Failed to execute {description}: {fallback_exc}"
208
+ ) from fallback_exc
209
+
210
+ if result.returncode != 0:
211
+ detail = (result.stderr or "").strip() or (result.stdout or "").strip()
212
+ message = f"{description} exited with status {result.returncode}"
213
+ if primary_error is not None and invoked is not command:
214
+ message = f"{message} (initial failure: {primary_error})"
215
+ if detail:
216
+ message = f"{message}: {detail}"
217
+ raise TransformerExecutionError(message)
218
+
219
+
220
+ def _compose_fallback_error(
221
+ tool: str, primary: Exception, fallback: Exception
222
+ ) -> TransformerExecutionError:
223
+ """Combine CLI and Docker failures into a single diagnostic."""
224
+ message = f"{tool} CLI failed: {primary}\nDocker fallback also failed: {fallback}"
225
+ return TransformerExecutionError(message)
226
+
227
+
228
+ class SvgToPdfStrategy(CachedConversionStrategy):
229
+ """Convert inline SVG payloads or files to PDF using CairoSVG."""
230
+
231
+ def __init__(self) -> None:
232
+ super().__init__("svg")
233
+
234
+ def _perform_conversion(
235
+ self,
236
+ source: Path | str,
237
+ *,
238
+ target: Path,
239
+ cache_dir: Path,
240
+ **options: Any,
241
+ ) -> Path:
242
+ emitter = options.get("emitter")
243
+ svg_text = _read_text(source)
244
+
245
+ try:
246
+ import cairosvg # type: ignore[import]
247
+ except ImportError as exc: # pragma: no cover - optional dependency
248
+ msg = (
249
+ "cairosvg is required to convert SVG assets. "
250
+ "Install 'cairosvg' or provide a custom converter."
251
+ )
252
+ raise TransformerExecutionError(msg) from exc
253
+
254
+ target.parent.mkdir(parents=True, exist_ok=True)
255
+ try:
256
+ cairosvg.svg2pdf(bytestring=svg_text.encode("utf-8"), write_to=str(target))
257
+ except OSError as exc:
258
+ hint = _cairo_dependency_hint()
259
+ _emit_dependency_warning(emitter, hint)
260
+ raise TransformerExecutionError(
261
+ f"Failed to render SVG with CairoSVG: {exc}. {hint}"
262
+ ) from exc
263
+ except Exception as exc:
264
+ raise TransformerExecutionError(f"Failed to render SVG with CairoSVG: {exc}") from exc
265
+ normalise_pdf_version(target)
266
+ return target
267
+
268
+
269
+ class ImageToPdfStrategy(CachedConversionStrategy):
270
+ """Convert bitmap images to PDF using Pillow."""
271
+
272
+ def __init__(self) -> None:
273
+ super().__init__("image")
274
+
275
+ def _perform_conversion(
276
+ self,
277
+ source: Path | str,
278
+ *,
279
+ target: Path,
280
+ cache_dir: Path,
281
+ **options: Any,
282
+ ) -> Path:
283
+ image_path = Path(source)
284
+ if not image_path.exists():
285
+ msg = f"Image file '{image_path}' does not exist"
286
+ raise TransformerExecutionError(msg)
287
+
288
+ try:
289
+ from PIL import Image # type: ignore[import]
290
+ except ImportError as exc: # pragma: no cover - optional dependency
291
+ msg = (
292
+ "Pillow is required to convert images. "
293
+ "Install 'Pillow' or provide a custom converter."
294
+ )
295
+ raise TransformerExecutionError(msg) from exc
296
+
297
+ with Image.open(image_path) as image:
298
+ pdf_ready = image.convert("RGB")
299
+ pdf_ready.save(target, "PDF")
300
+
301
+ normalise_pdf_version(target)
302
+
303
+ return target
304
+
305
+
306
+ class FetchImageStrategy(CachedConversionStrategy):
307
+ """Fetch a remote image, normalise it to PDF, and cache the result."""
308
+
309
+ _NATIVE_SUFFIXES: ClassVar[set[str]] = {".png", ".jpg", ".jpeg", ".pdf"}
310
+ _MIMETYPE_SUFFIXES: ClassVar[dict[str, str]] = {
311
+ "image/png": ".png",
312
+ "image/jpeg": ".jpg",
313
+ "image/jpg": ".jpg",
314
+ "image/pjpeg": ".jpg",
315
+ "image/svg+xml": ".svg",
316
+ "text/svg": ".svg",
317
+ "application/svg+xml": ".svg",
318
+ "image/gif": ".gif",
319
+ "image/bmp": ".bmp",
320
+ "application/pdf": ".pdf",
321
+ }
322
+
323
+ def __init__(self, timeout: float = 10.0) -> None:
324
+ super().__init__("fetch-image")
325
+ self.timeout = timeout
326
+
327
+ def output_suffix(self, source: Any, options: dict[str, Any]) -> str:
328
+ candidate = options.get("output_suffix")
329
+ if isinstance(candidate, str) and candidate.strip():
330
+ suffix = candidate.strip()
331
+ return suffix if suffix.startswith(".") else f".{suffix.lstrip('.')}"
332
+ return super().output_suffix(source, options)
333
+
334
+ def _perform_conversion(
335
+ self,
336
+ source: Path | str,
337
+ *,
338
+ target: Path,
339
+ cache_dir: Path,
340
+ **options: Any,
341
+ ) -> Path:
342
+ emitter = ensure_emitter(options.get("emitter"))
343
+ url = str(source)
344
+ convert_requested = bool(options.get("convert", True))
345
+ metadata: dict[str, str] | None = options.get("metadata")
346
+ if metadata is not None and not isinstance(metadata, dict):
347
+ metadata = None
348
+ manifest = options.get("manifest")
349
+ manifest = manifest if isinstance(manifest, dict) else {}
350
+ manifest_dirty = options.get("manifest_dirty")
351
+ if not isinstance(manifest_dirty, dict):
352
+ manifest_dirty = {"dirty": False}
353
+ cache_entry = manifest.get(url)
354
+ response: Any | None = None
355
+
356
+ try:
357
+ import requests # type: ignore[import]
358
+ except ImportError as exc: # pragma: no cover - optional dependency
359
+ msg = "requests is required to fetch remote images."
360
+ raise TransformerExecutionError(msg) from exc
361
+
362
+ user_agent = None
363
+ candidate = options.get("user_agent")
364
+ if isinstance(candidate, str) and candidate.strip():
365
+ user_agent = candidate.strip()
366
+ elif os.getenv("TEXSMITH_HTTP_USER_AGENT"):
367
+ user_agent = os.environ["TEXSMITH_HTTP_USER_AGENT"].strip()
368
+ else:
369
+ try:
370
+ version = importlib_metadata.version("texsmith")
371
+ except importlib_metadata.PackageNotFoundError:
372
+ version = "unknown"
373
+ user_agent = f"texsmith/{version}"
374
+
375
+ headers = {"User-Agent": user_agent}
376
+
377
+ reuse_reason = None
378
+ cached_path: Path | None = None
379
+ if isinstance(cache_entry, dict):
380
+ cached_path = self._existing_cached_path(cache_entry)
381
+ wiki_title = self._wikimedia_title(url)
382
+ if wiki_title and cached_path:
383
+ wiki_meta = self._fetch_wikimedia_metadata(wiki_title, user_agent)
384
+ if (
385
+ wiki_meta
386
+ and cache_entry.get("sha1")
387
+ and wiki_meta.get("sha1") == cache_entry.get("sha1")
388
+ ):
389
+ reuse_reason = "wikimedia"
390
+ cache_entry.update(wiki_meta)
391
+ if reuse_reason is None and cached_path:
392
+ conditional_headers = self._conditional_headers(cache_entry)
393
+ if conditional_headers:
394
+ try:
395
+ response = requests.get(
396
+ url, timeout=self.timeout, headers={**headers, **conditional_headers}
397
+ )
398
+ except requests.exceptions.RequestException:
399
+ response = None
400
+ if response is not None and response.status_code == 304:
401
+ reuse_reason = "etag"
402
+ cache_entry["etag"] = response.headers.get("ETag", cache_entry.get("etag"))
403
+ cache_entry["last_modified"] = response.headers.get(
404
+ "Last-Modified", cache_entry.get("last_modified")
405
+ )
406
+ elif response is not None and not response.ok:
407
+ response = None
408
+ else:
409
+ response = None
410
+
411
+ if reuse_reason and cached_path:
412
+ if metadata is not None and cache_entry:
413
+ if cache_entry.get("content_type"):
414
+ metadata["content_type"] = str(cache_entry["content_type"])
415
+ if cache_entry.get("suffix"):
416
+ metadata["suffix"] = str(cache_entry["suffix"])
417
+ manifest[url] = self._build_manifest_entry(
418
+ url,
419
+ cache_entry or {},
420
+ target_path=target,
421
+ content_type=cache_entry.get("content_type"),
422
+ suffix=cache_entry.get("suffix") or target.suffix or ".bin",
423
+ )
424
+ manifest_dirty["dirty"] = True
425
+ self._copy_cached_artifact(cached_path, target)
426
+ record_event(emitter, "asset_fetch_cached", {"url": url, "reason": reuse_reason})
427
+ return target
428
+
429
+ if response is None:
430
+ try:
431
+ response = requests.get(url, timeout=self.timeout, headers=headers)
432
+ except requests.exceptions.RequestException as exc: # pragma: no cover - network
433
+ msg = f"Failed to fetch image '{url}': {exc}"
434
+ raise TransformerExecutionError(msg) from exc
435
+
436
+ status_code = getattr(response, "status_code", 200)
437
+ if status_code == 304 and cached_path:
438
+ reuse_reason = "etag"
439
+ if metadata is not None and cache_entry:
440
+ if cache_entry.get("content_type"):
441
+ metadata["content_type"] = str(cache_entry["content_type"])
442
+ if cache_entry.get("suffix"):
443
+ metadata["suffix"] = str(cache_entry["suffix"])
444
+ manifest[url] = self._build_manifest_entry(
445
+ url,
446
+ cache_entry or {},
447
+ target_path=target,
448
+ content_type=cache_entry.get("content_type"),
449
+ suffix=cache_entry.get("suffix") or target.suffix or ".bin",
450
+ )
451
+ manifest_dirty["dirty"] = True
452
+ self._copy_cached_artifact(cached_path, target)
453
+ record_event(emitter, "asset_fetch_cached", {"url": url, "reason": reuse_reason})
454
+ return target
455
+
456
+ if not getattr(response, "ok", False):
457
+ raise TransformerExecutionError(f"Failed to fetch image '{url}': HTTP {status_code}")
458
+
459
+ content_type = response.headers.get("Content-Type", "")
460
+ mimetype = content_type.split(";", 1)[0].strip().lower()
461
+ suffix = self._suffix_from_request(url, mimetype)
462
+
463
+ native_supported = self._can_emit_native(suffix)
464
+ should_convert = not native_supported
465
+ if convert_requested and suffix.lower() != ".pdf":
466
+ should_convert = True
467
+ final_suffix = ".pdf" if should_convert else suffix or ".pdf"
468
+
469
+ wiki_meta: dict[str, Any] = {}
470
+ wiki_title = self._wikimedia_title(url)
471
+ if wiki_title:
472
+ wiki_meta = self._fetch_wikimedia_metadata(wiki_title, user_agent) or {}
473
+
474
+ if metadata is not None:
475
+ metadata["content_type"] = mimetype
476
+ metadata["suffix"] = final_suffix
477
+ manifest_extra = {
478
+ "etag": response.headers.get("ETag"),
479
+ "last_modified": response.headers.get("Last-Modified"),
480
+ "sha1": wiki_meta.get("sha1"),
481
+ "timestamp": wiki_meta.get("timestamp"),
482
+ "size": self._safe_int(response.headers.get("Content-Length")) or wiki_meta.get("size"),
483
+ }
484
+
485
+ if not should_convert:
486
+ target.parent.mkdir(parents=True, exist_ok=True)
487
+ target.write_bytes(response.content)
488
+ manifest[url] = self._build_manifest_entry(
489
+ url,
490
+ cache_entry or {},
491
+ target_path=target,
492
+ content_type=mimetype,
493
+ suffix=final_suffix,
494
+ extra=manifest_extra,
495
+ )
496
+ manifest_dirty["dirty"] = True
497
+ return target
498
+
499
+ if mimetype in ("image/svg+xml", "text/svg", "application/svg+xml"):
500
+ try:
501
+ import cairosvg # type: ignore[import]
502
+ except ImportError as exc: # pragma: no cover - optional dependency
503
+ msg = "cairosvg is required to convert remote SVG assets."
504
+ raise TransformerExecutionError(msg) from exc
505
+ try:
506
+ cairosvg.svg2pdf(bytestring=response.content, write_to=str(target))
507
+ except OSError as exc:
508
+ hint = _cairo_dependency_hint()
509
+ _emit_dependency_warning(emitter, hint)
510
+ raise TransformerExecutionError(
511
+ f"Failed to convert remote SVG '{url}': {exc}. {hint}"
512
+ ) from exc
513
+ except Exception as exc:
514
+ raise TransformerExecutionError(
515
+ f"Failed to convert remote SVG '{url}': {exc}"
516
+ ) from exc
517
+ normalise_pdf_version(target)
518
+ return target
519
+
520
+ try:
521
+ from PIL import Image # type: ignore[import]
522
+ except ImportError as exc: # pragma: no cover - optional dependency
523
+ msg = "Pillow is required to normalise remote images."
524
+ raise TransformerExecutionError(msg) from exc
525
+ try:
526
+ with Image.open(BytesIO(response.content)) as image:
527
+ pdf_ready = image.convert("RGB")
528
+ pdf_ready.save(target, "PDF")
529
+ except Exception as exc:
530
+ raise TransformerExecutionError(
531
+ f"Failed to convert remote image '{url}': {exc}"
532
+ ) from exc
533
+
534
+ normalise_pdf_version(target)
535
+ manifest[url] = self._build_manifest_entry(
536
+ url,
537
+ cache_entry or {},
538
+ target_path=target,
539
+ content_type=mimetype,
540
+ suffix=final_suffix,
541
+ extra=manifest_extra,
542
+ )
543
+ manifest_dirty["dirty"] = True
544
+
545
+ return target
546
+
547
+ def _suffix_from_request(self, url: str, mimetype: str) -> str:
548
+ parsed = urlparse(url)
549
+ suffix = Path(parsed.path or "").suffix.lower()
550
+ if suffix:
551
+ return suffix
552
+ return self._MIMETYPE_SUFFIXES.get(mimetype, "")
553
+
554
+ def _can_emit_native(self, suffix: str) -> bool:
555
+ lowered = suffix.lower()
556
+ if not lowered:
557
+ return False
558
+ return lowered in self._NATIVE_SUFFIXES
559
+
560
+ def _existing_cached_path(self, entry: Mapping[str, Any]) -> Path | None:
561
+ candidate = entry.get("path")
562
+ if isinstance(candidate, str):
563
+ path = Path(candidate)
564
+ if path.exists():
565
+ return path
566
+ return None
567
+
568
+ def _safe_int(self, value: Any) -> int | None:
569
+ try:
570
+ return int(value)
571
+ except Exception:
572
+ return None
573
+
574
+ def _conditional_headers(self, entry: Mapping[str, Any]) -> dict[str, str]:
575
+ headers: dict[str, str] = {}
576
+ etag = entry.get("etag")
577
+ last_modified = entry.get("last_modified")
578
+ if isinstance(etag, str) and etag.strip():
579
+ headers["If-None-Match"] = etag.strip()
580
+ if isinstance(last_modified, str) and last_modified.strip():
581
+ headers["If-Modified-Since"] = last_modified.strip()
582
+ return headers
583
+
584
+ def _copy_cached_artifact(self, source: Path, target: Path) -> None:
585
+ target.parent.mkdir(parents=True, exist_ok=True)
586
+ if source.resolve() == target.resolve():
587
+ return
588
+ target.write_bytes(source.read_bytes())
589
+
590
+ def _build_manifest_entry(
591
+ self,
592
+ url: str,
593
+ base: Mapping[str, Any],
594
+ *,
595
+ target_path: Path,
596
+ content_type: str | None,
597
+ suffix: str,
598
+ extra: Mapping[str, Any] | None = None,
599
+ ) -> dict[str, Any]:
600
+ entry = dict(base or {})
601
+ entry.update(
602
+ {
603
+ "url": url,
604
+ "path": str(target_path),
605
+ "content_type": content_type,
606
+ "suffix": suffix,
607
+ "checked_at": datetime.now(timezone.utc).isoformat(),
608
+ }
609
+ )
610
+ if extra:
611
+ entry.update({k: v for k, v in extra.items() if v is not None})
612
+ return entry
613
+
614
+ def _wikimedia_title(self, url: str) -> str | None:
615
+ parsed = urlparse(url)
616
+ if "wikimedia.org" not in parsed.netloc and "wikipedia.org" not in parsed.netloc:
617
+ return None
618
+ name = Path(parsed.path).name
619
+ if not name:
620
+ return None
621
+ return f"File:{unquote(name)}"
622
+
623
+ def _fetch_wikimedia_metadata(self, title: str, user_agent: str) -> dict[str, Any] | None:
624
+ try:
625
+ import requests # type: ignore[import]
626
+ except Exception:
627
+ return None
628
+ params = {
629
+ "action": "query",
630
+ "titles": title,
631
+ "prop": "imageinfo",
632
+ "iiprop": "timestamp|sha1|size|url",
633
+ "format": "json",
634
+ }
635
+ headers = {"User-Agent": user_agent}
636
+ try:
637
+ resp = requests.get(
638
+ "https://commons.wikimedia.org/w/api.php",
639
+ params=params,
640
+ headers=headers,
641
+ timeout=5,
642
+ )
643
+ except requests.exceptions.RequestException:
644
+ return None
645
+ if not resp.ok:
646
+ return None
647
+ try:
648
+ payload = resp.json()
649
+ except Exception:
650
+ return None
651
+ try:
652
+ page = next(iter(payload.get("query", {}).get("pages", {}).values()))
653
+ info = page.get("imageinfo", [])
654
+ if not info:
655
+ return None
656
+ entry = info[0]
657
+ return {
658
+ "sha1": entry.get("sha1"),
659
+ "timestamp": entry.get("timestamp"),
660
+ "size": entry.get("size"),
661
+ "source_url": entry.get("url"),
662
+ }
663
+ except Exception:
664
+ return None
665
+
666
+
667
+ class PdfMetadataStrategy:
668
+ """Inspect PDF files and expose structural metadata."""
669
+
670
+ def __call__(
671
+ self,
672
+ source: Path | str,
673
+ *,
674
+ output_dir: Path,
675
+ **options: Any,
676
+ ) -> dict[str, Any]:
677
+ pdf_path = Path(source)
678
+ if not pdf_path.exists():
679
+ msg = f"PDF file '{pdf_path}' does not exist"
680
+ raise TransformerExecutionError(msg)
681
+
682
+ try:
683
+ import pypdf # type: ignore[import]
684
+ except ImportError as exc: # pragma: no cover - optional dependency
685
+ msg = "pypdf is required to inspect PDF metadata."
686
+ raise TransformerExecutionError(msg) from exc
687
+
688
+ reader = pypdf.PdfReader(pdf_path)
689
+ pages: list[dict[str, Any]] = []
690
+ for page in reader.pages:
691
+ media_box = page.mediabox
692
+ pages.append(
693
+ {
694
+ "width": points_to_mm(float(media_box.width)),
695
+ "height": points_to_mm(float(media_box.height)),
696
+ }
697
+ )
698
+ return {"pages": pages}
699
+
700
+
701
+ class NotConfiguredStrategy:
702
+ """Strategy used to signal that a converter must be provided by the host."""
703
+
704
+ def __init__(self, name: str) -> None:
705
+ self.name = name
706
+
707
+ def __call__(
708
+ self,
709
+ source: Path | str,
710
+ *,
711
+ output_dir: Path,
712
+ **_: Any,
713
+ ):
714
+ msg = (
715
+ f"No converter configured for '{self.name}'. "
716
+ f"Register a strategy via 'register_converter(\"{self.name}\", strategy)'."
717
+ )
718
+ raise TransformerExecutionError(msg)
719
+
720
+
721
+ def _read_text(source: Path | str) -> str:
722
+ if isinstance(source, Path):
723
+ return source.read_text("utf-8")
724
+ if isinstance(source, str):
725
+ candidate = Path(source)
726
+ try:
727
+ if candidate.exists():
728
+ return candidate.read_text("utf-8")
729
+ except OSError:
730
+ return source
731
+ return source
732
+ return str(source)
733
+
734
+
735
+ def _texsmith_cache_root() -> Path:
736
+ return get_user_dir().cache_dir(create=False)
737
+
738
+
739
+ T = TypeVar("T")
740
+
741
+
742
+ class _PlaywrightWorker:
743
+ """Run Playwright sync API calls on an isolated thread."""
744
+
745
+ @classmethod
746
+ def run(cls, func: Callable[[], T]) -> T:
747
+ result: list[T] = []
748
+ error: list[BaseException] = []
749
+
750
+ def target() -> None:
751
+ try:
752
+ result.append(func())
753
+ except BaseException as exc: # pragma: no cover - pass through
754
+ error.append(exc)
755
+ finally:
756
+ with contextlib.suppress(Exception):
757
+ _PlaywrightManager._cleanup() # noqa: SLF001
758
+
759
+ thread = Thread(target=target, name="texsmith-playwright", daemon=True)
760
+ thread.start()
761
+ thread.join(timeout=120)
762
+ if thread.is_alive():
763
+ # Defensive: avoid hanging CI if Playwright download/launch stalls.
764
+ raise TransformerExecutionError("Playwright worker timed out after 120s")
765
+
766
+ if error:
767
+ raise error[0]
768
+ return result[0]
769
+
770
+
771
+ class _PlaywrightManager:
772
+ """Keep a shared Playwright browser alive across conversions."""
773
+
774
+ _playwright = None
775
+ _browser = None
776
+ _owner_thread_id: ClassVar[int | None] = None
777
+ _lock: ClassVar[Lock] = Lock()
778
+ _cleanup_registered = False
779
+
780
+ @classmethod
781
+ def ensure_browser(cls, *, emitter: Any = None) -> Any:
782
+ with cls._lock:
783
+ current_thread = threading.get_ident()
784
+ # Always recreate per call to avoid cross-thread greenlet issues.
785
+ cls._cleanup_unlocked()
786
+ try:
787
+ from playwright._impl._errors import Error as PlaywrightError
788
+ from playwright.sync_api import sync_playwright
789
+ except ModuleNotFoundError as exc: # pragma: no cover - optional dependency
790
+ raise TransformerExecutionError(
791
+ "Playwright backend requested but the 'playwright' package is not installed."
792
+ ) from exc
793
+
794
+ cache_root = _texsmith_cache_root() / "playwright"
795
+ browser_cache = cache_root / "browsers"
796
+ browser_cache.mkdir(parents=True, exist_ok=True)
797
+ # Playwright reads PLAYWRIGHT_BROWSERS_PATH during startup, so set it before start().
798
+ os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(browser_cache))
799
+ # Silence Node.js deprecation spew (e.g., url.parse) while staying compatible with Py3.10+.
800
+ existing_node_opts = os.environ.get("NODE_OPTIONS", "")
801
+ if "--no-deprecation" not in existing_node_opts:
802
+ merged_opts = (existing_node_opts + " --no-deprecation").strip()
803
+ os.environ["NODE_OPTIONS"] = merged_opts
804
+
805
+ try:
806
+ cls._playwright = sync_playwright().start()
807
+ except PlaywrightError as exc:
808
+ raise _wrap_playwright_error(exc, emitter) from exc
809
+
810
+ try:
811
+ cls._browser = cls._playwright.chromium.launch(headless=True)
812
+ except PlaywrightError as exc:
813
+ msg = str(exc)
814
+ if "Executable doesn't exist" in msg or "Failed to launch" in msg:
815
+ subprocess.run(
816
+ [sys.executable, "-m", "playwright", "install", "chromium"],
817
+ env=os.environ,
818
+ check=True,
819
+ )
820
+ cls._browser = cls._playwright.chromium.launch(headless=True)
821
+ else:
822
+ cls._playwright.stop()
823
+ cls._playwright = None
824
+ raise _wrap_playwright_error(exc, emitter) from exc
825
+ cls._owner_thread_id = current_thread
826
+ if not cls._cleanup_registered:
827
+ atexit.register(cls._cleanup)
828
+ cls._cleanup_registered = True
829
+ return cls._browser
830
+
831
+ @classmethod
832
+ def _cleanup(cls) -> None:
833
+ with cls._lock:
834
+ cls._cleanup_unlocked()
835
+
836
+ @classmethod
837
+ def _cleanup_unlocked(cls) -> None:
838
+ try:
839
+ if cls._browser is not None:
840
+ cls._browser.close()
841
+ except Exception:
842
+ pass
843
+ try:
844
+ if cls._playwright is not None:
845
+ cls._playwright.stop()
846
+ except Exception:
847
+ pass
848
+ cls._browser = None
849
+ cls._playwright = None
850
+ cls._owner_thread_id = None
851
+
852
+
853
+ class MermaidToPdfStrategy(CachedConversionStrategy):
854
+ """Render Mermaid diagrams to PDF using the official CLI image."""
855
+
856
+ def __init__(
857
+ self,
858
+ image: str = "minlag/mermaid-cli",
859
+ *,
860
+ default_theme: str = "neutral",
861
+ ) -> None:
862
+ super().__init__("mermaid")
863
+ self.image = image
864
+ self.default_theme = default_theme
865
+
866
+ def output_suffix(self, source: Any, options: dict[str, Any]) -> str:
867
+ fmt = str(options.get("format", "pdf") or "pdf").lower()
868
+ return ".png" if fmt == "png" else ".pdf"
869
+
870
+ def _perform_conversion(
871
+ self,
872
+ source: Path | str,
873
+ *,
874
+ target: Path,
875
+ cache_dir: Path,
876
+ **options: Any,
877
+ ) -> Path:
878
+ emitter = options.get("emitter")
879
+ backend = str(options.get("backend") or options.get("diagrams_backend") or "auto").lower()
880
+ format_opt = str(options.get("format", "pdf") or "pdf").lower()
881
+ working_dir = cache_dir / "mermaid"
882
+ working_dir.mkdir(parents=True, exist_ok=True)
883
+
884
+ content = _read_text(source)
885
+ input_name = options.get("input_name") or "diagram.mmd"
886
+ output_ext = ".png" if format_opt == "png" else ".pdf"
887
+ output_name = options.get("output_name") or f"diagram{output_ext}"
888
+ theme = options.get("theme", self.default_theme)
889
+ mermaid_config = options.get("mermaid_config")
890
+
891
+ input_path = working_dir / input_name
892
+ input_path.write_text(content, encoding="utf-8")
893
+
894
+ extra_args: list[str] = []
895
+ config_path = options.get("config_filename") or options.get("config_path")
896
+ if config_path:
897
+ config_data = Path(config_path).read_text("utf-8")
898
+ config_file = working_dir / "mermaid-config.json"
899
+ config_file.write_text(config_data, encoding="utf-8")
900
+ extra_args.extend(["-c", config_file.name])
901
+ if not config_path and _DEFAULT_MERMAID_CONFIG_PATH.exists():
902
+ extra_args.extend(["-c", str(_DEFAULT_MERMAID_CONFIG_PATH)])
903
+
904
+ if "backgroundColor" in options:
905
+ extra_args.extend(["-b", str(options["backgroundColor"])])
906
+
907
+ extra_args.extend(options.get("cli_args", []))
908
+
909
+ produced = working_dir / output_name
910
+ if produced.exists():
911
+ produced.unlink()
912
+
913
+ primary_error: TransformerExecutionError | None = None
914
+ if backend in {"playwright", "auto"}:
915
+ try:
916
+ self._run_playwright(
917
+ content,
918
+ target=produced,
919
+ format_opt=format_opt,
920
+ theme=theme,
921
+ mermaid_config=mermaid_config,
922
+ emitter=emitter,
923
+ )
924
+ except TransformerExecutionError as exc:
925
+ primary_error = exc
926
+ if backend == "playwright":
927
+ raise
928
+
929
+ cli_path, discovered_via_path = _resolve_cli(["mmdc"], MERMAID_CLI_HINT_PATHS)
930
+ cli_error: TransformerExecutionError | None = None
931
+ if backend in {"local", "auto"} and not produced.exists() and cli_path:
932
+ if not discovered_via_path:
933
+ _warn_add_to_path("mmdc", cli_path)
934
+ try:
935
+ self._run_local_cli(
936
+ cli_path,
937
+ working_dir=working_dir,
938
+ input_name=input_path.name,
939
+ output_name=output_name,
940
+ theme=theme,
941
+ extra_args=extra_args,
942
+ format_opt=format_opt,
943
+ )
944
+ except TransformerExecutionError as exc:
945
+ cli_error = exc
946
+ if backend == "local":
947
+ raise
948
+
949
+ docker_error: TransformerExecutionError | None = None
950
+ if backend in {"docker", "auto"} and not produced.exists():
951
+ if shutil.which("docker") is None:
952
+ docker_error = TransformerExecutionError("Docker is not available on this system.")
953
+ else:
954
+ docker_args = [
955
+ "-i",
956
+ input_path.name,
957
+ "-o",
958
+ output_name,
959
+ ]
960
+ if format_opt:
961
+ docker_args.extend(["-O", format_opt])
962
+ docker_args.extend(["-t", str(theme)])
963
+ docker_args.extend(extra_args)
964
+
965
+ try:
966
+ run_container(
967
+ self.image,
968
+ args=docker_args,
969
+ mounts=[VolumeMount(working_dir, "/data")],
970
+ environment={"HOME": "/data/home"},
971
+ workdir="/data",
972
+ limits=DockerLimits(cpus=1.0, memory="1g", pids_limit=512),
973
+ )
974
+ except TransformerExecutionError as exc:
975
+ docker_error = exc
976
+ if backend == "docker":
977
+ raise
978
+ if cli_error is not None:
979
+ raise _compose_fallback_error("Mermaid", cli_error, exc) from exc
980
+ if primary_error is not None:
981
+ raise _compose_fallback_error("Mermaid", primary_error, exc) from exc
982
+
983
+ if not produced.exists():
984
+ if cli_error and docker_error:
985
+ raise _compose_fallback_error("Mermaid", cli_error, docker_error)
986
+ if primary_error and cli_error:
987
+ raise _compose_fallback_error("Mermaid", primary_error, cli_error)
988
+ if primary_error and docker_error:
989
+ raise _compose_fallback_error("Mermaid", primary_error, docker_error)
990
+ if primary_error:
991
+ raise primary_error
992
+ raise TransformerExecutionError("Mermaid conversion did not produce the expected file.")
993
+
994
+ target.parent.mkdir(parents=True, exist_ok=True)
995
+ shutil.copy2(produced, target)
996
+ if target.suffix.lower() == ".pdf":
997
+ normalise_pdf_version(target)
998
+ return target
999
+
1000
+ def _run_local_cli(
1001
+ self,
1002
+ executable: str,
1003
+ *,
1004
+ working_dir: Path,
1005
+ input_name: str,
1006
+ output_name: str,
1007
+ theme: str,
1008
+ extra_args: list[str],
1009
+ format_opt: str,
1010
+ ) -> None:
1011
+ command = [
1012
+ executable,
1013
+ "-i",
1014
+ input_name,
1015
+ "-o",
1016
+ output_name,
1017
+ ]
1018
+ if format_opt:
1019
+ command.extend(["-O", format_opt])
1020
+ command.extend(["-t", str(theme)])
1021
+ command.extend(extra_args)
1022
+ _run_cli(command, cwd=working_dir, description="Mermaid CLI")
1023
+
1024
+ def _run_playwright(
1025
+ self,
1026
+ content: str,
1027
+ *,
1028
+ target: Path,
1029
+ format_opt: str,
1030
+ theme: str,
1031
+ mermaid_config: Any,
1032
+ emitter: Any = None,
1033
+ ) -> None:
1034
+ def task() -> None:
1035
+ try:
1036
+ from playwright._impl._errors import Error as PlaywrightError
1037
+ except Exception: # pragma: no cover - defensive import fallback
1038
+ PlaywrightError = Exception # noqa: N806
1039
+
1040
+ try:
1041
+ browser = _PlaywrightManager.ensure_browser(emitter=emitter)
1042
+ page = browser.new_page(viewport={"width": 2400, "height": 1800})
1043
+ page.set_content(
1044
+ """<!doctype html>
1045
+ <html><head><meta charset="utf-8" />
1046
+ <script src="https://unpkg.com/mermaid@11/dist/mermaid.min.js"></script>
1047
+ </head><body style="margin:0; background:white;"><div id="container"></div></body></html>""",
1048
+ wait_until="load",
1049
+ )
1050
+ resolved_config: dict[str, Any] = {}
1051
+
1052
+ if isinstance(mermaid_config, Mapping):
1053
+ resolved_config = dict(mermaid_config)
1054
+ elif isinstance(mermaid_config, str):
1055
+ cfg_path = Path(mermaid_config).expanduser()
1056
+ if cfg_path.exists():
1057
+ try:
1058
+ resolved_config = json.loads(cfg_path.read_text("utf-8"))
1059
+ except Exception:
1060
+ resolved_config = {}
1061
+
1062
+ if not resolved_config:
1063
+ resolved_config = (
1064
+ dict(_DEFAULT_MERMAID_CONFIG) if _DEFAULT_MERMAID_CONFIG else {}
1065
+ )
1066
+ resolved_config.setdefault("startOnLoad", False)
1067
+ resolved_config.setdefault("theme", theme)
1068
+ page.evaluate("cfg => { window.mermaid.initialize(cfg); }", resolved_config)
1069
+ svg = page.evaluate(
1070
+ """
1071
+ async (code) => {
1072
+ const result = await window.mermaid.render("theGraph", code);
1073
+ const header = '<?xml version="1.0" encoding="UTF-8"?>\\n';
1074
+ return header + result.svg;
1075
+ }
1076
+ """,
1077
+ content,
1078
+ )
1079
+ page.close()
1080
+ target.parent.mkdir(parents=True, exist_ok=True)
1081
+ if format_opt == "png":
1082
+ self._svg_to_png(browser, svg, target)
1083
+ else:
1084
+ self._svg_to_pdf(browser, svg, target)
1085
+ except PlaywrightError as exc:
1086
+ raise _wrap_playwright_error(exc, emitter) from exc
1087
+ except TransformerExecutionError:
1088
+ raise
1089
+ except Exception as exc:
1090
+ raise TransformerExecutionError(
1091
+ f"Mermaid Playwright backend failed: {exc}"
1092
+ ) from exc
1093
+
1094
+ _PlaywrightWorker.run(task)
1095
+
1096
+ def _svg_to_pdf(self, browser: Any, svg: str, target: Path) -> None:
1097
+ page = browser.new_page()
1098
+ page.set_content(f"<html><body style='margin:0; display:inline-block'>{svg}</body></html>")
1099
+ locator = page.locator("svg")
1100
+ box = locator.bounding_box()
1101
+ width = math.ceil(box["width"]) if box else 800
1102
+ height = math.ceil(box["height"]) if box else 600
1103
+
1104
+ scaled_width = math.ceil(width * SCALE)
1105
+ scaled_height = math.ceil(height * SCALE)
1106
+
1107
+ page.set_viewport_size({"width": scaled_width, "height": scaled_height})
1108
+ page.pdf(
1109
+ path=str(target),
1110
+ print_background=True,
1111
+ width=f"{width}px",
1112
+ height=f"{height}px",
1113
+ page_ranges="1",
1114
+ )
1115
+ page.close()
1116
+
1117
+ def _svg_to_png(self, browser: Any, svg: str, target: Path) -> None:
1118
+ page = browser.new_page(viewport={"width": 2400, "height": 1800})
1119
+ page.set_content(f"<html><body style='margin:0; display:inline-block'>{svg}</body></html>")
1120
+ locator = page.locator("svg")
1121
+ box = locator.bounding_box()
1122
+ screenshot_kwargs: dict[str, Any] = {"path": str(target)}
1123
+ if box:
1124
+ screenshot_kwargs["clip"] = {
1125
+ "x": box["x"],
1126
+ "y": box["y"],
1127
+ "width": box["width"],
1128
+ "height": box["height"],
1129
+ }
1130
+ page.screenshot(**screenshot_kwargs)
1131
+ page.close()
1132
+
1133
+
1134
+ class DrawioToPdfStrategy(CachedConversionStrategy):
1135
+ """Convert draw.io diagrams using selectable backends (playwright, local, docker)."""
1136
+
1137
+ def __init__(
1138
+ self,
1139
+ image: str = "rlespinasse/drawio-desktop-headless",
1140
+ ) -> None:
1141
+ super().__init__("drawio")
1142
+ self.image = image
1143
+ self.export_url = _EXPORT3_URL
1144
+
1145
+ def output_suffix(self, source: Any, options: dict[str, Any]) -> str:
1146
+ fmt = str(options.get("format", "pdf") or "pdf").lower()
1147
+ return ".png" if fmt == "png" else ".pdf"
1148
+
1149
+ def _perform_conversion(
1150
+ self,
1151
+ source: Path | str,
1152
+ *,
1153
+ target: Path,
1154
+ cache_dir: Path,
1155
+ **options: Any,
1156
+ ) -> Path:
1157
+ emitter = options.get("emitter")
1158
+ backend = str(options.get("backend") or options.get("diagrams_backend") or "auto").lower()
1159
+ format_opt = str(options.get("format", "pdf") or "pdf").lower()
1160
+ theme = str(options.get("theme", "auto") or "auto")
1161
+
1162
+ source_path = Path(source)
1163
+ if not source_path.exists():
1164
+ raise TransformerExecutionError(f"Draw.io file '{source_path}' does not exist.")
1165
+
1166
+ working_dir = cache_dir / "drawio" / target.stem
1167
+ if working_dir.exists():
1168
+ shutil.rmtree(working_dir, ignore_errors=True)
1169
+ if working_dir.exists():
1170
+ working_dir = cache_dir / "drawio" / f"{target.stem}-{os.getpid()}"
1171
+ working_dir.mkdir(parents=True, exist_ok=True)
1172
+ home_dir = working_dir / "home"
1173
+ home_dir.mkdir(parents=True, exist_ok=True)
1174
+
1175
+ diagram_name = source_path.name
1176
+ working_source = working_dir / diagram_name
1177
+ shutil.copy2(source_path, working_source)
1178
+
1179
+ output_ext = ".png" if format_opt == "png" else ".pdf"
1180
+ output_name = options.get("output_name") or f"{working_source.stem}{output_ext}"
1181
+ produced = working_dir / output_name
1182
+ if produced.exists():
1183
+ produced.unlink()
1184
+
1185
+ primary_error: TransformerExecutionError | None = None
1186
+ if backend in {"playwright", "auto"}:
1187
+ try:
1188
+ self._run_playwright(
1189
+ working_source,
1190
+ target=produced,
1191
+ cache_dir=cache_dir,
1192
+ format_opt=format_opt,
1193
+ theme=theme,
1194
+ emitter=emitter,
1195
+ )
1196
+ except TransformerExecutionError as exc:
1197
+ primary_error = exc
1198
+ if backend == "playwright":
1199
+ raise
1200
+ cli_path, discovered_via_path = _resolve_cli(["drawio", "draw.io"], DRAWIO_CLI_HINT_PATHS)
1201
+ cli_error: TransformerExecutionError | None = None
1202
+ if backend in {"local", "auto"} and not produced.exists() and cli_path:
1203
+ if not discovered_via_path:
1204
+ _warn_add_to_path("drawio", cli_path)
1205
+ try:
1206
+ self._run_local_cli(
1207
+ cli_path,
1208
+ working_dir=working_dir,
1209
+ source_name=working_source.name,
1210
+ output_name=output_name,
1211
+ options=options | {"format": format_opt},
1212
+ )
1213
+ except TransformerExecutionError as exc:
1214
+ cli_error = exc
1215
+ if backend == "local":
1216
+ raise
1217
+
1218
+ docker_error: TransformerExecutionError | None = None
1219
+ if backend in {"docker", "auto"} and not produced.exists():
1220
+ if shutil.which("docker") is None:
1221
+ docker_error = TransformerExecutionError("Docker is not available on this system.")
1222
+ else:
1223
+ mounts: list[VolumeMount] = [VolumeMount(working_dir, "/data")]
1224
+ passwd_path = Path("/etc/passwd")
1225
+ group_path = Path("/etc/group")
1226
+ if passwd_path.exists():
1227
+ mounts.append(VolumeMount(passwd_path, "/etc/passwd", read_only=True))
1228
+ if group_path.exists():
1229
+ mounts.append(VolumeMount(group_path, "/etc/group", read_only=True))
1230
+ docker_args = [
1231
+ "--export",
1232
+ "--format",
1233
+ format_opt,
1234
+ "--output",
1235
+ ".",
1236
+ ]
1237
+
1238
+ if options.get("crop", False):
1239
+ docker_args.extend(["--crop"])
1240
+
1241
+ dpi = options.get("dpi")
1242
+ if dpi:
1243
+ docker_args.extend(["--quality", str(dpi)])
1244
+
1245
+ docker_args.append(working_source.name)
1246
+
1247
+ try:
1248
+ run_container(
1249
+ self.image,
1250
+ args=docker_args,
1251
+ mounts=mounts,
1252
+ environment={
1253
+ "HOME": "/data/home",
1254
+ "XDG_CACHE_HOME": "/data/home/.cache",
1255
+ "XDG_CONFIG_HOME": "/data/home/.config",
1256
+ },
1257
+ workdir="/data",
1258
+ use_host_user=True,
1259
+ limits=DockerLimits(cpus=1.0, memory="1g", pids_limit=512),
1260
+ )
1261
+ except TransformerExecutionError as exc:
1262
+ docker_error = exc
1263
+ if backend == "docker":
1264
+ raise
1265
+ if cli_error is not None:
1266
+ raise _compose_fallback_error("draw.io", cli_error, exc) from exc
1267
+ if primary_error is not None:
1268
+ raise _compose_fallback_error("draw.io", primary_error, exc) from exc
1269
+
1270
+ if not produced.exists():
1271
+ if cli_error and docker_error:
1272
+ raise _compose_fallback_error("draw.io", cli_error, docker_error)
1273
+ if primary_error and cli_error:
1274
+ raise _compose_fallback_error("draw.io", primary_error, cli_error)
1275
+ if primary_error and docker_error:
1276
+ raise _compose_fallback_error("draw.io", primary_error, docker_error)
1277
+ if primary_error:
1278
+ raise primary_error
1279
+ raise TransformerExecutionError("draw.io conversion did not produce the expected file.")
1280
+
1281
+ target.parent.mkdir(parents=True, exist_ok=True)
1282
+ shutil.copy2(produced, target)
1283
+ if target.suffix.lower() == ".pdf":
1284
+ normalise_pdf_version(target)
1285
+ return target
1286
+
1287
+ def _run_local_cli(
1288
+ self,
1289
+ executable: str,
1290
+ *,
1291
+ working_dir: Path,
1292
+ source_name: str,
1293
+ output_name: str,
1294
+ options: dict[str, Any],
1295
+ ) -> None:
1296
+ fmt = str(options.get("format", "pdf") or "pdf").lower()
1297
+ command = [
1298
+ executable,
1299
+ "--export",
1300
+ source_name,
1301
+ "--format",
1302
+ fmt,
1303
+ "--output",
1304
+ output_name,
1305
+ ]
1306
+
1307
+ if options.get("crop", False):
1308
+ command.append("--crop")
1309
+
1310
+ dpi = options.get("dpi")
1311
+ if dpi:
1312
+ command.extend(["--quality", str(dpi)])
1313
+
1314
+ _run_cli(command, cwd=working_dir, description="draw.io CLI")
1315
+
1316
+ def _run_playwright(
1317
+ self,
1318
+ source: Path,
1319
+ *,
1320
+ target: Path,
1321
+ cache_dir: Path,
1322
+ format_opt: str,
1323
+ theme: str,
1324
+ emitter: Any = None,
1325
+ ) -> None:
1326
+ def task() -> None:
1327
+ try:
1328
+ from playwright._impl._errors import Error as PlaywrightError
1329
+ except Exception: # pragma: no cover - defensive import fallback
1330
+ PlaywrightError = Exception # noqa: N806
1331
+
1332
+ try:
1333
+ cache_root = _texsmith_cache_root() / "playwright"
1334
+ browser_cache = cache_root / "browsers"
1335
+ browser_cache.mkdir(parents=True, exist_ok=True)
1336
+ os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(browser_cache))
1337
+
1338
+ export_page = cache_root / "export3.html"
1339
+ export_url = None
1340
+ if not export_page.exists():
1341
+ try:
1342
+ with urlopen(self.export_url) as response:
1343
+ export_page.write_bytes(response.read())
1344
+ except Exception:
1345
+ export_url = self.export_url
1346
+
1347
+ xml = source.read_text(encoding="utf-8")
1348
+ browser = _PlaywrightManager.ensure_browser(emitter=emitter)
1349
+ page = browser.new_page(viewport={"width": 2400, "height": 1800})
1350
+ page.goto(export_page.as_uri() if export_url is None else export_url)
1351
+ page.wait_for_function("() => typeof window.render === 'function'", timeout=30_000)
1352
+ page.evaluate(
1353
+ """
1354
+ () => {
1355
+ const orig = window.render;
1356
+ window.render = function(data) {
1357
+ const g = orig.call(window, data);
1358
+ window.__lastGraph = g;
1359
+ window.__lastData = data;
1360
+ return g;
1361
+ };
1362
+ }
1363
+ """
1364
+ )
1365
+ payload = {
1366
+ "xml": xml,
1367
+ "format": "svg",
1368
+ "border": 0,
1369
+ "scale": 1,
1370
+ "w": 0,
1371
+ "h": 0,
1372
+ "extras": "{}",
1373
+ "embedXml": "1",
1374
+ "embedImages": "1",
1375
+ "embedFonts": "1",
1376
+ "shadows": "1",
1377
+ "theme": theme,
1378
+ }
1379
+ page.evaluate("data => window.render(data)", payload)
1380
+ page.wait_for_selector("#LoadingComplete", state="attached", timeout=60_000)
1381
+ svg = page.evaluate(
1382
+ """
1383
+ () => {
1384
+ const graph = window.__lastGraph;
1385
+ const data = window.__lastData || {};
1386
+ const done = document.getElementById('LoadingComplete');
1387
+ const scale = done ? parseFloat(done.getAttribute('scale')) || graph.view.scale || 1 : graph.view.scale || 1;
1388
+ let bg = graph.background;
1389
+ if (bg === mxConstants.NONE) bg = null;
1390
+
1391
+ const svgRoot = graph.getSvg(bg, scale, data.border || 0, false, null,
1392
+ true, null, null, null, null, null, data.theme || 'auto');
1393
+
1394
+ if (data.embedXml === '1') {
1395
+ svgRoot.setAttribute('content', data.xml);
1396
+ }
1397
+
1398
+ const header = (Graph.xmlDeclaration || '') + '\\n' +
1399
+ (Graph.svgDoctype || '') + '\\n' +
1400
+ (Graph.svgFileComment || '');
1401
+ return header + '\\n' + mxUtils.getXml(svgRoot);
1402
+ }
1403
+ """
1404
+ )
1405
+ page.close()
1406
+ target.parent.mkdir(parents=True, exist_ok=True)
1407
+ if format_opt == "png":
1408
+ self._svg_to_png(browser, svg, target)
1409
+ else:
1410
+ self._svg_to_pdf(browser, svg, target)
1411
+ except PlaywrightError as exc:
1412
+ raise _wrap_playwright_error(exc, emitter) from exc
1413
+ except TransformerExecutionError:
1414
+ raise
1415
+ except Exception as exc:
1416
+ raise TransformerExecutionError(
1417
+ f"draw.io Playwright backend failed: {exc}"
1418
+ ) from exc
1419
+
1420
+ _PlaywrightWorker.run(task)
1421
+
1422
+ def _svg_to_pdf(self, browser: Any, svg: str, target: Path) -> None:
1423
+ page = browser.new_page()
1424
+ page.set_content(f"<html><body style='margin:0; display:inline-block'>{svg}</body></html>")
1425
+ locator = page.locator("svg")
1426
+ box = locator.bounding_box()
1427
+ width = math.ceil(box["width"]) if box else 800
1428
+ height = math.ceil(box["height"]) if box else 600
1429
+ page.set_viewport_size({"width": width, "height": height})
1430
+ page.pdf(
1431
+ path=str(target),
1432
+ print_background=True,
1433
+ width=f"{width}px",
1434
+ height=f"{height}px",
1435
+ page_ranges="1",
1436
+ )
1437
+ page.close()
1438
+
1439
+ def _svg_to_png(self, browser: Any, svg: str, target: Path) -> None:
1440
+ page = browser.new_page(viewport={"width": 2400, "height": 1800})
1441
+ page.set_content(f"<html><body style='margin:0; display:inline-block'>{svg}</body></html>")
1442
+ locator = page.locator("svg")
1443
+ box = locator.bounding_box()
1444
+ if box:
1445
+ page.screenshot(
1446
+ path=str(target),
1447
+ clip={
1448
+ "x": box["x"],
1449
+ "y": box["y"],
1450
+ "width": box["width"],
1451
+ "height": box["height"],
1452
+ },
1453
+ )
1454
+ else:
1455
+ page.screenshot(path=str(target))
1456
+ page.close()