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,347 @@
|
|
|
1
|
+
"""Runtime helpers for binding LaTeX templates to rendered documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from .base import WrappableTemplate
|
|
11
|
+
from .loader import load_template
|
|
12
|
+
from .manifest import (
|
|
13
|
+
DEFAULT_TEMPLATE_LANGUAGE,
|
|
14
|
+
TemplateError,
|
|
15
|
+
TemplateSlot,
|
|
16
|
+
_BABEL_LANGUAGE_ALIASES,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from texsmith.core.fragments import FRAGMENT_REGISTRY
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from texsmith.adapters.latex.formatter import LaTeXFormatter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class TemplateRuntime:
|
|
27
|
+
"""Resolved template metadata reused across conversions."""
|
|
28
|
+
|
|
29
|
+
instance: WrappableTemplate
|
|
30
|
+
name: str
|
|
31
|
+
engine: str | None
|
|
32
|
+
requires_shell_escape: bool
|
|
33
|
+
slots: dict[str, TemplateSlot]
|
|
34
|
+
default_slot: str
|
|
35
|
+
formatter_overrides: dict[str, Path]
|
|
36
|
+
base_level: int | None
|
|
37
|
+
required_partials: set[str] = field(default_factory=set)
|
|
38
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class TemplateBinding:
|
|
43
|
+
"""Binding between slot requests and a LaTeX template."""
|
|
44
|
+
|
|
45
|
+
runtime: TemplateRuntime | None
|
|
46
|
+
instance: WrappableTemplate | None
|
|
47
|
+
name: str | None
|
|
48
|
+
engine: str | None
|
|
49
|
+
requires_shell_escape: bool
|
|
50
|
+
formatter_overrides: dict[str, Path]
|
|
51
|
+
slots: dict[str, TemplateSlot]
|
|
52
|
+
default_slot: str
|
|
53
|
+
base_level: int | None
|
|
54
|
+
required_partials: set[str] = field(default_factory=set)
|
|
55
|
+
|
|
56
|
+
def slot_levels(self, *, offset: int = 0) -> dict[str, int]:
|
|
57
|
+
"""Return the resolved base level for each slot."""
|
|
58
|
+
base = (self.base_level or 0) + offset
|
|
59
|
+
return {name: slot.resolve_level(base) for name, slot in self.slots.items()}
|
|
60
|
+
|
|
61
|
+
def apply_formatter_overrides(self, formatter: "LaTeXFormatter") -> None:
|
|
62
|
+
"""Apply template-provided overrides to a formatter."""
|
|
63
|
+
for key, override_path in self.formatter_overrides.items():
|
|
64
|
+
formatter.override_template(key, override_path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def coerce_base_level(value: Any, *, allow_none: bool = True) -> int | None:
|
|
68
|
+
"""Normalise base-level metadata to an integer or ``None``."""
|
|
69
|
+
if value is None:
|
|
70
|
+
if allow_none:
|
|
71
|
+
return None
|
|
72
|
+
raise TemplateError("Base level value is missing.")
|
|
73
|
+
|
|
74
|
+
if isinstance(value, bool):
|
|
75
|
+
raise TemplateError("Base level must be an integer, booleans are not supported.")
|
|
76
|
+
|
|
77
|
+
if isinstance(value, (int, float)):
|
|
78
|
+
return int(value)
|
|
79
|
+
|
|
80
|
+
if isinstance(value, str):
|
|
81
|
+
candidate = value.strip().lower()
|
|
82
|
+
if not candidate:
|
|
83
|
+
if allow_none:
|
|
84
|
+
return None
|
|
85
|
+
raise TemplateError("Base level value cannot be empty.")
|
|
86
|
+
alias_map = {
|
|
87
|
+
"part": -1,
|
|
88
|
+
"chapter": 0,
|
|
89
|
+
"section": 1,
|
|
90
|
+
"subsection": 2,
|
|
91
|
+
}
|
|
92
|
+
if candidate in alias_map:
|
|
93
|
+
return alias_map[candidate]
|
|
94
|
+
try:
|
|
95
|
+
return int(candidate)
|
|
96
|
+
except ValueError as exc: # pragma: no cover - defensive
|
|
97
|
+
raise TemplateError(
|
|
98
|
+
f"Invalid base level '{value}'. Expected an integer value or one of "
|
|
99
|
+
f"{', '.join(alias_map)}."
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
raise TemplateError(
|
|
103
|
+
f"Base level should be provided as an integer value, got type '{type(value).__name__}'."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_base_level_override(overrides: Mapping[str, Any] | None) -> Any:
|
|
108
|
+
"""Extract a base level override from template metadata overrides."""
|
|
109
|
+
if not overrides:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
press_section = overrides.get("press")
|
|
113
|
+
direct_candidate = overrides.get("base_level")
|
|
114
|
+
if direct_candidate is not None:
|
|
115
|
+
return direct_candidate
|
|
116
|
+
if isinstance(press_section, Mapping):
|
|
117
|
+
return press_section.get("base_level")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_template_overrides(front_matter: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
122
|
+
"""Build template overrides from front matter while preserving metadata."""
|
|
123
|
+
if not front_matter or not isinstance(front_matter, Mapping):
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
overrides = dict(front_matter)
|
|
127
|
+
press_section = overrides.get("press")
|
|
128
|
+
if isinstance(press_section, Mapping):
|
|
129
|
+
overrides["press"] = dict(press_section)
|
|
130
|
+
|
|
131
|
+
fragments = overrides.get("fragments")
|
|
132
|
+
if fragments is None and isinstance(press_section, Mapping):
|
|
133
|
+
fragments = press_section.get("fragments")
|
|
134
|
+
if isinstance(fragments, list):
|
|
135
|
+
overrides["fragments"] = list(fragments)
|
|
136
|
+
|
|
137
|
+
callouts_section = overrides.get("callouts")
|
|
138
|
+
if callouts_section is None and isinstance(press_section, Mapping):
|
|
139
|
+
callouts_section = press_section.get("callouts")
|
|
140
|
+
if isinstance(callouts_section, Mapping):
|
|
141
|
+
overrides["callouts"] = dict(callouts_section)
|
|
142
|
+
|
|
143
|
+
callouts_style = overrides.get("callouts_style")
|
|
144
|
+
if callouts_style is None and isinstance(press_section, Mapping):
|
|
145
|
+
callouts_style = press_section.get("callouts_style")
|
|
146
|
+
if callouts_style is not None:
|
|
147
|
+
overrides["callout_style"] = callouts_style
|
|
148
|
+
|
|
149
|
+
base_override = overrides.get("base_level")
|
|
150
|
+
if base_override is None and isinstance(press_section, Mapping):
|
|
151
|
+
base_override = press_section.get("base_level")
|
|
152
|
+
if base_override is not None:
|
|
153
|
+
try:
|
|
154
|
+
overrides["base_level"] = coerce_base_level(base_override)
|
|
155
|
+
except TemplateError:
|
|
156
|
+
overrides["base_level"] = base_override
|
|
157
|
+
|
|
158
|
+
return overrides
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def extract_language_from_front_matter(
|
|
162
|
+
front_matter: Mapping[str, Any] | None,
|
|
163
|
+
) -> str | None:
|
|
164
|
+
"""Inspect front matter for language hints."""
|
|
165
|
+
if not isinstance(front_matter, Mapping):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
for key in ("language", "lang"):
|
|
169
|
+
value = front_matter.get(key)
|
|
170
|
+
if isinstance(value, str):
|
|
171
|
+
stripped = value.strip()
|
|
172
|
+
if stripped:
|
|
173
|
+
return stripped
|
|
174
|
+
|
|
175
|
+
press_entry = front_matter.get("press")
|
|
176
|
+
if isinstance(press_entry, Mapping):
|
|
177
|
+
for key in ("language", "lang"):
|
|
178
|
+
value = press_entry.get(key)
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
stripped = value.strip()
|
|
181
|
+
if stripped:
|
|
182
|
+
return stripped
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def normalise_template_language(value: str | None) -> str | None:
|
|
187
|
+
"""Normalise language codes and map them through babel aliases when available."""
|
|
188
|
+
if value is None:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
stripped = value.strip()
|
|
192
|
+
if not stripped:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
lowered = stripped.lower().replace("_", "-")
|
|
196
|
+
alias = _BABEL_LANGUAGE_ALIASES.get(lowered)
|
|
197
|
+
if alias:
|
|
198
|
+
return alias
|
|
199
|
+
|
|
200
|
+
primary = lowered.split("-", 1)[0]
|
|
201
|
+
alias = _BABEL_LANGUAGE_ALIASES.get(primary)
|
|
202
|
+
if alias:
|
|
203
|
+
return alias
|
|
204
|
+
|
|
205
|
+
if lowered.isalpha():
|
|
206
|
+
return lowered
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def resolve_template_language(
|
|
212
|
+
explicit: str | None,
|
|
213
|
+
front_matter: Mapping[str, Any] | None,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Resolve the effective template language from CLI and front matter inputs."""
|
|
216
|
+
candidates = (
|
|
217
|
+
normalise_template_language(explicit),
|
|
218
|
+
normalise_template_language(extract_language_from_front_matter(front_matter)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for candidate in candidates:
|
|
222
|
+
if candidate:
|
|
223
|
+
return candidate
|
|
224
|
+
|
|
225
|
+
return DEFAULT_TEMPLATE_LANGUAGE
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_template_runtime(template: str) -> TemplateRuntime:
|
|
229
|
+
"""Resolve template metadata for repeated conversions."""
|
|
230
|
+
template_instance = load_template(template)
|
|
231
|
+
|
|
232
|
+
template_base = coerce_base_level(
|
|
233
|
+
template_instance.info.get_attribute_default("base_level"),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
slots, default_slot = template_instance.info.resolve_slots()
|
|
237
|
+
formatter_overrides = dict(template_instance.iter_formatter_overrides())
|
|
238
|
+
extras_payload = getattr(template_instance, "extras", {}) or {}
|
|
239
|
+
extras = {key: value for key, value in extras_payload.items()}
|
|
240
|
+
declared_fragments = (
|
|
241
|
+
list(template_instance.info.fragments) if template_instance.info.fragments is not None else None
|
|
242
|
+
)
|
|
243
|
+
extras.setdefault(
|
|
244
|
+
"fragments",
|
|
245
|
+
declared_fragments if declared_fragments is not None else [],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return TemplateRuntime(
|
|
249
|
+
instance=template_instance,
|
|
250
|
+
name=template_instance.info.name,
|
|
251
|
+
engine=template_instance.info.engine,
|
|
252
|
+
requires_shell_escape=bool(template_instance.info.shell_escape),
|
|
253
|
+
slots=slots,
|
|
254
|
+
default_slot=default_slot,
|
|
255
|
+
formatter_overrides=formatter_overrides,
|
|
256
|
+
base_level=template_base,
|
|
257
|
+
required_partials=set(template_instance.info.required_partials or []),
|
|
258
|
+
extras=extras,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def resolve_template_binding(
|
|
263
|
+
*,
|
|
264
|
+
template: str | None,
|
|
265
|
+
template_runtime: TemplateRuntime | None,
|
|
266
|
+
template_overrides: Mapping[str, Any],
|
|
267
|
+
slot_requests: Mapping[str, str],
|
|
268
|
+
warn: Callable[[str], None] | None = None,
|
|
269
|
+
) -> tuple[TemplateBinding, dict[str, str]]:
|
|
270
|
+
"""Resolve template runtime metadata and apply slot overrides."""
|
|
271
|
+
runtime = template_runtime
|
|
272
|
+
if runtime is None and template:
|
|
273
|
+
runtime = load_template_runtime(template)
|
|
274
|
+
|
|
275
|
+
if runtime is not None:
|
|
276
|
+
# Adjust base level for book parts when requested via overrides.
|
|
277
|
+
binding_base_level = runtime.base_level
|
|
278
|
+
part_flag = None
|
|
279
|
+
press_section = template_overrides.get("press")
|
|
280
|
+
if isinstance(template_overrides.get("part"), bool):
|
|
281
|
+
part_flag = template_overrides.get("part")
|
|
282
|
+
elif isinstance(press_section, Mapping) and isinstance(press_section.get("part"), bool):
|
|
283
|
+
part_flag = press_section.get("part")
|
|
284
|
+
if part_flag and runtime.name == "book":
|
|
285
|
+
binding_base_level = coerce_base_level("part")
|
|
286
|
+
|
|
287
|
+
binding = TemplateBinding(
|
|
288
|
+
runtime=runtime,
|
|
289
|
+
instance=runtime.instance,
|
|
290
|
+
name=runtime.name,
|
|
291
|
+
engine=runtime.engine,
|
|
292
|
+
requires_shell_escape=runtime.requires_shell_escape,
|
|
293
|
+
formatter_overrides=dict(runtime.formatter_overrides),
|
|
294
|
+
slots=runtime.slots,
|
|
295
|
+
default_slot=runtime.default_slot,
|
|
296
|
+
base_level=binding_base_level,
|
|
297
|
+
required_partials=set(runtime.required_partials),
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
binding = TemplateBinding(
|
|
301
|
+
runtime=None,
|
|
302
|
+
instance=None,
|
|
303
|
+
name=None,
|
|
304
|
+
engine=None,
|
|
305
|
+
requires_shell_escape=False,
|
|
306
|
+
formatter_overrides={},
|
|
307
|
+
slots={"mainmatter": TemplateSlot(default=True)},
|
|
308
|
+
default_slot="mainmatter",
|
|
309
|
+
base_level=None,
|
|
310
|
+
required_partials=set(),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
base_override = coerce_base_level(extract_base_level_override(template_overrides))
|
|
314
|
+
if base_override is not None:
|
|
315
|
+
binding.base_level = base_override
|
|
316
|
+
|
|
317
|
+
filtered: dict[str, str] = {}
|
|
318
|
+
for slot_name, selector in slot_requests.items():
|
|
319
|
+
if slot_name not in binding.slots:
|
|
320
|
+
if warn is not None:
|
|
321
|
+
template_hint = f"template '{binding.name}'" if binding.name else "the template"
|
|
322
|
+
warn(
|
|
323
|
+
f"slot '{slot_name}' is not defined by {template_hint}; "
|
|
324
|
+
f"content will remain in '{binding.default_slot}'."
|
|
325
|
+
)
|
|
326
|
+
continue
|
|
327
|
+
if binding.runtime is None:
|
|
328
|
+
if warn is not None:
|
|
329
|
+
warn(f"slot '{slot_name}' was requested but no template is selected; ignoring.")
|
|
330
|
+
continue
|
|
331
|
+
filtered[slot_name] = selector
|
|
332
|
+
|
|
333
|
+
return binding, filtered
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
__all__ = [
|
|
337
|
+
"TemplateBinding",
|
|
338
|
+
"TemplateRuntime",
|
|
339
|
+
"build_template_overrides",
|
|
340
|
+
"coerce_base_level",
|
|
341
|
+
"extract_base_level_override",
|
|
342
|
+
"extract_language_from_front_matter",
|
|
343
|
+
"load_template_runtime",
|
|
344
|
+
"normalise_template_language",
|
|
345
|
+
"resolve_template_binding",
|
|
346
|
+
"resolve_template_language",
|
|
347
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Utilities for post-processing generated LaTeX artefacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def squash_blank_lines(text: str) -> str:
|
|
9
|
+
"""Trim trailing whitespace and collapse runs of blank lines."""
|
|
10
|
+
trimmed = re.sub(r"[ \t]+(?=\r?\n|$)", "", text)
|
|
11
|
+
return re.sub(r"\n{3,}", "\n\n", trimmed)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["squash_blank_lines"]
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Shared helpers for wrapping LaTeX bodies with template metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jinja2 import meta
|
|
12
|
+
|
|
13
|
+
from texsmith.core.callouts import DEFAULT_CALLOUTS, merge_callouts, normalise_callouts
|
|
14
|
+
from texsmith.core.fragments import (
|
|
15
|
+
FRAGMENT_REGISTRY,
|
|
16
|
+
inject_fragment_attributes,
|
|
17
|
+
render_fragments,
|
|
18
|
+
)
|
|
19
|
+
from texsmith.core.templates import TemplateRuntime
|
|
20
|
+
from texsmith.core.templates.manifest import TemplateError
|
|
21
|
+
|
|
22
|
+
from texsmith.core.conversion.debug import ensure_emitter
|
|
23
|
+
from texsmith.core.diagnostics import DiagnosticEmitter
|
|
24
|
+
from texsmith.fonts.scripts import render_script_macros
|
|
25
|
+
from ..context import DocumentState
|
|
26
|
+
from .base import WrappableTemplate
|
|
27
|
+
from .loader import copy_template_assets
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class TemplateWrapResult:
|
|
32
|
+
"""Result artefacts produced after wrapping LaTeX with a template."""
|
|
33
|
+
|
|
34
|
+
latex_output: str
|
|
35
|
+
template_context: dict[str, Any]
|
|
36
|
+
output_path: Path | None
|
|
37
|
+
asset_paths: list[Path] = field(default_factory=list)
|
|
38
|
+
asset_pairs: list[tuple[Path, Path]] = field(default_factory=list)
|
|
39
|
+
rendered_fragments: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def wrap_template_document(
|
|
43
|
+
*,
|
|
44
|
+
template: WrappableTemplate,
|
|
45
|
+
default_slot: str,
|
|
46
|
+
slot_outputs: Mapping[str, str],
|
|
47
|
+
slot_output_overrides: Mapping[str, str] | None = None,
|
|
48
|
+
document_state: DocumentState,
|
|
49
|
+
template_overrides: Mapping[str, Any] | None,
|
|
50
|
+
output_dir: Path,
|
|
51
|
+
copy_assets: bool = True,
|
|
52
|
+
output_name: str | None = None,
|
|
53
|
+
bibliography_path: Path | None = None,
|
|
54
|
+
emitter: DiagnosticEmitter | None = None,
|
|
55
|
+
fragments: list[str] | None = None,
|
|
56
|
+
template_runtime: TemplateRuntime | None = None,
|
|
57
|
+
) -> TemplateWrapResult:
|
|
58
|
+
"""Wrap LaTeX content using a template and optional asset copying."""
|
|
59
|
+
output_dir = Path(output_dir).resolve()
|
|
60
|
+
resolved_slots = {name: value for name, value in slot_outputs.items()}
|
|
61
|
+
override_slots = (
|
|
62
|
+
{name: value for name, value in slot_output_overrides.items()}
|
|
63
|
+
if slot_output_overrides
|
|
64
|
+
else None
|
|
65
|
+
)
|
|
66
|
+
def _process_slot(value: Any) -> Any:
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
main_slot_content = _process_slot(resolved_slots.get(default_slot, ""))
|
|
70
|
+
resolved_slots[default_slot] = main_slot_content
|
|
71
|
+
resolved_slots.setdefault(default_slot, main_slot_content)
|
|
72
|
+
processed_override_slots: dict[str, Any] | None = None
|
|
73
|
+
if override_slots is not None:
|
|
74
|
+
processed_override_slots = {
|
|
75
|
+
name: _process_slot(value) for name, value in override_slots.items()
|
|
76
|
+
}
|
|
77
|
+
processed_override_slots.setdefault(
|
|
78
|
+
default_slot, resolved_slots.get(default_slot, "")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
overrides_payload = dict(template_overrides) if template_overrides else None
|
|
82
|
+
source_dir = None
|
|
83
|
+
overrides_press = overrides_payload.get("press") if overrides_payload else None
|
|
84
|
+
if isinstance(overrides_payload, Mapping):
|
|
85
|
+
raw_source_dir = overrides_payload.get("_source_dir") or overrides_payload.get("source_dir")
|
|
86
|
+
if isinstance(raw_source_dir, (str, Path)) and str(raw_source_dir):
|
|
87
|
+
source_dir = Path(raw_source_dir)
|
|
88
|
+
if source_dir is None and isinstance(overrides_press, Mapping):
|
|
89
|
+
source_dir_raw = overrides_press.get("_source_dir") or overrides_press.get("source_dir")
|
|
90
|
+
if source_dir_raw:
|
|
91
|
+
source_dir = Path(source_dir_raw)
|
|
92
|
+
fragment_names = list(fragments or [])
|
|
93
|
+
if not fragment_names:
|
|
94
|
+
if template_runtime is not None:
|
|
95
|
+
fragment_names = list(template_runtime.extras.get("fragments") or [])
|
|
96
|
+
else:
|
|
97
|
+
manifest_fragments = getattr(template.info, "fragments", None)
|
|
98
|
+
fragment_names = list(manifest_fragments or [])
|
|
99
|
+
|
|
100
|
+
template_context = template.prepare_context(
|
|
101
|
+
main_slot_content,
|
|
102
|
+
overrides=overrides_payload,
|
|
103
|
+
)
|
|
104
|
+
if isinstance(overrides_press, Mapping):
|
|
105
|
+
template_context.setdefault("press", overrides_press)
|
|
106
|
+
root_name: str | None = None
|
|
107
|
+
if output_name:
|
|
108
|
+
root_name = Path(output_name).stem
|
|
109
|
+
if root_name:
|
|
110
|
+
template_context.setdefault("root_filename", root_name)
|
|
111
|
+
|
|
112
|
+
for slot_name, raw_content in resolved_slots.items():
|
|
113
|
+
if slot_name == default_slot:
|
|
114
|
+
continue
|
|
115
|
+
processed_content = _process_slot(raw_content)
|
|
116
|
+
resolved_slots[slot_name] = processed_content
|
|
117
|
+
template_context[slot_name] = processed_content
|
|
118
|
+
|
|
119
|
+
template_context["index_entries"] = document_state.has_index_entries
|
|
120
|
+
index_terms = list(dict.fromkeys(getattr(document_state, "index_entries", [])))
|
|
121
|
+
template_context["has_index"] = bool(index_terms)
|
|
122
|
+
template_context["index_terms"] = [tuple(term) for term in index_terms]
|
|
123
|
+
|
|
124
|
+
registry_entries = index_terms
|
|
125
|
+
try: # pragma: no cover - optional dependency
|
|
126
|
+
from texsmith.index import get_registry
|
|
127
|
+
except ModuleNotFoundError:
|
|
128
|
+
template_context["index_registry"] = [tuple(term) for term in registry_entries]
|
|
129
|
+
else:
|
|
130
|
+
snapshot = sorted(get_registry().snapshot())
|
|
131
|
+
template_context["index_registry"] = [tuple(term) for term in snapshot]
|
|
132
|
+
template_context["acronyms"] = document_state.acronyms.copy()
|
|
133
|
+
template_context["citations"] = list(document_state.citations)
|
|
134
|
+
template_context["bibliography_entries"] = document_state.bibliography
|
|
135
|
+
|
|
136
|
+
fragment_attributes: dict[str, Any] = {}
|
|
137
|
+
if fragment_names:
|
|
138
|
+
fragment_attributes = inject_fragment_attributes(
|
|
139
|
+
fragment_names,
|
|
140
|
+
context=template_context,
|
|
141
|
+
overrides=overrides_payload,
|
|
142
|
+
source_dir=source_dir,
|
|
143
|
+
declared_attribute_owners=(
|
|
144
|
+
template.info.attribute_owners() if hasattr(template, "info") else {}
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
code_section = template_context.get("code")
|
|
149
|
+
code_engine = None
|
|
150
|
+
code_style = "bw"
|
|
151
|
+
if isinstance(code_section, Mapping):
|
|
152
|
+
raw_engine = code_section.get("engine")
|
|
153
|
+
code_engine = raw_engine if isinstance(raw_engine, str) else None
|
|
154
|
+
raw_style = code_section.get("style")
|
|
155
|
+
if isinstance(raw_style, str) and raw_style.strip():
|
|
156
|
+
code_style = raw_style.strip()
|
|
157
|
+
elif isinstance(code_section, str):
|
|
158
|
+
code_engine = code_section
|
|
159
|
+
code_engine = (code_engine or "pygments").strip().lower()
|
|
160
|
+
template_context["code_engine"] = code_engine or "pygments"
|
|
161
|
+
template_context.setdefault("code_style", code_style)
|
|
162
|
+
if "code" not in template_context:
|
|
163
|
+
template_context["code"] = {
|
|
164
|
+
"engine": template_context["code_engine"],
|
|
165
|
+
"style": template_context["code_style"],
|
|
166
|
+
}
|
|
167
|
+
elif isinstance(template_context["code"], dict):
|
|
168
|
+
template_context["code"].setdefault("style", template_context["code_style"])
|
|
169
|
+
if code_engine == "pygments" and getattr(document_state, "pygments_styles", {}):
|
|
170
|
+
styles = getattr(document_state, "pygments_styles", {})
|
|
171
|
+
template_context["pygments_style_defs"] = "\n".join(styles.values())
|
|
172
|
+
|
|
173
|
+
template_context["requires_shell_escape"] = bool(
|
|
174
|
+
template_context.get("requires_shell_escape", False)
|
|
175
|
+
or getattr(document_state, "requires_shell_escape", False)
|
|
176
|
+
or (template_runtime.requires_shell_escape if template_runtime else False)
|
|
177
|
+
or code_engine == "minted"
|
|
178
|
+
)
|
|
179
|
+
if template_runtime and template_runtime.engine:
|
|
180
|
+
template_context.setdefault("latex_engine", template_runtime.engine)
|
|
181
|
+
|
|
182
|
+
emitter_obj = ensure_emitter(emitter)
|
|
183
|
+
|
|
184
|
+
if document_state.citations and bibliography_path is not None:
|
|
185
|
+
template_context["bibliography"] = bibliography_path.stem
|
|
186
|
+
template_context["bibliography_resource"] = bibliography_path.name
|
|
187
|
+
template_context.setdefault("bibliography_style", "numeric")
|
|
188
|
+
|
|
189
|
+
template_context["ts_uses_callouts"] = bool(getattr(document_state, "callouts_used", False))
|
|
190
|
+
|
|
191
|
+
# Render fragments and inject declarations into template variables.
|
|
192
|
+
requested_fragments = list(fragment_names)
|
|
193
|
+
callout_overrides = overrides_payload.get("callouts") if overrides_payload else None
|
|
194
|
+
callouts_defs = normalise_callouts(
|
|
195
|
+
merge_callouts(
|
|
196
|
+
DEFAULT_CALLOUTS, callout_overrides if isinstance(callout_overrides, Mapping) else None
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
template_context.setdefault("callouts_definitions", callouts_defs)
|
|
200
|
+
variable_injections: dict[str, list[str]] = {}
|
|
201
|
+
fragment_providers: dict[str, list[str]] = {}
|
|
202
|
+
declared_slots, _default_slot = template.info.resolve_slots()
|
|
203
|
+
declared_slot_names = set(declared_slots.keys())
|
|
204
|
+
declared_vars = _discover_template_variables(template)
|
|
205
|
+
rendered_fragments: set[str] = set()
|
|
206
|
+
if fragment_names:
|
|
207
|
+
fragment_context: dict[str, Any] = template_context
|
|
208
|
+
|
|
209
|
+
def _reassert_effective_emoji_mode(target: dict[str, Any]) -> None:
|
|
210
|
+
effective_mode = target.get("_texsmith_effective_emoji_mode")
|
|
211
|
+
if not effective_mode:
|
|
212
|
+
return
|
|
213
|
+
target["emoji"] = effective_mode
|
|
214
|
+
target["emoji_mode"] = effective_mode
|
|
215
|
+
fonts_section = target.get("fonts")
|
|
216
|
+
if isinstance(fonts_section, Mapping):
|
|
217
|
+
if isinstance(fonts_section, dict):
|
|
218
|
+
fonts_section["emoji"] = effective_mode
|
|
219
|
+
else:
|
|
220
|
+
updated_fonts = dict(fonts_section)
|
|
221
|
+
updated_fonts["emoji"] = effective_mode
|
|
222
|
+
target["fonts"] = updated_fonts
|
|
223
|
+
|
|
224
|
+
if overrides_payload:
|
|
225
|
+
for key, value in overrides_payload.items():
|
|
226
|
+
if key in fragment_attributes:
|
|
227
|
+
continue
|
|
228
|
+
fragment_context.setdefault(key, value)
|
|
229
|
+
press_section = overrides_payload.get("press")
|
|
230
|
+
if isinstance(press_section, Mapping):
|
|
231
|
+
for key, value in press_section.items():
|
|
232
|
+
fragment_context.setdefault(key, value)
|
|
233
|
+
_reassert_effective_emoji_mode(fragment_context)
|
|
234
|
+
else:
|
|
235
|
+
_reassert_effective_emoji_mode(fragment_context)
|
|
236
|
+
fragment_result = render_fragments(
|
|
237
|
+
fragment_names,
|
|
238
|
+
context=fragment_context,
|
|
239
|
+
output_dir=output_dir,
|
|
240
|
+
source_dir=source_dir,
|
|
241
|
+
overrides=overrides_payload,
|
|
242
|
+
declared_slots=declared_slot_names,
|
|
243
|
+
declared_variables=declared_vars,
|
|
244
|
+
template_name=template.info.name,
|
|
245
|
+
)
|
|
246
|
+
variable_injections = fragment_result.variable_injections
|
|
247
|
+
fragment_providers = fragment_result.providers
|
|
248
|
+
for provider_list in fragment_providers.values():
|
|
249
|
+
rendered_fragments.update(provider_list)
|
|
250
|
+
template_context["requested_fragments"] = requested_fragments
|
|
251
|
+
template_context["fragments"] = sorted(rendered_fragments)
|
|
252
|
+
|
|
253
|
+
if variable_injections:
|
|
254
|
+
for variable_name, injections in variable_injections.items():
|
|
255
|
+
base = template_context.get(variable_name, "")
|
|
256
|
+
parts: list[str] = [base] if base else []
|
|
257
|
+
parts.extend(injections)
|
|
258
|
+
template_context[variable_name] = "\n".join(part for part in parts if part)
|
|
259
|
+
|
|
260
|
+
script_macros = render_script_macros(getattr(document_state, "script_usage", []))
|
|
261
|
+
if script_macros:
|
|
262
|
+
existing_extra = template_context.get("extra_packages", "")
|
|
263
|
+
template_context["extra_packages"] = "\n".join(
|
|
264
|
+
part for part in (existing_extra, script_macros) if part
|
|
265
|
+
)
|
|
266
|
+
template_context["script_macros"] = script_macros
|
|
267
|
+
|
|
268
|
+
final_slots = processed_override_slots if processed_override_slots is not None else resolved_slots
|
|
269
|
+
for slot_name, value in final_slots.items():
|
|
270
|
+
template_context[slot_name] = value
|
|
271
|
+
main_slot_content = final_slots.get(default_slot, main_slot_content)
|
|
272
|
+
|
|
273
|
+
# Append pdfLaTeX-specific packages when not using LuaLaTeX.
|
|
274
|
+
extra_lines = [line for line in template_context.get("extra_packages", "").splitlines() if line]
|
|
275
|
+
engine = str(template_context.get("latex_engine") or "").strip().lower()
|
|
276
|
+
if engine and engine != "lualatex":
|
|
277
|
+
for package in template_context.get("pdflatex_extra_packages") or []:
|
|
278
|
+
if package:
|
|
279
|
+
extra_lines.append(f"\\usepackage{{{package}}}")
|
|
280
|
+
template_context["extra_packages"] = "\n".join(extra_lines)
|
|
281
|
+
|
|
282
|
+
if document_state.citations and bibliography_path is not None:
|
|
283
|
+
template_context["bibliography"] = bibliography_path.stem
|
|
284
|
+
template_context["bibliography_resource"] = bibliography_path.name
|
|
285
|
+
template_context.setdefault("bibliography_style", "plain")
|
|
286
|
+
|
|
287
|
+
latex_output = template.wrap_document(
|
|
288
|
+
main_slot_content,
|
|
289
|
+
context=template_context,
|
|
290
|
+
)
|
|
291
|
+
latex_output = _squash_blank_lines(latex_output)
|
|
292
|
+
|
|
293
|
+
asset_paths: list[Path] = []
|
|
294
|
+
asset_pairs: list[tuple[Path, Path]] = []
|
|
295
|
+
if copy_assets:
|
|
296
|
+
declared_assets = list(template.iter_assets())
|
|
297
|
+
asset_paths = copy_template_assets(
|
|
298
|
+
template,
|
|
299
|
+
output_dir,
|
|
300
|
+
context=template_context,
|
|
301
|
+
overrides=overrides_payload,
|
|
302
|
+
assets=declared_assets,
|
|
303
|
+
)
|
|
304
|
+
asset_pairs = [(entry.source, dest) for entry, dest in zip(declared_assets, asset_paths)]
|
|
305
|
+
|
|
306
|
+
output_path: Path | None = None
|
|
307
|
+
if output_name:
|
|
308
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
output_path = output_dir / output_name
|
|
310
|
+
output_path.write_text(latex_output, encoding="utf-8")
|
|
311
|
+
template_context.setdefault("root_filename", output_path.stem)
|
|
312
|
+
|
|
313
|
+
return TemplateWrapResult(
|
|
314
|
+
latex_output=latex_output,
|
|
315
|
+
template_context=template_context,
|
|
316
|
+
output_path=output_path,
|
|
317
|
+
asset_paths=asset_paths,
|
|
318
|
+
asset_pairs=asset_pairs,
|
|
319
|
+
rendered_fragments=sorted(rendered_fragments),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _discover_template_variables(template: WrappableTemplate) -> set[str] | None:
|
|
324
|
+
"""Return undeclared variables referenced by the template entrypoint."""
|
|
325
|
+
env = template.environment
|
|
326
|
+
try:
|
|
327
|
+
source, _, _ = env.loader.get_source(env, template.info.entrypoint)
|
|
328
|
+
except Exception:
|
|
329
|
+
return None
|
|
330
|
+
parsed = env.parse(source)
|
|
331
|
+
return set(meta.find_undeclared_variables(parsed))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _squash_blank_lines(text: str) -> str:
|
|
335
|
+
"""Normalise LaTeX output by trimming trailing whitespace and blank lines."""
|
|
336
|
+
trimmed = re.sub(r"[ \t]+(?=\r?\n|$)", "", text)
|
|
337
|
+
return re.sub(r"\n{3,}", "\n\n", trimmed)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
__all__ = ["TemplateWrapResult", "wrap_template_document"]
|