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,851 @@
|
|
|
1
|
+
"""Block-level handlers for structural HTML elements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
import io
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
from bs4.element import NavigableString, Tag
|
|
13
|
+
from pybtex.database.input import bibtex
|
|
14
|
+
from pybtex.exceptions import PybtexError
|
|
15
|
+
|
|
16
|
+
from texsmith.adapters.latex.utils import escape_latex_chars
|
|
17
|
+
from texsmith.core.context import RenderContext
|
|
18
|
+
from texsmith.core.exceptions import AssetMissingError, InvalidNodeError
|
|
19
|
+
from texsmith.core.rules import RenderPhase, renders
|
|
20
|
+
from texsmith.fonts.scripts import record_script_usage_for_slug, render_moving_text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover - typing helpers
|
|
24
|
+
from texsmith.core.bibliography.collection import BibliographyCollection
|
|
25
|
+
|
|
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 .code import (
|
|
35
|
+
render_code_blocks as _render_code_block,
|
|
36
|
+
render_preformatted_code as _render_preformatted_code,
|
|
37
|
+
render_standalone_code_blocks as _render_standalone_code_block,
|
|
38
|
+
)
|
|
39
|
+
from .inline import _MATH_PAYLOAD_PATTERN, render_inline_code as _render_inline_code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prepare_rich_text_content(container: Tag, context: RenderContext) -> None:
|
|
43
|
+
"""Ensure inline and block code inside containers render before flattening."""
|
|
44
|
+
for highlight in list(container.find_all("div")):
|
|
45
|
+
classes = gather_classes(highlight.get("class"))
|
|
46
|
+
if "highlight" in classes or "codehilite" in classes:
|
|
47
|
+
_render_code_block(highlight, context)
|
|
48
|
+
|
|
49
|
+
for pre in list(container.find_all("pre")):
|
|
50
|
+
_render_preformatted_code(pre, context)
|
|
51
|
+
|
|
52
|
+
for code_element in list(container.find_all("code")):
|
|
53
|
+
_render_standalone_code_block(code_element, context)
|
|
54
|
+
|
|
55
|
+
for inline in list(container.find_all("code")):
|
|
56
|
+
if inline.find_parent("pre"):
|
|
57
|
+
continue
|
|
58
|
+
_render_inline_code(inline, context)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _iter_reversed(nodes: Iterable[Tag]) -> Iterable[Tag]:
|
|
62
|
+
stack = list(nodes)
|
|
63
|
+
while stack:
|
|
64
|
+
yield stack.pop()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_source_path(context: RenderContext, src: str) -> Path | None:
|
|
68
|
+
runtime_dir = context.runtime.get("source_dir")
|
|
69
|
+
if runtime_dir is not None:
|
|
70
|
+
candidate = Path(runtime_dir) / src
|
|
71
|
+
if candidate.exists():
|
|
72
|
+
return candidate.resolve()
|
|
73
|
+
|
|
74
|
+
document_path = context.runtime.get("document_path")
|
|
75
|
+
if document_path is not None:
|
|
76
|
+
resolved = resolve_asset_path(Path(document_path), src)
|
|
77
|
+
if resolved is not None:
|
|
78
|
+
return resolved
|
|
79
|
+
|
|
80
|
+
project_dir = getattr(context.config, "project_dir", None)
|
|
81
|
+
if project_dir:
|
|
82
|
+
candidate = Path(project_dir) / src
|
|
83
|
+
if candidate.exists():
|
|
84
|
+
return candidate.resolve()
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _figure_template_for(element: Tag, context: RenderContext) -> str:
|
|
90
|
+
current = element
|
|
91
|
+
while current is not None:
|
|
92
|
+
raw_classes = getattr(current, "get", lambda *_: None)("class")
|
|
93
|
+
class_list = gather_classes(raw_classes)
|
|
94
|
+
if any(cls in {"admonition", "exercise"} for cls in class_list):
|
|
95
|
+
return "figure_tcolorbox"
|
|
96
|
+
if getattr(current, "name", None) == "details":
|
|
97
|
+
return "figure_tcolorbox"
|
|
98
|
+
current = getattr(current, "parent", None)
|
|
99
|
+
return context.runtime.get("figure_template", "figure")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strip_caption_prefix(node: Tag | None) -> None:
|
|
103
|
+
if node is None:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
for span in list(node.find_all("span")):
|
|
107
|
+
classes = gather_classes(span.get("class"))
|
|
108
|
+
if "caption-prefix" in classes or "figure-prefix" in classes:
|
|
109
|
+
span.decompose()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _render_script_paragraphs(element: Tag, context: RenderContext) -> bool:
|
|
113
|
+
"""Render consecutive data-script paragraphs into grouped environments."""
|
|
114
|
+
slug = coerce_attribute(element.get("data-script"))
|
|
115
|
+
if not slug:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
paragraphs: list[Tag] = []
|
|
119
|
+
cursor: Tag | None = element
|
|
120
|
+
while cursor is not None and isinstance(cursor, Tag):
|
|
121
|
+
if cursor.name != "p" or coerce_attribute(cursor.get("data-script")) != slug:
|
|
122
|
+
break
|
|
123
|
+
paragraphs.append(cursor)
|
|
124
|
+
cursor = cursor.find_next_sibling(lambda tag: isinstance(tag, Tag))
|
|
125
|
+
|
|
126
|
+
if not paragraphs:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
legacy_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
130
|
+
bodies: list[str] = []
|
|
131
|
+
for para in paragraphs:
|
|
132
|
+
text = para.get_text(strip=False)
|
|
133
|
+
if not text.strip():
|
|
134
|
+
continue
|
|
135
|
+
bodies.append(escape_latex_chars(text, legacy_accents=legacy_accents))
|
|
136
|
+
|
|
137
|
+
plain_text = "\n\n".join(p.get_text(strip=False) for p in paragraphs)
|
|
138
|
+
record_script_usage_for_slug(slug, plain_text, context)
|
|
139
|
+
|
|
140
|
+
content = "\n\n".join(bodies)
|
|
141
|
+
latex = f"\\begin{{{slug}}}\n{content}\n\\end{{{slug}}}\n\n"
|
|
142
|
+
replacement = mark_processed(NavigableString(latex))
|
|
143
|
+
paragraphs[-1].insert_after(replacement)
|
|
144
|
+
for para in paragraphs:
|
|
145
|
+
para.decompose()
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _split_citation_keys(identifier: str) -> list[str]:
|
|
150
|
+
"""Split a comma-separated string into individual citation keys."""
|
|
151
|
+
if not identifier:
|
|
152
|
+
return []
|
|
153
|
+
if "," not in identifier:
|
|
154
|
+
return [identifier.strip()] if _is_doi_key(identifier) else []
|
|
155
|
+
return [part.strip() for part in identifier.split(",") if part.strip()]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_multiline_footnote(text: str) -> bool:
|
|
159
|
+
"""Check whether rendered footnote text spans multiple non-empty lines."""
|
|
160
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
161
|
+
return len(lines) > 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_bibliography_placeholder(text: str) -> bool:
|
|
165
|
+
"""Return True when a footnote just points readers to the bibliography."""
|
|
166
|
+
normalised = text.strip().rstrip(".").strip().lower()
|
|
167
|
+
return normalised in {"see bibliography", "see the bibliography"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_DOI_KEY_PATTERN = r"10\.\d{4,9}/[^\s,\]]+"
|
|
171
|
+
_CITATION_KEY_PATTERN = rf"(?:{_DOI_KEY_PATTERN}|[A-Za-z0-9_\-:]+)"
|
|
172
|
+
_DOI_KEY_RE = re.compile(rf"^{_DOI_KEY_PATTERN}$")
|
|
173
|
+
_DEFAULT_DOI_SOURCE = Path("inline-doi-citations.bib")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_doi_key(candidate: str) -> bool:
|
|
177
|
+
"""Return True when a citation key matches a DOI shape."""
|
|
178
|
+
return bool(_DOI_KEY_RE.match(candidate.strip()))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _emit_bibliography_warning(context: RenderContext, message: str) -> None:
|
|
182
|
+
emitter = context.runtime.get("emitter")
|
|
183
|
+
if emitter is not None:
|
|
184
|
+
emitter.warning(message)
|
|
185
|
+
return
|
|
186
|
+
warnings.warn(message, stacklevel=2)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _inline_doi_source_path(context: RenderContext) -> Path:
|
|
190
|
+
"""Return a synthetic source path for inline DOI citations."""
|
|
191
|
+
document_path = context.runtime.get("document_path")
|
|
192
|
+
if isinstance(document_path, Path):
|
|
193
|
+
return Path(f"inline-doi-{document_path.stem}.bib")
|
|
194
|
+
try:
|
|
195
|
+
return Path(f"inline-doi-{Path(str(document_path)).stem}.bib")
|
|
196
|
+
except Exception:
|
|
197
|
+
return _DEFAULT_DOI_SOURCE
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ensure_bibliography_runtime(
|
|
201
|
+
context: RenderContext,
|
|
202
|
+
) -> tuple[dict[str, dict[str, object]], BibliographyCollection]:
|
|
203
|
+
from texsmith.core.bibliography.collection import BibliographyCollection
|
|
204
|
+
|
|
205
|
+
runtime_bibliography = context.runtime.get("bibliography")
|
|
206
|
+
if not isinstance(runtime_bibliography, dict):
|
|
207
|
+
runtime_bibliography = {}
|
|
208
|
+
context.runtime["bibliography"] = runtime_bibliography
|
|
209
|
+
|
|
210
|
+
collection = context.runtime.get("bibliography_collection")
|
|
211
|
+
if not isinstance(collection, BibliographyCollection):
|
|
212
|
+
collection = BibliographyCollection()
|
|
213
|
+
context.runtime["bibliography_collection"] = collection
|
|
214
|
+
|
|
215
|
+
return runtime_bibliography, collection
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _resolve_doi_fetcher(context: RenderContext) -> Any:
|
|
219
|
+
from texsmith.core.bibliography.doi import DoiBibliographyFetcher
|
|
220
|
+
|
|
221
|
+
fetcher = context.runtime.get("doi_fetcher")
|
|
222
|
+
if fetcher is not None:
|
|
223
|
+
return fetcher
|
|
224
|
+
|
|
225
|
+
fetcher = DoiBibliographyFetcher()
|
|
226
|
+
context.runtime["doi_fetcher"] = fetcher
|
|
227
|
+
return fetcher
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _materialise_doi_entry(key: str, context: RenderContext) -> dict[str, object] | None:
|
|
231
|
+
"""Fetch and register a bibliography entry for a DOI citation."""
|
|
232
|
+
from texsmith.core.bibliography.doi import DoiLookupError
|
|
233
|
+
|
|
234
|
+
bibliography = context.state.bibliography
|
|
235
|
+
runtime_bibliography, collection = _ensure_bibliography_runtime(context)
|
|
236
|
+
|
|
237
|
+
fetcher = _resolve_doi_fetcher(context)
|
|
238
|
+
fetch = getattr(fetcher, "fetch", None)
|
|
239
|
+
if not callable(fetch):
|
|
240
|
+
raise DoiLookupError("Configured DOI fetcher is missing a 'fetch' method.")
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
payload = fetch(key)
|
|
244
|
+
except DoiLookupError as exc:
|
|
245
|
+
_emit_bibliography_warning(context, f"Failed to resolve DOI '{key}': {exc}")
|
|
246
|
+
return None
|
|
247
|
+
except Exception as exc: # pragma: no cover - defensive fallback
|
|
248
|
+
_emit_bibliography_warning(context, f"Failed to resolve DOI '{key}': {exc}")
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
parser = bibtex.Parser()
|
|
252
|
+
try:
|
|
253
|
+
parsed = parser.parse_stream(io.StringIO(payload))
|
|
254
|
+
except (OSError, PybtexError) as exc:
|
|
255
|
+
_emit_bibliography_warning(context, f"Failed to parse bibliography entry '{key}': {exc}")
|
|
256
|
+
return None
|
|
257
|
+
if not parsed.entries:
|
|
258
|
+
_emit_bibliography_warning(context, f"Bibliography entry for DOI '{key}' is empty.")
|
|
259
|
+
return None
|
|
260
|
+
if len(parsed.entries) > 1:
|
|
261
|
+
_emit_bibliography_warning(
|
|
262
|
+
context,
|
|
263
|
+
f"Bibliography entry for DOI '{key}' contains multiple records; using the first.",
|
|
264
|
+
)
|
|
265
|
+
resolved_key, _entry_obj = next(iter(parsed.entries.items()))
|
|
266
|
+
|
|
267
|
+
source = _inline_doi_source_path(context)
|
|
268
|
+
collection.load_data(parsed, source=source)
|
|
269
|
+
entry = collection.find(resolved_key)
|
|
270
|
+
if entry is None:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
bibliography[resolved_key] = entry
|
|
274
|
+
runtime_bibliography[resolved_key] = entry
|
|
275
|
+
doi_map: dict[str, str] = context.runtime.setdefault("doi_citation_keys", {})
|
|
276
|
+
doi_map[key] = resolved_key
|
|
277
|
+
return entry
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ensure_doi_entries(keys: list[str], context: RenderContext) -> None:
|
|
281
|
+
"""Materialise bibliography entries for any DOI keys not yet loaded."""
|
|
282
|
+
doi_map: dict[str, str] = context.runtime.setdefault("doi_citation_keys", {})
|
|
283
|
+
for key in list(keys):
|
|
284
|
+
if key in context.state.bibliography:
|
|
285
|
+
continue
|
|
286
|
+
if not _is_doi_key(key):
|
|
287
|
+
continue
|
|
288
|
+
if key in doi_map:
|
|
289
|
+
continue
|
|
290
|
+
resolved = _materialise_doi_entry(key, context)
|
|
291
|
+
if resolved is None:
|
|
292
|
+
continue
|
|
293
|
+
resolved_key = doi_map.get(key)
|
|
294
|
+
if resolved_key:
|
|
295
|
+
# replace original DOI key in-place for downstream handling
|
|
296
|
+
try:
|
|
297
|
+
index = keys.index(key)
|
|
298
|
+
keys[index] = resolved_key
|
|
299
|
+
except ValueError:
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@renders("div", phase=RenderPhase.PRE, priority=120, name="tabbed_content", auto_mark=False)
|
|
304
|
+
def render_tabbed_content(element: Tag, context: RenderContext) -> None:
|
|
305
|
+
"""Unwrap MkDocs tabbed content structures."""
|
|
306
|
+
classes = gather_classes(element.get("class"))
|
|
307
|
+
if "tabbed-set" not in classes:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
titles: list[str] = []
|
|
311
|
+
if labels := element.find("div", class_="tabbed-labels"):
|
|
312
|
+
for label in labels.find_all("label"):
|
|
313
|
+
titles.append(label.get_text(strip=True))
|
|
314
|
+
labels.extract()
|
|
315
|
+
else:
|
|
316
|
+
fallback_labels = element.find_all("label", recursive=False)
|
|
317
|
+
for label in fallback_labels:
|
|
318
|
+
titles.append(label.get_text(strip=True))
|
|
319
|
+
label.extract()
|
|
320
|
+
|
|
321
|
+
for input_node in element.find_all("input", recursive=False):
|
|
322
|
+
input_node.extract()
|
|
323
|
+
|
|
324
|
+
content_containers = element.find_all("div", class_="tabbed-content", recursive=False)
|
|
325
|
+
if not content_containers:
|
|
326
|
+
candidate = element.find("div", class_="tabbed-content")
|
|
327
|
+
if candidate is None:
|
|
328
|
+
raise InvalidNodeError("Missing tabbed-content container inside tabbed-set")
|
|
329
|
+
content_containers = [candidate]
|
|
330
|
+
|
|
331
|
+
blocks: list[Tag] = []
|
|
332
|
+
for container in content_containers:
|
|
333
|
+
inner_blocks = container.find_all("div", class_="tabbed-block", recursive=False)
|
|
334
|
+
if inner_blocks:
|
|
335
|
+
blocks.extend(inner_blocks)
|
|
336
|
+
else:
|
|
337
|
+
blocks.append(container)
|
|
338
|
+
|
|
339
|
+
soup = element.soup
|
|
340
|
+
|
|
341
|
+
for index, block in enumerate(blocks):
|
|
342
|
+
title = titles[index] if index < len(titles) else ""
|
|
343
|
+
if soup is None:
|
|
344
|
+
heading = mark_processed(NavigableString(f"\\textbf{{{title}}}\\par\n"))
|
|
345
|
+
else:
|
|
346
|
+
heading = soup.new_tag("p")
|
|
347
|
+
strong = soup.new_tag("strong")
|
|
348
|
+
strong.string = title
|
|
349
|
+
heading.append(strong)
|
|
350
|
+
block.insert_before(heading)
|
|
351
|
+
|
|
352
|
+
for highlight in block.find_all("div"):
|
|
353
|
+
highlight_classes = gather_classes(highlight.get("class"))
|
|
354
|
+
if "highlight" not in highlight_classes:
|
|
355
|
+
continue
|
|
356
|
+
if context.is_processed(highlight):
|
|
357
|
+
continue
|
|
358
|
+
parent_before = highlight.parent
|
|
359
|
+
_render_code_block(highlight, context)
|
|
360
|
+
if highlight.parent is not None and highlight.parent is parent_before:
|
|
361
|
+
code_element = highlight.find("code")
|
|
362
|
+
code_text = (
|
|
363
|
+
code_element.get_text(strip=False)
|
|
364
|
+
if code_element is not None
|
|
365
|
+
else highlight.get_text(strip=False)
|
|
366
|
+
)
|
|
367
|
+
if code_text and not code_text.endswith("\n"):
|
|
368
|
+
code_text += "\n"
|
|
369
|
+
fallback = mark_processed(
|
|
370
|
+
NavigableString(
|
|
371
|
+
context.formatter.codeblock(
|
|
372
|
+
code=code_text,
|
|
373
|
+
language="text",
|
|
374
|
+
lineno=False,
|
|
375
|
+
filename=None,
|
|
376
|
+
highlight=[],
|
|
377
|
+
baselinestretch=None,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
highlight.replace_with(fallback)
|
|
382
|
+
context.mark_processed(highlight)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@renders(
|
|
386
|
+
"div",
|
|
387
|
+
phase=RenderPhase.PRE,
|
|
388
|
+
priority=130,
|
|
389
|
+
name="tabbed_cleanup",
|
|
390
|
+
auto_mark=False,
|
|
391
|
+
after_children=True,
|
|
392
|
+
)
|
|
393
|
+
def cleanup_tabbed_content(element: Tag, _context: RenderContext) -> None:
|
|
394
|
+
"""Remove tabbed container wrappers after children are processed."""
|
|
395
|
+
classes = gather_classes(element.get("class"))
|
|
396
|
+
if "tabbed-set" not in classes:
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
containers = element.find_all("div", class_="tabbed-content", recursive=False)
|
|
400
|
+
if not containers:
|
|
401
|
+
containers = element.find_all("div", class_="tabbed-content")
|
|
402
|
+
if not containers:
|
|
403
|
+
element.unwrap()
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
for container in containers:
|
|
407
|
+
inner_blocks = container.find_all("div", class_="tabbed-block", recursive=False)
|
|
408
|
+
if inner_blocks:
|
|
409
|
+
for block in inner_blocks:
|
|
410
|
+
block.unwrap()
|
|
411
|
+
container.unwrap()
|
|
412
|
+
element.unwrap()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@renders(
|
|
416
|
+
"blockquote",
|
|
417
|
+
phase=RenderPhase.POST,
|
|
418
|
+
priority=200,
|
|
419
|
+
name="blockquotes",
|
|
420
|
+
nestable=False,
|
|
421
|
+
after_children=True,
|
|
422
|
+
)
|
|
423
|
+
def render_blockquotes(element: Tag, context: RenderContext) -> None:
|
|
424
|
+
"""Convert blockquote elements into LaTeX blockquote environments."""
|
|
425
|
+
classes = element.get("class") or []
|
|
426
|
+
if "epigraph" in classes:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
text = element.get_text(strip=False)
|
|
430
|
+
latex = context.formatter.blockquote(text)
|
|
431
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@renders(phase=RenderPhase.POST, name="lists", auto_mark=False)
|
|
435
|
+
def render_lists(root: Tag, context: RenderContext) -> None:
|
|
436
|
+
"""Render ordered and unordered lists."""
|
|
437
|
+
for element in _iter_reversed(root.find_all(["ol", "ul"])):
|
|
438
|
+
_prepare_rich_text_content(element, context)
|
|
439
|
+
items: list[str] = []
|
|
440
|
+
checkboxes: list[int] = []
|
|
441
|
+
|
|
442
|
+
for li in element.find_all("li", recursive=False):
|
|
443
|
+
checkbox_input = li.find("input", attrs={"type": "checkbox"})
|
|
444
|
+
if checkbox_input is not None:
|
|
445
|
+
is_checked = checkbox_input.has_attr("checked")
|
|
446
|
+
checkboxes.append(1 if is_checked else -1)
|
|
447
|
+
checkbox_input.extract()
|
|
448
|
+
text = li.get_text(strip=False).strip()
|
|
449
|
+
else:
|
|
450
|
+
text = li.get_text(strip=False).strip()
|
|
451
|
+
if text.startswith("[ ]"):
|
|
452
|
+
checkboxes.append(-1)
|
|
453
|
+
text = text[3:].strip()
|
|
454
|
+
elif text.startswith("[x]") or text.startswith("[X]"):
|
|
455
|
+
checkboxes.append(1)
|
|
456
|
+
text = text[3:].strip()
|
|
457
|
+
else:
|
|
458
|
+
checkboxes.append(0)
|
|
459
|
+
items.append(text)
|
|
460
|
+
|
|
461
|
+
has_checkbox = any(checkboxes) or bool(gather_classes(element.get("class")))
|
|
462
|
+
latex: str
|
|
463
|
+
|
|
464
|
+
if element.name == "ol":
|
|
465
|
+
latex = context.formatter.ordered_list(items=items)
|
|
466
|
+
else:
|
|
467
|
+
if has_checkbox:
|
|
468
|
+
choices = list(zip((c > 0 for c in checkboxes), items, strict=False))
|
|
469
|
+
latex = context.formatter.choices(items=choices)
|
|
470
|
+
else:
|
|
471
|
+
latex = context.formatter.unordered_list(items=items)
|
|
472
|
+
|
|
473
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@renders(phase=RenderPhase.POST, priority=15, name="description_lists", auto_mark=False)
|
|
477
|
+
def render_description_lists(root: Tag, context: RenderContext) -> None:
|
|
478
|
+
"""Render <dl> elements."""
|
|
479
|
+
for dl in _iter_reversed(root.find_all("dl")):
|
|
480
|
+
_prepare_rich_text_content(dl, context)
|
|
481
|
+
items: list[tuple[str | None, str]] = []
|
|
482
|
+
current_term: str | None = None
|
|
483
|
+
|
|
484
|
+
for child in dl.find_all(["dt", "dd"], recursive=False):
|
|
485
|
+
if child.name == "dt":
|
|
486
|
+
term = child.get_text(strip=False).strip()
|
|
487
|
+
current_term = term or None
|
|
488
|
+
elif child.name == "dd":
|
|
489
|
+
content = child.get_text(strip=False).strip()
|
|
490
|
+
if not content and current_term is None:
|
|
491
|
+
continue
|
|
492
|
+
items.append((current_term, content))
|
|
493
|
+
|
|
494
|
+
if not items:
|
|
495
|
+
warnings.warn("Discarding empty description list.", stacklevel=2)
|
|
496
|
+
dl.decompose()
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
latex = context.formatter.description_list(items=items)
|
|
500
|
+
dl.replace_with(mark_processed(NavigableString(latex)))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@renders(phase=RenderPhase.POST, priority=5, name="fallback_highlight_blocks", auto_mark=False)
|
|
504
|
+
def render_remaining_code_blocks(root: Tag, context: RenderContext) -> None:
|
|
505
|
+
"""Convert any remaining MkDocs highlight blocks that escaped earlier passes."""
|
|
506
|
+
for highlight in _iter_reversed(root.find_all("div", class_="highlight")):
|
|
507
|
+
if context.is_processed(highlight):
|
|
508
|
+
continue
|
|
509
|
+
_render_code_block(highlight, context)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@renders(phase=RenderPhase.POST, priority=-10, name="footnotes", auto_mark=False)
|
|
513
|
+
def render_footnotes(root: Tag, context: RenderContext) -> None:
|
|
514
|
+
"""Extract and render footnote references."""
|
|
515
|
+
footnotes: dict[str, str] = {}
|
|
516
|
+
bibliography = context.state.bibliography
|
|
517
|
+
|
|
518
|
+
def _normalise_footnote_id(value: str | None) -> str:
|
|
519
|
+
if not value:
|
|
520
|
+
return ""
|
|
521
|
+
text = str(value).strip()
|
|
522
|
+
if ":" in text:
|
|
523
|
+
prefix, suffix = text.split(":", 1)
|
|
524
|
+
if prefix.startswith("fnref") or prefix.startswith("fn"):
|
|
525
|
+
return suffix
|
|
526
|
+
return text or ""
|
|
527
|
+
|
|
528
|
+
def _replace_with_latex(node: Tag, latex: str) -> None:
|
|
529
|
+
replacement = mark_processed(NavigableString(latex))
|
|
530
|
+
node.replace_with(replacement)
|
|
531
|
+
|
|
532
|
+
_citation_payload_pattern = re.compile(
|
|
533
|
+
rf"^\s*({_CITATION_KEY_PATTERN}(?:\s*,\s*{_CITATION_KEY_PATTERN})*)\s*$"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def _citation_keys_from_payload(text: str | None) -> list[str]:
|
|
537
|
+
if not text:
|
|
538
|
+
return []
|
|
539
|
+
match = _citation_payload_pattern.match(text)
|
|
540
|
+
if not match:
|
|
541
|
+
return []
|
|
542
|
+
keys = [part.strip() for part in match.group(1).split(",")]
|
|
543
|
+
return [key for key in keys if key]
|
|
544
|
+
|
|
545
|
+
def _render_citation(node: Tag, keys: list[str]) -> bool:
|
|
546
|
+
if not keys:
|
|
547
|
+
return False
|
|
548
|
+
_ensure_doi_entries(keys, context)
|
|
549
|
+
missing = [key for key in keys if key not in bibliography]
|
|
550
|
+
if missing:
|
|
551
|
+
return False
|
|
552
|
+
for key in keys:
|
|
553
|
+
context.state.record_citation(key)
|
|
554
|
+
latex = context.formatter.citation(key=",".join(keys))
|
|
555
|
+
_replace_with_latex(node, latex)
|
|
556
|
+
return True
|
|
557
|
+
|
|
558
|
+
citation_footnotes: dict[str, list[str]] = {}
|
|
559
|
+
invalid_footnotes: set[str] = set()
|
|
560
|
+
|
|
561
|
+
for container in root.find_all("div", class_="footnote"):
|
|
562
|
+
for li in container.find_all("li"):
|
|
563
|
+
footnote_id = _normalise_footnote_id(coerce_attribute(li.get("id")))
|
|
564
|
+
if not footnote_id:
|
|
565
|
+
raise InvalidNodeError("Footnote item missing identifier")
|
|
566
|
+
text = li.get_text(strip=False)
|
|
567
|
+
if _is_multiline_footnote(text):
|
|
568
|
+
warnings.warn(
|
|
569
|
+
f"Footnote '{footnote_id}' spans multiple lines and cannot be rendered; dropping it.",
|
|
570
|
+
stacklevel=2,
|
|
571
|
+
)
|
|
572
|
+
invalid_footnotes.add(footnote_id)
|
|
573
|
+
continue
|
|
574
|
+
text = text.strip()
|
|
575
|
+
footnotes[footnote_id] = text
|
|
576
|
+
recovered = _citation_keys_from_payload(text)
|
|
577
|
+
if recovered:
|
|
578
|
+
citation_footnotes[footnote_id] = recovered
|
|
579
|
+
container.decompose()
|
|
580
|
+
|
|
581
|
+
if footnotes:
|
|
582
|
+
context.state.footnotes.update(footnotes)
|
|
583
|
+
|
|
584
|
+
for sup in root.find_all("sup", id=True):
|
|
585
|
+
footnote_id = _normalise_footnote_id(coerce_attribute(sup.get("id")))
|
|
586
|
+
if footnote_id in invalid_footnotes:
|
|
587
|
+
sup.decompose()
|
|
588
|
+
continue
|
|
589
|
+
citation_keys = citation_footnotes.get(footnote_id)
|
|
590
|
+
if citation_keys and _render_citation(sup, citation_keys):
|
|
591
|
+
continue
|
|
592
|
+
payload = footnotes.get(footnote_id)
|
|
593
|
+
if payload is None:
|
|
594
|
+
payload = context.state.footnotes.get(footnote_id)
|
|
595
|
+
if payload is None:
|
|
596
|
+
citation_keys = _split_citation_keys(footnote_id)
|
|
597
|
+
if citation_keys and _render_citation(sup, citation_keys):
|
|
598
|
+
continue
|
|
599
|
+
# Fall back to default handling/warnings for unresolved citations.
|
|
600
|
+
if footnote_id and footnote_id in bibliography:
|
|
601
|
+
placeholder_note = bool(payload) and _is_bibliography_placeholder(payload)
|
|
602
|
+
if payload and not placeholder_note:
|
|
603
|
+
warnings.warn(
|
|
604
|
+
f"Conflicting bibliography definition for '{footnote_id}'.",
|
|
605
|
+
stacklevel=2,
|
|
606
|
+
)
|
|
607
|
+
context.state.record_citation(footnote_id)
|
|
608
|
+
latex = context.formatter.citation(key=footnote_id)
|
|
609
|
+
_replace_with_latex(sup, latex)
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
if payload is None:
|
|
613
|
+
if footnote_id and footnote_id not in bibliography:
|
|
614
|
+
warnings.warn(
|
|
615
|
+
f"Reference to '{footnote_id}' is not in your bibliography...",
|
|
616
|
+
stacklevel=2,
|
|
617
|
+
)
|
|
618
|
+
continue
|
|
619
|
+
|
|
620
|
+
latex = context.formatter.footnote(payload)
|
|
621
|
+
_replace_with_latex(sup, latex)
|
|
622
|
+
|
|
623
|
+
for placeholder in root.find_all("texsmith-missing-footnote"):
|
|
624
|
+
identifier_attr = coerce_attribute(placeholder.get("data-footnote-id"))
|
|
625
|
+
identifier = identifier_attr or placeholder.get_text(strip=True)
|
|
626
|
+
footnote_id = identifier.strip() if identifier else ""
|
|
627
|
+
if not footnote_id:
|
|
628
|
+
placeholder.decompose()
|
|
629
|
+
continue
|
|
630
|
+
if footnote_id in invalid_footnotes:
|
|
631
|
+
placeholder.decompose()
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
citation_keys = citation_footnotes.get(footnote_id)
|
|
635
|
+
if citation_keys and _render_citation(placeholder, citation_keys):
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
citation_keys = _split_citation_keys(footnote_id)
|
|
639
|
+
if citation_keys and _render_citation(placeholder, citation_keys):
|
|
640
|
+
continue
|
|
641
|
+
# Fall back to default handling for unresolved citations.
|
|
642
|
+
|
|
643
|
+
if footnote_id in bibliography:
|
|
644
|
+
context.state.record_citation(footnote_id)
|
|
645
|
+
latex = context.formatter.citation(key=footnote_id)
|
|
646
|
+
_replace_with_latex(placeholder, latex)
|
|
647
|
+
else:
|
|
648
|
+
payload = context.state.footnotes.get(footnote_id)
|
|
649
|
+
if payload:
|
|
650
|
+
latex = context.formatter.footnote(payload)
|
|
651
|
+
_replace_with_latex(placeholder, latex)
|
|
652
|
+
continue
|
|
653
|
+
warnings.warn(
|
|
654
|
+
f"Reference to '{footnote_id}' is not in your bibliography...",
|
|
655
|
+
stacklevel=2,
|
|
656
|
+
)
|
|
657
|
+
replacement = mark_processed(NavigableString(footnote_id))
|
|
658
|
+
placeholder.replace_with(replacement)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@renders(
|
|
662
|
+
"p",
|
|
663
|
+
"span",
|
|
664
|
+
phase=RenderPhase.POST,
|
|
665
|
+
priority=100,
|
|
666
|
+
name="latex_raw",
|
|
667
|
+
nestable=False,
|
|
668
|
+
)
|
|
669
|
+
def render_latex_raw(element: Tag, _context: RenderContext) -> None:
|
|
670
|
+
"""Preserve raw LaTeX payloads embedded in hidden paragraphs."""
|
|
671
|
+
classes = gather_classes(element.get("class"))
|
|
672
|
+
if "latex-raw" not in classes:
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
text = element.get_text(strip=False)
|
|
676
|
+
replacement = mark_processed(NavigableString(text))
|
|
677
|
+
element.replace_with(replacement)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@renders("p", phase=RenderPhase.POST, priority=90, name="paragraphs", nestable=False)
|
|
681
|
+
def render_paragraphs(element: Tag, context: RenderContext) -> None:
|
|
682
|
+
"""Render plain paragraphs with script-aware wrapping."""
|
|
683
|
+
if _render_script_paragraphs(element, context):
|
|
684
|
+
return
|
|
685
|
+
if element.get("data-texsmith-latex") == "true":
|
|
686
|
+
content = element.get_text(strip=False)
|
|
687
|
+
element.replace_with(mark_processed(NavigableString(f"{content}\n")))
|
|
688
|
+
return
|
|
689
|
+
if element.get("class"):
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
raw_text = element.get_text(strip=False).strip("\n")
|
|
693
|
+
if not raw_text.strip():
|
|
694
|
+
element.decompose()
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
legacy_accents = getattr(context.config, "legacy_latex_accents", False)
|
|
698
|
+
contains_math = bool(_MATH_PAYLOAD_PATTERN.search(raw_text))
|
|
699
|
+
escape_text = "\\" not in raw_text and not contains_math
|
|
700
|
+
rendered = render_moving_text(
|
|
701
|
+
raw_text,
|
|
702
|
+
context,
|
|
703
|
+
legacy_accents=legacy_accents,
|
|
704
|
+
include_whitespace=True,
|
|
705
|
+
wrap_scripts=escape_text,
|
|
706
|
+
escape=escape_text,
|
|
707
|
+
)
|
|
708
|
+
element.replace_with(mark_processed(NavigableString(f"{rendered}\n")))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@renders("div", phase=RenderPhase.POST, priority=60, name="multicolumns", nestable=False)
|
|
712
|
+
def render_columns(element: Tag, context: RenderContext) -> None:
|
|
713
|
+
"""Render lists specially marked as multi-column blocks."""
|
|
714
|
+
classes = gather_classes(element.get("class"))
|
|
715
|
+
if "two-column-list" in classes:
|
|
716
|
+
columns = 2
|
|
717
|
+
elif "three-column-list" in classes:
|
|
718
|
+
columns = 3
|
|
719
|
+
else:
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
text = element.get_text(strip=False)
|
|
723
|
+
latex = context.formatter.multicolumn(text, columns=columns)
|
|
724
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
@renders("figure", phase=RenderPhase.POST, priority=30, name="figures", nestable=False)
|
|
728
|
+
def render_figures(element: Tag, context: RenderContext) -> None:
|
|
729
|
+
"""Render <figure> elements and manage associated assets."""
|
|
730
|
+
classes = gather_classes(element.get("class"))
|
|
731
|
+
if "mermaid-figure" in classes:
|
|
732
|
+
return
|
|
733
|
+
|
|
734
|
+
image = element.find("img")
|
|
735
|
+
if image is None:
|
|
736
|
+
table = element.find("table")
|
|
737
|
+
if table is not None:
|
|
738
|
+
identifier = coerce_attribute(element.get("id"))
|
|
739
|
+
if identifier and not table.get("id"):
|
|
740
|
+
table["id"] = identifier
|
|
741
|
+
figcaption = element.find("figcaption")
|
|
742
|
+
if figcaption is not None and table.find("caption") is None:
|
|
743
|
+
caption = context.document.new_tag("caption")
|
|
744
|
+
_strip_caption_prefix(figcaption)
|
|
745
|
+
caption.string = figcaption.get_text(strip=False)
|
|
746
|
+
table.insert(0, caption)
|
|
747
|
+
figcaption.decompose()
|
|
748
|
+
render_tables(table, context)
|
|
749
|
+
element.unwrap()
|
|
750
|
+
return
|
|
751
|
+
raise InvalidNodeError("Figure missing <img> element")
|
|
752
|
+
|
|
753
|
+
src = coerce_attribute(image.get("src"))
|
|
754
|
+
if not src:
|
|
755
|
+
raise InvalidNodeError("Figure image missing 'src' attribute")
|
|
756
|
+
|
|
757
|
+
width = coerce_attribute(image.get("width")) or None
|
|
758
|
+
alt_text = coerce_attribute(image.get("alt")) or None
|
|
759
|
+
if not context.runtime.get("copy_assets", True):
|
|
760
|
+
caption_node = element.find("figcaption")
|
|
761
|
+
caption_text = caption_node.get_text(strip=False).strip() if caption_node else None
|
|
762
|
+
placeholder = caption_text or alt_text or "[figure]"
|
|
763
|
+
element.replace_with(mark_processed(NavigableString(placeholder)))
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
if is_valid_url(src):
|
|
767
|
+
stored_path = store_remote_image_asset(context, src)
|
|
768
|
+
else:
|
|
769
|
+
resolved = _resolve_source_path(context, src)
|
|
770
|
+
if resolved is None:
|
|
771
|
+
raise AssetMissingError(f"Unable to resolve figure asset '{src}'")
|
|
772
|
+
|
|
773
|
+
stored_path = store_local_image_asset(context, resolved)
|
|
774
|
+
|
|
775
|
+
caption_text = None
|
|
776
|
+
short_caption = alt_text
|
|
777
|
+
|
|
778
|
+
if figcaption := element.find("figcaption"):
|
|
779
|
+
_strip_caption_prefix(figcaption)
|
|
780
|
+
caption_text = figcaption.get_text(strip=False).strip()
|
|
781
|
+
figcaption.decompose()
|
|
782
|
+
|
|
783
|
+
if short_caption and caption_text and len(caption_text) > len(short_caption):
|
|
784
|
+
short_caption = None
|
|
785
|
+
|
|
786
|
+
label = coerce_attribute(element.get("id"))
|
|
787
|
+
|
|
788
|
+
template_name = _figure_template_for(element, context)
|
|
789
|
+
formatter = getattr(context.formatter, template_name)
|
|
790
|
+
asset_path = context.assets.latex_path(stored_path)
|
|
791
|
+
latex = formatter(
|
|
792
|
+
path=asset_path,
|
|
793
|
+
caption=caption_text or short_caption,
|
|
794
|
+
shortcaption=short_caption,
|
|
795
|
+
label=label,
|
|
796
|
+
width=width,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _cell_alignment(cell: Tag) -> str:
|
|
803
|
+
style = coerce_attribute(cell.get("style")) or ""
|
|
804
|
+
if "text-align: right" in style:
|
|
805
|
+
return "right"
|
|
806
|
+
if "text-align: center" in style:
|
|
807
|
+
return "center"
|
|
808
|
+
return "left"
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@renders("table", phase=RenderPhase.POST, priority=40, name="tables", nestable=False)
|
|
812
|
+
def render_tables(element: Tag, context: RenderContext) -> None:
|
|
813
|
+
"""Render HTML tables to LaTeX."""
|
|
814
|
+
caption = None
|
|
815
|
+
if caption_node := element.find("caption"):
|
|
816
|
+
_strip_caption_prefix(caption_node)
|
|
817
|
+
caption = caption_node.get_text(strip=False).strip()
|
|
818
|
+
caption_node.decompose()
|
|
819
|
+
|
|
820
|
+
label = coerce_attribute(element.get("id"))
|
|
821
|
+
|
|
822
|
+
table_rows: list[list[str]] = []
|
|
823
|
+
styles: list[list[str]] = []
|
|
824
|
+
is_large = False
|
|
825
|
+
|
|
826
|
+
for row in element.find_all("tr"):
|
|
827
|
+
row_values: list[str] = []
|
|
828
|
+
row_styles: list[str] = []
|
|
829
|
+
for cell in row.find_all(["th", "td"]):
|
|
830
|
+
content = cell.get_text(strip=False).strip()
|
|
831
|
+
row_values.append(content)
|
|
832
|
+
row_styles.append(_cell_alignment(cell))
|
|
833
|
+
table_rows.append(row_values)
|
|
834
|
+
styles.append(row_styles)
|
|
835
|
+
|
|
836
|
+
stripped = "".join(
|
|
837
|
+
re.sub(r"\\href\{[^\}]+?\}|\\\w{3,}|[\{\}|]", "", col) for col in row_values
|
|
838
|
+
)
|
|
839
|
+
if len(stripped) > 50:
|
|
840
|
+
is_large = True
|
|
841
|
+
|
|
842
|
+
columns = styles[0] if styles else []
|
|
843
|
+
latex = context.formatter.table(
|
|
844
|
+
columns=columns,
|
|
845
|
+
rows=table_rows,
|
|
846
|
+
caption=caption,
|
|
847
|
+
label=label,
|
|
848
|
+
is_large=is_large,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
element.replace_with(mark_processed(NavigableString(latex)))
|