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.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- 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()
|