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,780 @@
|
|
|
1
|
+
"""Core orchestration logic for the conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
import copy
|
|
7
|
+
import dataclasses
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from texsmith.adapters.docker import is_docker_available
|
|
14
|
+
from texsmith.adapters.latex.formatter import LaTeXFormatter
|
|
15
|
+
from texsmith.adapters.latex.renderer import LaTeXRenderer
|
|
16
|
+
from texsmith.adapters.transformers import has_converter, register_converter
|
|
17
|
+
from texsmith.core.bibliography.collection import BibliographyCollection
|
|
18
|
+
from texsmith.core.callouts import DEFAULT_CALLOUTS, merge_callouts, normalise_callouts
|
|
19
|
+
from texsmith.core.context import DocumentState
|
|
20
|
+
from texsmith.core.conversion_contexts import (
|
|
21
|
+
BinderContext,
|
|
22
|
+
DocumentContext,
|
|
23
|
+
GenerationStrategy,
|
|
24
|
+
SegmentContext,
|
|
25
|
+
)
|
|
26
|
+
from texsmith.core.exceptions import LatexRenderingError, TransformerExecutionError
|
|
27
|
+
from texsmith.core.fragments import collect_fragment_partials
|
|
28
|
+
from texsmith.core.templates import (
|
|
29
|
+
TemplateBinding,
|
|
30
|
+
TemplateError,
|
|
31
|
+
TemplateRuntime,
|
|
32
|
+
wrap_template_document,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from ..diagnostics import DiagnosticEmitter
|
|
36
|
+
from .debug import (
|
|
37
|
+
debug_enabled,
|
|
38
|
+
ensure_emitter,
|
|
39
|
+
format_user_friendly_render_error,
|
|
40
|
+
persist_debug_artifacts,
|
|
41
|
+
raise_conversion_error,
|
|
42
|
+
record_event,
|
|
43
|
+
)
|
|
44
|
+
from .templates import (
|
|
45
|
+
SlotFragment,
|
|
46
|
+
build_binder_context,
|
|
47
|
+
extract_slot_fragments,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class ConversionResult:
|
|
53
|
+
"""Artifacts produced during a document conversion."""
|
|
54
|
+
|
|
55
|
+
latex_output: str
|
|
56
|
+
tex_path: Path | None
|
|
57
|
+
template_engine: str | None
|
|
58
|
+
template_shell_escape: bool
|
|
59
|
+
language: str
|
|
60
|
+
has_bibliography: bool = False
|
|
61
|
+
slot_outputs: dict[str, str] = field(default_factory=dict)
|
|
62
|
+
default_slot: str = "mainmatter"
|
|
63
|
+
document_state: DocumentState | None = None
|
|
64
|
+
bibliography_path: Path | None = None
|
|
65
|
+
template_overrides: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
document_context: DocumentContext | None = None
|
|
67
|
+
binder_context: BinderContext | None = None
|
|
68
|
+
rule_descriptions: list[dict[str, Any]] = field(default_factory=list)
|
|
69
|
+
assets_map: dict[str, Path] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_EMOJI_SPECIAL_MODES = {"artifact", "symbola", "color", "black", "twemoji"}
|
|
73
|
+
_CODE_ENGINES = {"minted", "listings", "verbatim", "pygments"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _coerce_emoji_mode(value: Any) -> str | None:
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
return None
|
|
79
|
+
candidate = value.strip()
|
|
80
|
+
if not candidate:
|
|
81
|
+
return None
|
|
82
|
+
lowered = candidate.lower()
|
|
83
|
+
return lowered if lowered in _EMOJI_SPECIAL_MODES else candidate
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_code_options(
|
|
87
|
+
binding: TemplateBinding,
|
|
88
|
+
overrides: Mapping[str, Any] | None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Return the effective code configuration merging defaults and overrides."""
|
|
91
|
+
default_options: dict[str, Any] = {}
|
|
92
|
+
instance = binding.instance
|
|
93
|
+
if instance is not None:
|
|
94
|
+
try:
|
|
95
|
+
defaults = instance.info.attribute_defaults()
|
|
96
|
+
except Exception:
|
|
97
|
+
defaults = {}
|
|
98
|
+
code_default = defaults.get("code")
|
|
99
|
+
if isinstance(code_default, Mapping):
|
|
100
|
+
default_options.update(code_default)
|
|
101
|
+
|
|
102
|
+
merged = dict(default_options)
|
|
103
|
+
override_sources: list[Any] = []
|
|
104
|
+
if overrides:
|
|
105
|
+
if "code" in overrides:
|
|
106
|
+
override_sources.append(overrides.get("code"))
|
|
107
|
+
press_section = overrides.get("press")
|
|
108
|
+
if (
|
|
109
|
+
isinstance(press_section, Mapping)
|
|
110
|
+
and "code" in press_section
|
|
111
|
+
and "code" not in overrides
|
|
112
|
+
):
|
|
113
|
+
override_sources.append(press_section.get("code"))
|
|
114
|
+
|
|
115
|
+
for candidate in override_sources:
|
|
116
|
+
if isinstance(candidate, Mapping):
|
|
117
|
+
merged.update(candidate)
|
|
118
|
+
elif isinstance(candidate, str):
|
|
119
|
+
merged["engine"] = candidate
|
|
120
|
+
|
|
121
|
+
engine_value = str(merged.get("engine", "pygments") or "pygments").strip().lower()
|
|
122
|
+
merged["engine"] = engine_value if engine_value in _CODE_ENGINES else "pygments"
|
|
123
|
+
style_value = merged.get("style", "bw")
|
|
124
|
+
if isinstance(style_value, str):
|
|
125
|
+
style_candidate = style_value.strip()
|
|
126
|
+
else:
|
|
127
|
+
style_candidate = str(style_value).strip() if style_value is not None else ""
|
|
128
|
+
merged["style"] = style_candidate or "bw"
|
|
129
|
+
return merged
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extract_emoji_mode(mapping: Mapping[str, Any] | None) -> str | None:
|
|
133
|
+
if not isinstance(mapping, Mapping):
|
|
134
|
+
return None
|
|
135
|
+
direct = _coerce_emoji_mode(mapping.get("emoji"))
|
|
136
|
+
if direct:
|
|
137
|
+
return direct
|
|
138
|
+
fonts_section = mapping.get("fonts")
|
|
139
|
+
if isinstance(fonts_section, Mapping):
|
|
140
|
+
direct_fonts = _coerce_emoji_mode(fonts_section.get("emoji"))
|
|
141
|
+
if direct_fonts:
|
|
142
|
+
return direct_fonts
|
|
143
|
+
press = mapping.get("press")
|
|
144
|
+
if isinstance(press, Mapping):
|
|
145
|
+
press_direct = _coerce_emoji_mode(press.get("emoji"))
|
|
146
|
+
if press_direct:
|
|
147
|
+
return press_direct
|
|
148
|
+
press_fonts = press.get("fonts")
|
|
149
|
+
if isinstance(press_fonts, Mapping):
|
|
150
|
+
return _coerce_emoji_mode(press_fonts.get("emoji"))
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_active_fragments(
|
|
155
|
+
binding: TemplateBinding, overrides: Mapping[str, Any] | None
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
"""Return the fragment list, respecting explicit overrides when provided."""
|
|
158
|
+
if isinstance(overrides, Mapping) and "fragments" in overrides:
|
|
159
|
+
override_payload = overrides.get("fragments")
|
|
160
|
+
if isinstance(override_payload, list):
|
|
161
|
+
return list(override_payload)
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
runtime = binding.runtime
|
|
165
|
+
if runtime is not None:
|
|
166
|
+
fragments = runtime.extras.get("fragments") if runtime.extras else None
|
|
167
|
+
if isinstance(fragments, list):
|
|
168
|
+
return list(fragments)
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_fragment_source_dir(
|
|
173
|
+
overrides: Mapping[str, Any] | None, binder_context: BinderContext
|
|
174
|
+
) -> Path | None:
|
|
175
|
+
"""Infer the base directory used to resolve fragment paths."""
|
|
176
|
+
candidates = []
|
|
177
|
+
press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
|
|
178
|
+
for container in (overrides, press_section):
|
|
179
|
+
if not isinstance(container, Mapping):
|
|
180
|
+
continue
|
|
181
|
+
for key in ("_source_dir", "source_dir"):
|
|
182
|
+
raw_value = container.get(key)
|
|
183
|
+
if isinstance(raw_value, str) and raw_value.strip():
|
|
184
|
+
candidates.append(Path(raw_value))
|
|
185
|
+
if candidates:
|
|
186
|
+
return candidates[0]
|
|
187
|
+
if getattr(binder_context, "config", None) is not None:
|
|
188
|
+
try:
|
|
189
|
+
return binder_context.config.project_dir
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
if binder_context.documents:
|
|
193
|
+
try:
|
|
194
|
+
return binder_context.documents[0].source_path.parent
|
|
195
|
+
except Exception:
|
|
196
|
+
return None
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def convert_document(
|
|
201
|
+
document: DocumentContext,
|
|
202
|
+
output_dir: Path,
|
|
203
|
+
parser: str | None,
|
|
204
|
+
disable_fallback_converters: bool,
|
|
205
|
+
copy_assets: bool,
|
|
206
|
+
convert_assets: bool,
|
|
207
|
+
hash_assets: bool,
|
|
208
|
+
manifest: bool,
|
|
209
|
+
template: str | None,
|
|
210
|
+
persist_debug_html: bool,
|
|
211
|
+
language: str | None,
|
|
212
|
+
slot_overrides: Mapping[str, str] | None,
|
|
213
|
+
bibliography_files: list[Path],
|
|
214
|
+
legacy_latex_accents: bool,
|
|
215
|
+
diagrams_backend: str | None = None,
|
|
216
|
+
*,
|
|
217
|
+
template_overrides: Mapping[str, Any] | None = None,
|
|
218
|
+
state: DocumentState | None = None,
|
|
219
|
+
template_runtime: TemplateRuntime | None = None,
|
|
220
|
+
wrap_document: bool = True,
|
|
221
|
+
emitter: DiagnosticEmitter | None = None,
|
|
222
|
+
preloaded_bibliography: BibliographyCollection | None = None,
|
|
223
|
+
seen_bibliography_issues: set[tuple[str, str | None, str | None]] | None = None,
|
|
224
|
+
) -> ConversionResult:
|
|
225
|
+
"""Orchestrate the full HTML-to-LaTeX conversion for a single document."""
|
|
226
|
+
emitter = ensure_emitter(emitter)
|
|
227
|
+
record_event(
|
|
228
|
+
emitter,
|
|
229
|
+
"convert_document",
|
|
230
|
+
{
|
|
231
|
+
"source": str(document.source_path),
|
|
232
|
+
"template": template,
|
|
233
|
+
"language": language,
|
|
234
|
+
"copy_assets": copy_assets,
|
|
235
|
+
"convert_assets": convert_assets,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
strategy = GenerationStrategy(
|
|
239
|
+
copy_assets=copy_assets,
|
|
240
|
+
convert_assets=convert_assets,
|
|
241
|
+
hash_assets=hash_assets,
|
|
242
|
+
prefer_inputs=False,
|
|
243
|
+
persist_manifest=manifest,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
output_dir = output_dir.resolve()
|
|
247
|
+
|
|
248
|
+
binder_context = build_binder_context(
|
|
249
|
+
document_context=document,
|
|
250
|
+
template=template,
|
|
251
|
+
template_runtime=template_runtime,
|
|
252
|
+
requested_language=language,
|
|
253
|
+
bibliography_files=bibliography_files,
|
|
254
|
+
slot_overrides=slot_overrides,
|
|
255
|
+
output_dir=output_dir,
|
|
256
|
+
strategy=strategy,
|
|
257
|
+
emitter=emitter,
|
|
258
|
+
legacy_latex_accents=legacy_latex_accents,
|
|
259
|
+
session_overrides=template_overrides,
|
|
260
|
+
preloaded_bibliography=preloaded_bibliography,
|
|
261
|
+
seen_bibliography_issues=seen_bibliography_issues,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
renderer_kwargs: dict[str, Any] = {
|
|
265
|
+
"output_root": output_dir,
|
|
266
|
+
"copy_assets": strategy.copy_assets,
|
|
267
|
+
"convert_assets": strategy.convert_assets,
|
|
268
|
+
"hash_assets": strategy.hash_assets,
|
|
269
|
+
"parser": parser or "html.parser",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return _render_document(
|
|
273
|
+
document_context=document,
|
|
274
|
+
binder_context=binder_context,
|
|
275
|
+
renderer_kwargs=renderer_kwargs,
|
|
276
|
+
strategy=strategy,
|
|
277
|
+
disable_fallback_converters=disable_fallback_converters,
|
|
278
|
+
persist_debug_html=persist_debug_html,
|
|
279
|
+
emitter=emitter,
|
|
280
|
+
initial_state=state,
|
|
281
|
+
wrap_document=wrap_document,
|
|
282
|
+
legacy_latex_accents=legacy_latex_accents,
|
|
283
|
+
diagrams_backend=diagrams_backend,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _render_document(
|
|
288
|
+
*,
|
|
289
|
+
document_context: DocumentContext,
|
|
290
|
+
binder_context: BinderContext,
|
|
291
|
+
renderer_kwargs: dict[str, Any],
|
|
292
|
+
strategy: GenerationStrategy,
|
|
293
|
+
disable_fallback_converters: bool,
|
|
294
|
+
persist_debug_html: bool,
|
|
295
|
+
emitter: DiagnosticEmitter,
|
|
296
|
+
initial_state: DocumentState | None,
|
|
297
|
+
wrap_document: bool,
|
|
298
|
+
legacy_latex_accents: bool,
|
|
299
|
+
diagrams_backend: str | None,
|
|
300
|
+
) -> ConversionResult:
|
|
301
|
+
if persist_debug_html:
|
|
302
|
+
persist_debug_artifacts(
|
|
303
|
+
binder_context.output_dir,
|
|
304
|
+
document_context.source_path,
|
|
305
|
+
document_context.html,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
binding = binder_context.template_binding
|
|
309
|
+
if binding is None: # pragma: no cover - defensive safeguard
|
|
310
|
+
raise RuntimeError("BinderContext is missing a template binding.")
|
|
311
|
+
|
|
312
|
+
effective_base_level = binding.base_level or 0
|
|
313
|
+
slot_base_levels = binding.slot_levels()
|
|
314
|
+
|
|
315
|
+
runtime_common = _build_runtime_common(
|
|
316
|
+
binding=binding,
|
|
317
|
+
binder_context=binder_context,
|
|
318
|
+
document_context=document_context,
|
|
319
|
+
strategy=strategy,
|
|
320
|
+
diagrams_backend=diagrams_backend,
|
|
321
|
+
emitter=emitter,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
active_slot_requests = binder_context.slot_requests
|
|
325
|
+
|
|
326
|
+
parser_backend = str(renderer_kwargs.get("parser", "html.parser"))
|
|
327
|
+
slot_fragments, missing_slots = extract_slot_fragments(
|
|
328
|
+
document_context.html,
|
|
329
|
+
active_slot_requests,
|
|
330
|
+
binding.default_slot,
|
|
331
|
+
slot_definitions=binding.slots,
|
|
332
|
+
parser_backend=parser_backend,
|
|
333
|
+
)
|
|
334
|
+
for message in missing_slots:
|
|
335
|
+
emitter.warning(message)
|
|
336
|
+
|
|
337
|
+
manual_base_level = document_context.base_level
|
|
338
|
+
drop_title_flag = bool(document_context.drop_title)
|
|
339
|
+
if drop_title_flag and document_context.slot_requests and not binder_context.slot_requests:
|
|
340
|
+
drop_title_flag = False
|
|
341
|
+
|
|
342
|
+
fragment_offsets: dict[str, int] = {}
|
|
343
|
+
for fragment in slot_fragments:
|
|
344
|
+
levels = list(getattr(fragment, "heading_levels", []) or [])
|
|
345
|
+
if drop_title_flag and fragment.name == binding.default_slot and levels:
|
|
346
|
+
levels = levels[1:]
|
|
347
|
+
if not levels:
|
|
348
|
+
fragment_offsets[fragment.name] = 0
|
|
349
|
+
else:
|
|
350
|
+
fragment_offsets[fragment.name] = 1 - min(levels)
|
|
351
|
+
|
|
352
|
+
segment_registry: dict[str, list[SegmentContext]] = {}
|
|
353
|
+
for fragment in slot_fragments:
|
|
354
|
+
base_value = slot_base_levels.get(fragment.name, effective_base_level)
|
|
355
|
+
fragment_offset = fragment_offsets.get(fragment.name, 0)
|
|
356
|
+
base_offset = manual_base_level + fragment_offset
|
|
357
|
+
segment_registry.setdefault(fragment.name, []).append(
|
|
358
|
+
SegmentContext(
|
|
359
|
+
name=fragment.name,
|
|
360
|
+
html=fragment.html,
|
|
361
|
+
base_level=base_value + base_offset,
|
|
362
|
+
metadata=document_context.front_matter,
|
|
363
|
+
bibliography=binder_context.bibliography_map,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
render_result = _render_slot_fragments(
|
|
369
|
+
slot_fragments=slot_fragments,
|
|
370
|
+
binding=binding,
|
|
371
|
+
runtime_common=runtime_common,
|
|
372
|
+
slot_base_levels=slot_base_levels,
|
|
373
|
+
fragment_offsets=fragment_offsets,
|
|
374
|
+
manual_base_level=manual_base_level,
|
|
375
|
+
disable_fallback_converters=disable_fallback_converters,
|
|
376
|
+
renderer_kwargs=renderer_kwargs,
|
|
377
|
+
initial_state=initial_state,
|
|
378
|
+
drop_title_flag=drop_title_flag,
|
|
379
|
+
binder_context=binder_context,
|
|
380
|
+
legacy_latex_accents=legacy_latex_accents,
|
|
381
|
+
emitter=emitter,
|
|
382
|
+
)
|
|
383
|
+
except TemplateError as exc:
|
|
384
|
+
if debug_enabled(emitter):
|
|
385
|
+
raise
|
|
386
|
+
raise_conversion_error(emitter, str(exc), exc)
|
|
387
|
+
slot_outputs = render_result["slot_outputs"]
|
|
388
|
+
document_state = render_result["document_state"]
|
|
389
|
+
renderer = render_result["renderer"]
|
|
390
|
+
|
|
391
|
+
default_content = slot_outputs.get(binding.default_slot)
|
|
392
|
+
if default_content is None:
|
|
393
|
+
default_content = ""
|
|
394
|
+
slot_outputs[binding.default_slot] = default_content
|
|
395
|
+
latex_output = default_content
|
|
396
|
+
|
|
397
|
+
document_context.segments = segment_registry
|
|
398
|
+
for slot_name, segments in segment_registry.items():
|
|
399
|
+
binder_context.bound_segments.setdefault(slot_name, []).extend(segments)
|
|
400
|
+
|
|
401
|
+
citations = list(document_state.citations)
|
|
402
|
+
bibliography_output: Path | None = None
|
|
403
|
+
if (
|
|
404
|
+
citations
|
|
405
|
+
and binder_context.bibliography_collection is not None
|
|
406
|
+
and binder_context.bibliography_map
|
|
407
|
+
):
|
|
408
|
+
try:
|
|
409
|
+
binder_context.output_dir.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
bibliography_output = binder_context.output_dir / "texsmith-bibliography.bib"
|
|
411
|
+
binder_context.bibliography_collection.write_bibtex(
|
|
412
|
+
bibliography_output,
|
|
413
|
+
keys=citations,
|
|
414
|
+
)
|
|
415
|
+
except OSError as exc:
|
|
416
|
+
if debug_enabled(emitter):
|
|
417
|
+
raise
|
|
418
|
+
emitter.warning(f"Failed to write bibliography file: {exc}")
|
|
419
|
+
bibliography_output = None
|
|
420
|
+
|
|
421
|
+
tex_path: Path | None = None
|
|
422
|
+
if document_state is not None:
|
|
423
|
+
document_state.requires_shell_escape = (
|
|
424
|
+
document_state.requires_shell_escape or binding.requires_shell_escape
|
|
425
|
+
)
|
|
426
|
+
template_instance = binding.instance
|
|
427
|
+
if template_instance is not None and wrap_document:
|
|
428
|
+
try:
|
|
429
|
+
wrap_result = wrap_template_document(
|
|
430
|
+
template=template_instance,
|
|
431
|
+
default_slot=binding.default_slot,
|
|
432
|
+
slot_outputs=slot_outputs,
|
|
433
|
+
document_state=document_state,
|
|
434
|
+
template_overrides=(
|
|
435
|
+
binder_context.template_overrides if binder_context.template_overrides else None
|
|
436
|
+
),
|
|
437
|
+
output_dir=binder_context.output_dir,
|
|
438
|
+
copy_assets=strategy.copy_assets,
|
|
439
|
+
output_name=f"{document_context.source_path.stem}.tex",
|
|
440
|
+
bibliography_path=bibliography_output,
|
|
441
|
+
emitter=emitter,
|
|
442
|
+
fragments=list(
|
|
443
|
+
binder_context.template_overrides.get(
|
|
444
|
+
"fragments", binding.runtime.extras.get("fragments", [])
|
|
445
|
+
)
|
|
446
|
+
),
|
|
447
|
+
template_runtime=binding.runtime,
|
|
448
|
+
)
|
|
449
|
+
latex_output = wrap_result.latex_output
|
|
450
|
+
tex_path = wrap_result.output_path
|
|
451
|
+
except TemplateError as exc:
|
|
452
|
+
if debug_enabled(emitter):
|
|
453
|
+
raise
|
|
454
|
+
raise_conversion_error(emitter, str(exc), exc)
|
|
455
|
+
except OSError as exc:
|
|
456
|
+
if debug_enabled(emitter):
|
|
457
|
+
raise
|
|
458
|
+
raise_conversion_error(
|
|
459
|
+
emitter,
|
|
460
|
+
f"Failed to write LaTeX output to '{binder_context.output_dir}': {exc}",
|
|
461
|
+
exc,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
rule_descriptions: list[dict[str, Any]] = []
|
|
465
|
+
if renderer is not None:
|
|
466
|
+
try:
|
|
467
|
+
rule_descriptions = list(renderer.describe_registered_rules())
|
|
468
|
+
except Exception:
|
|
469
|
+
rule_descriptions = []
|
|
470
|
+
asset_map: dict[str, Path] = {}
|
|
471
|
+
if renderer is not None:
|
|
472
|
+
try:
|
|
473
|
+
asset_map = {str(key): Path(path) for key, path in renderer.assets.items()}
|
|
474
|
+
except Exception:
|
|
475
|
+
asset_map = {}
|
|
476
|
+
|
|
477
|
+
return ConversionResult(
|
|
478
|
+
latex_output=latex_output,
|
|
479
|
+
tex_path=tex_path,
|
|
480
|
+
template_engine=binding.engine,
|
|
481
|
+
template_shell_escape=bool(
|
|
482
|
+
binding.requires_shell_escape
|
|
483
|
+
or (document_state and document_state.requires_shell_escape)
|
|
484
|
+
),
|
|
485
|
+
language=binder_context.language,
|
|
486
|
+
has_bibliography=bool(bibliography_output),
|
|
487
|
+
slot_outputs=dict(slot_outputs),
|
|
488
|
+
default_slot=binding.default_slot,
|
|
489
|
+
document_state=document_state,
|
|
490
|
+
bibliography_path=bibliography_output,
|
|
491
|
+
template_overrides=dict(binder_context.template_overrides),
|
|
492
|
+
document_context=document_context,
|
|
493
|
+
binder_context=binder_context,
|
|
494
|
+
rule_descriptions=rule_descriptions,
|
|
495
|
+
assets_map=asset_map,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _build_runtime_common(
|
|
500
|
+
*,
|
|
501
|
+
binding: TemplateBinding,
|
|
502
|
+
binder_context: BinderContext,
|
|
503
|
+
document_context: DocumentContext,
|
|
504
|
+
strategy: GenerationStrategy,
|
|
505
|
+
diagrams_backend: str | None,
|
|
506
|
+
emitter: DiagnosticEmitter,
|
|
507
|
+
) -> dict[str, object]:
|
|
508
|
+
"""Prepare immutable runtime metadata shared across fragment rendering."""
|
|
509
|
+
code_options = _resolve_code_options(binding, binder_context.template_overrides)
|
|
510
|
+
|
|
511
|
+
runtime_common: dict[str, object] = {
|
|
512
|
+
"numbered": document_context.numbered,
|
|
513
|
+
"source_dir": document_context.source_path.parent,
|
|
514
|
+
"document_path": document_context.source_path,
|
|
515
|
+
"copy_assets": strategy.copy_assets,
|
|
516
|
+
"convert_assets": strategy.convert_assets,
|
|
517
|
+
"hash_assets": strategy.hash_assets,
|
|
518
|
+
"language": binder_context.language,
|
|
519
|
+
"emitter": emitter,
|
|
520
|
+
}
|
|
521
|
+
template_callouts = binder_context.template_overrides.get("callouts")
|
|
522
|
+
runtime_common["callouts_definitions"] = normalise_callouts(
|
|
523
|
+
merge_callouts(
|
|
524
|
+
DEFAULT_CALLOUTS,
|
|
525
|
+
template_callouts if isinstance(template_callouts, Mapping) else None,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
runtime_common["bibliography"] = binder_context.bibliography_map
|
|
529
|
+
runtime_common["bibliography_collection"] = binder_context.bibliography_collection
|
|
530
|
+
if binding.name is not None:
|
|
531
|
+
runtime_common["template"] = binding.name
|
|
532
|
+
runtime_common["code"] = code_options
|
|
533
|
+
runtime_common["diagrams_backend"] = diagrams_backend or "playwright"
|
|
534
|
+
mermaid_config = binder_context.template_overrides.get("mermaid_config") or (
|
|
535
|
+
binder_context.template_overrides.get("press") or {}
|
|
536
|
+
).get("mermaid_config")
|
|
537
|
+
if not mermaid_config and binding.runtime and binding.runtime.extras:
|
|
538
|
+
mermaid_config = binding.runtime.extras.get("mermaid_config")
|
|
539
|
+
if mermaid_config:
|
|
540
|
+
runtime_common["mermaid_config"] = mermaid_config
|
|
541
|
+
if strategy.persist_manifest:
|
|
542
|
+
runtime_common["generate_manifest"] = True
|
|
543
|
+
emoji_mode = _extract_emoji_mode(binder_context.template_overrides)
|
|
544
|
+
if not emoji_mode:
|
|
545
|
+
emoji_mode = _extract_emoji_mode(document_context.front_matter)
|
|
546
|
+
if emoji_mode:
|
|
547
|
+
runtime_common["emoji_mode"] = emoji_mode
|
|
548
|
+
binder_context.template_overrides.setdefault("emoji", emoji_mode)
|
|
549
|
+
if emoji_mode != "artifact":
|
|
550
|
+
runtime_common.setdefault("emoji_command", r"\texsmithEmoji")
|
|
551
|
+
|
|
552
|
+
return runtime_common
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _render_slot_fragments(
|
|
556
|
+
*,
|
|
557
|
+
slot_fragments: list[SlotFragment],
|
|
558
|
+
binding: TemplateBinding,
|
|
559
|
+
runtime_common: dict[str, object],
|
|
560
|
+
slot_base_levels: Mapping[str, int],
|
|
561
|
+
fragment_offsets: Mapping[str, int],
|
|
562
|
+
manual_base_level: int,
|
|
563
|
+
disable_fallback_converters: bool,
|
|
564
|
+
renderer_kwargs: dict[str, Any],
|
|
565
|
+
initial_state: DocumentState | None,
|
|
566
|
+
drop_title_flag: bool,
|
|
567
|
+
binder_context: BinderContext,
|
|
568
|
+
legacy_latex_accents: bool,
|
|
569
|
+
emitter: DiagnosticEmitter,
|
|
570
|
+
) -> dict[str, Any]:
|
|
571
|
+
"""Render slot fragments, applying fallback converters when required."""
|
|
572
|
+
formatter = LaTeXFormatter()
|
|
573
|
+
formatter.legacy_latex_accents = legacy_latex_accents
|
|
574
|
+
formatter.default_code_engine = runtime_common.get("code", {}).get(
|
|
575
|
+
"engine", formatter.default_code_engine
|
|
576
|
+
)
|
|
577
|
+
style_override = runtime_common.get("code", {}).get("style", formatter.default_code_style)
|
|
578
|
+
if not isinstance(style_override, str):
|
|
579
|
+
style_override = str(style_override or "")
|
|
580
|
+
formatter.default_code_style = style_override.strip() or formatter.default_code_style
|
|
581
|
+
available_templates = formatter.template_names
|
|
582
|
+
partial_providers: dict[str, str] = dict.fromkeys(available_templates, "core")
|
|
583
|
+
fragment_names = _resolve_active_fragments(binding, binder_context.template_overrides)
|
|
584
|
+
fragment_source_dir = _resolve_fragment_source_dir(
|
|
585
|
+
binder_context.template_overrides, binder_context
|
|
586
|
+
)
|
|
587
|
+
required_partials: dict[str, set[str]] = {}
|
|
588
|
+
if fragment_names:
|
|
589
|
+
fragment_overrides, fragment_required, fragment_providers = collect_fragment_partials(
|
|
590
|
+
fragment_names,
|
|
591
|
+
source_dir=fragment_source_dir,
|
|
592
|
+
)
|
|
593
|
+
for key, override_path in fragment_overrides.items():
|
|
594
|
+
formatter.override_template(key, override_path)
|
|
595
|
+
partial_providers[key] = fragment_providers.get(key, "fragment")
|
|
596
|
+
for key, owners in fragment_required.items():
|
|
597
|
+
required_partials.setdefault(key, set()).update(owners)
|
|
598
|
+
|
|
599
|
+
binding.apply_formatter_overrides(formatter)
|
|
600
|
+
template_provider = binding.name or "template"
|
|
601
|
+
for key in binding.formatter_overrides:
|
|
602
|
+
partial_providers[key] = template_provider
|
|
603
|
+
if binding.required_partials:
|
|
604
|
+
for name in binding.required_partials:
|
|
605
|
+
required_partials.setdefault(name, set()).add(template_provider)
|
|
606
|
+
|
|
607
|
+
available_partials = set(available_templates)
|
|
608
|
+
missing_partials = [name for name in required_partials if name not in available_partials]
|
|
609
|
+
if missing_partials:
|
|
610
|
+
details = []
|
|
611
|
+
for name in sorted(missing_partials):
|
|
612
|
+
owners = ", ".join(sorted(required_partials.get(name, set()))) or "unknown providers"
|
|
613
|
+
details.append(f"partial '{name}' required by {owners}")
|
|
614
|
+
raise TemplateError(f"Missing {', '.join(details)}.")
|
|
615
|
+
runtime_common["partial_providers"] = partial_providers
|
|
616
|
+
renderer: LaTeXRenderer | None = None
|
|
617
|
+
|
|
618
|
+
def renderer_factory() -> LaTeXRenderer:
|
|
619
|
+
nonlocal renderer
|
|
620
|
+
if renderer is None:
|
|
621
|
+
renderer = LaTeXRenderer(
|
|
622
|
+
config=binder_context.config,
|
|
623
|
+
formatter=formatter,
|
|
624
|
+
**renderer_kwargs,
|
|
625
|
+
)
|
|
626
|
+
return renderer
|
|
627
|
+
|
|
628
|
+
if not disable_fallback_converters:
|
|
629
|
+
ensure_fallback_converters()
|
|
630
|
+
|
|
631
|
+
slot_outputs: dict[str, str] = {}
|
|
632
|
+
document_state: DocumentState | None = initial_state
|
|
633
|
+
for fragment in slot_fragments:
|
|
634
|
+
runtime_fragment = dict(runtime_common)
|
|
635
|
+
base_value = slot_base_levels.get(fragment.name, binding.base_level or 0)
|
|
636
|
+
fragment_offset = fragment_offsets.get(fragment.name, 0)
|
|
637
|
+
base_offset = manual_base_level + fragment_offset
|
|
638
|
+
runtime_fragment["base_level"] = base_value + base_offset
|
|
639
|
+
if fragment.name == "preface":
|
|
640
|
+
runtime_fragment["numbered"] = False
|
|
641
|
+
if drop_title_flag and fragment.name == binding.default_slot:
|
|
642
|
+
runtime_fragment["drop_title"] = True
|
|
643
|
+
drop_title_flag = False
|
|
644
|
+
fragment_output = ""
|
|
645
|
+
try:
|
|
646
|
+
fragment_output, document_state = render_with_fallback(
|
|
647
|
+
renderer_factory,
|
|
648
|
+
fragment.html,
|
|
649
|
+
runtime_fragment,
|
|
650
|
+
binder_context.bibliography_map,
|
|
651
|
+
state=document_state,
|
|
652
|
+
emitter=emitter,
|
|
653
|
+
)
|
|
654
|
+
except LatexRenderingError as exc:
|
|
655
|
+
if debug_enabled(emitter):
|
|
656
|
+
raise
|
|
657
|
+
message = format_user_friendly_render_error(exc)
|
|
658
|
+
raise_conversion_error(emitter, message, exc)
|
|
659
|
+
existing_fragment = slot_outputs.get(fragment.name, "")
|
|
660
|
+
slot_outputs[fragment.name] = f"{existing_fragment}{fragment_output}"
|
|
661
|
+
|
|
662
|
+
if document_state is None:
|
|
663
|
+
document_state = DocumentState(bibliography=dict(binder_context.bibliography_map))
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"slot_outputs": slot_outputs,
|
|
667
|
+
"document_state": document_state,
|
|
668
|
+
"renderer": renderer,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def copy_document_state(target: DocumentState, source: DocumentState) -> None:
|
|
673
|
+
"""Synchronise ``target`` with a source ``DocumentState`` instance."""
|
|
674
|
+
for metadata_field in dataclasses.fields(DocumentState):
|
|
675
|
+
setattr(
|
|
676
|
+
target,
|
|
677
|
+
metadata_field.name,
|
|
678
|
+
copy.deepcopy(getattr(source, metadata_field.name)),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def render_with_fallback(
|
|
683
|
+
renderer_factory: Callable[[], LaTeXRenderer],
|
|
684
|
+
html: str,
|
|
685
|
+
runtime: dict[str, object],
|
|
686
|
+
bibliography: Mapping[str, dict[str, Any]] | None = None,
|
|
687
|
+
*,
|
|
688
|
+
state: DocumentState | None = None,
|
|
689
|
+
emitter: DiagnosticEmitter | None = None,
|
|
690
|
+
) -> tuple[str, DocumentState]:
|
|
691
|
+
"""Render HTML to LaTeX, retrying with fallback converters when available."""
|
|
692
|
+
emitter = ensure_emitter(emitter)
|
|
693
|
+
attempts = 0
|
|
694
|
+
bibliography_payload = dict(bibliography or {})
|
|
695
|
+
base_state = state
|
|
696
|
+
|
|
697
|
+
while True:
|
|
698
|
+
current_state = (
|
|
699
|
+
copy.deepcopy(base_state)
|
|
700
|
+
if base_state is not None
|
|
701
|
+
else DocumentState(bibliography=dict(bibliography_payload))
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
renderer = renderer_factory()
|
|
705
|
+
try:
|
|
706
|
+
output = renderer.render(
|
|
707
|
+
html,
|
|
708
|
+
runtime=runtime,
|
|
709
|
+
state=current_state,
|
|
710
|
+
emitter=emitter,
|
|
711
|
+
)
|
|
712
|
+
except LatexRenderingError as exc:
|
|
713
|
+
attempts += 1
|
|
714
|
+
if attempts >= 5 or not attempt_transformer_fallback(exc):
|
|
715
|
+
raise
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
if base_state is not None:
|
|
719
|
+
copy_document_state(base_state, current_state)
|
|
720
|
+
return output, base_state
|
|
721
|
+
|
|
722
|
+
return output, current_state
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def attempt_transformer_fallback(error: LatexRenderingError) -> bool:
|
|
726
|
+
"""Register placeholder converters when known transformers are unavailable."""
|
|
727
|
+
cause = error.__cause__
|
|
728
|
+
if not isinstance(cause, TransformerExecutionError):
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
message = str(cause).lower()
|
|
732
|
+
applied = False
|
|
733
|
+
|
|
734
|
+
if "drawio" in message:
|
|
735
|
+
return False
|
|
736
|
+
if "mermaid" in message:
|
|
737
|
+
return False
|
|
738
|
+
if ("fetch-image" in message or "fetch image" in message) and not has_converter("fetch-image"):
|
|
739
|
+
register_converter("fetch-image", _FallbackConverter("image"))
|
|
740
|
+
applied = True
|
|
741
|
+
return applied
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def ensure_fallback_converters() -> None:
|
|
745
|
+
"""Install placeholder converters for optional transformer dependencies."""
|
|
746
|
+
if is_docker_available():
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
if not has_converter("fetch-image"):
|
|
750
|
+
register_converter("fetch-image", _FallbackConverter("image"))
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class _FallbackConverter:
|
|
754
|
+
def __init__(self, name: str):
|
|
755
|
+
self.name = name
|
|
756
|
+
|
|
757
|
+
def __call__(self, source: Path | str, *, output_dir: Path, **_: Any) -> Path:
|
|
758
|
+
output_dir = Path(output_dir)
|
|
759
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
760
|
+
|
|
761
|
+
original = str(source) if isinstance(source, Path) else source
|
|
762
|
+
digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:12]
|
|
763
|
+
suffix = Path(original).suffix or ".txt"
|
|
764
|
+
filename = f"{self.name}-{digest}.pdf"
|
|
765
|
+
target = output_dir / filename
|
|
766
|
+
target.write_text(
|
|
767
|
+
f"Placeholder PDF for {self.name} ({suffix})",
|
|
768
|
+
encoding="utf-8",
|
|
769
|
+
)
|
|
770
|
+
return target
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
__all__ = [
|
|
774
|
+
"ConversionResult",
|
|
775
|
+
"attempt_transformer_fallback",
|
|
776
|
+
"convert_document",
|
|
777
|
+
"copy_document_state",
|
|
778
|
+
"ensure_fallback_converters",
|
|
779
|
+
"render_with_fallback",
|
|
780
|
+
]
|