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,861 @@
|
|
|
1
|
+
"""Pydantic models describing LaTeX template manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
import copy
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Callable, Literal
|
|
10
|
+
|
|
11
|
+
try: # Python >=3.11
|
|
12
|
+
import tomllib # type: ignore[attr-defined]
|
|
13
|
+
except ModuleNotFoundError: # Python 3.10
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
from pydantic import (
|
|
16
|
+
BaseModel,
|
|
17
|
+
ConfigDict,
|
|
18
|
+
Field,
|
|
19
|
+
PrivateAttr,
|
|
20
|
+
ValidationError,
|
|
21
|
+
model_validator,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from bs4 import BeautifulSoup, NavigableString, Tag
|
|
25
|
+
|
|
26
|
+
from texsmith.adapters.latex.utils import escape_latex_chars
|
|
27
|
+
from texsmith.adapters.markdown import DEFAULT_MARKDOWN_EXTENSIONS, render_markdown
|
|
28
|
+
from texsmith.core.exceptions import LatexRenderingError
|
|
29
|
+
from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
|
|
30
|
+
from texsmith.core.partials import normalise_partial_key
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TemplateError(LatexRenderingError):
|
|
34
|
+
"""Raised when a LaTeX template cannot be loaded or rendered."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_TEMPLATE_LANGUAGE = "english"
|
|
38
|
+
|
|
39
|
+
_BABEL_LANGUAGE_ALIASES = {
|
|
40
|
+
"ad": "catalan",
|
|
41
|
+
"ca": "catalan",
|
|
42
|
+
"cs": "czech",
|
|
43
|
+
"da": "danish",
|
|
44
|
+
"de": "ngerman",
|
|
45
|
+
"de-de": "ngerman",
|
|
46
|
+
"en": "english",
|
|
47
|
+
"en-gb": "british",
|
|
48
|
+
"en-us": "english",
|
|
49
|
+
"en-au": "australian",
|
|
50
|
+
"en-ca": "canadian",
|
|
51
|
+
"es": "spanish",
|
|
52
|
+
"es-es": "spanish",
|
|
53
|
+
"es-mx": "mexican",
|
|
54
|
+
"fi": "finnish",
|
|
55
|
+
"fr": "french",
|
|
56
|
+
"fr-fr": "french",
|
|
57
|
+
"fr-ca": "canadien",
|
|
58
|
+
"it": "italian",
|
|
59
|
+
"nl": "dutch",
|
|
60
|
+
"nb": "norwegian",
|
|
61
|
+
"nn": "nynorsk",
|
|
62
|
+
"pl": "polish",
|
|
63
|
+
"pt": "portuguese",
|
|
64
|
+
"pt-br": "brazilian",
|
|
65
|
+
"ro": "romanian",
|
|
66
|
+
"ru": "russian",
|
|
67
|
+
"sk": "slovak",
|
|
68
|
+
"sl": "slovene",
|
|
69
|
+
"sv": "swedish",
|
|
70
|
+
"tr": "turkish",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
LATEX_HEADING_LEVELS: dict[str, int] = {
|
|
74
|
+
"part": -1,
|
|
75
|
+
"chapter": 0,
|
|
76
|
+
"section": 1,
|
|
77
|
+
"subsection": 2,
|
|
78
|
+
"subsubsection": 3,
|
|
79
|
+
"paragraph": 4,
|
|
80
|
+
"subparagraph": 5,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CompatInfo(BaseModel):
|
|
85
|
+
"""Compatibility constraints declared by the template."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(extra="ignore")
|
|
88
|
+
|
|
89
|
+
texsmith: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TemplateAsset(BaseModel):
|
|
93
|
+
"""Description of individual template assets."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(extra="forbid")
|
|
96
|
+
|
|
97
|
+
source: str
|
|
98
|
+
template: bool = False
|
|
99
|
+
encoding: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TemplateSlot(BaseModel):
|
|
103
|
+
"""Configuration describing how content is injected into a template slot."""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(extra="forbid")
|
|
106
|
+
|
|
107
|
+
base_level: int | None = None
|
|
108
|
+
depth: str | None = None
|
|
109
|
+
offset: int = 0
|
|
110
|
+
default: bool = False
|
|
111
|
+
strip_heading: bool = False
|
|
112
|
+
description: str | None = None
|
|
113
|
+
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def _validate_depth(self) -> TemplateSlot:
|
|
116
|
+
if self.depth is not None and self.depth not in LATEX_HEADING_LEVELS:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"Unsupported slot depth '{self.depth}', "
|
|
119
|
+
f"expected one of {', '.join(LATEX_HEADING_LEVELS)}."
|
|
120
|
+
)
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def resolve_level(self, fallback: int) -> int:
|
|
124
|
+
"""Return the base level applied to rendered headings for this slot."""
|
|
125
|
+
base = fallback
|
|
126
|
+
if self.base_level is not None:
|
|
127
|
+
base = self.base_level
|
|
128
|
+
elif self.depth is not None:
|
|
129
|
+
base = fallback + LATEX_HEADING_LEVELS[self.depth]
|
|
130
|
+
return base + self.offset
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
AttributePrimitiveType = Literal["any", "string", "integer", "float", "boolean", "list", "mapping"]
|
|
134
|
+
AttributeFormatType = Literal["markdown", "raw"]
|
|
135
|
+
AttributeEscapeMode = Literal["latex"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_UNSET = object()
|
|
139
|
+
_CODE_ENGINES = {"minted", "listings", "verbatim", "pygments"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _lookup_path(container: Mapping[str, Any], path: str) -> tuple[Any, bool]:
|
|
143
|
+
"""Return a value stored at ``path`` within ``container``."""
|
|
144
|
+
segments = [segment for segment in path.split(".") if segment]
|
|
145
|
+
current: Any = container
|
|
146
|
+
for index, segment in enumerate(segments):
|
|
147
|
+
if not isinstance(current, Mapping):
|
|
148
|
+
return (None, False)
|
|
149
|
+
if segment not in current:
|
|
150
|
+
return (None, False)
|
|
151
|
+
current = current[segment]
|
|
152
|
+
if current is None and index + 1 < len(segments):
|
|
153
|
+
return (None, False)
|
|
154
|
+
return (current, True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _normalise_sources(payload: Any) -> list[str]:
|
|
158
|
+
if payload is None:
|
|
159
|
+
return []
|
|
160
|
+
if isinstance(payload, str):
|
|
161
|
+
candidate = payload.strip()
|
|
162
|
+
return [candidate] if candidate else []
|
|
163
|
+
if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
|
|
164
|
+
tokens: list[str] = []
|
|
165
|
+
for element in payload:
|
|
166
|
+
if isinstance(element, str) and element.strip():
|
|
167
|
+
tokens.append(element.strip())
|
|
168
|
+
return tokens
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _render_attribute_markdown(value: str, language: str = DEFAULT_TEMPLATE_LANGUAGE) -> str:
|
|
173
|
+
"""Render a short Markdown snippet through the standard TeXSmith pipeline."""
|
|
174
|
+
from texsmith.adapters.latex.formatter import LaTeXFormatter
|
|
175
|
+
from texsmith.adapters.latex.renderer import LaTeXRenderer
|
|
176
|
+
from texsmith.core.config import BookConfig
|
|
177
|
+
from texsmith.core.conversion.core import render_with_fallback
|
|
178
|
+
|
|
179
|
+
document = render_markdown(value, DEFAULT_MARKDOWN_EXTENSIONS)
|
|
180
|
+
html = document.html
|
|
181
|
+
|
|
182
|
+
config = BookConfig(
|
|
183
|
+
project_dir=Path("."),
|
|
184
|
+
language=language or DEFAULT_TEMPLATE_LANGUAGE,
|
|
185
|
+
legacy_latex_accents=False,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
formatter = LaTeXFormatter()
|
|
189
|
+
renderer_kwargs = {
|
|
190
|
+
"output_root": Path("."),
|
|
191
|
+
"copy_assets": False,
|
|
192
|
+
"convert_assets": False,
|
|
193
|
+
"hash_assets": False,
|
|
194
|
+
"parser": "html.parser",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def _renderer_factory() -> LaTeXRenderer:
|
|
198
|
+
return LaTeXRenderer(config=config, formatter=formatter, **renderer_kwargs)
|
|
199
|
+
|
|
200
|
+
rendered, _ = render_with_fallback(
|
|
201
|
+
_renderer_factory,
|
|
202
|
+
html,
|
|
203
|
+
runtime={"language": config.language, "copy_assets": False, "convert_assets": False, "hash_assets": False},
|
|
204
|
+
bibliography={},
|
|
205
|
+
state=None,
|
|
206
|
+
emitter=None,
|
|
207
|
+
)
|
|
208
|
+
return rendered.strip()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _render_attribute_markdown(value: str) -> str:
|
|
212
|
+
"""Render a short Markdown snippet into LaTeX-safe text."""
|
|
213
|
+
doc = render_markdown(value, DEFAULT_MARKDOWN_EXTENSIONS)
|
|
214
|
+
html = doc.html
|
|
215
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
216
|
+
|
|
217
|
+
def render_node(node: Tag | NavigableString) -> str:
|
|
218
|
+
if isinstance(node, NavigableString):
|
|
219
|
+
return escape_latex_chars(str(node))
|
|
220
|
+
|
|
221
|
+
name = (node.name or "").lower()
|
|
222
|
+
classes = set(node.get("class") or [])
|
|
223
|
+
rendered_children = "".join(render_node(child) for child in node.children)
|
|
224
|
+
|
|
225
|
+
if name in {"strong", "b"}:
|
|
226
|
+
return rf"\textbf{{{rendered_children}}}"
|
|
227
|
+
if name in {"em", "i"}:
|
|
228
|
+
return rf"\emph{{{rendered_children}}}"
|
|
229
|
+
if name == "code":
|
|
230
|
+
return rf"\texttt{{{rendered_children}}}"
|
|
231
|
+
if name == "span" and "texsmith-smallcaps" in classes:
|
|
232
|
+
return rf"\textsc{{{rendered_children}}}"
|
|
233
|
+
if name == "br":
|
|
234
|
+
return r"\\"
|
|
235
|
+
if name == "p":
|
|
236
|
+
return rendered_children + "\n\n"
|
|
237
|
+
if name == "li":
|
|
238
|
+
return "- " + rendered_children + "\n"
|
|
239
|
+
return rendered_children
|
|
240
|
+
|
|
241
|
+
body = soup.body if soup.body else soup
|
|
242
|
+
rendered = "".join(render_node(child) for child in body.children)
|
|
243
|
+
return rendered.strip()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_ATTRIBUTE_NORMALISERS: dict[str, Callable[[Any, "TemplateAttributeSpec", Any], Any]] = {}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _register_attribute_normaliser(
|
|
250
|
+
name: str,
|
|
251
|
+
) -> Callable[[Callable[[Any, "TemplateAttributeSpec", Any], Any]], Callable[[Any, "TemplateAttributeSpec", Any], Any]]:
|
|
252
|
+
"""Decorator used to register attribute normaliser callables (internal use)."""
|
|
253
|
+
|
|
254
|
+
def decorator(
|
|
255
|
+
func: Callable[[Any, "TemplateAttributeSpec", Any], Any],
|
|
256
|
+
) -> Callable[[Any, "TemplateAttributeSpec", Any], Any]:
|
|
257
|
+
_ATTRIBUTE_NORMALISERS[name] = func
|
|
258
|
+
return func
|
|
259
|
+
|
|
260
|
+
return decorator
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _resolve_attribute_normaliser(
|
|
264
|
+
name: str,
|
|
265
|
+
) -> Callable[[Any, "TemplateAttributeSpec", Any], Any]:
|
|
266
|
+
try:
|
|
267
|
+
return _ATTRIBUTE_NORMALISERS[name]
|
|
268
|
+
except KeyError as exc: # pragma: no cover - defensive
|
|
269
|
+
raise TemplateError(f"Unknown attribute normaliser '{name}'.") from exc
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@_register_attribute_normaliser("paper_option")
|
|
273
|
+
def _normalise_paper_option(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
|
|
274
|
+
valid_bases = {
|
|
275
|
+
"a0",
|
|
276
|
+
"a1",
|
|
277
|
+
"a2",
|
|
278
|
+
"a3",
|
|
279
|
+
"a4",
|
|
280
|
+
"a5",
|
|
281
|
+
"a6",
|
|
282
|
+
"b0",
|
|
283
|
+
"b1",
|
|
284
|
+
"b2",
|
|
285
|
+
"b3",
|
|
286
|
+
"b4",
|
|
287
|
+
"b5",
|
|
288
|
+
"b6",
|
|
289
|
+
"letter",
|
|
290
|
+
"legal",
|
|
291
|
+
"executive",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if value is None or value == "":
|
|
295
|
+
return fallback
|
|
296
|
+
if not isinstance(value, str):
|
|
297
|
+
raise TemplateError(
|
|
298
|
+
f"Invalid paper option type '{type(value).__name__}' for attribute '{spec.name}'."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
candidate = value.strip().lower()
|
|
302
|
+
if not candidate:
|
|
303
|
+
return fallback
|
|
304
|
+
|
|
305
|
+
if candidate.endswith("paper"):
|
|
306
|
+
candidate = candidate[:-5]
|
|
307
|
+
|
|
308
|
+
if candidate not in valid_bases:
|
|
309
|
+
allowed = ", ".join(sorted(f"{base}paper" for base in valid_bases))
|
|
310
|
+
raise TemplateError(
|
|
311
|
+
f"Invalid paper option '{value}' for attribute '{spec.name}'. Allowed values: {allowed}."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return f"{candidate}paper"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@_register_attribute_normaliser("orientation")
|
|
318
|
+
def _normalise_orientation(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
|
|
319
|
+
valid_orientations = {"portrait", "landscape"}
|
|
320
|
+
|
|
321
|
+
if value is None or value == "":
|
|
322
|
+
return fallback
|
|
323
|
+
|
|
324
|
+
if isinstance(value, str):
|
|
325
|
+
candidate = value.strip().lower()
|
|
326
|
+
if candidate == "vertical":
|
|
327
|
+
candidate = "portrait"
|
|
328
|
+
elif candidate == "horizontal":
|
|
329
|
+
candidate = "landscape"
|
|
330
|
+
else:
|
|
331
|
+
raise TemplateError(
|
|
332
|
+
f"Invalid orientation type '{type(value).__name__}' for attribute '{spec.name}'."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if not candidate:
|
|
336
|
+
return fallback
|
|
337
|
+
|
|
338
|
+
if candidate not in valid_orientations:
|
|
339
|
+
allowed = ", ".join(sorted(valid_orientations))
|
|
340
|
+
raise TemplateError(
|
|
341
|
+
f"Invalid orientation option '{value}' for attribute '{spec.name}'. "
|
|
342
|
+
f"Allowed values: {allowed}."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return candidate
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@_register_attribute_normaliser("babel_language")
|
|
349
|
+
def _normalise_language(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
|
|
350
|
+
if value is None or value == "":
|
|
351
|
+
return fallback
|
|
352
|
+
|
|
353
|
+
if not isinstance(value, str):
|
|
354
|
+
candidate = str(value)
|
|
355
|
+
else:
|
|
356
|
+
candidate = value
|
|
357
|
+
|
|
358
|
+
mapped = _map_babel_language(candidate)
|
|
359
|
+
if mapped:
|
|
360
|
+
return mapped
|
|
361
|
+
|
|
362
|
+
if fallback is not None:
|
|
363
|
+
return fallback
|
|
364
|
+
|
|
365
|
+
raise TemplateError(
|
|
366
|
+
f"Attribute '{spec.name}' received unsupported language value '{value}'."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@_register_attribute_normaliser("callout_style")
|
|
371
|
+
def _normalise_callout_style_attribute(
|
|
372
|
+
value: Any, spec: "TemplateAttributeSpec", fallback: Any
|
|
373
|
+
) -> Any:
|
|
374
|
+
allowed = {"fancy", "classic", "minimal"}
|
|
375
|
+
if value is None or value == "":
|
|
376
|
+
return fallback
|
|
377
|
+
|
|
378
|
+
if isinstance(value, str):
|
|
379
|
+
candidate = value.strip().lower()
|
|
380
|
+
else:
|
|
381
|
+
candidate = str(value).strip().lower()
|
|
382
|
+
|
|
383
|
+
if not candidate:
|
|
384
|
+
return fallback
|
|
385
|
+
|
|
386
|
+
if candidate not in allowed:
|
|
387
|
+
allowed_values = ", ".join(sorted(allowed))
|
|
388
|
+
raise TemplateError(
|
|
389
|
+
f"Attribute '{spec.name}' value '{value}' is invalid. Expected one of: {allowed_values}."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return candidate
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@_register_attribute_normaliser("margin_style")
|
|
396
|
+
def _normalise_margin_style(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
|
|
397
|
+
if value is None or value == "":
|
|
398
|
+
return fallback
|
|
399
|
+
|
|
400
|
+
if isinstance(value, str):
|
|
401
|
+
candidate = value.strip()
|
|
402
|
+
else:
|
|
403
|
+
candidate = str(value).strip()
|
|
404
|
+
|
|
405
|
+
lowered = candidate.lower()
|
|
406
|
+
if lowered in {"narrow", "default", "wide"}:
|
|
407
|
+
return lowered
|
|
408
|
+
return candidate
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@_register_attribute_normaliser("code_options")
|
|
412
|
+
def _normalise_code_options(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
|
|
413
|
+
"""Normalise code highlighting options ensuring a supported engine."""
|
|
414
|
+
|
|
415
|
+
def _pick_engine(candidate: Any) -> str:
|
|
416
|
+
if isinstance(candidate, str):
|
|
417
|
+
engine = candidate.strip().lower()
|
|
418
|
+
else:
|
|
419
|
+
engine = str(candidate).strip().lower() if candidate is not None else ""
|
|
420
|
+
if not engine:
|
|
421
|
+
return "pygments"
|
|
422
|
+
if engine not in _CODE_ENGINES:
|
|
423
|
+
allowed = ", ".join(sorted(_CODE_ENGINES))
|
|
424
|
+
raise TemplateError(
|
|
425
|
+
f"Attribute '{spec.name}' value '{engine}' is invalid. Expected one of: {allowed}."
|
|
426
|
+
)
|
|
427
|
+
return engine
|
|
428
|
+
|
|
429
|
+
def _pick_style(candidate: Any, default: str) -> str:
|
|
430
|
+
if isinstance(candidate, str):
|
|
431
|
+
stripped = candidate.strip()
|
|
432
|
+
return stripped or default
|
|
433
|
+
if candidate is None:
|
|
434
|
+
return default
|
|
435
|
+
stripped = str(candidate).strip()
|
|
436
|
+
return stripped or default
|
|
437
|
+
|
|
438
|
+
fallback_engine = (
|
|
439
|
+
_pick_engine(fallback.get("engine")) if isinstance(fallback, Mapping) else "pygments"
|
|
440
|
+
)
|
|
441
|
+
fallback_style = (
|
|
442
|
+
_pick_style(fallback.get("style"), "bw") if isinstance(fallback, Mapping) else "bw"
|
|
443
|
+
)
|
|
444
|
+
options: dict[str, Any] = {"engine": fallback_engine, "style": fallback_style}
|
|
445
|
+
|
|
446
|
+
if value is None:
|
|
447
|
+
return options
|
|
448
|
+
|
|
449
|
+
if isinstance(value, Mapping):
|
|
450
|
+
engine_value = value.get("engine", fallback_engine)
|
|
451
|
+
style_value = value.get("style", fallback_style)
|
|
452
|
+
options = dict(value)
|
|
453
|
+
options["engine"] = _pick_engine(engine_value)
|
|
454
|
+
options["style"] = _pick_style(style_value, fallback_style)
|
|
455
|
+
return options
|
|
456
|
+
|
|
457
|
+
if isinstance(value, str):
|
|
458
|
+
engine_value = value.strip()
|
|
459
|
+
if not engine_value:
|
|
460
|
+
return options
|
|
461
|
+
options["engine"] = _pick_engine(engine_value)
|
|
462
|
+
return options
|
|
463
|
+
|
|
464
|
+
options["engine"] = fallback_engine
|
|
465
|
+
return options
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class TemplateAttributeSpec(BaseModel):
|
|
469
|
+
"""Typed attribute definition used to build template defaults."""
|
|
470
|
+
|
|
471
|
+
model_config = ConfigDict(extra="forbid")
|
|
472
|
+
|
|
473
|
+
default: Any = None
|
|
474
|
+
type: AttributePrimitiveType | None = None
|
|
475
|
+
format: AttributeFormatType = "markdown"
|
|
476
|
+
choices: list[Any] = Field(default_factory=list)
|
|
477
|
+
sources: list[str] = Field(default_factory=list)
|
|
478
|
+
escape: AttributeEscapeMode | None = None
|
|
479
|
+
normaliser: str | None = None
|
|
480
|
+
required: bool = False
|
|
481
|
+
allow_empty: bool = True
|
|
482
|
+
description: str | None = None
|
|
483
|
+
range: list[Any] | None = None
|
|
484
|
+
owner: str | None = None
|
|
485
|
+
|
|
486
|
+
# Populated lazily after validation
|
|
487
|
+
name: str = ""
|
|
488
|
+
|
|
489
|
+
# Private caches
|
|
490
|
+
_default_cache: Any = PrivateAttr(default=_UNSET)
|
|
491
|
+
|
|
492
|
+
@model_validator(mode="before")
|
|
493
|
+
@classmethod
|
|
494
|
+
def _coerce_sources(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
495
|
+
if not isinstance(data, Mapping):
|
|
496
|
+
return data
|
|
497
|
+
coerced = dict(data)
|
|
498
|
+
coerced["sources"] = _normalise_sources(data.get("sources"))
|
|
499
|
+
return coerced
|
|
500
|
+
|
|
501
|
+
@model_validator(mode="after")
|
|
502
|
+
def _finalise(self) -> "TemplateAttributeSpec":
|
|
503
|
+
self._default_cache = self._coerce_value(
|
|
504
|
+
self.default,
|
|
505
|
+
from_override=False,
|
|
506
|
+
is_default=True,
|
|
507
|
+
fallback=None,
|
|
508
|
+
)
|
|
509
|
+
return self
|
|
510
|
+
|
|
511
|
+
def default_value(self) -> Any:
|
|
512
|
+
"""Return a deep copy of the attribute default."""
|
|
513
|
+
return copy.deepcopy(self._default_cache)
|
|
514
|
+
|
|
515
|
+
def _effective_type(self) -> AttributePrimitiveType:
|
|
516
|
+
if self.type:
|
|
517
|
+
return self.type
|
|
518
|
+
default = self.default
|
|
519
|
+
if isinstance(default, bool):
|
|
520
|
+
return "boolean"
|
|
521
|
+
if isinstance(default, int) and not isinstance(default, bool):
|
|
522
|
+
return "integer"
|
|
523
|
+
if isinstance(default, float):
|
|
524
|
+
return "float"
|
|
525
|
+
if isinstance(default, str):
|
|
526
|
+
return "string"
|
|
527
|
+
if isinstance(default, Mapping):
|
|
528
|
+
return "mapping"
|
|
529
|
+
if isinstance(default, Sequence) and not isinstance(default, (str, bytes)):
|
|
530
|
+
return "list"
|
|
531
|
+
return "any"
|
|
532
|
+
|
|
533
|
+
def effective_sources(self) -> list[str]:
|
|
534
|
+
if self.sources:
|
|
535
|
+
return list(self.sources)
|
|
536
|
+
return [self.name]
|
|
537
|
+
|
|
538
|
+
def fetch_override(
|
|
539
|
+
self,
|
|
540
|
+
overrides: Mapping[str, Any],
|
|
541
|
+
) -> tuple[Any, bool]:
|
|
542
|
+
for path in self.effective_sources():
|
|
543
|
+
if not path:
|
|
544
|
+
continue
|
|
545
|
+
value, found = _lookup_path(overrides, path)
|
|
546
|
+
if found:
|
|
547
|
+
return (value, True)
|
|
548
|
+
return (_UNSET, False)
|
|
549
|
+
|
|
550
|
+
def coerce_value(self, value: Any, *, from_override: bool) -> Any:
|
|
551
|
+
fallback = self._default_cache if self._default_cache is not _UNSET else None
|
|
552
|
+
result = self._coerce_value(
|
|
553
|
+
value,
|
|
554
|
+
from_override=from_override,
|
|
555
|
+
is_default=False,
|
|
556
|
+
fallback=fallback,
|
|
557
|
+
)
|
|
558
|
+
if result is None and self.required:
|
|
559
|
+
raise TemplateError(f"Attribute '{self.name}' requires a value.")
|
|
560
|
+
return result
|
|
561
|
+
|
|
562
|
+
def _coerce_value(
|
|
563
|
+
self,
|
|
564
|
+
value: Any,
|
|
565
|
+
*,
|
|
566
|
+
from_override: bool,
|
|
567
|
+
is_default: bool,
|
|
568
|
+
fallback: Any,
|
|
569
|
+
) -> Any:
|
|
570
|
+
target_type = self._effective_type()
|
|
571
|
+
|
|
572
|
+
if value is None:
|
|
573
|
+
result: Any = None
|
|
574
|
+
elif target_type == "string":
|
|
575
|
+
if isinstance(value, str):
|
|
576
|
+
result = value.strip()
|
|
577
|
+
else:
|
|
578
|
+
result = str(value).strip()
|
|
579
|
+
if not result and not self.allow_empty:
|
|
580
|
+
result = None
|
|
581
|
+
if result and self.format == "markdown":
|
|
582
|
+
result = _render_attribute_markdown(result)
|
|
583
|
+
elif target_type == "integer":
|
|
584
|
+
try:
|
|
585
|
+
result = int(value)
|
|
586
|
+
except (TypeError, ValueError) as exc:
|
|
587
|
+
raise TemplateError(
|
|
588
|
+
f"Attribute '{self.name}' expects an integer value."
|
|
589
|
+
) from exc
|
|
590
|
+
elif target_type == "float":
|
|
591
|
+
try:
|
|
592
|
+
result = float(value)
|
|
593
|
+
except (TypeError, ValueError) as exc:
|
|
594
|
+
raise TemplateError(
|
|
595
|
+
f"Attribute '{self.name}' expects a numeric value."
|
|
596
|
+
) from exc
|
|
597
|
+
elif target_type == "boolean":
|
|
598
|
+
if isinstance(value, bool):
|
|
599
|
+
result = value
|
|
600
|
+
elif isinstance(value, str):
|
|
601
|
+
lowered = value.strip().lower()
|
|
602
|
+
if lowered in {"true", "yes", "1", "on"}:
|
|
603
|
+
result = True
|
|
604
|
+
elif lowered in {"false", "no", "0", "off"}:
|
|
605
|
+
result = False
|
|
606
|
+
else:
|
|
607
|
+
raise TemplateError(
|
|
608
|
+
f"Attribute '{self.name}' expects a boolean value."
|
|
609
|
+
)
|
|
610
|
+
elif isinstance(value, (int, float)):
|
|
611
|
+
result = bool(value)
|
|
612
|
+
else:
|
|
613
|
+
raise TemplateError(
|
|
614
|
+
f"Attribute '{self.name}' expects a boolean-compatible value."
|
|
615
|
+
)
|
|
616
|
+
elif target_type == "list":
|
|
617
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
618
|
+
result = [copy.deepcopy(item) for item in value]
|
|
619
|
+
elif isinstance(value, str):
|
|
620
|
+
tokens = [item.strip() for item in value.split(",")]
|
|
621
|
+
result = [token for token in tokens if token]
|
|
622
|
+
else:
|
|
623
|
+
result = [value]
|
|
624
|
+
elif target_type == "mapping":
|
|
625
|
+
if isinstance(value, Mapping):
|
|
626
|
+
result = dict(value)
|
|
627
|
+
else:
|
|
628
|
+
raise TemplateError(f"Attribute '{self.name}' expects a mapping value.")
|
|
629
|
+
else:
|
|
630
|
+
result = copy.deepcopy(value)
|
|
631
|
+
|
|
632
|
+
if self.normaliser and result is not None:
|
|
633
|
+
normaliser = _resolve_attribute_normaliser(self.normaliser)
|
|
634
|
+
result = normaliser(result, self, fallback)
|
|
635
|
+
|
|
636
|
+
if self.choices and result is not None and result not in self.choices:
|
|
637
|
+
allowed = ", ".join(str(choice) for choice in self.choices)
|
|
638
|
+
raise TemplateError(
|
|
639
|
+
f"Attribute '{self.name}' value '{result}' is invalid. Expected one of: {allowed}."
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if (
|
|
643
|
+
self.escape == "latex"
|
|
644
|
+
and isinstance(result, str)
|
|
645
|
+
and from_override
|
|
646
|
+
and not is_default
|
|
647
|
+
and self.format != "markdown"
|
|
648
|
+
):
|
|
649
|
+
result = escape_latex_chars(result)
|
|
650
|
+
|
|
651
|
+
return copy.deepcopy(result)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class TemplateAttributeResolver:
|
|
655
|
+
"""Resolve attribute values from overrides using a typed specification."""
|
|
656
|
+
|
|
657
|
+
def __init__(self, specs: Mapping[str, TemplateAttributeSpec]):
|
|
658
|
+
self._specs = dict(specs)
|
|
659
|
+
|
|
660
|
+
def defaults(self) -> dict[str, Any]:
|
|
661
|
+
return {name: spec.default_value() for name, spec in self._specs.items()}
|
|
662
|
+
|
|
663
|
+
def merge(self, overrides: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
664
|
+
resolved = self.defaults()
|
|
665
|
+
if not overrides:
|
|
666
|
+
return resolved
|
|
667
|
+
|
|
668
|
+
override_payload = dict(overrides)
|
|
669
|
+
try:
|
|
670
|
+
normalise_press_metadata(override_payload)
|
|
671
|
+
except PressMetadataError:
|
|
672
|
+
pass
|
|
673
|
+
|
|
674
|
+
for name, spec in self._specs.items():
|
|
675
|
+
value, from_override = spec.fetch_override(override_payload)
|
|
676
|
+
if value is _UNSET:
|
|
677
|
+
continue
|
|
678
|
+
coerced = spec.coerce_value(value, from_override=from_override)
|
|
679
|
+
resolved[name] = coerced
|
|
680
|
+
|
|
681
|
+
return resolved
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class TemplateInfo(BaseModel):
|
|
685
|
+
"""Metadata describing the LaTeX template payload."""
|
|
686
|
+
|
|
687
|
+
model_config = ConfigDict(extra="allow")
|
|
688
|
+
|
|
689
|
+
name: str
|
|
690
|
+
version: str
|
|
691
|
+
entrypoint: str = "template.tex"
|
|
692
|
+
engine: str | None = None
|
|
693
|
+
shell_escape: bool = False
|
|
694
|
+
texlive_year: int | None = None
|
|
695
|
+
tlmgr_packages: list[str] = Field(default_factory=list)
|
|
696
|
+
fragments: list[str] | None = None
|
|
697
|
+
override: list[str] = Field(default_factory=list)
|
|
698
|
+
required_partials: list[str] = Field(default_factory=list)
|
|
699
|
+
attributes: dict[str, TemplateAttributeSpec] = Field(default_factory=dict)
|
|
700
|
+
assets: dict[str, TemplateAsset] = Field(default_factory=dict)
|
|
701
|
+
slots: dict[str, TemplateSlot] = Field(default_factory=dict)
|
|
702
|
+
emit: dict[str, Any] = Field(default_factory=dict)
|
|
703
|
+
|
|
704
|
+
_attribute_resolver: TemplateAttributeResolver = PrivateAttr()
|
|
705
|
+
_attribute_defaults: dict[str, Any] = PrivateAttr(default_factory=dict)
|
|
706
|
+
_attribute_owners: dict[str, str] = PrivateAttr(default_factory=dict)
|
|
707
|
+
|
|
708
|
+
@model_validator(mode="before")
|
|
709
|
+
@classmethod
|
|
710
|
+
def _normalise_assets(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
711
|
+
assets = data.get("assets")
|
|
712
|
+
if isinstance(assets, dict):
|
|
713
|
+
normalised: dict[str, Any] = {}
|
|
714
|
+
for destination, spec in assets.items():
|
|
715
|
+
if isinstance(spec, str):
|
|
716
|
+
normalised[destination] = {"source": spec}
|
|
717
|
+
else:
|
|
718
|
+
normalised[destination] = spec
|
|
719
|
+
data = dict(data)
|
|
720
|
+
data["assets"] = normalised
|
|
721
|
+
return data
|
|
722
|
+
|
|
723
|
+
@model_validator(mode="before")
|
|
724
|
+
@classmethod
|
|
725
|
+
def _normalise_attributes(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
726
|
+
attributes = data.get("attributes")
|
|
727
|
+
if not isinstance(attributes, Mapping):
|
|
728
|
+
return data
|
|
729
|
+
|
|
730
|
+
normalised: dict[str, Any] = {}
|
|
731
|
+
for name, payload in attributes.items():
|
|
732
|
+
if isinstance(payload, TemplateAttributeSpec):
|
|
733
|
+
normalised[name] = payload
|
|
734
|
+
elif isinstance(payload, Mapping):
|
|
735
|
+
if "default" not in payload and "type" not in payload:
|
|
736
|
+
normalised[name] = {"default": payload}
|
|
737
|
+
else:
|
|
738
|
+
normalised[name] = payload
|
|
739
|
+
else:
|
|
740
|
+
normalised[name] = {"default": payload}
|
|
741
|
+
|
|
742
|
+
updated = dict(data)
|
|
743
|
+
updated["attributes"] = normalised
|
|
744
|
+
return updated
|
|
745
|
+
|
|
746
|
+
@model_validator(mode="after")
|
|
747
|
+
def _bind_attribute_names(self) -> "TemplateInfo":
|
|
748
|
+
for name, spec in self.attributes.items():
|
|
749
|
+
spec.name = name
|
|
750
|
+
if spec.owner is None:
|
|
751
|
+
spec.owner = self.name
|
|
752
|
+
self._attribute_owners[name] = spec.owner
|
|
753
|
+
self._attribute_resolver = TemplateAttributeResolver(self.attributes)
|
|
754
|
+
self._attribute_defaults = self._attribute_resolver.defaults()
|
|
755
|
+
normalised_required: list[str] = []
|
|
756
|
+
for entry in self.required_partials or []:
|
|
757
|
+
if not isinstance(entry, str):
|
|
758
|
+
continue
|
|
759
|
+
key = normalise_partial_key(entry)
|
|
760
|
+
if key:
|
|
761
|
+
normalised_required.append(key)
|
|
762
|
+
self.required_partials = normalised_required
|
|
763
|
+
return self
|
|
764
|
+
|
|
765
|
+
def resolve_slots(self) -> tuple[dict[str, TemplateSlot], str]:
|
|
766
|
+
"""Return declared slots ensuring a single default sink exists."""
|
|
767
|
+
resolved = {
|
|
768
|
+
name: slot if isinstance(slot, TemplateSlot) else TemplateSlot.model_validate(slot)
|
|
769
|
+
for name, slot in self.slots.items()
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if "mainmatter" not in resolved:
|
|
773
|
+
resolved["mainmatter"] = TemplateSlot(default=True)
|
|
774
|
+
|
|
775
|
+
defaults = [name for name, slot in resolved.items() if slot.default]
|
|
776
|
+
if not defaults:
|
|
777
|
+
resolved["mainmatter"] = resolved["mainmatter"].model_copy(update={"default": True})
|
|
778
|
+
defaults = ["mainmatter"]
|
|
779
|
+
elif len(defaults) > 1:
|
|
780
|
+
formatted = ", ".join(defaults)
|
|
781
|
+
raise TemplateError(f"Multiple default slots declared: {formatted}")
|
|
782
|
+
|
|
783
|
+
return resolved, defaults[0]
|
|
784
|
+
|
|
785
|
+
def attribute_defaults(self) -> dict[str, Any]:
|
|
786
|
+
"""Return a deep copy of template attribute defaults."""
|
|
787
|
+
return copy.deepcopy(self._attribute_defaults)
|
|
788
|
+
|
|
789
|
+
def emit_defaults(self) -> dict[str, Any]:
|
|
790
|
+
"""Return default attributes emitted by the template."""
|
|
791
|
+
return copy.deepcopy(self.emit)
|
|
792
|
+
|
|
793
|
+
def get_attribute_default(self, name: str, default: Any | None = None) -> Any:
|
|
794
|
+
return copy.deepcopy(self._attribute_defaults.get(name, default))
|
|
795
|
+
|
|
796
|
+
def resolve_attributes(self, overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
797
|
+
"""Return defaults merged with overrides using the attribute specification."""
|
|
798
|
+
return self._attribute_resolver.merge(overrides)
|
|
799
|
+
|
|
800
|
+
def attribute_owners(self) -> dict[str, str]:
|
|
801
|
+
"""Return attribute ownership map (name -> owner)."""
|
|
802
|
+
return dict(self._attribute_owners)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
class LatexSection(BaseModel):
|
|
806
|
+
"""Section grouping LaTeX-specific manifest settings."""
|
|
807
|
+
|
|
808
|
+
template: TemplateInfo
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
class TemplateManifest(BaseModel):
|
|
812
|
+
"""Structured manifest describing a LaTeX template."""
|
|
813
|
+
|
|
814
|
+
compat: CompatInfo | None = None
|
|
815
|
+
latex: LatexSection
|
|
816
|
+
|
|
817
|
+
@classmethod
|
|
818
|
+
def load(cls, manifest_path: Path) -> TemplateManifest:
|
|
819
|
+
"""Load and validate a manifest from disk."""
|
|
820
|
+
try:
|
|
821
|
+
content = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
|
822
|
+
except FileNotFoundError as exc: # pragma: no cover - sanity check
|
|
823
|
+
raise TemplateError(f"Template manifest is missing: {manifest_path}") from exc
|
|
824
|
+
except OSError as exc: # pragma: no cover - IO failure
|
|
825
|
+
raise TemplateError(f"Failed to read template manifest: {exc}") from exc
|
|
826
|
+
except tomllib.TOMLDecodeError as exc:
|
|
827
|
+
raise TemplateError(f"Invalid template manifest: {exc}") from exc
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
return cls.model_validate(content)
|
|
831
|
+
except ValidationError as exc:
|
|
832
|
+
raise TemplateError(f"Template manifest validation failed: {exc}") from exc
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
__all__ = [
|
|
836
|
+
"CompatInfo",
|
|
837
|
+
"DEFAULT_TEMPLATE_LANGUAGE",
|
|
838
|
+
"LATEX_HEADING_LEVELS",
|
|
839
|
+
"LatexSection",
|
|
840
|
+
"TemplateAsset",
|
|
841
|
+
"TemplateAttributeSpec",
|
|
842
|
+
"TemplateError",
|
|
843
|
+
"TemplateInfo",
|
|
844
|
+
"TemplateManifest",
|
|
845
|
+
"TemplateSlot",
|
|
846
|
+
]
|
|
847
|
+
def _map_babel_language(value: str | None) -> str | None:
|
|
848
|
+
if value is None:
|
|
849
|
+
return None
|
|
850
|
+
candidate = value.strip()
|
|
851
|
+
if not candidate:
|
|
852
|
+
return None
|
|
853
|
+
lowered = candidate.lower().replace("_", "-")
|
|
854
|
+
if lowered in _BABEL_LANGUAGE_ALIASES:
|
|
855
|
+
return _BABEL_LANGUAGE_ALIASES[lowered]
|
|
856
|
+
primary = lowered.split("-", 1)[0]
|
|
857
|
+
if primary in _BABEL_LANGUAGE_ALIASES:
|
|
858
|
+
return _BABEL_LANGUAGE_ALIASES[primary]
|
|
859
|
+
if lowered.isalpha():
|
|
860
|
+
return lowered
|
|
861
|
+
return None
|