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,937 @@
|
|
|
1
|
+
"""Advanced inline handlers ported from the legacy renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
from bs4.element import NavigableString, Tag
|
|
11
|
+
import emoji
|
|
12
|
+
from requests.utils import requote_uri as requote_url
|
|
13
|
+
|
|
14
|
+
from texsmith.core.context import RenderContext
|
|
15
|
+
from texsmith.core.exceptions import InvalidNodeError, TransformerExecutionError
|
|
16
|
+
from texsmith.core.rules import RenderPhase, renders
|
|
17
|
+
from texsmith.fonts.scripts import record_script_usage_for_slug, render_moving_text
|
|
18
|
+
|
|
19
|
+
from ..latex.utils import escape_latex_chars
|
|
20
|
+
from ..transformers import fetch_image, svg2pdf
|
|
21
|
+
from ._helpers import coerce_attribute, gather_classes, is_valid_url, mark_processed
|
|
22
|
+
from .code import _resolve_code_engine
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_MATH_PAYLOAD_PATTERN = re.compile(
|
|
26
|
+
r"""
|
|
27
|
+
(?:\$\$.*?\$\$) # display math $$...$$
|
|
28
|
+
|(?:\\\[.*?\\\]) # display math \[...\]
|
|
29
|
+
|(?:\\\(.*?\\\)) # inline math \(...\)
|
|
30
|
+
|(?:\\begin\{[a-zA-Z*]+\}.*?\\end\{[a-zA-Z*]+\})# LaTeX environments
|
|
31
|
+
|(?<!\\)\$(?!\$)(?!\s)(?:\\.|[^$])*?(?<!\\)\$ # inline math $...$
|
|
32
|
+
""",
|
|
33
|
+
re.DOTALL | re.VERBOSE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_SUPERSCRIPT_MAP = {
|
|
37
|
+
"⁰": "0",
|
|
38
|
+
"¹": "1",
|
|
39
|
+
"²": "2",
|
|
40
|
+
"³": "3",
|
|
41
|
+
"⁴": "4",
|
|
42
|
+
"⁵": "5",
|
|
43
|
+
"⁶": "6",
|
|
44
|
+
"⁷": "7",
|
|
45
|
+
"⁸": "8",
|
|
46
|
+
"⁹": "9",
|
|
47
|
+
"⁺": "+",
|
|
48
|
+
"⁻": "-",
|
|
49
|
+
"⁼": "=",
|
|
50
|
+
"⁽": "(",
|
|
51
|
+
"⁾": ")",
|
|
52
|
+
"ⁿ": "n",
|
|
53
|
+
"ⁱ": "i",
|
|
54
|
+
"ᵃ": "a",
|
|
55
|
+
"ᵇ": "b",
|
|
56
|
+
"ᶜ": "c",
|
|
57
|
+
"ᵈ": "d",
|
|
58
|
+
"ᵉ": "e",
|
|
59
|
+
"ᶠ": "f",
|
|
60
|
+
"ᵍ": "g",
|
|
61
|
+
"ʰ": "h",
|
|
62
|
+
"ᶦ": "i",
|
|
63
|
+
"ʲ": "j",
|
|
64
|
+
"ᵏ": "k",
|
|
65
|
+
"ˡ": "l",
|
|
66
|
+
"ᵐ": "m",
|
|
67
|
+
"ᶰ": "n",
|
|
68
|
+
"ᵒ": "o",
|
|
69
|
+
"ᵖ": "p",
|
|
70
|
+
"ʳ": "r",
|
|
71
|
+
"ˢ": "s",
|
|
72
|
+
"ᵗ": "t",
|
|
73
|
+
"ᵘ": "u",
|
|
74
|
+
"ᵛ": "v",
|
|
75
|
+
"ʷ": "w",
|
|
76
|
+
"ˣ": "x",
|
|
77
|
+
"ʸ": "y",
|
|
78
|
+
"ᶻ": "z",
|
|
79
|
+
"ᴬ": "A",
|
|
80
|
+
"ᴮ": "B",
|
|
81
|
+
"ᴰ": "D",
|
|
82
|
+
"ᴱ": "E",
|
|
83
|
+
"ᴳ": "G",
|
|
84
|
+
"ᴴ": "H",
|
|
85
|
+
"ᴵ": "I",
|
|
86
|
+
"ᴶ": "J",
|
|
87
|
+
"ᴷ": "K",
|
|
88
|
+
"ᴸ": "L",
|
|
89
|
+
"ᴹ": "M",
|
|
90
|
+
"ᴺ": "N",
|
|
91
|
+
"ᴼ": "O",
|
|
92
|
+
"ᴾ": "P",
|
|
93
|
+
"ᴿ": "R",
|
|
94
|
+
"ᵀ": "T",
|
|
95
|
+
"ᵁ": "U",
|
|
96
|
+
"ⱽ": "V",
|
|
97
|
+
"ᵂ": "W",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_SUPERSCRIPT_PATTERN = re.compile(f"([{''.join(re.escape(char) for char in _SUPERSCRIPT_MAP)}]+)")
|
|
101
|
+
|
|
102
|
+
_SUBSCRIPT_MAP = {
|
|
103
|
+
"₀": "0",
|
|
104
|
+
"₁": "1",
|
|
105
|
+
"₂": "2",
|
|
106
|
+
"₃": "3",
|
|
107
|
+
"₄": "4",
|
|
108
|
+
"₅": "5",
|
|
109
|
+
"₆": "6",
|
|
110
|
+
"₇": "7",
|
|
111
|
+
"₈": "8",
|
|
112
|
+
"₉": "9",
|
|
113
|
+
"₊": "+",
|
|
114
|
+
"₋": "-",
|
|
115
|
+
"₌": "=",
|
|
116
|
+
"₍": "(",
|
|
117
|
+
"₎": ")",
|
|
118
|
+
"ₐ": "a",
|
|
119
|
+
"ₑ": "e",
|
|
120
|
+
"ₒ": "o",
|
|
121
|
+
"ₔ": "ə",
|
|
122
|
+
"ₓ": "x",
|
|
123
|
+
"ₕ": "h",
|
|
124
|
+
"ₖ": "k",
|
|
125
|
+
"ₗ": "l",
|
|
126
|
+
"ₘ": "m",
|
|
127
|
+
"ₙ": "n",
|
|
128
|
+
"ₚ": "p",
|
|
129
|
+
"ₛ": "s",
|
|
130
|
+
"ₜ": "t",
|
|
131
|
+
"ᵢ": "i",
|
|
132
|
+
"ᵣ": "r",
|
|
133
|
+
"ᵤ": "u",
|
|
134
|
+
"ᵥ": "v",
|
|
135
|
+
"ᵦ": r"\beta",
|
|
136
|
+
"ᵧ": r"\gamma",
|
|
137
|
+
"ᵨ": r"\rho",
|
|
138
|
+
"ᵩ": r"\phi",
|
|
139
|
+
"ᵪ": r"\chi",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_SUBSCRIPT_PATTERN = re.compile(f"([{''.join(re.escape(char) for char in _SUBSCRIPT_MAP)}]+)")
|
|
143
|
+
_UNICODE_DASH_MAP = {
|
|
144
|
+
"\N{EN DASH}": "--",
|
|
145
|
+
"\N{FIGURE DASH}": "--",
|
|
146
|
+
"\N{EM DASH}": "---",
|
|
147
|
+
"\N{HORIZONTAL BAR}": "---",
|
|
148
|
+
}
|
|
149
|
+
_UNICODE_DASH_PATTERN = re.compile("[" + "".join(re.escape(k) for k in _UNICODE_DASH_MAP) + "]")
|
|
150
|
+
|
|
151
|
+
_UNICODE_PUNCT_MAP = {
|
|
152
|
+
"\N{RIGHT SINGLE QUOTATION MARK}": "'",
|
|
153
|
+
"\N{LEFT SINGLE QUOTATION MARK}": "`",
|
|
154
|
+
"\N{SINGLE LOW-9 QUOTATION MARK}": ",",
|
|
155
|
+
"\N{SINGLE HIGH-REVERSED-9 QUOTATION MARK}": "'",
|
|
156
|
+
"\N{LEFT DOUBLE QUOTATION MARK}": "``",
|
|
157
|
+
"\N{RIGHT DOUBLE QUOTATION MARK}": "''",
|
|
158
|
+
"\N{DOUBLE LOW-9 QUOTATION MARK}": ",,",
|
|
159
|
+
"\N{DOUBLE HIGH-REVERSED-9 QUOTATION MARK}": "''",
|
|
160
|
+
"\N{HORIZONTAL ELLIPSIS}": "...",
|
|
161
|
+
}
|
|
162
|
+
_UNICODE_PUNCT_PATTERN = re.compile("[" + "".join(re.escape(k) for k in _UNICODE_PUNCT_MAP) + "]")
|
|
163
|
+
_MINTINLINE_DELIMITERS: tuple[str, ...] = (
|
|
164
|
+
"|",
|
|
165
|
+
"!",
|
|
166
|
+
";",
|
|
167
|
+
":",
|
|
168
|
+
"+",
|
|
169
|
+
"/",
|
|
170
|
+
"-",
|
|
171
|
+
"=",
|
|
172
|
+
"~",
|
|
173
|
+
"*",
|
|
174
|
+
"#",
|
|
175
|
+
"?",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _replace_unicode_scripts(
|
|
180
|
+
text: str, pattern: re.Pattern[str], mapping: dict[str, str], command: str
|
|
181
|
+
) -> str:
|
|
182
|
+
if not text:
|
|
183
|
+
return text
|
|
184
|
+
|
|
185
|
+
def _normalize(match: re.Match[str]) -> str:
|
|
186
|
+
payload = match.group(0)
|
|
187
|
+
normalized = "".join(mapping.get(char, char) for char in payload)
|
|
188
|
+
return f"\\{command}{{{normalized}}}"
|
|
189
|
+
|
|
190
|
+
return pattern.sub(_normalize, text)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _replace_unicode_superscripts(text: str) -> str:
|
|
194
|
+
"""Convert sequences of Unicode superscript characters to LaTeX text macros."""
|
|
195
|
+
return _replace_unicode_scripts(text, _SUPERSCRIPT_PATTERN, _SUPERSCRIPT_MAP, "textsuperscript")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _replace_unicode_subscripts(text: str) -> str:
|
|
199
|
+
"""Convert sequences of Unicode subscript characters to LaTeX text macros."""
|
|
200
|
+
return _replace_unicode_scripts(text, _SUBSCRIPT_PATTERN, _SUBSCRIPT_MAP, "textsubscript")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _replace_unicode_dashes(text: str) -> str:
|
|
204
|
+
"""Convert Unicode dash characters to LaTeX-friendly representations."""
|
|
205
|
+
if not text:
|
|
206
|
+
return text
|
|
207
|
+
return _UNICODE_DASH_PATTERN.sub(lambda match: _UNICODE_DASH_MAP.get(match.group(0), "-"), text)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _replace_unicode_punctuation(text: str) -> str:
|
|
211
|
+
"""Replace curly quotes and ellipsis with LaTeX-friendly sequences."""
|
|
212
|
+
if not text:
|
|
213
|
+
return text
|
|
214
|
+
return _UNICODE_PUNCT_PATTERN.sub(
|
|
215
|
+
lambda match: _UNICODE_PUNCT_MAP.get(match.group(0), ""), text
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _get_emoji_mode(context: RenderContext) -> str:
|
|
220
|
+
value = context.runtime.get("emoji_mode")
|
|
221
|
+
if isinstance(value, str):
|
|
222
|
+
candidate = value.strip()
|
|
223
|
+
if candidate:
|
|
224
|
+
return candidate
|
|
225
|
+
return "black"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _get_emoji_command(context: RenderContext) -> str:
|
|
229
|
+
value = context.runtime.get("emoji_command")
|
|
230
|
+
if isinstance(value, str) and value.strip():
|
|
231
|
+
return value.strip()
|
|
232
|
+
return r"\texsmithEmoji"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _has_ancestor(node: NavigableString, *names: str) -> bool:
|
|
236
|
+
parent = node.parent
|
|
237
|
+
while parent is not None:
|
|
238
|
+
if getattr(parent, "name", None) in names:
|
|
239
|
+
return True
|
|
240
|
+
parent = getattr(parent, "parent", None)
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _allow_hyphenation(text: str) -> str:
|
|
245
|
+
"""Allow hyphenation in long words.
|
|
246
|
+
|
|
247
|
+
TODO: This function is bad. Instead we should look into a dictionary
|
|
248
|
+
based hyphenation solution.
|
|
249
|
+
"""
|
|
250
|
+
return text # Temporary disable hyphenation handling
|
|
251
|
+
|
|
252
|
+
if len(text) < 50:
|
|
253
|
+
return text
|
|
254
|
+
return re.sub(r"(\b[^\W\d_]{2,}-)([^\W\d_]{7,})\b", r"\1\\allowhyphens \2", text)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _prepare_plain_text(text: str, *, legacy_latex_accents: bool) -> str:
|
|
258
|
+
text = _replace_unicode_punctuation(text)
|
|
259
|
+
text = _replace_unicode_dashes(text)
|
|
260
|
+
escaped = escape_latex_chars(text, legacy_accents=legacy_latex_accents)
|
|
261
|
+
escaped = _allow_hyphenation(escaped)
|
|
262
|
+
escaped = _replace_unicode_superscripts(escaped)
|
|
263
|
+
return _replace_unicode_subscripts(escaped)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _segment_text_with_emoji(text: str) -> list[tuple[str, str]]:
|
|
267
|
+
"""Split text into plain fragments and emoji clusters."""
|
|
268
|
+
if not text:
|
|
269
|
+
return []
|
|
270
|
+
if text.isascii():
|
|
271
|
+
return [("text", text)]
|
|
272
|
+
entries = emoji.emoji_list(text)
|
|
273
|
+
if not entries:
|
|
274
|
+
return [("text", text)]
|
|
275
|
+
|
|
276
|
+
segments: list[tuple[str, str]] = []
|
|
277
|
+
cursor = 0
|
|
278
|
+
for entry in entries:
|
|
279
|
+
start = entry["match_start"]
|
|
280
|
+
end = entry["match_end"]
|
|
281
|
+
if start > cursor:
|
|
282
|
+
segments.append(("text", text[cursor:start]))
|
|
283
|
+
token = text[start:end]
|
|
284
|
+
segments.append(("emoji", token))
|
|
285
|
+
cursor = end
|
|
286
|
+
if cursor < len(text):
|
|
287
|
+
segments.append(("text", text[cursor:]))
|
|
288
|
+
return segments
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _emoji_twemoji_url(token: str) -> str:
|
|
292
|
+
codepoints = "-".join(f"{ord(char):x}" for char in token)
|
|
293
|
+
return f"https://twemoji.maxcdn.com/v/latest/svg/{codepoints}.svg"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _render_emoji(token: str, context: RenderContext) -> str:
|
|
297
|
+
url = _emoji_twemoji_url(token)
|
|
298
|
+
try:
|
|
299
|
+
artefact = fetch_image(url, output_dir=context.assets.output_root)
|
|
300
|
+
except TransformerExecutionError as exc:
|
|
301
|
+
warnings.warn(f"Failed to fetch emoji '{token}': {exc}", stacklevel=2)
|
|
302
|
+
legacy = getattr(context.config, "legacy_latex_accents", False)
|
|
303
|
+
return _prepare_plain_text(token, legacy_latex_accents=legacy)
|
|
304
|
+
|
|
305
|
+
stored_path = context.assets.register(url, artefact)
|
|
306
|
+
asset_path = context.assets.latex_path(stored_path)
|
|
307
|
+
return context.formatter.icon(asset_path)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _render_font_emoji(token: str, command: str) -> str:
|
|
311
|
+
if not token:
|
|
312
|
+
return ""
|
|
313
|
+
return f"{command}{{{token}}}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _escape_text_segment(text: str, context: RenderContext, *, legacy_latex_accents: bool) -> str:
|
|
317
|
+
chunks: list[str] = []
|
|
318
|
+
emoji_mode = _get_emoji_mode(context)
|
|
319
|
+
emoji_command = _get_emoji_command(context)
|
|
320
|
+
for kind, payload in _segment_text_with_emoji(text):
|
|
321
|
+
if kind == "text":
|
|
322
|
+
if payload:
|
|
323
|
+
chunks.append(
|
|
324
|
+
_prepare_plain_text(payload, legacy_latex_accents=legacy_latex_accents)
|
|
325
|
+
)
|
|
326
|
+
else:
|
|
327
|
+
if emoji_mode == "artifact":
|
|
328
|
+
chunks.append(_render_emoji(payload, context))
|
|
329
|
+
else:
|
|
330
|
+
chunks.append(_render_font_emoji(payload, emoji_command))
|
|
331
|
+
return "".join(chunks)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@renders(phase=RenderPhase.PRE, name="escape_plain_text", auto_mark=False)
|
|
335
|
+
def escape_plain_text(root: Tag, context: RenderContext) -> None:
|
|
336
|
+
"""Escape LaTeX characters on plain text nodes outside code blocks."""
|
|
337
|
+
legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
338
|
+
for node in list(root.find_all(string=True)):
|
|
339
|
+
if getattr(node, "processed", False):
|
|
340
|
+
continue
|
|
341
|
+
if _has_ancestor(node, "code", "script"):
|
|
342
|
+
continue
|
|
343
|
+
ancestor = getattr(node, "parent", None)
|
|
344
|
+
skip_plain_text = False
|
|
345
|
+
while ancestor is not None:
|
|
346
|
+
classes = gather_classes(getattr(ancestor, "get", lambda *_: None)("class"))
|
|
347
|
+
if "latex-raw" in classes or "arithmatex" in classes:
|
|
348
|
+
skip_plain_text = True
|
|
349
|
+
break
|
|
350
|
+
ancestor = getattr(ancestor, "parent", None)
|
|
351
|
+
if skip_plain_text:
|
|
352
|
+
continue
|
|
353
|
+
text = str(node)
|
|
354
|
+
if not text:
|
|
355
|
+
continue
|
|
356
|
+
if "\\keystroke{" in text or "\\keystrokes{" in text:
|
|
357
|
+
node.replace_with(mark_processed(NavigableString(text)))
|
|
358
|
+
continue
|
|
359
|
+
matches = list(_MATH_PAYLOAD_PATTERN.finditer(text))
|
|
360
|
+
if not matches:
|
|
361
|
+
escaped = _escape_text_segment(text, context, legacy_latex_accents=legacy_latex_accents)
|
|
362
|
+
if escaped != text:
|
|
363
|
+
node.replace_with(mark_processed(NavigableString(escaped)))
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
parts: list[str] = []
|
|
367
|
+
cursor = 0
|
|
368
|
+
for match in matches:
|
|
369
|
+
if match.start() > cursor:
|
|
370
|
+
segment = text[cursor : match.start()]
|
|
371
|
+
if segment:
|
|
372
|
+
escaped = _escape_text_segment(
|
|
373
|
+
segment,
|
|
374
|
+
context,
|
|
375
|
+
legacy_latex_accents=legacy_latex_accents,
|
|
376
|
+
)
|
|
377
|
+
parts.append(escaped)
|
|
378
|
+
parts.append(match.group(0))
|
|
379
|
+
cursor = match.end()
|
|
380
|
+
if cursor < len(text):
|
|
381
|
+
tail = text[cursor:]
|
|
382
|
+
if tail:
|
|
383
|
+
escaped = _escape_text_segment(
|
|
384
|
+
tail,
|
|
385
|
+
context,
|
|
386
|
+
legacy_latex_accents=legacy_latex_accents,
|
|
387
|
+
)
|
|
388
|
+
parts.append(escaped)
|
|
389
|
+
|
|
390
|
+
replacement = mark_processed(NavigableString("".join(parts)))
|
|
391
|
+
node.replace_with(replacement)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@renders("a", phase=RenderPhase.PRE, priority=80, name="unicode_links", nestable=False)
|
|
395
|
+
def render_unicode_link(element: Tag, context: RenderContext) -> None:
|
|
396
|
+
"""Render Unicode helper links."""
|
|
397
|
+
classes = gather_classes(element.get("class"))
|
|
398
|
+
if "ycr-unicode" not in classes:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
code = element.get_text(strip=True)
|
|
402
|
+
href = coerce_attribute(element.get("href")) or ""
|
|
403
|
+
latex = context.formatter.href(text=f"U+{code}", url=requote_url(href))
|
|
404
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@renders("a", phase=RenderPhase.PRE, priority=70, name="regex_links", nestable=False)
|
|
408
|
+
def render_regex_link(element: Tag, context: RenderContext) -> None:
|
|
409
|
+
"""Render custom regex helper links."""
|
|
410
|
+
classes = gather_classes(element.get("class"))
|
|
411
|
+
if "ycr-regex" not in classes:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
code = element.get_text(strip=False)
|
|
415
|
+
if code_tag := element.find("code"):
|
|
416
|
+
code = code_tag.get_text(strip=False)
|
|
417
|
+
code = code.replace("&", "\\&").replace("#", "\\#")
|
|
418
|
+
|
|
419
|
+
href = coerce_attribute(element.get("href")) or ""
|
|
420
|
+
latex = context.formatter.regex(code, url=requote_url(href))
|
|
421
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _extract_code_text(element: Tag) -> str:
|
|
425
|
+
classes = gather_classes(element.get("class"))
|
|
426
|
+
if any(cls.startswith("language-") for cls in classes) or "highlight" in classes:
|
|
427
|
+
return "".join(child.get_text(strip=False) for child in element.find_all("span"))
|
|
428
|
+
return element.get_text(strip=False)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _pick_mintinline_delimiter(text: str) -> str | None:
|
|
432
|
+
for delimiter in _MINTINLINE_DELIMITERS:
|
|
433
|
+
if delimiter not in text:
|
|
434
|
+
return delimiter
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@renders("code", phase=RenderPhase.PRE, priority=50, name="inline_code", nestable=False)
|
|
439
|
+
def render_inline_code(element: Tag, context: RenderContext) -> None:
|
|
440
|
+
"""Render inline code elements using the formatter."""
|
|
441
|
+
if element.find_parent("pre"):
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
classes = gather_classes(element.get("class"))
|
|
445
|
+
code = _extract_code_text(element)
|
|
446
|
+
if "\n" in code:
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
engine = _resolve_code_engine(context)
|
|
450
|
+
language_hint = None
|
|
451
|
+
if code.startswith("#!"):
|
|
452
|
+
shebang_parts = code[2:].strip().split(None, 1)
|
|
453
|
+
if shebang_parts:
|
|
454
|
+
language_hint = shebang_parts[0]
|
|
455
|
+
code = shebang_parts[1] if len(shebang_parts) > 1 else ""
|
|
456
|
+
|
|
457
|
+
has_language = any(cls.startswith("language-") for cls in classes)
|
|
458
|
+
language = None
|
|
459
|
+
if has_language or "highlight" in classes:
|
|
460
|
+
language = next(
|
|
461
|
+
(cls[len("language-") :] or "text" for cls in classes if cls.startswith("language-")),
|
|
462
|
+
"text",
|
|
463
|
+
)
|
|
464
|
+
if language_hint and not language:
|
|
465
|
+
language = language_hint
|
|
466
|
+
|
|
467
|
+
if language:
|
|
468
|
+
delimiter = _pick_mintinline_delimiter(code)
|
|
469
|
+
if delimiter and engine == "minted":
|
|
470
|
+
context.state.requires_shell_escape = (
|
|
471
|
+
context.state.requires_shell_escape or engine == "minted"
|
|
472
|
+
)
|
|
473
|
+
latex = context.formatter.codeinline(
|
|
474
|
+
language=language or "text",
|
|
475
|
+
text=code,
|
|
476
|
+
engine=engine,
|
|
477
|
+
)
|
|
478
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
479
|
+
return
|
|
480
|
+
latex = context.formatter.codeinline(
|
|
481
|
+
language=language or "text",
|
|
482
|
+
text=code,
|
|
483
|
+
engine=engine,
|
|
484
|
+
state=context.state,
|
|
485
|
+
)
|
|
486
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
latex = context.formatter.codeinlinett(code)
|
|
490
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@renders(
|
|
494
|
+
"span",
|
|
495
|
+
phase=RenderPhase.PRE,
|
|
496
|
+
priority=60,
|
|
497
|
+
name="inline_math",
|
|
498
|
+
auto_mark=False,
|
|
499
|
+
)
|
|
500
|
+
def render_math_inline(element: Tag, _context: RenderContext) -> None:
|
|
501
|
+
"""Preserve inline math payloads untouched."""
|
|
502
|
+
classes = gather_classes(element.get("class"))
|
|
503
|
+
if "arithmatex" not in classes:
|
|
504
|
+
return
|
|
505
|
+
text = element.get_text(strip=False)
|
|
506
|
+
element.replace_with(mark_processed(NavigableString(text)))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@renders(
|
|
510
|
+
"div",
|
|
511
|
+
phase=RenderPhase.PRE,
|
|
512
|
+
priority=30,
|
|
513
|
+
name="math_block",
|
|
514
|
+
auto_mark=False,
|
|
515
|
+
)
|
|
516
|
+
def render_math_block(element: Tag, _context: RenderContext) -> None:
|
|
517
|
+
"""Preserve block math payloads."""
|
|
518
|
+
classes = gather_classes(element.get("class"))
|
|
519
|
+
if "arithmatex" not in classes:
|
|
520
|
+
return
|
|
521
|
+
text = element.get_text(strip=False)
|
|
522
|
+
stripped = text.strip()
|
|
523
|
+
|
|
524
|
+
match = _DISPLAY_MATH_PATTERN.match(stripped)
|
|
525
|
+
if match:
|
|
526
|
+
inner = match.group(1)
|
|
527
|
+
if _payload_is_block_environment(inner):
|
|
528
|
+
# align/equation environments already provide display math.
|
|
529
|
+
latex = f"\n{inner.strip()}\n"
|
|
530
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
element.replace_with(mark_processed(NavigableString(f"\n{text}\n")))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@renders(
|
|
537
|
+
"script",
|
|
538
|
+
phase=RenderPhase.PRE,
|
|
539
|
+
priority=65,
|
|
540
|
+
name="math_script",
|
|
541
|
+
nestable=False,
|
|
542
|
+
auto_mark=False,
|
|
543
|
+
)
|
|
544
|
+
def render_math_script(element: Tag, _context: RenderContext) -> None:
|
|
545
|
+
"""Preserve math payloads generated via script tags (e.g. mdx_math)."""
|
|
546
|
+
type_attr = coerce_attribute(element.get("type"))
|
|
547
|
+
if type_attr is None:
|
|
548
|
+
return
|
|
549
|
+
if not type_attr.startswith("math/tex"):
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
payload = element.get_text(strip=False)
|
|
553
|
+
if payload is None:
|
|
554
|
+
payload = ""
|
|
555
|
+
payload = payload.strip()
|
|
556
|
+
is_display = "mode=display" in type_attr
|
|
557
|
+
|
|
558
|
+
if not payload:
|
|
559
|
+
node = NavigableString("")
|
|
560
|
+
elif is_display:
|
|
561
|
+
if _payload_is_block_environment(payload):
|
|
562
|
+
node = NavigableString(f"\n{payload}\n")
|
|
563
|
+
else:
|
|
564
|
+
node = NavigableString(f"\n$$\n{payload}\n$$\n")
|
|
565
|
+
else:
|
|
566
|
+
node = NavigableString(f"${payload}$")
|
|
567
|
+
|
|
568
|
+
element.replace_with(mark_processed(node))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@renders("abbr", phase=RenderPhase.INLINE, priority=30, name="abbreviation", nestable=False)
|
|
572
|
+
def render_abbreviation(element: Tag, context: RenderContext) -> None:
|
|
573
|
+
"""Register and render abbreviations."""
|
|
574
|
+
title_attr = element.get("title")
|
|
575
|
+
description = title_attr.strip() if isinstance(title_attr, str) else ""
|
|
576
|
+
term = element.get_text(strip=True)
|
|
577
|
+
|
|
578
|
+
if not term:
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
if not description:
|
|
582
|
+
legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
583
|
+
latex_text = escape_latex_chars(term, legacy_accents=legacy_latex_accents)
|
|
584
|
+
element.replace_with(mark_processed(NavigableString(latex_text)))
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
key = context.state.remember_abbreviation(term, description)
|
|
588
|
+
if not key:
|
|
589
|
+
key = term
|
|
590
|
+
|
|
591
|
+
latex = f"\\acrshort{{{key}}}"
|
|
592
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@renders("span", phase=RenderPhase.INLINE, priority=40, name="keystrokes", nestable=False)
|
|
596
|
+
def render_keystrokes(element: Tag, context: RenderContext) -> None:
|
|
597
|
+
"""Render keyboard shortcut markup."""
|
|
598
|
+
classes = gather_classes(element.get("class"))
|
|
599
|
+
if "keys" not in classes:
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
keys: list[str] = []
|
|
603
|
+
for key in element.find_all("kbd"):
|
|
604
|
+
key_classes = gather_classes(key.get("class"))
|
|
605
|
+
matched: Iterable[str] = (cls[4:] for cls in key_classes if cls.startswith("key-"))
|
|
606
|
+
value = next(matched, None)
|
|
607
|
+
if value:
|
|
608
|
+
keys.append(value)
|
|
609
|
+
else:
|
|
610
|
+
keys.append(key.get_text(strip=True))
|
|
611
|
+
|
|
612
|
+
latex = context.formatter.keystroke(keys)
|
|
613
|
+
node = mark_processed(NavigableString(latex))
|
|
614
|
+
context.mark_processed(element)
|
|
615
|
+
context.suppress_children(element)
|
|
616
|
+
element.replace_with(node)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@renders(
|
|
620
|
+
"span",
|
|
621
|
+
phase=RenderPhase.INLINE,
|
|
622
|
+
priority=30,
|
|
623
|
+
name="script_spans",
|
|
624
|
+
nestable=False,
|
|
625
|
+
auto_mark=False,
|
|
626
|
+
)
|
|
627
|
+
def render_script_spans(element: Tag, context: RenderContext) -> None:
|
|
628
|
+
"""Render spans tagged with data-script into explicit text commands."""
|
|
629
|
+
slug = coerce_attribute(element.get("data-script"))
|
|
630
|
+
if not slug:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
raw_text = element.get_text(strip=False)
|
|
634
|
+
if not raw_text:
|
|
635
|
+
element.decompose()
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
record_script_usage_for_slug(slug, raw_text, context)
|
|
639
|
+
legacy_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
640
|
+
payload = escape_latex_chars(raw_text, legacy_accents=legacy_accents)
|
|
641
|
+
latex = f"\\text{slug}{{{payload}}}"
|
|
642
|
+
parent = element.parent
|
|
643
|
+
if parent is not None and getattr(parent, "attrs", None) is not None:
|
|
644
|
+
parent.attrs["data-texsmith-latex"] = "true"
|
|
645
|
+
context.mark_processed(element)
|
|
646
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@renders(
|
|
650
|
+
"span",
|
|
651
|
+
phase=RenderPhase.INLINE,
|
|
652
|
+
priority=30,
|
|
653
|
+
name="latex_text",
|
|
654
|
+
nestable=False,
|
|
655
|
+
auto_mark=False,
|
|
656
|
+
)
|
|
657
|
+
def render_latex_text_span(element: Tag, context: RenderContext) -> None:
|
|
658
|
+
"""Render the custom ``latex-text`` span into canonical LaTeX."""
|
|
659
|
+
classes = gather_classes(element.get("class"))
|
|
660
|
+
if "latex-text" not in classes:
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
latex = mark_processed(NavigableString(r"\LaTeX{}"))
|
|
664
|
+
context.mark_processed(element)
|
|
665
|
+
context.suppress_children(element)
|
|
666
|
+
element.replace_with(latex)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _extract_emoji_token(element: Tag) -> str:
|
|
670
|
+
for attr in ("alt", "data-emoji"):
|
|
671
|
+
candidate = coerce_attribute(element.get(attr))
|
|
672
|
+
if candidate:
|
|
673
|
+
return candidate
|
|
674
|
+
fallback = coerce_attribute(element.get("title"))
|
|
675
|
+
return fallback or ""
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@renders("img", phase=RenderPhase.INLINE, priority=20, name="twemoji_images", nestable=False)
|
|
679
|
+
def render_twemoji_image(element: Tag, context: RenderContext) -> None:
|
|
680
|
+
"""Render Twitter emoji images as inline icons."""
|
|
681
|
+
classes = gather_classes(element.get("class"))
|
|
682
|
+
if not {"twemoji", "emojione"}.intersection(classes):
|
|
683
|
+
return
|
|
684
|
+
emoji_mode = _get_emoji_mode(context)
|
|
685
|
+
if emoji_mode != "artifact":
|
|
686
|
+
token = _extract_emoji_token(element)
|
|
687
|
+
latex = _render_font_emoji(token, _get_emoji_command(context))
|
|
688
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
689
|
+
return
|
|
690
|
+
if not context.runtime.get("copy_assets", True):
|
|
691
|
+
placeholder = (
|
|
692
|
+
coerce_attribute(element.get("alt")) or coerce_attribute(element.get("title")) or ""
|
|
693
|
+
)
|
|
694
|
+
element.replace_with(mark_processed(NavigableString(placeholder)))
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
src = coerce_attribute(element.get("src"))
|
|
698
|
+
if not src:
|
|
699
|
+
raise InvalidNodeError("Twemoji image without 'src' attribute")
|
|
700
|
+
if not is_valid_url(src):
|
|
701
|
+
raise InvalidNodeError("Twemoji images must reference remote assets")
|
|
702
|
+
|
|
703
|
+
artefact = fetch_image(src, output_dir=context.assets.output_root)
|
|
704
|
+
stored_path = context.assets.register(src, artefact)
|
|
705
|
+
asset_path = context.assets.latex_path(stored_path)
|
|
706
|
+
|
|
707
|
+
latex = context.formatter.icon(asset_path)
|
|
708
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@renders(
|
|
712
|
+
"span",
|
|
713
|
+
phase=RenderPhase.INLINE,
|
|
714
|
+
priority=25,
|
|
715
|
+
name="twemoji_svg",
|
|
716
|
+
nestable=False,
|
|
717
|
+
auto_mark=False,
|
|
718
|
+
)
|
|
719
|
+
def render_twemoji_span(element: Tag, context: RenderContext) -> None:
|
|
720
|
+
"""Render inline SVG emoji payloads."""
|
|
721
|
+
classes = gather_classes(element.get("class"))
|
|
722
|
+
if "twemoji" not in classes:
|
|
723
|
+
return
|
|
724
|
+
emoji_mode = _get_emoji_mode(context)
|
|
725
|
+
if emoji_mode != "artifact":
|
|
726
|
+
token = _extract_emoji_token(element)
|
|
727
|
+
latex = _render_font_emoji(token, _get_emoji_command(context))
|
|
728
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
729
|
+
return
|
|
730
|
+
if not context.runtime.get("copy_assets", True):
|
|
731
|
+
placeholder = coerce_attribute(element.get("title")) or element.get_text(strip=True) or ""
|
|
732
|
+
element.replace_with(mark_processed(NavigableString(placeholder)))
|
|
733
|
+
return
|
|
734
|
+
|
|
735
|
+
svg = element.find("svg")
|
|
736
|
+
if svg is None:
|
|
737
|
+
raise InvalidNodeError("Expected inline SVG inside span.twemoji")
|
|
738
|
+
|
|
739
|
+
svg_payload = str(svg)
|
|
740
|
+
artefact = svg2pdf(svg_payload, output_dir=context.assets.output_root)
|
|
741
|
+
digest = hashlib.sha256(svg_payload.encode("utf-8")).hexdigest()
|
|
742
|
+
stored_path = context.assets.register(f"twemoji::{digest}", artefact)
|
|
743
|
+
asset_path = context.assets.latex_path(stored_path)
|
|
744
|
+
|
|
745
|
+
latex = context.formatter.icon(asset_path)
|
|
746
|
+
node = mark_processed(NavigableString(latex))
|
|
747
|
+
context.mark_processed(element)
|
|
748
|
+
context.suppress_children(element)
|
|
749
|
+
element.replace_with(node)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@renders(
|
|
753
|
+
"span",
|
|
754
|
+
"a",
|
|
755
|
+
phase=RenderPhase.INLINE,
|
|
756
|
+
priority=45,
|
|
757
|
+
name="index_entries",
|
|
758
|
+
nestable=False,
|
|
759
|
+
auto_mark=False,
|
|
760
|
+
)
|
|
761
|
+
def render_index_entry(element: Tag, context: RenderContext) -> None:
|
|
762
|
+
"""Render inline index term annotations."""
|
|
763
|
+
tag_name = coerce_attribute(element.get("data-tag-name"))
|
|
764
|
+
if not tag_name:
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
raw_entry = str(tag_name)
|
|
768
|
+
parts = [segment.strip() for segment in raw_entry.split(",") if segment.strip()]
|
|
769
|
+
if not parts:
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
773
|
+
escaped_fragments = [
|
|
774
|
+
render_moving_text(part, context, legacy_accents=legacy_latex_accents, wrap_scripts=True)
|
|
775
|
+
or ""
|
|
776
|
+
for part in parts
|
|
777
|
+
]
|
|
778
|
+
escaped_entry = "!".join(fragment for fragment in escaped_fragments if fragment)
|
|
779
|
+
style_value = coerce_attribute(element.get("data-tag-style"))
|
|
780
|
+
style_key = style_value.strip().lower() if style_value else ""
|
|
781
|
+
if style_key not in {"b", "i", "bi"}:
|
|
782
|
+
style_key = ""
|
|
783
|
+
|
|
784
|
+
display_text = element.get_text(strip=False) or ""
|
|
785
|
+
escaped_text = (
|
|
786
|
+
render_moving_text(
|
|
787
|
+
display_text, context, legacy_accents=legacy_latex_accents, wrap_scripts=True
|
|
788
|
+
)
|
|
789
|
+
or ""
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
latex = context.formatter.index(escaped_text, entry=escaped_entry, style=style_key)
|
|
793
|
+
node = mark_processed(NavigableString(latex))
|
|
794
|
+
context.state.has_index_entries = True
|
|
795
|
+
context.mark_processed(element)
|
|
796
|
+
context.suppress_children(element)
|
|
797
|
+
element.replace_with(node)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@renders(
|
|
801
|
+
"del",
|
|
802
|
+
phase=RenderPhase.INLINE,
|
|
803
|
+
priority=35,
|
|
804
|
+
name="critic_deletions",
|
|
805
|
+
auto_mark=False,
|
|
806
|
+
)
|
|
807
|
+
def render_critic_deletions(element: Tag, context: RenderContext) -> None:
|
|
808
|
+
"""Convert critic-marked deletions into LaTeX review macros."""
|
|
809
|
+
classes = gather_classes(element.get("class"))
|
|
810
|
+
if "critic" not in classes:
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
text = element.get_text(strip=False)
|
|
814
|
+
latex = context.formatter.deletion(text=text)
|
|
815
|
+
node = mark_processed(NavigableString(latex))
|
|
816
|
+
context.mark_processed(element)
|
|
817
|
+
context.suppress_children(element)
|
|
818
|
+
element.replace_with(node)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@renders(
|
|
822
|
+
"ins",
|
|
823
|
+
phase=RenderPhase.INLINE,
|
|
824
|
+
priority=35,
|
|
825
|
+
name="critic_additions",
|
|
826
|
+
auto_mark=False,
|
|
827
|
+
)
|
|
828
|
+
def render_critic_additions(element: Tag, context: RenderContext) -> None:
|
|
829
|
+
"""Convert critic-marked insertions into LaTeX review macros."""
|
|
830
|
+
classes = gather_classes(element.get("class"))
|
|
831
|
+
if "critic" not in classes:
|
|
832
|
+
return
|
|
833
|
+
|
|
834
|
+
text = element.get_text(strip=False)
|
|
835
|
+
latex = context.formatter.addition(text=text)
|
|
836
|
+
node = mark_processed(NavigableString(latex))
|
|
837
|
+
context.mark_processed(element)
|
|
838
|
+
context.suppress_children(element)
|
|
839
|
+
element.replace_with(node)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
@renders(
|
|
843
|
+
"span",
|
|
844
|
+
phase=RenderPhase.INLINE,
|
|
845
|
+
priority=35,
|
|
846
|
+
name="critic_comments",
|
|
847
|
+
auto_mark=False,
|
|
848
|
+
)
|
|
849
|
+
def render_critic_comments(element: Tag, context: RenderContext) -> None:
|
|
850
|
+
"""Render critic comments as inline LaTeX annotations."""
|
|
851
|
+
classes = gather_classes(element.get("class"))
|
|
852
|
+
if "critic" not in classes or "comment" not in classes:
|
|
853
|
+
return
|
|
854
|
+
|
|
855
|
+
text = element.get_text(strip=False)
|
|
856
|
+
latex = context.formatter.comment(text=text)
|
|
857
|
+
node = mark_processed(NavigableString(latex))
|
|
858
|
+
context.mark_processed(element)
|
|
859
|
+
context.suppress_children(element)
|
|
860
|
+
element.replace_with(node)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@renders(
|
|
864
|
+
"mark",
|
|
865
|
+
phase=RenderPhase.INLINE,
|
|
866
|
+
priority=35,
|
|
867
|
+
name="critic_highlight",
|
|
868
|
+
auto_mark=False,
|
|
869
|
+
)
|
|
870
|
+
def render_critic_highlight(element: Tag, context: RenderContext) -> None:
|
|
871
|
+
"""Render critic highlights using the formatter highlighting helper."""
|
|
872
|
+
classes = gather_classes(element.get("class"))
|
|
873
|
+
if "critic" not in classes:
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
text = element.get_text(strip=False)
|
|
877
|
+
latex = context.formatter.highlight(text=text)
|
|
878
|
+
node = mark_processed(NavigableString(latex))
|
|
879
|
+
context.mark_processed(element)
|
|
880
|
+
context.suppress_children(element)
|
|
881
|
+
element.replace_with(node)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@renders(
|
|
885
|
+
"span",
|
|
886
|
+
phase=RenderPhase.INLINE,
|
|
887
|
+
priority=-10,
|
|
888
|
+
name="critic_substitution",
|
|
889
|
+
auto_mark=False,
|
|
890
|
+
)
|
|
891
|
+
def render_critic_substitution(element: Tag, context: RenderContext) -> None:
|
|
892
|
+
"""Render critic substitutions as paired deletion/addition markup."""
|
|
893
|
+
classes = gather_classes(element.get("class"))
|
|
894
|
+
if "critic" not in classes or "subst" not in classes:
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
deleted = element.find("del")
|
|
898
|
+
inserted = element.find("ins")
|
|
899
|
+
if deleted is None or inserted is None:
|
|
900
|
+
raise InvalidNodeError("Critic substitution requires both <del> and <ins> children")
|
|
901
|
+
|
|
902
|
+
original = deleted.get_text(strip=False)
|
|
903
|
+
replacement = inserted.get_text(strip=False)
|
|
904
|
+
|
|
905
|
+
latex = context.formatter.substitution(original=original, replacement=replacement)
|
|
906
|
+
node = mark_processed(NavigableString(latex))
|
|
907
|
+
context.mark_processed(element)
|
|
908
|
+
context.suppress_children(element)
|
|
909
|
+
element.replace_with(node)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
_BLOCK_MATH_ENVIRONMENTS = {
|
|
913
|
+
"align",
|
|
914
|
+
"align*",
|
|
915
|
+
"equation",
|
|
916
|
+
"equation*",
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
_DISPLAY_MATH_PATTERN = re.compile(r"^\\\[\s*(.*?)\s*\\\]\s*$", re.DOTALL)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _payload_is_block_environment(payload: str) -> bool:
|
|
924
|
+
stripped = payload.lstrip()
|
|
925
|
+
match = re.match(r"\\begin\{([^}]+)\}", stripped)
|
|
926
|
+
return bool(match and match.group(1).lower() in _BLOCK_MATH_ENVIRONMENTS)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@renders(phase=RenderPhase.POST, priority=5, name="inline_code_fallback", auto_mark=False)
|
|
930
|
+
def render_inline_code_fallback(root: Tag, context: RenderContext) -> None:
|
|
931
|
+
"""Convert lingering inline code nodes that escaped the PRE phase."""
|
|
932
|
+
for code in list(root.find_all("code")):
|
|
933
|
+
if code.find_parent("pre"):
|
|
934
|
+
continue
|
|
935
|
+
if context.is_processed(code):
|
|
936
|
+
continue
|
|
937
|
+
render_inline_code(code, context)
|