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,363 @@
|
|
|
1
|
+
"""Asset helpers shared across media and block handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import unquote, urlparse
|
|
11
|
+
|
|
12
|
+
from texsmith.adapters.transformers import (
|
|
13
|
+
drawio2pdf,
|
|
14
|
+
fetch_image,
|
|
15
|
+
image2pdf,
|
|
16
|
+
mermaid2pdf,
|
|
17
|
+
svg2pdf,
|
|
18
|
+
)
|
|
19
|
+
from texsmith.adapters.transformers.strategies import _cairo_dependency_hint
|
|
20
|
+
from texsmith.core.context import RenderContext
|
|
21
|
+
from texsmith.core.conversion.debug import ensure_emitter, record_event
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_NATIVE_IMAGE_SUFFIXES: set[str] = {".png", ".jpg", ".jpeg", ".pdf"}
|
|
25
|
+
_FORCED_CONVERSION_SUFFIXES: set[str] = {".svg", ".drawio"}
|
|
26
|
+
_CONVERSION_CACHE_DIR = ".converted"
|
|
27
|
+
_ASSET_MANIFEST = "remote-assets.json"
|
|
28
|
+
_PLACEHOLDER_PDF = b"%PDF-1.4\n1 0 obj<<>>\nendobj\nxref\n0 1\n0000000000 65535 f \ntrailer<<>>\nstartxref\n9\n%%EOF\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def store_local_image_asset(context: RenderContext, resolved: Path) -> Path:
|
|
32
|
+
"""Copy or convert a local asset and register it on the context."""
|
|
33
|
+
asset_key = str(resolved)
|
|
34
|
+
existing = context.assets.lookup(asset_key)
|
|
35
|
+
if existing is not None:
|
|
36
|
+
return existing
|
|
37
|
+
|
|
38
|
+
suffix = resolved.suffix.lower()
|
|
39
|
+
convert_requested = bool(context.runtime.get("convert_assets", False))
|
|
40
|
+
needs_conversion = _requires_conversion(suffix, convert_requested)
|
|
41
|
+
emitter = ensure_emitter(context.runtime.get("emitter"))
|
|
42
|
+
|
|
43
|
+
if needs_conversion:
|
|
44
|
+
record_event(
|
|
45
|
+
emitter,
|
|
46
|
+
"asset_convert",
|
|
47
|
+
{
|
|
48
|
+
"source": str(resolved),
|
|
49
|
+
"suffix": suffix,
|
|
50
|
+
"reason": "requested" if convert_requested else "forced",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
staged = _convert_local_asset(context, resolved, suffix)
|
|
54
|
+
final_suffix = ".pdf"
|
|
55
|
+
else:
|
|
56
|
+
staged = resolved
|
|
57
|
+
final_suffix = suffix or ".bin"
|
|
58
|
+
|
|
59
|
+
record_event(
|
|
60
|
+
emitter,
|
|
61
|
+
"asset_local",
|
|
62
|
+
{
|
|
63
|
+
"source": str(resolved),
|
|
64
|
+
"stored_suffix": final_suffix,
|
|
65
|
+
"converted": needs_conversion,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
return _persist_asset(
|
|
69
|
+
context,
|
|
70
|
+
asset_key=asset_key,
|
|
71
|
+
staged_path=staged,
|
|
72
|
+
suffix=final_suffix,
|
|
73
|
+
source_path=resolved,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def store_remote_image_asset(context: RenderContext, url: str) -> Path:
|
|
78
|
+
"""Fetch a remote asset, mirror it locally, and register it."""
|
|
79
|
+
existing = context.assets.lookup(url)
|
|
80
|
+
if existing is not None:
|
|
81
|
+
return existing
|
|
82
|
+
|
|
83
|
+
convert_requested = bool(context.runtime.get("convert_assets", False))
|
|
84
|
+
metadata: dict[str, str] = {}
|
|
85
|
+
conversion_root = _conversion_cache_root(context)
|
|
86
|
+
manifest_path = conversion_root / _ASSET_MANIFEST
|
|
87
|
+
manifest, manifest_dirty = _load_asset_manifest(manifest_path)
|
|
88
|
+
url_suffix = _suffix_from_url(url)
|
|
89
|
+
suffix_hint = _normalise_suffix(url_suffix, default="") if url_suffix else ""
|
|
90
|
+
emitter = ensure_emitter(context.runtime.get("emitter"))
|
|
91
|
+
|
|
92
|
+
record_event(
|
|
93
|
+
emitter,
|
|
94
|
+
"asset_fetch",
|
|
95
|
+
{
|
|
96
|
+
"url": url,
|
|
97
|
+
"convert": convert_requested,
|
|
98
|
+
"suffix_hint": suffix_hint,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
fetch_options: dict[str, Any] = {
|
|
102
|
+
"convert": convert_requested,
|
|
103
|
+
"metadata": metadata,
|
|
104
|
+
"manifest": manifest,
|
|
105
|
+
"manifest_path": manifest_path,
|
|
106
|
+
"manifest_dirty": manifest_dirty,
|
|
107
|
+
"emitter": emitter,
|
|
108
|
+
}
|
|
109
|
+
if convert_requested:
|
|
110
|
+
fetch_options["output_suffix"] = ".pdf"
|
|
111
|
+
elif suffix_hint:
|
|
112
|
+
fetch_options["output_suffix"] = suffix_hint
|
|
113
|
+
artefact = fetch_image(
|
|
114
|
+
url,
|
|
115
|
+
output_dir=conversion_root,
|
|
116
|
+
**fetch_options,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
recorded_suffix = metadata.get("suffix") if metadata else None
|
|
120
|
+
if not recorded_suffix:
|
|
121
|
+
detected_suffix = _detect_file_suffix(Path(artefact))
|
|
122
|
+
if detected_suffix:
|
|
123
|
+
recorded_suffix = detected_suffix
|
|
124
|
+
if recorded_suffix:
|
|
125
|
+
final_suffix = _normalise_suffix(recorded_suffix)
|
|
126
|
+
else:
|
|
127
|
+
final_suffix = Path(artefact).suffix or ".pdf"
|
|
128
|
+
prefer_name = _extract_remote_name(url)
|
|
129
|
+
|
|
130
|
+
record_event(
|
|
131
|
+
emitter,
|
|
132
|
+
"asset_fetch_complete",
|
|
133
|
+
{
|
|
134
|
+
"url": url,
|
|
135
|
+
"stored_suffix": final_suffix,
|
|
136
|
+
"converted": convert_requested or suffix_hint in _FORCED_CONVERSION_SUFFIXES,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
if manifest_dirty["dirty"]:
|
|
140
|
+
_save_asset_manifest(manifest_path, manifest)
|
|
141
|
+
return _persist_asset(
|
|
142
|
+
context,
|
|
143
|
+
asset_key=url,
|
|
144
|
+
staged_path=artefact,
|
|
145
|
+
suffix=final_suffix,
|
|
146
|
+
source_path=None,
|
|
147
|
+
prefer_name=prefer_name,
|
|
148
|
+
force_hash=not bool(prefer_name),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _requires_conversion(suffix: str, convert_requested: bool) -> bool:
|
|
153
|
+
lowered = suffix.lower()
|
|
154
|
+
if lowered == ".pdf":
|
|
155
|
+
return False
|
|
156
|
+
if lowered in _FORCED_CONVERSION_SUFFIXES:
|
|
157
|
+
return True
|
|
158
|
+
if lowered not in _NATIVE_IMAGE_SUFFIXES:
|
|
159
|
+
return True
|
|
160
|
+
return convert_requested
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _write_placeholder_pdf(target: Path) -> Path:
|
|
164
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
target.write_bytes(_PLACEHOLDER_PDF)
|
|
166
|
+
return target
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _convert_local_asset(context: RenderContext, source: Path, suffix: str) -> Path:
|
|
170
|
+
conversion_root = _conversion_cache_root(context)
|
|
171
|
+
emitter = ensure_emitter(context.runtime.get("emitter"))
|
|
172
|
+
match suffix:
|
|
173
|
+
case ".svg":
|
|
174
|
+
record_event(emitter, "diagram_generate", {"source": str(source), "kind": "svg"})
|
|
175
|
+
try:
|
|
176
|
+
return svg2pdf(source, output_dir=conversion_root, emitter=emitter)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
emitter.warning(
|
|
179
|
+
f"Falling back to placeholder PDF for SVG '{source}': {exc}. "
|
|
180
|
+
f"{_cairo_dependency_hint()}"
|
|
181
|
+
)
|
|
182
|
+
placeholder = conversion_root / f"{source.stem}.pdf"
|
|
183
|
+
return _write_placeholder_pdf(placeholder)
|
|
184
|
+
case ".drawio":
|
|
185
|
+
record_event(emitter, "diagram_generate", {"source": str(source), "kind": "drawio"})
|
|
186
|
+
emit_info = getattr(emitter, "info", None)
|
|
187
|
+
if callable(emit_info):
|
|
188
|
+
emit_info(f"Converting draw.io diagram: {source}")
|
|
189
|
+
backend = context.runtime.get("diagrams_backend")
|
|
190
|
+
return drawio2pdf(source, output_dir=conversion_root, backend=backend, emitter=emitter)
|
|
191
|
+
case ".mmd" | ".mermaid":
|
|
192
|
+
record_event(emitter, "diagram_generate", {"source": str(source), "kind": "mermaid"})
|
|
193
|
+
emit_info = getattr(emitter, "info", None)
|
|
194
|
+
if callable(emit_info):
|
|
195
|
+
emit_info(f"Converting Mermaid diagram: {source}")
|
|
196
|
+
backend = context.runtime.get("diagrams_backend")
|
|
197
|
+
mermaid_config = context.runtime.get("mermaid_config")
|
|
198
|
+
return mermaid2pdf(
|
|
199
|
+
source,
|
|
200
|
+
output_dir=conversion_root,
|
|
201
|
+
backend=backend,
|
|
202
|
+
mermaid_config=mermaid_config,
|
|
203
|
+
emitter=emitter,
|
|
204
|
+
)
|
|
205
|
+
case _:
|
|
206
|
+
record_event(emitter, "asset_convert", {"source": str(source), "kind": "image"})
|
|
207
|
+
return image2pdf(source, output_dir=conversion_root, emitter=emitter)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _conversion_cache_root(context: RenderContext) -> Path:
|
|
211
|
+
root = context.assets.output_root / _CONVERSION_CACHE_DIR
|
|
212
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
return root
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_asset_manifest(path: Path) -> tuple[dict[str, dict[str, Any]], dict[str, bool]]:
|
|
217
|
+
if path.exists():
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
220
|
+
if isinstance(data, dict):
|
|
221
|
+
return {str(k): dict(v) for k, v in data.items()}, {"dirty": False}
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
return {}, {"dirty": False}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _save_asset_manifest(path: Path, manifest: dict[str, dict[str, Any]]) -> None:
|
|
228
|
+
try:
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _persist_asset(
|
|
236
|
+
context: RenderContext,
|
|
237
|
+
*,
|
|
238
|
+
asset_key: str,
|
|
239
|
+
staged_path: Path,
|
|
240
|
+
suffix: str,
|
|
241
|
+
source_path: Path | None = None,
|
|
242
|
+
prefer_name: str | None = None,
|
|
243
|
+
force_hash: bool = False,
|
|
244
|
+
) -> Path:
|
|
245
|
+
target = _determine_target_path(
|
|
246
|
+
context,
|
|
247
|
+
asset_key=asset_key,
|
|
248
|
+
suffix=suffix,
|
|
249
|
+
source_path=source_path,
|
|
250
|
+
prefer_name=prefer_name,
|
|
251
|
+
force_hash=force_hash,
|
|
252
|
+
)
|
|
253
|
+
staged = Path(staged_path)
|
|
254
|
+
if staged.resolve() != target.resolve():
|
|
255
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
shutil.copy2(staged, target)
|
|
257
|
+
else:
|
|
258
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
return context.assets.register(asset_key, target)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _determine_target_path(
|
|
263
|
+
context: RenderContext,
|
|
264
|
+
*,
|
|
265
|
+
asset_key: str,
|
|
266
|
+
suffix: str,
|
|
267
|
+
source_path: Path | None,
|
|
268
|
+
prefer_name: str | None,
|
|
269
|
+
force_hash: bool,
|
|
270
|
+
) -> Path:
|
|
271
|
+
hash_policy = force_hash or bool(context.runtime.get("hash_assets", False))
|
|
272
|
+
base = context.assets.output_root
|
|
273
|
+
|
|
274
|
+
if not hash_policy:
|
|
275
|
+
candidate = None
|
|
276
|
+
if source_path is not None:
|
|
277
|
+
candidate = _relative_path_for_asset(context, source_path)
|
|
278
|
+
if candidate is None and prefer_name:
|
|
279
|
+
candidate = Path(prefer_name)
|
|
280
|
+
if candidate is None and source_path is not None:
|
|
281
|
+
candidate = Path(source_path.name)
|
|
282
|
+
if candidate is not None:
|
|
283
|
+
adjusted = candidate.with_suffix(suffix)
|
|
284
|
+
candidate_path = (base / adjusted).resolve()
|
|
285
|
+
if not _candidate_conflicts(context, candidate_path, asset_key):
|
|
286
|
+
return candidate_path
|
|
287
|
+
|
|
288
|
+
digest = hashlib.sha256(asset_key.encode("utf-8")).hexdigest()
|
|
289
|
+
filename = f"{digest}{suffix}"
|
|
290
|
+
return (base / filename).resolve()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _candidate_conflicts(context: RenderContext, candidate: Path, asset_key: str) -> bool:
|
|
294
|
+
candidate_resolved = candidate.resolve()
|
|
295
|
+
for existing_key, stored in context.assets.assets_map.items():
|
|
296
|
+
stored_path = Path(stored).resolve()
|
|
297
|
+
if stored_path == candidate_resolved:
|
|
298
|
+
return existing_key != asset_key
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _relative_path_for_asset(context: RenderContext, source: Path) -> Path | None:
|
|
303
|
+
project_dir = getattr(context.config, "project_dir", None)
|
|
304
|
+
if project_dir:
|
|
305
|
+
try:
|
|
306
|
+
return source.relative_to(Path(project_dir))
|
|
307
|
+
except ValueError:
|
|
308
|
+
pass
|
|
309
|
+
document_path = context.runtime.get("document_path")
|
|
310
|
+
if document_path:
|
|
311
|
+
try:
|
|
312
|
+
return source.relative_to(Path(document_path).parent)
|
|
313
|
+
except ValueError:
|
|
314
|
+
pass
|
|
315
|
+
source_dir = context.runtime.get("source_dir")
|
|
316
|
+
if source_dir:
|
|
317
|
+
try:
|
|
318
|
+
return source.relative_to(Path(source_dir))
|
|
319
|
+
except ValueError:
|
|
320
|
+
pass
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _extract_remote_name(url: str) -> str | None:
|
|
325
|
+
parsed = urlparse(url)
|
|
326
|
+
name = Path(unquote(parsed.path or "")).name
|
|
327
|
+
return name or None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _suffix_from_url(url: str) -> str:
|
|
331
|
+
parsed = urlparse(url)
|
|
332
|
+
return Path(parsed.path or "").suffix.lower()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _normalise_suffix(value: str | None, default: str = ".bin") -> str:
|
|
336
|
+
candidate = (value or "").strip()
|
|
337
|
+
if not candidate:
|
|
338
|
+
return default
|
|
339
|
+
return candidate if candidate.startswith(".") else f".{candidate.lstrip('.')}"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _detect_file_suffix(path: Path) -> str | None:
|
|
343
|
+
try:
|
|
344
|
+
with path.open("rb") as handle:
|
|
345
|
+
header = handle.read(12)
|
|
346
|
+
except OSError:
|
|
347
|
+
return None
|
|
348
|
+
if header.startswith(b"%PDF"):
|
|
349
|
+
return ".pdf"
|
|
350
|
+
if header.startswith(b"\xff\xd8\xff"):
|
|
351
|
+
return ".jpg"
|
|
352
|
+
if header.startswith(b"\x89PNG"):
|
|
353
|
+
return ".png"
|
|
354
|
+
if header.startswith(b"GIF8"):
|
|
355
|
+
return ".gif"
|
|
356
|
+
if header.startswith(b"BM"):
|
|
357
|
+
return ".bmp"
|
|
358
|
+
if header[0:4] == b"RIFF" and header[8:12] == b"WEBP":
|
|
359
|
+
return ".webp"
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = ["store_local_image_asset", "store_remote_image_asset"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Internal helpers shared across handler modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TypeVar, cast
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from bs4.element import PageElement
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NodeT = TypeVar("NodeT", bound=PageElement)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def mark_processed(node: NodeT) -> NodeT:
|
|
17
|
+
"""Mark a BeautifulSoup node as processed and return it for chaining."""
|
|
18
|
+
cast(Any, node).processed = True # type: ignore[attr-defined]
|
|
19
|
+
return node
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def coerce_attribute(value: Any) -> str | None:
|
|
23
|
+
"""Normalise a BeautifulSoup attribute value to a string when possible."""
|
|
24
|
+
if isinstance(value, str):
|
|
25
|
+
return value
|
|
26
|
+
if isinstance(value, bytes):
|
|
27
|
+
try:
|
|
28
|
+
return value.decode("utf-8")
|
|
29
|
+
except UnicodeDecodeError:
|
|
30
|
+
return None
|
|
31
|
+
if isinstance(value, Iterable):
|
|
32
|
+
for item in value:
|
|
33
|
+
if isinstance(item, str):
|
|
34
|
+
return item
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gather_classes(value: Any) -> list[str]:
|
|
39
|
+
"""Return a list of classes extracted from a BeautifulSoup attribute."""
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
return [value]
|
|
42
|
+
if isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray)):
|
|
43
|
+
return [cast(str, item) for item in value if isinstance(item, str)]
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_asset_path(file_path: Path, path: str | Path) -> Path | None:
|
|
48
|
+
"""Resolve an asset path relative to a Markdown source file."""
|
|
49
|
+
origin = Path(file_path)
|
|
50
|
+
if origin.name == "index.md":
|
|
51
|
+
origin = origin.parent
|
|
52
|
+
target = (origin / path).resolve()
|
|
53
|
+
return target if target.exists() else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_valid_url(url: str) -> bool:
|
|
57
|
+
"""Check whether a URL string has a valid scheme/netloc combination."""
|
|
58
|
+
try:
|
|
59
|
+
result = urlparse(url)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return False
|
|
62
|
+
return bool(result.scheme and result.netloc)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Heuristics and helpers shared across handlers to detect Mermaid diagrams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
import zlib
|
|
10
|
+
|
|
11
|
+
from texsmith.core.exceptions import InvalidNodeError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MERMAID_FILE_SUFFIXES = (".mmd", ".mermaid")
|
|
15
|
+
|
|
16
|
+
MERMAID_KEYWORDS = {
|
|
17
|
+
"graph",
|
|
18
|
+
"flowchart",
|
|
19
|
+
"sequencediagram",
|
|
20
|
+
"classdiagram",
|
|
21
|
+
"statediagram",
|
|
22
|
+
"gantt",
|
|
23
|
+
"erdiagram",
|
|
24
|
+
"journey",
|
|
25
|
+
"packet",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def looks_like_mermaid(diagram: str) -> bool:
|
|
30
|
+
"""Return True when the payload resembles a Mermaid diagram."""
|
|
31
|
+
if not diagram:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
for line in diagram.splitlines():
|
|
35
|
+
stripped = line.strip()
|
|
36
|
+
if not stripped:
|
|
37
|
+
continue
|
|
38
|
+
if stripped.startswith("%%"):
|
|
39
|
+
continue
|
|
40
|
+
token = stripped.split(maxsplit=1)[0].lower()
|
|
41
|
+
return token in MERMAID_KEYWORDS
|
|
42
|
+
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_mermaid_code(payload: str) -> str:
|
|
47
|
+
"""Return Mermaid code embedded in a Mermaid Live payload."""
|
|
48
|
+
candidate = payload.lstrip()
|
|
49
|
+
if not candidate.startswith("{"):
|
|
50
|
+
return payload
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(candidate)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
return payload
|
|
56
|
+
|
|
57
|
+
code = data.get("code")
|
|
58
|
+
if isinstance(code, str) and code.strip():
|
|
59
|
+
return code
|
|
60
|
+
return payload
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_mermaid_pako(payload: str) -> str:
|
|
64
|
+
"""Decode a Mermaid Live payload compressed with pako."""
|
|
65
|
+
token = payload.strip()
|
|
66
|
+
if not token:
|
|
67
|
+
raise InvalidNodeError("Mermaid payload is empty")
|
|
68
|
+
|
|
69
|
+
padding = (-len(token)) % 4
|
|
70
|
+
if padding:
|
|
71
|
+
token += "=" * padding
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
compressed = base64.urlsafe_b64decode(token)
|
|
75
|
+
except (binascii.Error, ValueError) as exc:
|
|
76
|
+
raise InvalidNodeError("Mermaid payload is not valid base64") from exc
|
|
77
|
+
|
|
78
|
+
last_error: Exception | None = None
|
|
79
|
+
for wbits in (zlib.MAX_WBITS, -zlib.MAX_WBITS):
|
|
80
|
+
try:
|
|
81
|
+
data = zlib.decompress(compressed, wbits=wbits)
|
|
82
|
+
try:
|
|
83
|
+
extracted = data.decode("utf-8")
|
|
84
|
+
return _extract_mermaid_code(extracted)
|
|
85
|
+
except UnicodeDecodeError as exc: # pragma: no cover - defensive
|
|
86
|
+
raise InvalidNodeError("Mermaid payload is not UTF-8 text") from exc
|
|
87
|
+
except zlib.error as exc:
|
|
88
|
+
last_error = exc
|
|
89
|
+
|
|
90
|
+
raise InvalidNodeError("Unable to decompress Mermaid payload") from last_error
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_mermaid_live_diagram(src: str) -> str | None:
|
|
94
|
+
"""Return Mermaid source encoded in a mermaid.live URL."""
|
|
95
|
+
try:
|
|
96
|
+
parsed = urlparse(src)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
if not parsed.netloc or not parsed.scheme:
|
|
101
|
+
return None
|
|
102
|
+
if not parsed.netloc.endswith("mermaid.live"):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
fragment = (parsed.fragment or "").strip()
|
|
106
|
+
if not fragment:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
marker = fragment.find("pako:")
|
|
110
|
+
if marker == -1:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
payload = fragment[marker + len("pako:") :]
|
|
114
|
+
for delimiter in (";", "&"):
|
|
115
|
+
idx = payload.find(delimiter)
|
|
116
|
+
if idx != -1:
|
|
117
|
+
payload = payload[:idx]
|
|
118
|
+
|
|
119
|
+
if not payload:
|
|
120
|
+
raise InvalidNodeError("Mermaid URL is missing diagram payload")
|
|
121
|
+
|
|
122
|
+
return decode_mermaid_pako(payload)
|