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,239 @@
|
|
|
1
|
+
"""Link handling utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
from bs4.element import NavigableString, Tag
|
|
12
|
+
from requests.utils import requote_uri as requote_url
|
|
13
|
+
|
|
14
|
+
from texsmith.core.context import RenderContext
|
|
15
|
+
from texsmith.core.exceptions import AssetMissingError, InvalidNodeError
|
|
16
|
+
from texsmith.core.rules import RenderPhase, renders
|
|
17
|
+
|
|
18
|
+
from ..latex.utils import escape_latex_chars
|
|
19
|
+
from ..markdown import DEFAULT_MARKDOWN_EXTENSIONS, render_markdown
|
|
20
|
+
from ._helpers import coerce_attribute, mark_processed, resolve_asset_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _coerce_existing_path(candidate: Path) -> Path | None:
|
|
24
|
+
"""Return a concrete file path for a directory or file candidate."""
|
|
25
|
+
if candidate.is_file():
|
|
26
|
+
return candidate.resolve()
|
|
27
|
+
if candidate.is_dir():
|
|
28
|
+
index_file = candidate / "index.md"
|
|
29
|
+
if index_file.exists():
|
|
30
|
+
return index_file.resolve()
|
|
31
|
+
html_index = candidate / "index.html"
|
|
32
|
+
if html_index.exists():
|
|
33
|
+
return html_index.resolve()
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_local_href_candidates(href: str) -> list[str]:
|
|
38
|
+
"""Return potential filesystem paths for a MkDocs-style link."""
|
|
39
|
+
clean_href = href.split("#", 1)[0].split("?", 1)[0].strip()
|
|
40
|
+
if not clean_href:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
candidates: list[str] = []
|
|
44
|
+
|
|
45
|
+
def add_candidate(value: str | None) -> None:
|
|
46
|
+
if value and value not in candidates:
|
|
47
|
+
candidates.append(value)
|
|
48
|
+
|
|
49
|
+
add_candidate(clean_href)
|
|
50
|
+
|
|
51
|
+
trimmed = clean_href
|
|
52
|
+
while trimmed.startswith("./"):
|
|
53
|
+
trimmed = trimmed[2:]
|
|
54
|
+
if trimmed.startswith("/"):
|
|
55
|
+
trimmed = trimmed.lstrip("/")
|
|
56
|
+
add_candidate(trimmed)
|
|
57
|
+
|
|
58
|
+
if not trimmed:
|
|
59
|
+
add_candidate("index.md")
|
|
60
|
+
return candidates
|
|
61
|
+
|
|
62
|
+
if trimmed.endswith("/"):
|
|
63
|
+
stripped = trimmed.rstrip("/")
|
|
64
|
+
add_candidate(stripped)
|
|
65
|
+
add_candidate(f"{trimmed}index.md")
|
|
66
|
+
add_candidate(f"{trimmed}index.html")
|
|
67
|
+
if stripped:
|
|
68
|
+
add_candidate(f"{stripped}/index.md")
|
|
69
|
+
add_candidate(f"{stripped}/index.html")
|
|
70
|
+
add_candidate(f"{stripped}.md")
|
|
71
|
+
add_candidate(f"{stripped}.html")
|
|
72
|
+
else:
|
|
73
|
+
suffix = Path(trimmed).suffix
|
|
74
|
+
if not suffix:
|
|
75
|
+
add_candidate(f"{trimmed}.md")
|
|
76
|
+
add_candidate(f"{trimmed}.html")
|
|
77
|
+
add_candidate(f"{trimmed}/index.md")
|
|
78
|
+
add_candidate(f"{trimmed}/index.html")
|
|
79
|
+
|
|
80
|
+
return candidates
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_local_target(context: RenderContext, href: str) -> Path | None:
|
|
84
|
+
runtime_dir = context.runtime.get("source_dir")
|
|
85
|
+
if runtime_dir is not None:
|
|
86
|
+
for candidate in _iter_local_href_candidates(href):
|
|
87
|
+
candidate_path = _coerce_existing_path(Path(runtime_dir) / candidate)
|
|
88
|
+
if candidate_path is not None:
|
|
89
|
+
return candidate_path
|
|
90
|
+
|
|
91
|
+
document_path = context.runtime.get("document_path")
|
|
92
|
+
if document_path is not None:
|
|
93
|
+
for candidate in _iter_local_href_candidates(href):
|
|
94
|
+
resolved = resolve_asset_path(Path(document_path), candidate)
|
|
95
|
+
if resolved is not None:
|
|
96
|
+
coerced = _coerce_existing_path(resolved)
|
|
97
|
+
if coerced is not None:
|
|
98
|
+
return coerced
|
|
99
|
+
|
|
100
|
+
project_dir = getattr(context.config, "project_dir", None)
|
|
101
|
+
if project_dir:
|
|
102
|
+
for candidate in _iter_local_href_candidates(href):
|
|
103
|
+
candidate_path = _coerce_existing_path(Path(project_dir) / candidate)
|
|
104
|
+
if candidate_path is not None:
|
|
105
|
+
return candidate_path
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@renders(
|
|
111
|
+
"autoref",
|
|
112
|
+
phase=RenderPhase.INLINE,
|
|
113
|
+
priority=10,
|
|
114
|
+
name="autoref_tags",
|
|
115
|
+
nestable=False,
|
|
116
|
+
)
|
|
117
|
+
def render_autoref(element: Tag, context: RenderContext) -> None:
|
|
118
|
+
"""Render <autoref> custom tags."""
|
|
119
|
+
identifier = coerce_attribute(element.get("identifier"))
|
|
120
|
+
if not identifier:
|
|
121
|
+
legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
122
|
+
literal = f"<{element.name}>"
|
|
123
|
+
latex = escape_latex_chars(literal, legacy_accents=legacy_latex_accents)
|
|
124
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
125
|
+
return
|
|
126
|
+
text = element.get_text(strip=False)
|
|
127
|
+
|
|
128
|
+
latex = context.formatter.ref(text, ref=identifier)
|
|
129
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@renders(
|
|
133
|
+
"span",
|
|
134
|
+
phase=RenderPhase.INLINE,
|
|
135
|
+
priority=15,
|
|
136
|
+
name="autoref_spans",
|
|
137
|
+
nestable=False,
|
|
138
|
+
auto_mark=False,
|
|
139
|
+
)
|
|
140
|
+
def render_autoref_spans(element: Tag, context: RenderContext) -> None:
|
|
141
|
+
"""Render MkDocs autoref span placeholders."""
|
|
142
|
+
identifier = coerce_attribute(element.get("data-autorefs-identifier"))
|
|
143
|
+
if not identifier:
|
|
144
|
+
return
|
|
145
|
+
text = element.get_text(strip=False)
|
|
146
|
+
|
|
147
|
+
latex = context.formatter.ref(text, ref=identifier)
|
|
148
|
+
node = mark_processed(NavigableString(latex))
|
|
149
|
+
context.mark_processed(element)
|
|
150
|
+
context.suppress_children(element)
|
|
151
|
+
element.replace_with(node)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@renders("a", phase=RenderPhase.INLINE, priority=60, name="links", nestable=False)
|
|
155
|
+
def render_links(element: Tag, context: RenderContext) -> None:
|
|
156
|
+
"""Render hyperlinks and internal references."""
|
|
157
|
+
href = coerce_attribute(element.get("href")) or ""
|
|
158
|
+
element_id = coerce_attribute(element.get("id"))
|
|
159
|
+
text = element.get_text(strip=False)
|
|
160
|
+
|
|
161
|
+
# Already handled in preprocessing modules
|
|
162
|
+
if element.name != "a":
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
parsed_href = urlparse(href)
|
|
166
|
+
scheme = (parsed_href.scheme or "").lower()
|
|
167
|
+
fragment = parsed_href.fragment.strip() if parsed_href.fragment else ""
|
|
168
|
+
|
|
169
|
+
if scheme in {"http", "https"}:
|
|
170
|
+
latex = context.formatter.href(text=text, url=requote_url(href))
|
|
171
|
+
elif scheme:
|
|
172
|
+
raise InvalidNodeError(f"Unsupported link scheme '{scheme}' for '{href}'.")
|
|
173
|
+
elif href.startswith("#"):
|
|
174
|
+
latex = context.formatter.ref(text, ref=href[1:])
|
|
175
|
+
elif href == "" and element_id:
|
|
176
|
+
latex = context.formatter.label(element_id)
|
|
177
|
+
elif href:
|
|
178
|
+
resolved = _resolve_local_target(context, href)
|
|
179
|
+
if resolved is None:
|
|
180
|
+
raise AssetMissingError(f"Unable to resolve link target '{href}'")
|
|
181
|
+
target_ref = fragment or _infer_heading_reference(resolved)
|
|
182
|
+
if target_ref:
|
|
183
|
+
latex = context.formatter.ref(text or "", ref=target_ref)
|
|
184
|
+
else:
|
|
185
|
+
content = resolved.read_bytes()
|
|
186
|
+
digest = sha256(content).hexdigest()
|
|
187
|
+
reference = f"snippet:{digest}"
|
|
188
|
+
context.state.register_snippet(
|
|
189
|
+
reference,
|
|
190
|
+
{
|
|
191
|
+
"path": resolved,
|
|
192
|
+
"content": content,
|
|
193
|
+
"format": resolved.suffix[1:] if resolved.suffix else "",
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
latex = context.formatter.ref(text or "extrait", ref=reference)
|
|
197
|
+
else:
|
|
198
|
+
legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
199
|
+
latex = escape_latex_chars(text, legacy_accents=legacy_latex_accents)
|
|
200
|
+
|
|
201
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6")
|
|
205
|
+
_HTML_EXTENSIONS = {".html", ".htm"}
|
|
206
|
+
_MARKDOWN_EXTENSIONS = {".md", ".markdown", ".mkd", ".mkdn"}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@lru_cache(maxsize=256)
|
|
210
|
+
def _extract_primary_heading(path: str) -> str | None:
|
|
211
|
+
candidate = Path(path)
|
|
212
|
+
suffix = candidate.suffix.lower()
|
|
213
|
+
try:
|
|
214
|
+
payload = candidate.read_text(encoding="utf-8")
|
|
215
|
+
except (OSError, UnicodeDecodeError):
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
if suffix in _HTML_EXTENSIONS:
|
|
219
|
+
soup = BeautifulSoup(payload, "html.parser")
|
|
220
|
+
elif suffix in _MARKDOWN_EXTENSIONS:
|
|
221
|
+
document = render_markdown(
|
|
222
|
+
payload,
|
|
223
|
+
extensions=DEFAULT_MARKDOWN_EXTENSIONS,
|
|
224
|
+
base_path=candidate.parent,
|
|
225
|
+
)
|
|
226
|
+
soup = BeautifulSoup(document.html, "html.parser")
|
|
227
|
+
else:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
heading = soup.find(_HEADING_TAGS, id=True)
|
|
231
|
+
if heading is None:
|
|
232
|
+
return None
|
|
233
|
+
identifier = heading.get("id")
|
|
234
|
+
return identifier or None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _infer_heading_reference(path: Path) -> str | None:
|
|
238
|
+
reference = _extract_primary_heading(str(path.resolve()))
|
|
239
|
+
return reference
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Handlers responsible for assets such as images and diagrams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
from bs4.element import NavigableString, Tag
|
|
11
|
+
from requests.utils import requote_uri as requote_url
|
|
12
|
+
|
|
13
|
+
from texsmith.core.context import RenderContext
|
|
14
|
+
from texsmith.core.diagnostics import DiagnosticEmitter
|
|
15
|
+
from texsmith.core.exceptions import (
|
|
16
|
+
AssetMissingError,
|
|
17
|
+
InvalidNodeError,
|
|
18
|
+
exception_hint,
|
|
19
|
+
exception_messages,
|
|
20
|
+
)
|
|
21
|
+
from texsmith.core.rules import RenderPhase, renders
|
|
22
|
+
from texsmith.fonts.scripts import render_moving_text
|
|
23
|
+
|
|
24
|
+
from ..latex.utils import escape_latex_chars
|
|
25
|
+
from ..transformers import mermaid2pdf
|
|
26
|
+
from ._assets import store_local_image_asset, store_remote_image_asset
|
|
27
|
+
from ._helpers import (
|
|
28
|
+
coerce_attribute,
|
|
29
|
+
gather_classes,
|
|
30
|
+
is_valid_url,
|
|
31
|
+
mark_processed,
|
|
32
|
+
resolve_asset_path,
|
|
33
|
+
)
|
|
34
|
+
from ._mermaid import (
|
|
35
|
+
MERMAID_FILE_SUFFIXES,
|
|
36
|
+
extract_mermaid_live_diagram as _extract_mermaid_live_diagram,
|
|
37
|
+
looks_like_mermaid as _looks_like_mermaid,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _runtime_emitter(context: RenderContext) -> DiagnosticEmitter | None:
|
|
42
|
+
"""Return the diagnostic emitter bound to the current runtime, if any."""
|
|
43
|
+
emitter = context.runtime.get("emitter")
|
|
44
|
+
if isinstance(emitter, DiagnosticEmitter):
|
|
45
|
+
return emitter
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_mermaid_diagram(context: RenderContext, src: str) -> tuple[str, str] | None:
|
|
50
|
+
"""Attempt to load a Mermaid diagram from a local file or URL."""
|
|
51
|
+
lowercase = src.lower()
|
|
52
|
+
if lowercase.endswith(MERMAID_FILE_SUFFIXES):
|
|
53
|
+
resolved = _resolve_source_path(context, src)
|
|
54
|
+
if resolved is None:
|
|
55
|
+
raise AssetMissingError(f"Unable to resolve Mermaid diagram '{src}'")
|
|
56
|
+
try:
|
|
57
|
+
return resolved.read_text(encoding="utf-8"), "file"
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise AssetMissingError(f"Unable to read Mermaid diagram '{resolved}'") from exc
|
|
60
|
+
|
|
61
|
+
diagram = _extract_mermaid_live_diagram(src)
|
|
62
|
+
if diagram is not None:
|
|
63
|
+
return diagram, "url"
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _figure_template_for(element: Tag) -> str | None:
|
|
69
|
+
"""Determine which figure template to use based on ancestor metadata."""
|
|
70
|
+
current = element
|
|
71
|
+
while current is not None:
|
|
72
|
+
raw_classes = getattr(current, "get", lambda *_: None)("class")
|
|
73
|
+
class_list = gather_classes(raw_classes)
|
|
74
|
+
if any(cls in {"admonition", "exercise"} for cls in class_list):
|
|
75
|
+
return "figure_tcolorbox"
|
|
76
|
+
if getattr(current, "name", None) == "details":
|
|
77
|
+
return "figure_tcolorbox"
|
|
78
|
+
current = getattr(current, "parent", None)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_source_path(context: RenderContext, src: str) -> Path | None:
|
|
83
|
+
"""Resolve a local asset path using runtime hints."""
|
|
84
|
+
runtime_dir = context.runtime.get("source_dir")
|
|
85
|
+
if runtime_dir is not None:
|
|
86
|
+
candidate = Path(runtime_dir) / src
|
|
87
|
+
if candidate.exists():
|
|
88
|
+
return candidate.resolve()
|
|
89
|
+
|
|
90
|
+
document_path = context.runtime.get("document_path")
|
|
91
|
+
if document_path is not None:
|
|
92
|
+
resolved = resolve_asset_path(Path(document_path), src)
|
|
93
|
+
if resolved is not None:
|
|
94
|
+
return resolved
|
|
95
|
+
|
|
96
|
+
project_dir = getattr(context.config, "project_dir", None)
|
|
97
|
+
if project_dir:
|
|
98
|
+
candidate = Path(project_dir) / src
|
|
99
|
+
if candidate.exists():
|
|
100
|
+
return candidate.resolve()
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_MKDOCS_THEME_VARIANTS = {"only-light", "only-dark"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _strip_mkdocs_theme_variant(src: str) -> str:
|
|
109
|
+
"""Drop MkDocs Material light/dark suffixes appended to image URLs."""
|
|
110
|
+
base, sep, fragment = src.partition("#")
|
|
111
|
+
if not sep:
|
|
112
|
+
return src
|
|
113
|
+
if fragment.lower() in _MKDOCS_THEME_VARIANTS:
|
|
114
|
+
return base
|
|
115
|
+
return src
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _apply_figure_template(
|
|
119
|
+
context: RenderContext,
|
|
120
|
+
*,
|
|
121
|
+
path: Path,
|
|
122
|
+
caption: str | None = None,
|
|
123
|
+
shortcaption: str | None = None,
|
|
124
|
+
alt: str | None = None,
|
|
125
|
+
label: str | None = None,
|
|
126
|
+
width: str | None = None,
|
|
127
|
+
template: str | None = None,
|
|
128
|
+
adjustbox: bool = False,
|
|
129
|
+
link: str | None = None,
|
|
130
|
+
) -> NavigableString:
|
|
131
|
+
"""Render the shared figure template and return a new node."""
|
|
132
|
+
template_name = template or context.runtime.get("figure_template", "figure")
|
|
133
|
+
formatter = getattr(context.formatter, template_name)
|
|
134
|
+
asset_path = context.assets.latex_path(path)
|
|
135
|
+
legacy_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
136
|
+
caption = render_moving_text(caption, context, legacy_accents=legacy_accents, wrap_scripts=True)
|
|
137
|
+
shortcaption = render_moving_text(
|
|
138
|
+
shortcaption, context, legacy_accents=legacy_accents, wrap_scripts=True
|
|
139
|
+
)
|
|
140
|
+
alt_text = render_moving_text(alt, context, legacy_accents=legacy_accents, wrap_scripts=True)
|
|
141
|
+
effective_shortcaption = shortcaption or alt_text or caption
|
|
142
|
+
safe_link = (
|
|
143
|
+
escape_latex_chars(requote_url(link), legacy_accents=legacy_accents) if link else None
|
|
144
|
+
)
|
|
145
|
+
latex = formatter(
|
|
146
|
+
path=asset_path,
|
|
147
|
+
caption=caption,
|
|
148
|
+
shortcaption=effective_shortcaption,
|
|
149
|
+
label=label,
|
|
150
|
+
width=width,
|
|
151
|
+
adjustbox=adjustbox,
|
|
152
|
+
link=safe_link,
|
|
153
|
+
)
|
|
154
|
+
return mark_processed(NavigableString(latex))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _render_mermaid_diagram(
|
|
158
|
+
context: RenderContext,
|
|
159
|
+
diagram: str,
|
|
160
|
+
*,
|
|
161
|
+
template: str | None = None,
|
|
162
|
+
caption: str | None = None,
|
|
163
|
+
width: str | None = None,
|
|
164
|
+
) -> NavigableString | None:
|
|
165
|
+
"""Render a Mermaid diagram and return the resulting LaTeX node."""
|
|
166
|
+
extracted_caption, body = _mermaid_caption(diagram)
|
|
167
|
+
effective_caption = extracted_caption if extracted_caption is not None else caption
|
|
168
|
+
if not context.runtime.get("copy_assets", True):
|
|
169
|
+
placeholder = effective_caption or "Mermaid diagram"
|
|
170
|
+
return mark_processed(NavigableString(placeholder))
|
|
171
|
+
if not _looks_like_mermaid(body):
|
|
172
|
+
return None
|
|
173
|
+
if "```" in body or "~~~" in body:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
render_options: dict[str, Any] = {}
|
|
177
|
+
mermaid_config = getattr(context.config, "mermaid_config", None)
|
|
178
|
+
if mermaid_config:
|
|
179
|
+
project_dir = getattr(context.config, "project_dir", None)
|
|
180
|
+
if project_dir is None:
|
|
181
|
+
raise AssetMissingError("Project directory required to render Mermaid diagrams")
|
|
182
|
+
render_options["config_filename"] = Path(project_dir) / mermaid_config
|
|
183
|
+
backend = context.runtime.get("diagrams_backend")
|
|
184
|
+
if backend:
|
|
185
|
+
render_options["backend"] = backend
|
|
186
|
+
runtime_mermaid_config = context.runtime.get("mermaid_config")
|
|
187
|
+
if runtime_mermaid_config is not None:
|
|
188
|
+
render_options["mermaid_config"] = runtime_mermaid_config
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
artefact = mermaid2pdf(body, output_dir=context.assets.output_root, **render_options)
|
|
192
|
+
except Exception as exc: # pragma: no cover - safeguard
|
|
193
|
+
_warn_mermaid_failure(context, exc)
|
|
194
|
+
placeholder = effective_caption or "Mermaid diagram"
|
|
195
|
+
return mark_processed(NavigableString(f"[{placeholder} unavailable]"))
|
|
196
|
+
|
|
197
|
+
asset_key = f"mermaid::{hashlib.sha256(body.encode('utf-8')).hexdigest()}"
|
|
198
|
+
stored_path = context.assets.register(asset_key, artefact)
|
|
199
|
+
|
|
200
|
+
return _apply_figure_template(
|
|
201
|
+
context,
|
|
202
|
+
path=stored_path,
|
|
203
|
+
caption=effective_caption,
|
|
204
|
+
label=None,
|
|
205
|
+
width=width,
|
|
206
|
+
template=template,
|
|
207
|
+
adjustbox=True,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _mermaid_caption(diagram: str) -> tuple[str | None, str]:
|
|
212
|
+
"""Extract caption from the diagram comment header."""
|
|
213
|
+
lines = diagram.splitlines()
|
|
214
|
+
if lines and lines[0].strip().startswith("%%"):
|
|
215
|
+
caption = lines[0].strip()[2:].strip() or None
|
|
216
|
+
body = "\n".join(lines[1:])
|
|
217
|
+
return caption, body
|
|
218
|
+
return None, diagram
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@renders("img", phase=RenderPhase.BLOCK, name="render_images", nestable=False)
|
|
222
|
+
def render_images(element: Tag, context: RenderContext) -> None:
|
|
223
|
+
"""Convert <img> nodes into LaTeX figures and manage assets."""
|
|
224
|
+
if not context.runtime.get("copy_assets", True):
|
|
225
|
+
alt_text = (
|
|
226
|
+
coerce_attribute(element.get("alt")) or coerce_attribute(element.get("title")) or ""
|
|
227
|
+
)
|
|
228
|
+
placeholder = alt_text.strip() or "[image]"
|
|
229
|
+
element.replace_with(mark_processed(NavigableString(placeholder)))
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
src = coerce_attribute(element.get("src"))
|
|
233
|
+
if not src:
|
|
234
|
+
raise InvalidNodeError("Image tag without 'src' attribute")
|
|
235
|
+
|
|
236
|
+
src = _strip_mkdocs_theme_variant(src)
|
|
237
|
+
|
|
238
|
+
classes = gather_classes(element.get("class"))
|
|
239
|
+
if {"twemoji", "emojione"}.intersection(classes):
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if element.find_parent("figure"):
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
raw_alt = coerce_attribute(element.get("alt"))
|
|
246
|
+
alt_text = raw_alt.strip() if raw_alt else None
|
|
247
|
+
raw_title = coerce_attribute(element.get("title"))
|
|
248
|
+
caption_text = raw_title.strip() if raw_title else None
|
|
249
|
+
width = coerce_attribute(element.get("width")) or None
|
|
250
|
+
template = _figure_template_for(element)
|
|
251
|
+
|
|
252
|
+
link_wrapper = None
|
|
253
|
+
link_target = None
|
|
254
|
+
parent = element.parent
|
|
255
|
+
if isinstance(parent, Tag) and parent.name == "a":
|
|
256
|
+
candidates = [
|
|
257
|
+
child
|
|
258
|
+
for child in parent.contents
|
|
259
|
+
if not (isinstance(child, NavigableString) and not child.strip())
|
|
260
|
+
]
|
|
261
|
+
if len(candidates) == 1 and candidates[0] is element:
|
|
262
|
+
link_wrapper = parent
|
|
263
|
+
link_target = coerce_attribute(parent.get("href"))
|
|
264
|
+
|
|
265
|
+
mermaid_payload = _load_mermaid_diagram(context, src)
|
|
266
|
+
if mermaid_payload is not None:
|
|
267
|
+
diagram, _ = mermaid_payload
|
|
268
|
+
figure_node = _render_mermaid_diagram(
|
|
269
|
+
context,
|
|
270
|
+
diagram,
|
|
271
|
+
template=template,
|
|
272
|
+
caption=caption_text,
|
|
273
|
+
)
|
|
274
|
+
if figure_node is None:
|
|
275
|
+
raise InvalidNodeError(f"Mermaid source '{src}' does not contain a valid diagram")
|
|
276
|
+
element.replace_with(figure_node)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
if not caption_text:
|
|
280
|
+
caption_text = alt_text
|
|
281
|
+
|
|
282
|
+
if is_valid_url(src):
|
|
283
|
+
stored_path = store_remote_image_asset(context, src)
|
|
284
|
+
else:
|
|
285
|
+
resolved = _resolve_source_path(context, src)
|
|
286
|
+
if resolved is None:
|
|
287
|
+
raise AssetMissingError(f"Unable to resolve image asset '{src}'")
|
|
288
|
+
|
|
289
|
+
stored_path = store_local_image_asset(context, resolved)
|
|
290
|
+
|
|
291
|
+
figure_node = _apply_figure_template(
|
|
292
|
+
context,
|
|
293
|
+
path=stored_path,
|
|
294
|
+
caption=caption_text,
|
|
295
|
+
alt=alt_text,
|
|
296
|
+
label=None,
|
|
297
|
+
width=width,
|
|
298
|
+
template=template,
|
|
299
|
+
adjustbox=False,
|
|
300
|
+
link=link_target,
|
|
301
|
+
)
|
|
302
|
+
if link_wrapper:
|
|
303
|
+
link_wrapper.replace_with(figure_node)
|
|
304
|
+
else:
|
|
305
|
+
element.replace_with(figure_node)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@renders("div", phase=RenderPhase.BLOCK, name="render_mermaid", nestable=False)
|
|
309
|
+
def render_mermaid(element: Tag, context: RenderContext) -> None:
|
|
310
|
+
"""Convert Mermaid code blocks inside highlight containers."""
|
|
311
|
+
classes = gather_classes(element.get("class"))
|
|
312
|
+
if "highlight" not in classes and "mermaid" not in classes:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
code = element.find("code")
|
|
316
|
+
if code is None:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
diagram = code.get_text()
|
|
320
|
+
width = coerce_attribute(code.get("width") or element.get("width"))
|
|
321
|
+
template = _figure_template_for(element)
|
|
322
|
+
figure_node = _render_mermaid_diagram(context, diagram, template=template, width=width)
|
|
323
|
+
if figure_node is None:
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
element.replace_with(figure_node)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@renders("pre", phase=RenderPhase.BLOCK, name="render_mermaid_pre", nestable=False)
|
|
330
|
+
def render_mermaid_pre(element: Tag, context: RenderContext) -> None:
|
|
331
|
+
"""Handle <pre class=\"mermaid\"> blocks."""
|
|
332
|
+
classes = gather_classes(element.get("class"))
|
|
333
|
+
if "mermaid" not in classes:
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
diagram: str | None = None
|
|
337
|
+
source_hint = coerce_attribute(element.get("data-mermaid-source"))
|
|
338
|
+
if source_hint:
|
|
339
|
+
payload = _load_mermaid_diagram(context, source_hint)
|
|
340
|
+
if payload is not None:
|
|
341
|
+
diagram, _ = payload
|
|
342
|
+
if diagram is None:
|
|
343
|
+
diagram = element.get_text()
|
|
344
|
+
width = coerce_attribute(element.get("width"))
|
|
345
|
+
|
|
346
|
+
template = _figure_template_for(element)
|
|
347
|
+
figure_node = _render_mermaid_diagram(context, diagram, template=template, width=width)
|
|
348
|
+
if figure_node is None:
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
element.replace_with(figure_node)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _warn_mermaid_failure(context: RenderContext, exc: Exception) -> None:
|
|
355
|
+
"""Emit a CLI-friendly warning describing Mermaid rendering failures."""
|
|
356
|
+
emitter = _runtime_emitter(context)
|
|
357
|
+
guidance = (
|
|
358
|
+
"Install Docker and the 'minlag/mermaid-cli' image (or register a custom Mermaid "
|
|
359
|
+
"converter) to enable diagram rendering."
|
|
360
|
+
)
|
|
361
|
+
summary = "Mermaid diagram could not be rendered. TexSmith inserted a placeholder instead."
|
|
362
|
+
hint = exception_hint(exc)
|
|
363
|
+
|
|
364
|
+
if emitter is None:
|
|
365
|
+
detail = f" ({hint})" if hint else ""
|
|
366
|
+
message = f"{summary}{detail}. {guidance}"
|
|
367
|
+
warnings.warn(message, stacklevel=3)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if emitter.debug_enabled:
|
|
371
|
+
chain = exception_messages(exc)
|
|
372
|
+
detail_block = ""
|
|
373
|
+
if chain:
|
|
374
|
+
detail_lines = "\n".join(f"- {line}" for line in chain)
|
|
375
|
+
detail_block = f"\nDetails:\n{detail_lines}"
|
|
376
|
+
emitter.warning(f"{summary}{detail_block}\n{guidance}", exc=exc)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
detail = f" ({hint})" if hint else ""
|
|
380
|
+
emitter.warning(f"{summary}{detail}. {guidance} Run with --debug for technical details.")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""LaTeX-specific utilities exposed by Texsmith."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .formatter import LaTeXFormatter, optimize_list
|
|
6
|
+
from .renderer import LaTeXRenderer
|
|
7
|
+
from .utils import escape_latex_chars
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = ["LaTeXFormatter", "LaTeXRenderer", "escape_latex_chars", "optimize_list"]
|