Skip to content

-specific utilities exposed by Texsmith.

LaTeXFormatter

LaTeXFormatter(template_dir: Path = TEMPLATE_DIR)

Render templates using Jinja2 with custom delimiters.

Source code in src/texsmith/adapters/latex/formatter.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(self, template_dir: Path = TEMPLATE_DIR) -> None:
    self.env = Environment(
        block_start_string=r"\BLOCK{",
        block_end_string=r"}",
        variable_start_string=r"\VAR{",
        variable_end_string=r"}",
        comment_start_string=r"\COMMENT{",
        comment_end_string=r"}",
        loader=FileSystemLoader(template_dir),
    )
    self.legacy_latex_accents: bool = False
    self.env.filters.setdefault("latex_escape", self._escape_latex)
    self.env.filters.setdefault("escape_latex", self._escape_latex)

    template_paths: list[Path] = []
    for ext in (".tex", ".cls"):
        template_paths.extend(template_dir.glob(f"**/*{ext}"))

    self._template_names: dict[str, str] = {}
    for path in template_paths:
        relative = path.relative_to(template_dir)
        key = self._normalise_key(relative.with_suffix("").as_posix())
        self._template_names[key] = relative.as_posix()

    self.templates: dict[str, Template] = {}
    self.default_code_engine = "pygments"
    self.default_code_style = "bw"
    self._pygments: PygmentsLatexHighlighter | None = None

template_names property

template_names: set[str]

Return the set of available template identifiers.

handle_codeblock

handle_codeblock(
    code: str,
    language: str = "text",
    filename: str | None = None,
    lineno: bool = False,
    highlight: Iterable[int] | None = None,
    baselinestretch: float | None = None,
    engine: str | None = None,
    state: DocumentState | None = None,
    **_: Any,
) -> str

Render code blocks with optional line numbers and highlights.

Source code in src/texsmith/adapters/latex/formatter.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def handle_codeblock(
    self,
    code: str,
    language: str = "text",
    filename: str | None = None,
    lineno: bool = False,
    highlight: Iterable[int] | None = None,
    baselinestretch: float | None = None,
    engine: str | None = None,
    state: DocumentState | None = None,
    **_: Any,
) -> str:
    """Render code blocks with optional line numbers and highlights."""
    highlight = list(highlight or [])
    optimized_highlight = optimize_list(highlight)
    normalized_engine = (engine or self.default_code_engine or "pygments").lower()
    if normalized_engine not in {"minted", "listings", "verbatim", "pygments"}:
        normalized_engine = "pygments"

    if normalized_engine == "pygments":
        style_name = str(self.default_code_style or "bw").strip() or "bw"
        if self._pygments is None or self._pygments.style != style_name:
            self._pygments = PygmentsLatexHighlighter(style=style_name)
        latex_code, style_defs = self._pygments.render(
            code,
            language,
            linenos=lineno,
            highlight_lines=highlight,
        )
        if state is not None and style_defs:
            state.pygments_styles.setdefault(self._pygments.style_key, style_defs)
        return self._get_template("codeblock_pygments").render(
            code=latex_code,
            language=language,
            linenos=lineno,
            filename=filename,
            baselinestretch=baselinestretch,
            highlight=optimized_highlight,
        )

    if normalized_engine == "listings":
        return self._get_template("codeblock_listings").render(
            code=code,
            language=language,
            linenos=lineno,
            filename=filename,
            baselinestretch=baselinestretch,
            highlight=optimized_highlight,
        )

    if normalized_engine == "verbatim":
        return self._get_template("codeblock_verbatim").render(
            code=code,
            language=language,
            linenos=lineno,
            filename=filename,
            baselinestretch=baselinestretch,
            highlight=optimized_highlight,
        )

    return self._get_template("codeblock").render(
        code=code,
        language=language,
        linenos=lineno,
        filename=filename,
        baselinestretch=baselinestretch,
        highlight=optimized_highlight,
    )

handle_codeinline

handle_codeinline(
    *,
    language: str = "text",
    text: str,
    engine: str | None = None,
    state: DocumentState | None = None,
    delimiter: str | None = None,
) -> str

Render inline code with engine-specific highlighting.

Source code in src/texsmith/adapters/latex/formatter.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def handle_codeinline(
    self,
    *,
    language: str = "text",
    text: str,
    engine: str | None = None,
    state: DocumentState | None = None,
    delimiter: str | None = None,
) -> str:
    """Render inline code with engine-specific highlighting."""
    normalized_engine = (engine or self.default_code_engine or "pygments").lower()
    if normalized_engine == "minted":
        delimiter = delimiter or "|"
        return self._get_template("codeinline").render(
            language=language or "text",
            text=text,
            delimiter=delimiter,
        )

    if normalized_engine == "pygments":
        style_name = str(self.default_code_style or "bw").strip() or "bw"
        if self._pygments is None or self._pygments.style != style_name:
            self._pygments = PygmentsLatexHighlighter(style=style_name)
        latex_code, style_defs = self._pygments.render_inline(text, language)
        if state is not None and style_defs:
            state.pygments_styles.setdefault(self._pygments.style_key, style_defs)
        return r"{\ttfamily " + latex_code + "}"

    # listings/verbatim fallback to plain typewriter
    return self.handle_codeinlinett(text)

handle_codeinlinett

handle_codeinlinett(text: str) -> str

Render plain inline code inside \texttt.

Source code in src/texsmith/adapters/latex/formatter.py
141
142
143
144
145
def handle_codeinlinett(self, text: str) -> str:
    """Render plain inline code inside \\texttt."""
    escaped = escape_latex_chars(text, legacy_accents=self.legacy_latex_accents)
    escaped = escaped.replace("-", "-\\allowbreak{}")
    return self._get_template("codeinlinett").render(text=escaped)

handle_href

handle_href(text: str, url: str) -> str

Render \href links with escaped URLs.

Source code in src/texsmith/adapters/latex/formatter.py
216
217
218
def handle_href(self, text: str, url: str) -> str:
    """Render \\href links with escaped URLs."""
    return self._get_template("href").render(text=text, url=self._escape_url(url))

handle_regex

handle_regex(text: str, url: str) -> str

Render regex helper links with escaped URLs.

Source code in src/texsmith/adapters/latex/formatter.py
220
221
222
def handle_regex(self, text: str, url: str) -> str:
    """Render regex helper links with escaped URLs."""
    return self._get_template("regex").render(text=text, url=self._escape_url(url))

normalise_key classmethod

normalise_key(name: str) -> str

Public wrapper for normalising template identifiers.

Source code in src/texsmith/adapters/latex/formatter.py
88
89
90
91
@classmethod
def normalise_key(cls, name: str) -> str:
    """Public wrapper for normalising template identifiers."""
    return cls._normalise_key(name)

override_template

override_template(name: str, source: str | Path) -> None

Override a built-in template snippet using an external payload.

Source code in src/texsmith/adapters/latex/formatter.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def override_template(self, name: str, source: str | Path) -> None:
    """Override a built-in template snippet using an external payload."""
    if isinstance(source, Path):
        template_source = source.read_text(encoding="utf-8")
        template_name = source.as_posix()
    else:
        template_source = source
        template_name = name

    template = self.env.from_string(template_source)
    template.name = template_name
    normalised = self._normalise_key(name)
    self.templates[normalised] = template
    self._template_names[normalised] = template_name

render_template

render_template(
    name: str, *args: Any, **kwargs: Any
) -> str

Render the partial name by its identifier.

This is the explicit template-rendering entry point used by the writer emitters. A missing partial raises a clear TemplateNotFoundError (node/template name + backend) rather than an opaque AttributeError. The first positional argument, if any, binds the conventional text variable.

Source code in src/texsmith/adapters/latex/formatter.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def render_template(self, name: str, *args: Any, **kwargs: Any) -> str:
    """Render the partial ``name`` by its identifier.

    This is the explicit template-rendering entry point used by the writer
    emitters. A missing partial raises a clear ``TemplateNotFoundError``
    (node/template name + backend) rather than an opaque ``AttributeError``.
    The first positional argument, if any, binds the conventional ``text``
    variable.
    """
    mangled = f"handle_{name}"
    handler = getattr(type(self), mangled, None)
    if handler is not None:
        return handler(self, *args, **kwargs)
    try:
        template = self._get_template(name)
    except KeyError:
        raise TemplateNotFoundError(name) from None
    if len(args) > 1:
        msg = f"Expected at most 1 argument, got {len(args)}, use keyword arguments instead"
        raise ValueError(msg)
    if args:
        kwargs["text"] = args[0]
    return template.render(**kwargs)

LaTeXRenderer

LaTeXRenderer(
    config: BookConfig | None = None,
    formatter: LaTeXFormatter | None = None,
    output_root: Path | str = Path("build"),
    parser: str = "lxml",
    copy_assets: bool = True,
    convert_assets: bool = False,
    hash_assets: bool = False,
)

Convert HTML fragments to through the IR (read → write).

Source code in src/texsmith/adapters/latex/renderer.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(
    self,
    config: BookConfig | None = None,
    formatter: LaTeXFormatter | None = None,
    output_root: Path | str = Path("build"),
    parser: str = "lxml",
    copy_assets: bool = True,
    convert_assets: bool = False,
    hash_assets: bool = False,
) -> None:
    self.config = config or BookConfig()
    self.formatter = formatter or LaTeXFormatter()
    self.parser_backend = parser
    self.copy_assets = copy_assets
    self.convert_assets = convert_assets
    self.hash_assets = hash_assets

    self.output_root = Path(output_root)
    self.assets_root = (self.output_root / "assets").resolve()
    self.assets = AssetRegistry(self.assets_root, copy_assets=self.copy_assets)

    # Keep the formatter in sync with the runtime environment.
    self.formatter.legacy_latex_accents = self.config.legacy_latex_accents

    # The index extension ships an enriched ``index`` partial (registry +
    # styled-entry support). The writer reuses it, so apply the override.
    from texsmith.extensions.index.renderer import INDEX_TEMPLATE

    self.formatter.override_template("index", INDEX_TEMPLATE)

    # Extension hooks: a custom reader registry (extra ``@reads`` lowerings)
    # and/or a ``LaTeXWriter`` subclass (extra ``@writes`` emitters). Left
    # as ``None`` for the default read→write path.
    self.reader_registry: Any | None = None
    self.writer_class: type[LaTeXWriter] = LaTeXWriter

render

render(
    html: str,
    *,
    runtime: Mapping[str, Any] | None = None,
    state: DocumentState | None = None,
    emitter: DiagnosticEmitter | None = None,
) -> str

Render an HTML fragment into via the IR.

Source code in src/texsmith/adapters/latex/renderer.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def render(
    self,
    html: str,
    *,
    runtime: Mapping[str, Any] | None = None,
    state: DocumentState | None = None,
    emitter: DiagnosticEmitter | None = None,
) -> str:
    """Render an HTML fragment into LaTeX via the IR."""
    active_emitter = emitter or NullEmitter()
    document_state = state or DocumentState()

    merged_runtime: dict[str, Any] = {
        "copy_assets": self.copy_assets,
        "convert_assets": self.convert_assets,
        "hash_assets": self.hash_assets,
        "emitter": active_emitter,
    }
    if runtime:
        merged_runtime.update(dict(runtime))

    from bs4 import FeatureNotFound

    def _make_reader(parser: str) -> HtmlReader:
        return HtmlReader(
            diagnostics=active_emitter, parser=parser, registry=self.reader_registry
        )

    try:
        document = _make_reader(self.parser_backend).read(html)
    except FeatureNotFound:
        # Fall back to the always-available built-in parser, mirroring the
        # legacy renderer's behaviour when the preferred backend is missing.
        self.parser_backend = "html.parser"
        document = _make_reader("html.parser").read(html)
    except Exception as exc:  # pragma: no cover - defensive
        raise LatexRenderingError("HTML reading failed") from exc

    writer_state = WriterState(
        state=document_state,
        config=self.config,
        formatter=self.formatter,
        assets=self.assets,
        runtime=merged_runtime,
    )
    try:
        return self.writer_class(writer_state).write(document)
    except LatexRenderingError:
        raise
    except Exception as exc:  # pragma: no cover - defensive
        raise LatexRenderingError("LaTeX rendering failed") from exc

escape_latex_chars

escape_latex_chars(
    text: str, *, legacy_accents: bool = False
) -> str

Escape special characters leveraging pylatexenc.

Source code in src/texsmith/writers/latex/escaper.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def escape_latex_chars(text: str, *, legacy_accents: bool = False) -> str:
    """Escape LaTeX special characters leveraging pylatexenc."""
    if not text:
        return text
    parts: list[str] = []
    buffer: list[str] = []

    def _encode_chunk(chunk: str) -> str:
        escaped = "".join(_BASIC_LATEX_ESCAPE_MAP.get(char, char) for char in chunk)
        if legacy_accents:
            encoded = unicode_to_latex(escaped, non_ascii_only=True, unknown_char_warning=False)
            return _wrap_latex_output(encoded)
        return escaped

    def _should_skip_encoding(char: str) -> bool:
        try:
            name = unicodedata.name(char)
        except ValueError:
            return False
        if "SUPERSCRIPT" in name or "SUBSCRIPT" in name:
            return True
        return "MODIFIER LETTER" in name and ("SMALL" in name or "CAPITAL" in name)

    for char in text:
        replacement = _COMMON_SYMBOL_MAP.get(char)
        if replacement is not None:
            if buffer:
                parts.append(_encode_chunk("".join(buffer)))
                buffer.clear()
            parts.append(replacement)
            continue
        if _should_skip_encoding(char):
            if buffer:
                parts.append(_encode_chunk("".join(buffer)))
                buffer.clear()
            parts.append(char)
        else:
            buffer.append(char)

    if buffer:
        parts.append(_encode_chunk("".join(buffer)))

    return "".join(parts)

optimize_list

optimize_list(numbers: Iterable[int]) -> list[str]

Merge consecutive integers into human-readable ranges.

Source code in src/texsmith/adapters/latex/formatter.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def optimize_list(numbers: Iterable[int]) -> list[str]:
    """Merge consecutive integers into human-readable ranges."""
    values = sorted(numbers)
    if not values:
        return []

    optimized: list[str] = []
    start = end = values[0]

    for num in values[1:]:
        if num == end + 1:
            end = num
        else:
            optimized.append(f"{start}-{end}" if start != end else str(start))
            start = end = num

    optimized.append(f"{start}-{end}" if start != end else str(start))
    return optimized

Utilities for rendering partials (snippets).

LaTeXFormatter

LaTeXFormatter(template_dir: Path = TEMPLATE_DIR)

Render templates using Jinja2 with custom delimiters.

Source code in src/texsmith/adapters/latex/formatter.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(self, template_dir: Path = TEMPLATE_DIR) -> None:
    self.env = Environment(
        block_start_string=r"\BLOCK{",
        block_end_string=r"}",
        variable_start_string=r"\VAR{",
        variable_end_string=r"}",
        comment_start_string=r"\COMMENT{",
        comment_end_string=r"}",
        loader=FileSystemLoader(template_dir),
    )
    self.legacy_latex_accents: bool = False
    self.env.filters.setdefault("latex_escape", self._escape_latex)
    self.env.filters.setdefault("escape_latex", self._escape_latex)

    template_paths: list[Path] = []
    for ext in (".tex", ".cls"):
        template_paths.extend(template_dir.glob(f"**/*{ext}"))

    self._template_names: dict[str, str] = {}
    for path in template_paths:
        relative = path.relative_to(template_dir)
        key = self._normalise_key(relative.with_suffix("").as_posix())
        self._template_names[key] = relative.as_posix()

    self.templates: dict[str, Template] = {}
    self.default_code_engine = "pygments"
    self.default_code_style = "bw"
    self._pygments: PygmentsLatexHighlighter | None = None

template_names property

template_names: set[str]

Return the set of available template identifiers.

handle_codeblock

handle_codeblock(
    code: str,
    language: str = "text",
    filename: str | None = None,
    lineno: bool = False,
    highlight: Iterable[int] | None = None,
    baselinestretch: float | None = None,
    engine: str | None = None,
    state: DocumentState | None = None,
    **_: Any,
) -> str

Render code blocks with optional line numbers and highlights.

Source code in src/texsmith/adapters/latex/formatter.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def handle_codeblock(
    self,
    code: str,
    language: str = "text",
    filename: str | None = None,
    lineno: bool = False,
    highlight: Iterable[int] | None = None,
    baselinestretch: float | None = None,
    engine: str | None = None,
    state: DocumentState | None = None,
    **_: Any,
) -> str:
    """Render code blocks with optional line numbers and highlights."""
    highlight = list(highlight or [])
    optimized_highlight = optimize_list(highlight)
    normalized_engine = (engine or self.default_code_engine or "pygments").lower()
    if normalized_engine not in {"minted", "listings", "verbatim", "pygments"}:
        normalized_engine = "pygments"

    if normalized_engine == "pygments":
        style_name = str(self.default_code_style or "bw").strip() or "bw"
        if self._pygments is None or self._pygments.style != style_name:
            self._pygments = PygmentsLatexHighlighter(style=style_name)
        latex_code, style_defs = self._pygments.render(
            code,
            language,
            linenos=lineno,
            highlight_lines=highlight,
        )
        if state is not None and style_defs:
            state.pygments_styles.setdefault(self._pygments.style_key, style_defs)
        return self._get_template("codeblock_pygments").render(
            code=latex_code,
            language=language,
            linenos=lineno,
            filename=filename,
            baselinestretch=baselinestretch,
            highlight=optimized_highlight,
        )

    if normalized_engine == "listings":
        return self._get_template("codeblock_listings").render(
            code=code,
            language=language,
            linenos=lineno,
            filename=filename,
            baselinestretch=baselinestretch,
            highlight=optimized_highlight,
        )

    if normalized_engine == "verbatim":
        return self._get_template("codeblock_verbatim").render(
            code=code,
            language=language,
            linenos=lineno,
            filename=filename,
            baselinestretch=baselinestretch,
            highlight=optimized_highlight,
        )

    return self._get_template("codeblock").render(
        code=code,
        language=language,
        linenos=lineno,
        filename=filename,
        baselinestretch=baselinestretch,
        highlight=optimized_highlight,
    )

handle_codeinline

handle_codeinline(
    *,
    language: str = "text",
    text: str,
    engine: str | None = None,
    state: DocumentState | None = None,
    delimiter: str | None = None,
) -> str

Render inline code with engine-specific highlighting.

Source code in src/texsmith/adapters/latex/formatter.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def handle_codeinline(
    self,
    *,
    language: str = "text",
    text: str,
    engine: str | None = None,
    state: DocumentState | None = None,
    delimiter: str | None = None,
) -> str:
    """Render inline code with engine-specific highlighting."""
    normalized_engine = (engine or self.default_code_engine or "pygments").lower()
    if normalized_engine == "minted":
        delimiter = delimiter or "|"
        return self._get_template("codeinline").render(
            language=language or "text",
            text=text,
            delimiter=delimiter,
        )

    if normalized_engine == "pygments":
        style_name = str(self.default_code_style or "bw").strip() or "bw"
        if self._pygments is None or self._pygments.style != style_name:
            self._pygments = PygmentsLatexHighlighter(style=style_name)
        latex_code, style_defs = self._pygments.render_inline(text, language)
        if state is not None and style_defs:
            state.pygments_styles.setdefault(self._pygments.style_key, style_defs)
        return r"{\ttfamily " + latex_code + "}"

    # listings/verbatim fallback to plain typewriter
    return self.handle_codeinlinett(text)

handle_codeinlinett

handle_codeinlinett(text: str) -> str

Render plain inline code inside \texttt.

Source code in src/texsmith/adapters/latex/formatter.py
141
142
143
144
145
def handle_codeinlinett(self, text: str) -> str:
    """Render plain inline code inside \\texttt."""
    escaped = escape_latex_chars(text, legacy_accents=self.legacy_latex_accents)
    escaped = escaped.replace("-", "-\\allowbreak{}")
    return self._get_template("codeinlinett").render(text=escaped)

handle_href

handle_href(text: str, url: str) -> str

Render \href links with escaped URLs.

Source code in src/texsmith/adapters/latex/formatter.py
216
217
218
def handle_href(self, text: str, url: str) -> str:
    """Render \\href links with escaped URLs."""
    return self._get_template("href").render(text=text, url=self._escape_url(url))

handle_regex

handle_regex(text: str, url: str) -> str

Render regex helper links with escaped URLs.

Source code in src/texsmith/adapters/latex/formatter.py
220
221
222
def handle_regex(self, text: str, url: str) -> str:
    """Render regex helper links with escaped URLs."""
    return self._get_template("regex").render(text=text, url=self._escape_url(url))

normalise_key classmethod

normalise_key(name: str) -> str

Public wrapper for normalising template identifiers.

Source code in src/texsmith/adapters/latex/formatter.py
88
89
90
91
@classmethod
def normalise_key(cls, name: str) -> str:
    """Public wrapper for normalising template identifiers."""
    return cls._normalise_key(name)

override_template

override_template(name: str, source: str | Path) -> None

Override a built-in template snippet using an external payload.

Source code in src/texsmith/adapters/latex/formatter.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def override_template(self, name: str, source: str | Path) -> None:
    """Override a built-in template snippet using an external payload."""
    if isinstance(source, Path):
        template_source = source.read_text(encoding="utf-8")
        template_name = source.as_posix()
    else:
        template_source = source
        template_name = name

    template = self.env.from_string(template_source)
    template.name = template_name
    normalised = self._normalise_key(name)
    self.templates[normalised] = template
    self._template_names[normalised] = template_name

render_template

render_template(
    name: str, *args: Any, **kwargs: Any
) -> str

Render the partial name by its identifier.

This is the explicit template-rendering entry point used by the writer emitters. A missing partial raises a clear TemplateNotFoundError (node/template name + backend) rather than an opaque AttributeError. The first positional argument, if any, binds the conventional text variable.

Source code in src/texsmith/adapters/latex/formatter.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def render_template(self, name: str, *args: Any, **kwargs: Any) -> str:
    """Render the partial ``name`` by its identifier.

    This is the explicit template-rendering entry point used by the writer
    emitters. A missing partial raises a clear ``TemplateNotFoundError``
    (node/template name + backend) rather than an opaque ``AttributeError``.
    The first positional argument, if any, binds the conventional ``text``
    variable.
    """
    mangled = f"handle_{name}"
    handler = getattr(type(self), mangled, None)
    if handler is not None:
        return handler(self, *args, **kwargs)
    try:
        template = self._get_template(name)
    except KeyError:
        raise TemplateNotFoundError(name) from None
    if len(args) > 1:
        msg = f"Expected at most 1 argument, got {len(args)}, use keyword arguments instead"
        raise ValueError(msg)
    if args:
        kwargs["text"] = args[0]
    return template.render(**kwargs)

TemplateNotFoundError

TemplateNotFoundError(name: str)

Bases: KeyError

Raised when a requested partial does not exist.

Source code in src/texsmith/adapters/latex/formatter.py
46
47
48
def __init__(self, name: str) -> None:
    super().__init__(f"No LaTeX partial named '{name}' (backend: latex).")
    self.name = name

optimize_list

optimize_list(numbers: Iterable[int]) -> list[str]

Merge consecutive integers into human-readable ranges.

Source code in src/texsmith/adapters/latex/formatter.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def optimize_list(numbers: Iterable[int]) -> list[str]:
    """Merge consecutive integers into human-readable ranges."""
    values = sorted(numbers)
    if not values:
        return []

    optimized: list[str] = []
    start = end = values[0]

    for num in values[1:]:
        if num == end + 1:
            end = num
        else:
            optimized.append(f"{start}-{end}" if start != end else str(start))
            start = end = num

    optimized.append(f"{start}-{end}" if start != end else str(start))
    return optimized

High-level HTML→IR→ renderer.

LaTeXRenderer is now a thin orchestrator: it parses the HTML fragment into the typed IR via :class:texsmith.readers.html.HtmlReader, then emits from that IR via :class:texsmith.writers.latex.LaTeXWriter. The old mutate-the-soup-and-get_text pipeline (and its phase/handler engine) is gone — there is a single read→write path.

LaTeXRenderer

LaTeXRenderer(
    config: BookConfig | None = None,
    formatter: LaTeXFormatter | None = None,
    output_root: Path | str = Path("build"),
    parser: str = "lxml",
    copy_assets: bool = True,
    convert_assets: bool = False,
    hash_assets: bool = False,
)

Convert HTML fragments to through the IR (read → write).

Source code in src/texsmith/adapters/latex/renderer.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(
    self,
    config: BookConfig | None = None,
    formatter: LaTeXFormatter | None = None,
    output_root: Path | str = Path("build"),
    parser: str = "lxml",
    copy_assets: bool = True,
    convert_assets: bool = False,
    hash_assets: bool = False,
) -> None:
    self.config = config or BookConfig()
    self.formatter = formatter or LaTeXFormatter()
    self.parser_backend = parser
    self.copy_assets = copy_assets
    self.convert_assets = convert_assets
    self.hash_assets = hash_assets

    self.output_root = Path(output_root)
    self.assets_root = (self.output_root / "assets").resolve()
    self.assets = AssetRegistry(self.assets_root, copy_assets=self.copy_assets)

    # Keep the formatter in sync with the runtime environment.
    self.formatter.legacy_latex_accents = self.config.legacy_latex_accents

    # The index extension ships an enriched ``index`` partial (registry +
    # styled-entry support). The writer reuses it, so apply the override.
    from texsmith.extensions.index.renderer import INDEX_TEMPLATE

    self.formatter.override_template("index", INDEX_TEMPLATE)

    # Extension hooks: a custom reader registry (extra ``@reads`` lowerings)
    # and/or a ``LaTeXWriter`` subclass (extra ``@writes`` emitters). Left
    # as ``None`` for the default read→write path.
    self.reader_registry: Any | None = None
    self.writer_class: type[LaTeXWriter] = LaTeXWriter

render

render(
    html: str,
    *,
    runtime: Mapping[str, Any] | None = None,
    state: DocumentState | None = None,
    emitter: DiagnosticEmitter | None = None,
) -> str

Render an HTML fragment into via the IR.

Source code in src/texsmith/adapters/latex/renderer.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def render(
    self,
    html: str,
    *,
    runtime: Mapping[str, Any] | None = None,
    state: DocumentState | None = None,
    emitter: DiagnosticEmitter | None = None,
) -> str:
    """Render an HTML fragment into LaTeX via the IR."""
    active_emitter = emitter or NullEmitter()
    document_state = state or DocumentState()

    merged_runtime: dict[str, Any] = {
        "copy_assets": self.copy_assets,
        "convert_assets": self.convert_assets,
        "hash_assets": self.hash_assets,
        "emitter": active_emitter,
    }
    if runtime:
        merged_runtime.update(dict(runtime))

    from bs4 import FeatureNotFound

    def _make_reader(parser: str) -> HtmlReader:
        return HtmlReader(
            diagnostics=active_emitter, parser=parser, registry=self.reader_registry
        )

    try:
        document = _make_reader(self.parser_backend).read(html)
    except FeatureNotFound:
        # Fall back to the always-available built-in parser, mirroring the
        # legacy renderer's behaviour when the preferred backend is missing.
        self.parser_backend = "html.parser"
        document = _make_reader("html.parser").read(html)
    except Exception as exc:  # pragma: no cover - defensive
        raise LatexRenderingError("HTML reading failed") from exc

    writer_state = WriterState(
        state=document_state,
        config=self.config,
        formatter=self.formatter,
        assets=self.assets,
        runtime=merged_runtime,
    )
    try:
        return self.writer_class(writer_state).write(document)
    except LatexRenderingError:
        raise
    except Exception as exc:  # pragma: no cover - defensive
        raise LatexRenderingError("LaTeX rendering failed") from exc

Utilities for parsing and presenting output from engine builds.

LatexLogParser

LatexLogParser()

Incrementally parse output into structured messages.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
106
107
108
109
def __init__(self) -> None:
    self._current: LatexMessage | None = None
    self._messages: list[LatexMessage] = []
    self._depth: int = 0

messages property

messages: Sequence[LatexMessage]

Return the messages accumulated so far.

finalize

finalize() -> list[LatexMessage]

Flush any pending message.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
165
166
167
def finalize(self) -> list[LatexMessage]:
    """Flush any pending message."""
    return self._finalize_current()

process_line

process_line(line: str) -> list[LatexMessage]

Process a log line and return messages that have just completed.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def process_line(self, line: str) -> list[LatexMessage]:
    """Process a log line and return messages that have just completed."""
    completed: list[LatexMessage] = []
    segments = self._consume_structure(line)
    if not segments:
        return completed

    for indent_level, payload in segments:
        if not payload or self._should_ignore(payload):
            continue

        severity, summary = self._match_message(payload)
        if severity is not None:
            message_summary = summary if summary else payload
            if (
                severity is LatexMessageSeverity.ERROR
                and message_summary in _ERROR_CONTINUATIONS
                and self._current
                and self._current.severity is LatexMessageSeverity.ERROR
            ):
                self._current.details.append(message_summary)
                continue
            completed.extend(self._finalize_current())
            self._current = LatexMessage(
                severity=severity,
                summary=message_summary,
                indent=indent_level,
            )
            continue

        if self._current and self._is_detail_line(payload):
            self._current.details.append(payload)
            continue

        if self._current and self._merge_path_continuation(payload):
            continue

        if self._current and self._merge_text_continuation(payload, indent_level):
            continue

        completed.extend(self._finalize_current())
        self._current = LatexMessage(
            severity=LatexMessageSeverity.INFO,
            summary=payload,
            indent=indent_level,
        )

    return completed

LatexMessage dataclass

LatexMessage(
    severity: LatexMessageSeverity,
    summary: str,
    details: list[str] = list(),
    indent: int = 0,
)

Structured message extracted from the build log.

LatexMessageSeverity

Bases: Enum

Classification severity extracted from output.

LatexStreamResult dataclass

LatexStreamResult(
    returncode: int, messages: list[LatexMessage]
)

Result of streaming engine output.

parse_latex_log

parse_latex_log(log_path: Path) -> list[LatexMessage]

Parse a log file into structured messages.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
492
493
494
495
496
497
498
499
500
501
def parse_latex_log(log_path: Path) -> list[LatexMessage]:
    """Parse a LaTeX log file into structured messages."""
    if not log_path.exists():
        return []
    parser = LatexLogParser()
    with log_path.open("r", encoding="utf-8", errors="replace") as handle:
        for line in handle:
            parser.process_line(line)
    parser.finalize()
    return list(parser.messages)

stream_latexmk_output

stream_latexmk_output(
    command: Sequence[str],
    *,
    cwd: str,
    env: Mapping[str, str],
    console: Console,
    verbosity: int = 0,
) -> LatexStreamResult

Execute a engine command and render output incrementally.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def stream_latexmk_output(
    command: Sequence[str],
    *,
    cwd: str,
    env: Mapping[str, str],
    console: Console,
    verbosity: int = 0,
) -> LatexStreamResult:
    """Execute a LaTeX engine command and render output incrementally."""
    from .log_render import LatexLogRenderer

    parser = LatexLogParser()
    renderer = LatexLogRenderer(console)

    with subprocess.Popen(
        command,
        cwd=cwd,
        env=dict(env),
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,
        encoding="utf-8",
        errors="replace",
    ) as process:
        selector = selectors.DefaultSelector()
        if process.stdout:
            selector.register(process.stdout, selectors.EVENT_READ)
        if process.stderr:
            selector.register(process.stderr, selectors.EVENT_READ)

        while selector.get_map():
            for key, _ in selector.select():
                stream_obj = key.fileobj
                if isinstance(stream_obj, int) or not hasattr(stream_obj, "readline"):
                    selector.unregister(stream_obj)
                    continue
                stream = cast(TextIO, stream_obj)
                chunk = stream.readline()
                if chunk:
                    for completed in parser.process_line(chunk):
                        if _should_emit_message(completed, verbosity):
                            renderer.consume(completed)
                else:
                    selector.unregister(stream)

        for completed in parser.finalize():
            if _should_emit_message(completed, verbosity):
                renderer.consume(completed)

        returncode = process.wait()

    renderer.summarize()
    return LatexStreamResult(returncode=returncode, messages=renderer.messages)

escaping — single source of truth lives in the writer.

Escaping is a backend responsibility, so the canonical implementation moved to :mod:texsmith.writers.latex.escaper. This module re-exports it so existing importers (templates, fonts, extensions, legacy handler helpers) keep a stable import path without duplicating the logic.

escape_latex_chars

escape_latex_chars(
    text: str, *, legacy_accents: bool = False
) -> str

Escape special characters leveraging pylatexenc.

Source code in src/texsmith/writers/latex/escaper.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def escape_latex_chars(text: str, *, legacy_accents: bool = False) -> str:
    """Escape LaTeX special characters leveraging pylatexenc."""
    if not text:
        return text
    parts: list[str] = []
    buffer: list[str] = []

    def _encode_chunk(chunk: str) -> str:
        escaped = "".join(_BASIC_LATEX_ESCAPE_MAP.get(char, char) for char in chunk)
        if legacy_accents:
            encoded = unicode_to_latex(escaped, non_ascii_only=True, unknown_char_warning=False)
            return _wrap_latex_output(encoded)
        return escaped

    def _should_skip_encoding(char: str) -> bool:
        try:
            name = unicodedata.name(char)
        except ValueError:
            return False
        if "SUPERSCRIPT" in name or "SUBSCRIPT" in name:
            return True
        return "MODIFIER LETTER" in name and ("SMALL" in name or "CAPITAL" in name)

    for char in text:
        replacement = _COMMON_SYMBOL_MAP.get(char)
        if replacement is not None:
            if buffer:
                parts.append(_encode_chunk("".join(buffer)))
                buffer.clear()
            parts.append(replacement)
            continue
        if _should_skip_encoding(char):
            if buffer:
                parts.append(_encode_chunk("".join(buffer)))
                buffer.clear()
            parts.append(char)
        else:
            buffer.append(char)

    if buffer:
        parts.append(_encode_chunk("".join(buffer)))

    return "".join(parts)

Public template helpers shared across the conversion pipeline.

BaseTemplate

BaseTemplate(root: Path)

Base class shared by template implementations.

Source code in src/texsmith/core/templates/base.py
67
68
69
70
71
72
73
74
75
def __init__(self, root: Path) -> None:
    self.root = root.resolve()
    if not self.root.exists():
        raise TemplateError(f"Template root does not exist: {self.root}")

    manifest_path = _resolve_manifest_path(self.root)
    self.manifest = TemplateManifest.load(manifest_path)
    self.info = self.manifest.latex.template
    self.environment = _build_environment(self.root)

default_context

default_context() -> dict[str, Any]

Return a shallow copy of the manifest default attributes.

Source code in src/texsmith/core/templates/base.py
77
78
79
80
81
def default_context(self) -> dict[str, Any]:
    """Return a shallow copy of the manifest default attributes."""
    defaults = self.info.attribute_defaults()
    defaults.update(self.info.emit_defaults())
    return defaults

render_template

render_template(template_name: str, **context: Any) -> str

Render a template using the configured Jinja environment.

Source code in src/texsmith/core/templates/base.py
83
84
85
86
87
88
89
90
91
def render_template(self, template_name: str, **context: Any) -> str:
    """Render a template using the configured Jinja environment."""
    try:
        template = self.environment.get_template(template_name)
    except TemplateNotFound as exc:
        raise TemplateError(
            f"Template entry '{template_name}' is missing in {self.root}"
        ) from exc
    return template.render(context)

ResolvedAsset dataclass

ResolvedAsset(
    source: Path,
    destination: Path,
    template: bool = False,
    encoding: str | None = None,
    template_name: str | None = None,
)

Resolved template asset ready to be materialised.

TemplateAsset

Bases: BaseModel

Description of individual template assets.

TemplateAttributeSpec

Bases: BaseModel

Typed attribute definition used to build template defaults.

default_value

default_value() -> Any

Return a deep copy of the attribute default.

Source code in src/texsmith/core/templates/manifest.py
558
559
560
def default_value(self) -> Any:
    """Return a deep copy of the attribute default."""
    return copy.deepcopy(self._default_cache)

TemplateBinding dataclass

TemplateBinding(
    runtime: TemplateRuntime | None,
    instance: WrappableTemplate | None,
    name: str | None,
    engine: str | None,
    requires_shell_escape: bool,
    formatter_overrides: dict[str, Path],
    slots: dict[str, TemplateSlot],
    default_slot: str,
    base_level: int | None,
    required_partials: set[str] = set(),
)

Binding between slot requests and a template.

apply_formatter_overrides

apply_formatter_overrides(
    formatter: LaTeXFormatter,
) -> None

Apply template-provided overrides to a formatter.

Source code in src/texsmith/core/templates/runtime.py
60
61
62
63
def apply_formatter_overrides(self, formatter: LaTeXFormatter) -> None:
    """Apply template-provided overrides to a formatter."""
    for key, override_path in self.formatter_overrides.items():
        formatter.override_template(key, override_path)

slot_levels

slot_levels(*, offset: int = 0) -> dict[str, int]

Return the resolved base level for each slot.

Source code in src/texsmith/core/templates/runtime.py
55
56
57
58
def slot_levels(self, *, offset: int = 0) -> dict[str, int]:
    """Return the resolved base level for each slot."""
    base = (self.base_level or 0) + offset
    return {name: slot.resolve_level(base) for name, slot in self.slots.items()}

TemplateError

Bases: LatexRenderingError

Raised when a template cannot be loaded or rendered.

TemplateInfo

Bases: BaseModel

Metadata describing the template payload.

attribute_defaults

attribute_defaults() -> dict[str, Any]

Return a deep copy of template attribute defaults.

Source code in src/texsmith/core/templates/manifest.py
841
842
843
def attribute_defaults(self) -> dict[str, Any]:
    """Return a deep copy of template attribute defaults."""
    return copy.deepcopy(self._attribute_defaults)

attribute_owners

attribute_owners() -> dict[str, str]

Return attribute ownership map (name -> owner).

Source code in src/texsmith/core/templates/manifest.py
856
857
858
def attribute_owners(self) -> dict[str, str]:
    """Return attribute ownership map (name -> owner)."""
    return dict(self._attribute_owners)

emit_defaults

emit_defaults() -> dict[str, Any]

Return default attributes emitted by the template.

Source code in src/texsmith/core/templates/manifest.py
845
846
847
def emit_defaults(self) -> dict[str, Any]:
    """Return default attributes emitted by the template."""
    return copy.deepcopy(self.emit)

resolve_attributes

resolve_attributes(
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]

Return defaults merged with overrides using the attribute specification.

Source code in src/texsmith/core/templates/manifest.py
852
853
854
def resolve_attributes(self, overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
    """Return defaults merged with overrides using the attribute specification."""
    return self._attribute_resolver.merge(overrides)

resolve_slots

resolve_slots() -> tuple[dict[str, TemplateSlot], str]

Return declared slots ensuring a single default sink exists.

Source code in src/texsmith/core/templates/manifest.py
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
def resolve_slots(self) -> tuple[dict[str, TemplateSlot], str]:
    """Return declared slots ensuring a single default sink exists."""
    resolved = {
        name: slot if isinstance(slot, TemplateSlot) else TemplateSlot.model_validate(slot)
        for name, slot in self.slots.items()
    }

    if "mainmatter" not in resolved:
        resolved["mainmatter"] = TemplateSlot(default=True)

    defaults = [name for name, slot in resolved.items() if slot.default]
    if not defaults:
        resolved["mainmatter"] = resolved["mainmatter"].model_copy(update={"default": True})
        defaults = ["mainmatter"]
    elif len(defaults) > 1:
        formatted = ", ".join(defaults)
        raise TemplateError(f"Multiple default slots declared: {formatted}")

    return resolved, defaults[0]

TemplateManifest

Bases: BaseModel

Structured manifest describing a multi-backend template.

latex is always present (the primary backend); typst is optional and only present for templates that also support the Typst backend.

load classmethod

load(manifest_path: Path) -> TemplateManifest

Load and validate a manifest from disk.

Source code in src/texsmith/core/templates/manifest.py
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
@classmethod
def load(cls, manifest_path: Path) -> TemplateManifest:
    """Load and validate a manifest from disk."""
    try:
        content = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:  # pragma: no cover - sanity check
        raise TemplateError(f"Template manifest is missing: {manifest_path}") from exc
    except OSError as exc:  # pragma: no cover - IO failure
        raise TemplateError(f"Failed to read template manifest: {exc}") from exc
    except tomllib.TOMLDecodeError as exc:
        raise TemplateError(f"Invalid template manifest: {exc}") from exc

    try:
        return cls.model_validate(content)
    except ValidationError as exc:
        raise TemplateError(f"Template manifest validation failed: {exc}") from exc

section

section(backend: str) -> TemplateInfo

Return the :class:TemplateInfo for backend (latex/typst).

Raises :class:TemplateError when a template is asked for a backend it does not declare — explicit, never a silent fallback to .

Source code in src/texsmith/core/templates/manifest.py
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
def section(self, backend: str) -> TemplateInfo:
    """Return the :class:`TemplateInfo` for ``backend`` (``latex``/``typst``).

    Raises :class:`TemplateError` when a template is asked for a backend it
    does not declare — explicit, never a silent fallback to LaTeX.
    """
    if backend == "latex":
        return self.latex.template
    if backend == "typst":
        if self.typst is None:
            raise TemplateError(
                f"Template '{self.latex.template.name}' does not declare a "
                "[typst.template] section; it cannot be rendered with --format typst."
            )
        return self.typst.template
    raise TemplateError(f"Unknown template backend '{backend}'.")

TemplateRuntime dataclass

TemplateRuntime(
    instance: WrappableTemplate,
    name: str,
    engine: str | None,
    requires_shell_escape: bool,
    slots: dict[str, TemplateSlot],
    default_slot: str,
    formatter_overrides: dict[str, Path],
    base_level: int | None,
    required_partials: set[str] = set(),
    extras: dict[str, Any] = dict(),
)

Resolved template metadata reused across conversions.

TemplateSlot

Bases: BaseModel

Configuration describing how content is injected into a template slot.

resolve_level

resolve_level(fallback: int) -> int

Return the base level applied to rendered headings for this slot.

Source code in src/texsmith/core/templates/manifest.py
92
93
94
95
96
97
98
99
def resolve_level(self, fallback: int) -> int:
    """Return the base level applied to rendered headings for this slot."""
    base = fallback
    if self.base_level is not None:
        base = self.base_level
    elif self.depth is not None:
        base = fallback + LATEX_HEADING_LEVELS[self.depth]
    return base + self.offset

TemplateWrapResult dataclass

TemplateWrapResult(
    latex_output: str,
    template_context: dict[str, Any],
    output_path: Path | None,
    asset_paths: list[Path] = list(),
    asset_pairs: list[tuple[Path, Path]] = list(),
    rendered_fragments: list[str] = list(),
)

Result artefacts produced after wrapping with a template.

WrappableTemplate

WrappableTemplate(root: Path)

Bases: BaseTemplate

Template capable of wrapping a generated fragment.

Source code in src/texsmith/core/templates/base.py
67
68
69
70
71
72
73
74
75
def __init__(self, root: Path) -> None:
    self.root = root.resolve()
    if not self.root.exists():
        raise TemplateError(f"Template root does not exist: {self.root}")

    manifest_path = _resolve_manifest_path(self.root)
    self.manifest = TemplateManifest.load(manifest_path)
    self.info = self.manifest.latex.template
    self.environment = _build_environment(self.root)

iter_assets

iter_assets() -> Iterable[ResolvedAsset]

Yield declared template assets.

Source code in src/texsmith/core/templates/base.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def iter_assets(self) -> Iterable[ResolvedAsset]:
    """Yield declared template assets."""
    for destination, asset in self.info.assets.items():
        dest_path = Path(destination)
        if dest_path.is_absolute():
            raise TemplateError(
                f"Template asset destination must be relative, got '{destination}'."
            )

        source_path = Path(asset.source)
        if not source_path.is_absolute():
            source_path = (self.root / source_path).resolve()
        if not source_path.exists():
            raise TemplateError(
                f"Declared template asset '{asset.source}' is missing under {self.root}."
            )

        template_name: str | None = None
        if asset.template:
            if source_path.is_dir():
                raise TemplateError(
                    f"Templated assets must reference files, got directory '{asset.source}'."
                )
            try:
                relative = source_path.relative_to(self.root)
            except ValueError:  # pragma: no cover - defensive
                common_dir = self.root.parent / "common"
                try:
                    relative = source_path.relative_to(common_dir)
                except ValueError as nested_exc:
                    raise TemplateError(
                        f"Templated asset '{asset.source}' must live inside the template root "
                        "or the shared 'common' directory."
                    ) from nested_exc
            template_name = relative.as_posix()

        yield ResolvedAsset(
            source=source_path,
            destination=dest_path,
            template=asset.template,
            encoding=asset.encoding,
            template_name=template_name,
        )

iter_formatter_overrides

iter_formatter_overrides() -> Iterable[tuple[str, Path]]

Yield formatter override templates declared by the manifest.

Source code in src/texsmith/core/templates/base.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def iter_formatter_overrides(self) -> Iterable[tuple[str, Path]]:
    """Yield formatter override templates declared by the manifest."""
    if not self.info.override:
        return ()

    search_roots = [
        self.root / "overrides",
        self.root / "template" / "overrides",
        self.root,
        self.root.parent / "overrides",
    ]

    seen: set[str] = set()
    overrides: list[tuple[str, Path]] = []

    for entry in self.info.override:
        if not isinstance(entry, str):
            raise TemplateError("Formatter override entries must be provided as string paths.")
        candidate = entry.strip()
        if not candidate:
            continue

        relative_path = Path(candidate)
        if relative_path.is_absolute() or any(part == ".." for part in relative_path.parts):
            raise TemplateError(
                f"Formatter override '{entry}' must be a relative path without '..'."
            )

        resolved_path: Path | None = None
        for root in search_roots:
            if not root.exists():
                continue
            probe = (root / relative_path).resolve()
            if probe.exists():
                resolved_path = probe
                break

        if resolved_path is None:
            raise TemplateError(f"Formatter override '{entry}' is missing under '{self.root}'.")

        key = relative_path.with_suffix("").as_posix().replace("/", "_")
        if key in seen:
            continue
        seen.add(key)
        overrides.append((key, resolved_path))

    return overrides

prepare_context

prepare_context(
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]

Build the rendering context shared by the template and its assets.

Source code in src/texsmith/core/templates/base.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def prepare_context(
    self,
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
    """Build the rendering context shared by the template and its assets."""
    attribute_context = self.info.resolve_attributes(overrides)
    context = dict(attribute_context)
    for key, value in self.info.emit_defaults().items():
        context.setdefault(key, value)
    if overrides:
        for key, value in overrides.items():
            if key in context and key not in self.info.emit:
                continue
            context[key] = value

    context.setdefault("frontmatter", "")
    context.setdefault("backmatter", "")
    context.setdefault("index_entries", False)
    context.setdefault("has_index", False)
    context.setdefault("index_terms", [])
    context.setdefault("index_registry", [])
    context.setdefault("index_engine", "auto")
    context.setdefault("acronyms", {})
    context.setdefault("citations", [])
    context.setdefault("bibliography_entries", {})
    context.setdefault("bibliography_resource", None)

    slots, default_slot = self.info.resolve_slots()
    for name in slots:
        context.setdefault(name, "")

    if default_slot == "mainmatter":
        context["mainmatter"] = latex_body
    else:
        context.setdefault("mainmatter", "")
        context[default_slot] = latex_body

    return context

wrap_document

wrap_document(
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
    context: Mapping[str, Any] | None = None,
) -> str

Render the template entry point using the provided payload.

Source code in src/texsmith/core/templates/base.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def wrap_document(
    self,
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
    context: Mapping[str, Any] | None = None,
) -> str:
    """Render the template entry point using the provided LaTeX payload."""
    if context is None:
        context = self.prepare_context(latex_body, overrides=overrides)
    else:
        context = dict(context)
        context.setdefault("frontmatter", "")
        context.setdefault("backmatter", "")
        slots, default_slot = self.info.resolve_slots()
        for name in slots:
            context.setdefault(name, "")
        if default_slot == "mainmatter":
            context["mainmatter"] = latex_body
        else:
            context.setdefault("mainmatter", "")
            context[default_slot] = latex_body

    engine = str(context.get("index_engine") or "").strip().lower()
    if not engine or engine == "auto":
        context["index_engine"] = _detect_index_engine()
    else:
        context["index_engine"] = engine

    return self.render_template(self.info.entrypoint, **context)

build_template_overrides

build_template_overrides(
    front_matter: Mapping[str, Any] | None,
) -> dict[str, Any]

Build template overrides from front matter while preserving metadata.

Source code in src/texsmith/core/templates/runtime.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def build_template_overrides(front_matter: Mapping[str, Any] | None) -> dict[str, Any]:
    """Build template overrides from front matter while preserving metadata."""
    if not front_matter or not isinstance(front_matter, Mapping):
        return {}

    overrides = dict(front_matter)
    press_section = overrides.get("press")
    if isinstance(press_section, Mapping):
        overrides["press"] = dict(press_section)

    fragments = overrides.get("fragments")
    if fragments is None and isinstance(press_section, Mapping):
        fragments = press_section.get("fragments")
    if isinstance(fragments, list):
        overrides["fragments"] = list(fragments)

    callouts_section = overrides.get("callouts")
    if callouts_section is None and isinstance(press_section, Mapping):
        callouts_section = press_section.get("callouts")
    if isinstance(callouts_section, Mapping):
        overrides["callouts"] = dict(callouts_section)

    callouts_style = overrides.get("callouts_style")
    if callouts_style is None and isinstance(press_section, Mapping):
        callouts_style = press_section.get("callouts_style")
    if callouts_style is not None:
        overrides["callout_style"] = callouts_style

    base_override = overrides.get("base_level")
    if base_override is None and isinstance(press_section, Mapping):
        base_override = press_section.get("base_level")
    if base_override is not None:
        try:
            overrides["base_level"] = coerce_base_level(base_override)
        except TemplateError:
            overrides["base_level"] = base_override

    return overrides

coerce_base_level

coerce_base_level(
    value: Any, *, allow_none: bool = True
) -> int | None

Normalise base-level metadata to an integer or None.

Source code in src/texsmith/core/templates/runtime.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def coerce_base_level(value: Any, *, allow_none: bool = True) -> int | None:
    """Normalise base-level metadata to an integer or ``None``."""
    if value is None:
        if allow_none:
            return None
        raise TemplateError("Base level value is missing.")

    if isinstance(value, bool):
        raise TemplateError("Base level must be an integer, booleans are not supported.")

    if isinstance(value, (int, float)):
        return int(value)

    if isinstance(value, str):
        candidate = value.strip().lower()
        if not candidate:
            if allow_none:
                return None
            raise TemplateError("Base level value cannot be empty.")
        alias_map = {
            "part": -1,
            "chapter": 0,
            "section": 1,
            "subsection": 2,
        }
        if candidate in alias_map:
            return alias_map[candidate]
        try:
            return int(candidate)
        except ValueError as exc:  # pragma: no cover - defensive
            raise TemplateError(
                f"Invalid base level '{value}'. Expected an integer value or one of "
                f"{', '.join(alias_map)}."
            ) from exc

    raise TemplateError(
        f"Base level should be provided as an integer value, got type '{type(value).__name__}'."
    )

copy_template_assets

copy_template_assets(
    template: WrappableTemplate,
    output_dir: Path,
    *,
    context: Mapping[str, Any] | None = None,
    overrides: Mapping[str, Any] | None = None,
    assets: Iterable[ResolvedAsset] | None = None,
) -> list[Path]

Copy the template declared assets into the selected output directory.

Source code in src/texsmith/core/templates/loader.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def copy_template_assets(
    template: WrappableTemplate,
    output_dir: Path,
    *,
    context: Mapping[str, Any] | None = None,
    overrides: Mapping[str, Any] | None = None,
    assets: Iterable[ResolvedAsset] | None = None,
) -> list[Path]:
    """Copy the template declared assets into the selected output directory."""
    output_dir = Path(output_dir).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    if context is None:
        render_context = template.prepare_context("", overrides=overrides)
    else:
        render_context = dict(context)

    written: list[Path] = []
    asset_entries = list(assets) if assets is not None else list(template.iter_assets())
    for asset in asset_entries:
        destination_path = (output_dir / asset.destination).resolve()

        if asset.template:
            if asset.template_name is None:  # pragma: no cover - defensive
                raise TemplateError(
                    f"Templated asset '{asset.source}' is missing template metadata."
                )
            destination_path.parent.mkdir(parents=True, exist_ok=True)
            rendered = template.render_template(asset.template_name, **render_context)
            destination_path.write_text(
                rendered,
                encoding=asset.encoding or "utf-8",
            )
        elif asset.source.is_dir():
            shutil.copytree(asset.source, destination_path, dirs_exist_ok=True)
        else:
            destination_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(asset.source, destination_path)

        written.append(destination_path)

    return written

discover_templates

discover_templates() -> list[dict[str, str]]

Return available templates in discovery order.

Source code in src/texsmith/core/templates/loader.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def discover_templates() -> list[dict[str, str]]:
    """Return available templates in discovery order."""
    entries: list[dict[str, str]] = []

    def _record(origin: str, name: str, root: Path) -> None:
        entries.append({"name": name, "origin": origin, "root": str(root.resolve())})

    for slug in iter_builtin_templates():
        try:
            template = load_builtin_template(slug)
        except TemplateError:
            continue
        if template is not None:
            _record("builtin", slug, template.root)

    try:
        for dist in metadata.distributions():
            name = dist.metadata.get("Name", "")
            if not name.lower().startswith("texsmith-template-"):
                continue
            slug = name[len("texsmith-template-") :]
            root = _resolve_packaged_template_root(slug)
            if root is None:
                continue
            _record("package", slug, root)
    except Exception:
        # A broken third-party template distribution (corrupt metadata, missing
        # file, unreadable entry-point) must not take down the whole template
        # listing — users would lose access to their builtin and local
        # templates too. Swallow silently; builtins and local scans still run.
        pass

    seen_locals: set[str] = set()
    for candidate in _iter_local_candidates(""):
        if not _looks_like_template_root(candidate):
            continue
        key = str(candidate.resolve())
        if key in seen_locals:
            continue
        seen_locals.add(key)
        _record("local", candidate.name, candidate)

    home_root = get_user_dir().data_dir("templates", create=False)
    if home_root.exists():
        for child in sorted(home_root.iterdir()):
            if child.is_dir() and _looks_like_template_root(child):
                _record("home", child.name, child)

    return sorted(entries, key=lambda entry: (entry["origin"], entry["name"]))

extract_base_level_override

extract_base_level_override(
    overrides: Mapping[str, Any] | None,
) -> Any

Extract a base level override from template metadata overrides.

Source code in src/texsmith/core/templates/runtime.py
106
107
108
109
110
111
112
113
114
115
116
117
def extract_base_level_override(overrides: Mapping[str, Any] | None) -> Any:
    """Extract a base level override from template metadata overrides."""
    if not overrides:
        return None

    press_section = overrides.get("press")
    direct_candidate = overrides.get("base_level")
    if direct_candidate is not None:
        return direct_candidate
    if isinstance(press_section, Mapping):
        return press_section.get("base_level")
    return None

extract_language_from_front_matter

extract_language_from_front_matter(
    front_matter: Mapping[str, Any] | None,
) -> str | None

Inspect front matter for language hints.

Source code in src/texsmith/core/templates/runtime.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def extract_language_from_front_matter(
    front_matter: Mapping[str, Any] | None,
) -> str | None:
    """Inspect front matter for language hints."""
    if not isinstance(front_matter, Mapping):
        return None

    for key in ("language", "lang"):
        value = front_matter.get(key)
        if isinstance(value, str):
            stripped = value.strip()
            if stripped:
                return stripped

    press_entry = front_matter.get("press")
    if isinstance(press_entry, Mapping):
        for key in ("language", "lang"):
            value = press_entry.get(key)
            if isinstance(value, str):
                stripped = value.strip()
                if stripped:
                    return stripped
    return None

load_template

load_template(identifier: str) -> WrappableTemplate

Load a template selected by name or filesystem path.

Source code in src/texsmith/core/templates/loader.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def load_template(identifier: str) -> WrappableTemplate:
    """Load a template selected by name or filesystem path."""
    path_candidate = Path(identifier).expanduser()
    looks_like_path = (
        path_candidate.is_absolute()
        or "/" in identifier
        or "\\" in identifier
        or identifier.startswith(".")
    )
    if looks_like_path and path_candidate.exists():
        return _load_path_template(path_candidate)

    slug = _slug_from_identifier(identifier)

    builtin = load_builtin_template(identifier)
    if builtin is not None:
        return builtin

    packaged_root = _resolve_packaged_template_root(slug)
    if packaged_root is not None:
        return _load_path_template(packaged_root)

    for candidate in _iter_local_candidates(slug):
        if _looks_like_template_root(candidate):
            return _load_path_template(candidate)

    home_candidate = _home_template_candidate(slug)
    if home_candidate is not None and _looks_like_template_root(home_candidate):
        return _load_path_template(home_candidate)

    raise TemplateError(
        f"Unable to load template '{identifier}'. Provide a valid path or "
        "install a package exposing a 'texsmith.templates' entry point."
    )

load_template_runtime

load_template_runtime(template: str) -> TemplateRuntime

Resolve template metadata for repeated conversions.

Source code in src/texsmith/core/templates/runtime.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def load_template_runtime(template: str) -> TemplateRuntime:
    """Resolve template metadata for repeated conversions."""
    template_instance = load_template(template)

    template_base = coerce_base_level(
        template_instance.info.get_attribute_default("base_level"),
    )

    slots, default_slot = template_instance.info.resolve_slots()
    formatter_overrides = dict(template_instance.iter_formatter_overrides())
    extras_payload = getattr(template_instance, "extras", {}) or {}
    extras = {key: value for key, value in extras_payload.items()}
    declared_fragments = (
        list(template_instance.info.fragments)
        if template_instance.info.fragments is not None
        else None
    )
    extras.setdefault(
        "fragments",
        declared_fragments if declared_fragments is not None else [],
    )
    extras.setdefault("readers", list(template_instance.info.readers or []))
    extras.setdefault("writer", template_instance.info.writer)

    return TemplateRuntime(
        instance=template_instance,
        name=template_instance.info.name,
        engine=template_instance.info.engine,
        requires_shell_escape=bool(template_instance.info.shell_escape),
        slots=slots,
        default_slot=default_slot,
        formatter_overrides=formatter_overrides,
        base_level=template_base,
        required_partials=set(template_instance.info.required_partials or []),
        extras=extras,
    )

normalise_template_language

normalise_template_language(
    value: str | None,
) -> str | None

Normalise language codes and map them through babel aliases when available.

Source code in src/texsmith/core/templates/runtime.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def normalise_template_language(value: str | None) -> str | None:
    """Normalise language codes and map them through babel aliases when available."""
    if value is None:
        return None

    stripped = value.strip()
    if not stripped:
        return None

    lowered = stripped.lower().replace("_", "-")
    alias = _BABEL_LANGUAGE_ALIASES.get(lowered)
    if alias:
        return alias

    primary = lowered.split("-", 1)[0]
    alias = _BABEL_LANGUAGE_ALIASES.get(primary)
    if alias:
        return alias

    if lowered.isalpha():
        return lowered

    return None

register_attribute_normaliser

register_attribute_normaliser(
    name: str,
    func: Callable[[Any, TemplateAttributeSpec, Any], Any],
    *,
    override: bool = False,
) -> None

Register a custom attribute normaliser under a short name.

Public counterpart of the internal _register_attribute_normaliser decorator. Template packages may call this at import time so that a manifest can refer to the normaliser by name. Registering over an existing built-in name raises TemplateError unless override=True.

Source code in src/texsmith/core/templates/manifest.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def register_attribute_normaliser(
    name: str,
    func: Callable[[Any, TemplateAttributeSpec, Any], Any],
    *,
    override: bool = False,
) -> None:
    """Register a custom attribute normaliser under a short ``name``.

    Public counterpart of the internal ``_register_attribute_normaliser``
    decorator. Template packages may call this at import time so that a
    manifest can refer to the normaliser by ``name``. Registering over an
    existing built-in name raises ``TemplateError`` unless ``override=True``.
    """
    if name in _BUILTIN_NORMALISER_NAMES and not override:
        raise TemplateError(
            f"Attribute normaliser '{name}' shadows a built-in normaliser. "
            f"Pass override=True to replace it intentionally."
        )
    if name in _ATTRIBUTE_NORMALISERS and not override:
        warnings.warn(
            f"Overriding already-registered attribute normaliser '{name}'.",
            stacklevel=2,
        )
    _ATTRIBUTE_NORMALISERS[name] = func

resolve_template_binding

resolve_template_binding(
    *,
    template: str | None,
    template_runtime: TemplateRuntime | None,
    template_overrides: Mapping[str, Any],
    slot_requests: Mapping[str, str],
    warn: Callable[[str], None] | None = None,
) -> tuple[TemplateBinding, dict[str, str]]

Resolve template runtime metadata and apply slot overrides.

Source code in src/texsmith/core/templates/runtime.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def resolve_template_binding(
    *,
    template: str | None,
    template_runtime: TemplateRuntime | None,
    template_overrides: Mapping[str, Any],
    slot_requests: Mapping[str, str],
    warn: Callable[[str], None] | None = None,
) -> tuple[TemplateBinding, dict[str, str]]:
    """Resolve template runtime metadata and apply slot overrides."""
    runtime = template_runtime
    if runtime is None and template:
        runtime = load_template_runtime(template)

    if runtime is not None:
        # Adjust base level for book parts when requested via overrides.
        binding_base_level = runtime.base_level
        part_flag = None
        press_section = template_overrides.get("press")
        if isinstance(template_overrides.get("part"), bool):
            part_flag = template_overrides.get("part")
        elif isinstance(press_section, Mapping) and isinstance(press_section.get("part"), bool):
            part_flag = press_section.get("part")
        if part_flag and runtime.name == "book":
            binding_base_level = coerce_base_level("part")

        binding = TemplateBinding(
            runtime=runtime,
            instance=runtime.instance,
            name=runtime.name,
            engine=runtime.engine,
            requires_shell_escape=runtime.requires_shell_escape,
            formatter_overrides=dict(runtime.formatter_overrides),
            slots=runtime.slots,
            default_slot=runtime.default_slot,
            base_level=binding_base_level,
            required_partials=set(runtime.required_partials),
        )
    else:
        binding = TemplateBinding(
            runtime=None,
            instance=None,
            name=None,
            engine=None,
            requires_shell_escape=False,
            formatter_overrides={},
            slots={"mainmatter": TemplateSlot(default=True)},
            default_slot="mainmatter",
            base_level=None,
            required_partials=set(),
        )

    base_override = coerce_base_level(extract_base_level_override(template_overrides))
    if base_override is not None:
        binding.base_level = base_override

    filtered: dict[str, str] = {}
    for slot_name, selector in slot_requests.items():
        if slot_name not in binding.slots:
            if warn is not None:
                template_hint = f"template '{binding.name}'" if binding.name else "the template"
                warn(
                    f"slot '{slot_name}' is not defined by {template_hint}; "
                    f"content will remain in '{binding.default_slot}'."
                )
            continue
        if binding.runtime is None:
            if warn is not None:
                warn(f"slot '{slot_name}' was requested but no template is selected; ignoring.")
            continue
        filtered[slot_name] = selector

    return binding, filtered

resolve_template_language

resolve_template_language(
    explicit: str | None,
    front_matter: Mapping[str, Any] | None,
) -> str

Resolve the effective template language from CLI and front matter inputs.

Source code in src/texsmith/core/templates/runtime.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def resolve_template_language(
    explicit: str | None,
    front_matter: Mapping[str, Any] | None,
) -> str:
    """Resolve the effective template language from CLI and front matter inputs."""
    candidates = (
        normalise_template_language(explicit),
        normalise_template_language(extract_language_from_front_matter(front_matter)),
    )

    for candidate in candidates:
        if candidate:
            return candidate

    return DEFAULT_TEMPLATE_LANGUAGE

wrap_template_document

wrap_template_document(
    *,
    template: WrappableTemplate,
    default_slot: str,
    slot_outputs: Mapping[str, str],
    slot_output_overrides: Mapping[str, str] | None = None,
    document_state: DocumentState,
    template_overrides: Mapping[str, Any] | None,
    output_dir: Path,
    copy_assets: bool = True,
    output_name: str | None = None,
    bibliography_path: Path | None = None,
    emitter: DiagnosticEmitter | None = None,
    fragments: list[str] | None = None,
    template_runtime: TemplateRuntime | None = None,
) -> TemplateWrapResult

Wrap content using a template and optional asset copying.

Source code in src/texsmith/core/templates/wrapper.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def wrap_template_document(
    *,
    template: WrappableTemplate,
    default_slot: str,
    slot_outputs: Mapping[str, str],
    slot_output_overrides: Mapping[str, str] | None = None,
    document_state: DocumentState,
    template_overrides: Mapping[str, Any] | None,
    output_dir: Path,
    copy_assets: bool = True,
    output_name: str | None = None,
    bibliography_path: Path | None = None,
    emitter: DiagnosticEmitter | None = None,
    fragments: list[str] | None = None,
    template_runtime: TemplateRuntime | None = None,
) -> TemplateWrapResult:
    """Wrap LaTeX content using a template and optional asset copying."""
    output_dir = Path(output_dir).resolve()
    resolved_slots = {name: value for name, value in slot_outputs.items()}
    override_slots = (
        {name: value for name, value in slot_output_overrides.items()}
        if slot_output_overrides
        else None
    )

    def _process_slot(value: Any) -> Any:
        return value

    main_slot_content = _process_slot(resolved_slots.get(default_slot, ""))
    resolved_slots[default_slot] = main_slot_content
    resolved_slots.setdefault(default_slot, main_slot_content)
    processed_override_slots: dict[str, Any] | None = None
    if override_slots is not None:
        processed_override_slots = {
            name: _process_slot(value) for name, value in override_slots.items()
        }
        processed_override_slots.setdefault(default_slot, resolved_slots.get(default_slot, ""))

    overrides_payload = dict(template_overrides) if template_overrides else None
    source_dir = None
    overrides_press = overrides_payload.get("press") if overrides_payload else None
    if isinstance(overrides_payload, Mapping):
        raw_source_dir = overrides_payload.get("_source_dir") or overrides_payload.get("source_dir")
        if isinstance(raw_source_dir, (str, Path)) and str(raw_source_dir):
            source_dir = Path(raw_source_dir)
    if source_dir is None and isinstance(overrides_press, Mapping):
        source_dir_raw = overrides_press.get("_source_dir") or overrides_press.get("source_dir")
        if source_dir_raw:
            source_dir = Path(source_dir_raw)
    fragment_names = list(fragments or [])
    if not fragment_names:
        if template_runtime is not None:
            fragment_names = list(template_runtime.extras.get("fragments") or [])
        else:
            manifest_fragments = getattr(template.info, "fragments", None)
            fragment_names = list(manifest_fragments or [])

    template_context = template.prepare_context(
        main_slot_content,
        overrides=overrides_payload,
    )
    engine = str(template_context.get("index_engine") or "").strip().lower()
    if not engine or engine == "auto":
        template_context["index_engine"] = _detect_index_engine()
    else:
        template_context["index_engine"] = engine
    if isinstance(overrides_press, Mapping):
        template_context.setdefault("press", overrides_press)
    root_name: str | None = None
    if output_name:
        root_name = Path(output_name).stem
    if root_name:
        template_context.setdefault("root_filename", root_name)

    for slot_name, raw_content in resolved_slots.items():
        if slot_name == default_slot:
            continue
        processed_content = _process_slot(raw_content)
        resolved_slots[slot_name] = processed_content
        template_context[slot_name] = processed_content

    template_context["index_entries"] = document_state.has_index_entries
    index_terms = list(dict.fromkeys(getattr(document_state, "index_entries", [])))
    template_context["has_index"] = bool(index_terms)
    template_context["index_terms"] = [tuple(term) for term in index_terms]

    registry_entries = index_terms
    try:  # pragma: no cover - optional dependency
        from texsmith.extensions.index import get_registry
    except ModuleNotFoundError:
        template_context["index_registry"] = [tuple(term) for term in registry_entries]
    else:
        snapshot = sorted(get_registry().snapshot())
        template_context["index_registry"] = [tuple(term) for term in snapshot]
    _merge_front_matter_glossary(document_state, overrides_payload, template_context)
    template_context["acronyms"] = document_state.acronyms.copy()
    template_context["acronym_groups"] = list(document_state.acronym_groups)
    template_context["acronym_entry_groups"] = dict(document_state.acronym_entry_groups)
    template_context["citations"] = list(document_state.citations)
    template_context["bibliography_entries"] = document_state.bibliography

    fragment_attributes: dict[str, Any] = {}
    if fragment_names:
        fragment_attributes = inject_fragment_attributes(
            fragment_names,
            context=template_context,
            overrides=overrides_payload,
            source_dir=source_dir,
            declared_attribute_owners=(
                template.info.attribute_owners() if hasattr(template, "info") else {}
            ),
        )

    code_section = template_context.get("code")
    code_engine = None
    code_style = "bw"
    if isinstance(code_section, Mapping):
        raw_engine = code_section.get("engine")
        code_engine = raw_engine if isinstance(raw_engine, str) else None
        raw_style = code_section.get("style")
        if isinstance(raw_style, str) and raw_style.strip():
            code_style = raw_style.strip()
    elif isinstance(code_section, str):
        code_engine = code_section
    code_engine = (code_engine or "pygments").strip().lower()
    template_context["code_engine"] = code_engine or "pygments"
    template_context.setdefault("code_style", code_style)
    if "code" not in template_context:
        template_context["code"] = {
            "engine": template_context["code_engine"],
            "style": template_context["code_style"],
        }
    elif isinstance(template_context["code"], dict):
        template_context["code"].setdefault("style", template_context["code_style"])
    if code_engine == "pygments" and getattr(document_state, "pygments_styles", {}):
        styles = getattr(document_state, "pygments_styles", {})
        template_context["pygments_style_defs"] = "\n".join(styles.values())

    template_context["requires_shell_escape"] = bool(
        template_context.get("requires_shell_escape", False)
        or getattr(document_state, "requires_shell_escape", False)
        or (template_runtime.requires_shell_escape if template_runtime else False)
        or code_engine == "minted"
    )
    if template_runtime and template_runtime.engine:
        template_context.setdefault("latex_engine", template_runtime.engine)

    emitter_obj = ensure_emitter(emitter)

    if document_state.citations and bibliography_path is not None:
        template_context["bibliography"] = bibliography_path.stem
        template_context["bibliography_resource"] = bibliography_path.name
        template_context.setdefault("bibliography_style", "numeric")

    template_context["ts_uses_callouts"] = bool(getattr(document_state, "callouts_used", False))

    # Render fragments and inject declarations into template variables.
    requested_fragments = list(fragment_names)
    callout_overrides = overrides_payload.get("callouts") if overrides_payload else None
    callouts_defs = normalise_callouts(
        merge_callouts(
            DEFAULT_CALLOUTS, callout_overrides if isinstance(callout_overrides, Mapping) else None
        )
    )
    template_context.setdefault("callouts_definitions", callouts_defs)
    variable_injections: dict[str, list[str]] = {}
    fragment_providers: dict[str, list[str]] = {}
    declared_slots, _default_slot = template.info.resolve_slots()
    declared_slot_names = set(declared_slots.keys())
    declared_vars = _discover_template_variables(template)
    rendered_fragments: set[str] = set()
    if fragment_names:
        fragment_context: dict[str, Any] = template_context

        def _reassert_effective_emoji_mode(target: dict[str, Any]) -> None:
            effective_mode = target.get("_texsmith_effective_emoji_mode")
            if not effective_mode:
                return
            target["emoji"] = effective_mode
            target["emoji_mode"] = effective_mode
            fonts_section = target.get("fonts")
            if isinstance(fonts_section, Mapping):
                if isinstance(fonts_section, dict):
                    fonts_section["emoji"] = effective_mode
                else:
                    updated_fonts = dict(fonts_section)
                    updated_fonts["emoji"] = effective_mode
                    target["fonts"] = updated_fonts

        if overrides_payload:
            for key, value in overrides_payload.items():
                if key in fragment_attributes:
                    continue
                fragment_context.setdefault(key, value)
            press_section = overrides_payload.get("press")
            if isinstance(press_section, Mapping):
                for key, value in press_section.items():
                    fragment_context.setdefault(key, value)
            _reassert_effective_emoji_mode(fragment_context)
        else:
            _reassert_effective_emoji_mode(fragment_context)
        fragment_result = render_fragments(
            fragment_names,
            context=fragment_context,
            output_dir=output_dir,
            source_dir=source_dir,
            overrides=overrides_payload,
            declared_slots=declared_slot_names,
            declared_variables=declared_vars,
            template_name=template.info.name,
        )
        variable_injections = fragment_result.variable_injections
        fragment_providers = fragment_result.providers
        for provider_list in fragment_providers.values():
            rendered_fragments.update(provider_list)
    template_context["requested_fragments"] = requested_fragments
    template_context["fragments"] = sorted(rendered_fragments)

    if variable_injections:
        for variable_name, injections in variable_injections.items():
            base = template_context.get(variable_name, "")
            parts: list[str] = [base] if base else []
            parts.extend(injections)
            template_context[variable_name] = "\n".join(part for part in parts if part)

    script_macros = render_script_macros(getattr(document_state, "script_usage", []))
    if script_macros:
        existing_extra = template_context.get("extra_packages", "")
        template_context["extra_packages"] = "\n".join(
            part for part in (existing_extra, script_macros) if part
        )
        template_context["script_macros"] = script_macros

    final_slots = (
        processed_override_slots if processed_override_slots is not None else resolved_slots
    )
    for slot_name, value in final_slots.items():
        template_context[slot_name] = value
    main_slot_content = final_slots.get(default_slot, main_slot_content)

    # Append pdfLaTeX-specific packages when not using LuaLaTeX.
    extra_lines = [line for line in template_context.get("extra_packages", "").splitlines() if line]
    engine = str(template_context.get("latex_engine") or "").strip().lower()
    if engine and engine != "lualatex":
        for package in template_context.get("pdflatex_extra_packages") or []:
            if package:
                extra_lines.append(f"\\usepackage{{{package}}}")
    template_context["extra_packages"] = "\n".join(extra_lines)

    if document_state.citations and bibliography_path is not None:
        template_context["bibliography"] = bibliography_path.stem
        template_context["bibliography_resource"] = bibliography_path.name
        template_context.setdefault("bibliography_style", "plain")

    latex_output = template.wrap_document(
        main_slot_content,
        context=template_context,
    )
    latex_output = _squash_blank_lines(latex_output)

    asset_paths: list[Path] = []
    asset_pairs: list[tuple[Path, Path]] = []
    if copy_assets:
        declared_assets = list(template.iter_assets())
        asset_paths = copy_template_assets(
            template,
            output_dir,
            context=template_context,
            overrides=overrides_payload,
            assets=declared_assets,
        )
        asset_pairs = [(entry.source, dest) for entry, dest in zip(declared_assets, asset_paths)]

    output_path: Path | None = None
    if output_name:
        output_dir.mkdir(parents=True, exist_ok=True)
        output_path = output_dir / output_name
        output_path.write_text(latex_output, encoding="utf-8")
        template_context.setdefault("root_filename", output_path.stem)

    return TemplateWrapResult(
        latex_output=latex_output,
        template_context=template_context,
        output_path=output_path,
        asset_paths=asset_paths,
        asset_pairs=asset_pairs,
        rendered_fragments=sorted(rendered_fragments),
    )

Core classes used to load and wrap templates.

BaseTemplate

BaseTemplate(root: Path)

Base class shared by template implementations.

Source code in src/texsmith/core/templates/base.py
67
68
69
70
71
72
73
74
75
def __init__(self, root: Path) -> None:
    self.root = root.resolve()
    if not self.root.exists():
        raise TemplateError(f"Template root does not exist: {self.root}")

    manifest_path = _resolve_manifest_path(self.root)
    self.manifest = TemplateManifest.load(manifest_path)
    self.info = self.manifest.latex.template
    self.environment = _build_environment(self.root)

default_context

default_context() -> dict[str, Any]

Return a shallow copy of the manifest default attributes.

Source code in src/texsmith/core/templates/base.py
77
78
79
80
81
def default_context(self) -> dict[str, Any]:
    """Return a shallow copy of the manifest default attributes."""
    defaults = self.info.attribute_defaults()
    defaults.update(self.info.emit_defaults())
    return defaults

render_template

render_template(template_name: str, **context: Any) -> str

Render a template using the configured Jinja environment.

Source code in src/texsmith/core/templates/base.py
83
84
85
86
87
88
89
90
91
def render_template(self, template_name: str, **context: Any) -> str:
    """Render a template using the configured Jinja environment."""
    try:
        template = self.environment.get_template(template_name)
    except TemplateNotFound as exc:
        raise TemplateError(
            f"Template entry '{template_name}' is missing in {self.root}"
        ) from exc
    return template.render(context)

ResolvedAsset dataclass

ResolvedAsset(
    source: Path,
    destination: Path,
    template: bool = False,
    encoding: str | None = None,
    template_name: str | None = None,
)

Resolved template asset ready to be materialised.

TemplateError

Bases: LatexRenderingError

Raised when a template cannot be loaded or rendered.

WrappableTemplate

WrappableTemplate(root: Path)

Bases: BaseTemplate

Template capable of wrapping a generated fragment.

Source code in src/texsmith/core/templates/base.py
67
68
69
70
71
72
73
74
75
def __init__(self, root: Path) -> None:
    self.root = root.resolve()
    if not self.root.exists():
        raise TemplateError(f"Template root does not exist: {self.root}")

    manifest_path = _resolve_manifest_path(self.root)
    self.manifest = TemplateManifest.load(manifest_path)
    self.info = self.manifest.latex.template
    self.environment = _build_environment(self.root)

iter_assets

iter_assets() -> Iterable[ResolvedAsset]

Yield declared template assets.

Source code in src/texsmith/core/templates/base.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def iter_assets(self) -> Iterable[ResolvedAsset]:
    """Yield declared template assets."""
    for destination, asset in self.info.assets.items():
        dest_path = Path(destination)
        if dest_path.is_absolute():
            raise TemplateError(
                f"Template asset destination must be relative, got '{destination}'."
            )

        source_path = Path(asset.source)
        if not source_path.is_absolute():
            source_path = (self.root / source_path).resolve()
        if not source_path.exists():
            raise TemplateError(
                f"Declared template asset '{asset.source}' is missing under {self.root}."
            )

        template_name: str | None = None
        if asset.template:
            if source_path.is_dir():
                raise TemplateError(
                    f"Templated assets must reference files, got directory '{asset.source}'."
                )
            try:
                relative = source_path.relative_to(self.root)
            except ValueError:  # pragma: no cover - defensive
                common_dir = self.root.parent / "common"
                try:
                    relative = source_path.relative_to(common_dir)
                except ValueError as nested_exc:
                    raise TemplateError(
                        f"Templated asset '{asset.source}' must live inside the template root "
                        "or the shared 'common' directory."
                    ) from nested_exc
            template_name = relative.as_posix()

        yield ResolvedAsset(
            source=source_path,
            destination=dest_path,
            template=asset.template,
            encoding=asset.encoding,
            template_name=template_name,
        )

iter_formatter_overrides

iter_formatter_overrides() -> Iterable[tuple[str, Path]]

Yield formatter override templates declared by the manifest.

Source code in src/texsmith/core/templates/base.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def iter_formatter_overrides(self) -> Iterable[tuple[str, Path]]:
    """Yield formatter override templates declared by the manifest."""
    if not self.info.override:
        return ()

    search_roots = [
        self.root / "overrides",
        self.root / "template" / "overrides",
        self.root,
        self.root.parent / "overrides",
    ]

    seen: set[str] = set()
    overrides: list[tuple[str, Path]] = []

    for entry in self.info.override:
        if not isinstance(entry, str):
            raise TemplateError("Formatter override entries must be provided as string paths.")
        candidate = entry.strip()
        if not candidate:
            continue

        relative_path = Path(candidate)
        if relative_path.is_absolute() or any(part == ".." for part in relative_path.parts):
            raise TemplateError(
                f"Formatter override '{entry}' must be a relative path without '..'."
            )

        resolved_path: Path | None = None
        for root in search_roots:
            if not root.exists():
                continue
            probe = (root / relative_path).resolve()
            if probe.exists():
                resolved_path = probe
                break

        if resolved_path is None:
            raise TemplateError(f"Formatter override '{entry}' is missing under '{self.root}'.")

        key = relative_path.with_suffix("").as_posix().replace("/", "_")
        if key in seen:
            continue
        seen.add(key)
        overrides.append((key, resolved_path))

    return overrides

prepare_context

prepare_context(
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]

Build the rendering context shared by the template and its assets.

Source code in src/texsmith/core/templates/base.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def prepare_context(
    self,
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
    """Build the rendering context shared by the template and its assets."""
    attribute_context = self.info.resolve_attributes(overrides)
    context = dict(attribute_context)
    for key, value in self.info.emit_defaults().items():
        context.setdefault(key, value)
    if overrides:
        for key, value in overrides.items():
            if key in context and key not in self.info.emit:
                continue
            context[key] = value

    context.setdefault("frontmatter", "")
    context.setdefault("backmatter", "")
    context.setdefault("index_entries", False)
    context.setdefault("has_index", False)
    context.setdefault("index_terms", [])
    context.setdefault("index_registry", [])
    context.setdefault("index_engine", "auto")
    context.setdefault("acronyms", {})
    context.setdefault("citations", [])
    context.setdefault("bibliography_entries", {})
    context.setdefault("bibliography_resource", None)

    slots, default_slot = self.info.resolve_slots()
    for name in slots:
        context.setdefault(name, "")

    if default_slot == "mainmatter":
        context["mainmatter"] = latex_body
    else:
        context.setdefault("mainmatter", "")
        context[default_slot] = latex_body

    return context

wrap_document

wrap_document(
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
    context: Mapping[str, Any] | None = None,
) -> str

Render the template entry point using the provided payload.

Source code in src/texsmith/core/templates/base.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def wrap_document(
    self,
    latex_body: str,
    *,
    overrides: Mapping[str, Any] | None = None,
    context: Mapping[str, Any] | None = None,
) -> str:
    """Render the template entry point using the provided LaTeX payload."""
    if context is None:
        context = self.prepare_context(latex_body, overrides=overrides)
    else:
        context = dict(context)
        context.setdefault("frontmatter", "")
        context.setdefault("backmatter", "")
        slots, default_slot = self.info.resolve_slots()
        for name in slots:
            context.setdefault(name, "")
        if default_slot == "mainmatter":
            context["mainmatter"] = latex_body
        else:
            context.setdefault("mainmatter", "")
            context[default_slot] = latex_body

    engine = str(context.get("index_engine") or "").strip().lower()
    if not engine or engine == "auto":
        context["index_engine"] = _detect_index_engine()
    else:
        context["index_engine"] = engine

    return self.render_template(self.info.entrypoint, **context)

load_specialised_template

load_specialised_template(
    path: Path,
) -> WrappableTemplate | None

Import a template-specific module to retrieve a specialised implementation.

Source code in src/texsmith/core/templates/base.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def load_specialised_template(path: Path) -> WrappableTemplate | None:
    """Import a template-specific module to retrieve a specialised implementation."""
    init_path = path / "__init__.py"
    if not init_path.exists():
        return None

    resolved_init = init_path.resolve()
    module_name = f"_texsmith_template_{hash(resolved_init) & 0xFFFFFFFF:x}"
    spec = importlib.util.spec_from_file_location(
        module_name,
        resolved_init,
        submodule_search_locations=[str(path.resolve())],
    )
    if spec is None or spec.loader is None:  # pragma: no cover - defensive
        return None

    module = importlib.util.module_from_spec(spec)
    sys.modules[module_name] = module
    try:
        spec.loader.exec_module(module)
    except Exception as exc:  # pragma: no cover - surface import errors
        sys.modules.pop(module_name, None)
        raise TemplateError(f"Failed to import template module at '{path}': {exc}") from exc

    for attribute in ("Template", "template", "load_template", "get_template"):
        candidate = getattr(module, attribute, None)
        if candidate is None:
            continue
        specialised = _coerce_template(candidate)
        if specialised is not None:
            return specialised

    return None

Helpers for loading template instances from disk or entry points.

copy_template_assets

copy_template_assets(
    template: WrappableTemplate,
    output_dir: Path,
    *,
    context: Mapping[str, Any] | None = None,
    overrides: Mapping[str, Any] | None = None,
    assets: Iterable[ResolvedAsset] | None = None,
) -> list[Path]

Copy the template declared assets into the selected output directory.

Source code in src/texsmith/core/templates/loader.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def copy_template_assets(
    template: WrappableTemplate,
    output_dir: Path,
    *,
    context: Mapping[str, Any] | None = None,
    overrides: Mapping[str, Any] | None = None,
    assets: Iterable[ResolvedAsset] | None = None,
) -> list[Path]:
    """Copy the template declared assets into the selected output directory."""
    output_dir = Path(output_dir).resolve()
    output_dir.mkdir(parents=True, exist_ok=True)

    if context is None:
        render_context = template.prepare_context("", overrides=overrides)
    else:
        render_context = dict(context)

    written: list[Path] = []
    asset_entries = list(assets) if assets is not None else list(template.iter_assets())
    for asset in asset_entries:
        destination_path = (output_dir / asset.destination).resolve()

        if asset.template:
            if asset.template_name is None:  # pragma: no cover - defensive
                raise TemplateError(
                    f"Templated asset '{asset.source}' is missing template metadata."
                )
            destination_path.parent.mkdir(parents=True, exist_ok=True)
            rendered = template.render_template(asset.template_name, **render_context)
            destination_path.write_text(
                rendered,
                encoding=asset.encoding or "utf-8",
            )
        elif asset.source.is_dir():
            shutil.copytree(asset.source, destination_path, dirs_exist_ok=True)
        else:
            destination_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(asset.source, destination_path)

        written.append(destination_path)

    return written

discover_templates

discover_templates() -> list[dict[str, str]]

Return available templates in discovery order.

Source code in src/texsmith/core/templates/loader.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def discover_templates() -> list[dict[str, str]]:
    """Return available templates in discovery order."""
    entries: list[dict[str, str]] = []

    def _record(origin: str, name: str, root: Path) -> None:
        entries.append({"name": name, "origin": origin, "root": str(root.resolve())})

    for slug in iter_builtin_templates():
        try:
            template = load_builtin_template(slug)
        except TemplateError:
            continue
        if template is not None:
            _record("builtin", slug, template.root)

    try:
        for dist in metadata.distributions():
            name = dist.metadata.get("Name", "")
            if not name.lower().startswith("texsmith-template-"):
                continue
            slug = name[len("texsmith-template-") :]
            root = _resolve_packaged_template_root(slug)
            if root is None:
                continue
            _record("package", slug, root)
    except Exception:
        # A broken third-party template distribution (corrupt metadata, missing
        # file, unreadable entry-point) must not take down the whole template
        # listing — users would lose access to their builtin and local
        # templates too. Swallow silently; builtins and local scans still run.
        pass

    seen_locals: set[str] = set()
    for candidate in _iter_local_candidates(""):
        if not _looks_like_template_root(candidate):
            continue
        key = str(candidate.resolve())
        if key in seen_locals:
            continue
        seen_locals.add(key)
        _record("local", candidate.name, candidate)

    home_root = get_user_dir().data_dir("templates", create=False)
    if home_root.exists():
        for child in sorted(home_root.iterdir()):
            if child.is_dir() and _looks_like_template_root(child):
                _record("home", child.name, child)

    return sorted(entries, key=lambda entry: (entry["origin"], entry["name"]))

load_template

load_template(identifier: str) -> WrappableTemplate

Load a template selected by name or filesystem path.

Source code in src/texsmith/core/templates/loader.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def load_template(identifier: str) -> WrappableTemplate:
    """Load a template selected by name or filesystem path."""
    path_candidate = Path(identifier).expanduser()
    looks_like_path = (
        path_candidate.is_absolute()
        or "/" in identifier
        or "\\" in identifier
        or identifier.startswith(".")
    )
    if looks_like_path and path_candidate.exists():
        return _load_path_template(path_candidate)

    slug = _slug_from_identifier(identifier)

    builtin = load_builtin_template(identifier)
    if builtin is not None:
        return builtin

    packaged_root = _resolve_packaged_template_root(slug)
    if packaged_root is not None:
        return _load_path_template(packaged_root)

    for candidate in _iter_local_candidates(slug):
        if _looks_like_template_root(candidate):
            return _load_path_template(candidate)

    home_candidate = _home_template_candidate(slug)
    if home_candidate is not None and _looks_like_template_root(home_candidate):
        return _load_path_template(home_candidate)

    raise TemplateError(
        f"Unable to load template '{identifier}'. Provide a valid path or "
        "install a package exposing a 'texsmith.templates' entry point."
    )

Pydantic models describing template manifests.

CompatInfo

Bases: BaseModel

Compatibility constraints declared by the template.

LatexSection

Bases: BaseModel

Section grouping -specific manifest settings.

TemplateAsset

Bases: BaseModel

Description of individual template assets.

TemplateAttributeResolver

TemplateAttributeResolver(
    specs: Mapping[str, TemplateAttributeSpec],
)

Resolve attribute values from overrides using a typed specification.

Source code in src/texsmith/core/templates/manifest.py
696
697
def __init__(self, specs: Mapping[str, TemplateAttributeSpec]):
    self._specs = dict(specs)

TemplateAttributeSpec

Bases: BaseModel

Typed attribute definition used to build template defaults.

default_value

default_value() -> Any

Return a deep copy of the attribute default.

Source code in src/texsmith/core/templates/manifest.py
558
559
560
def default_value(self) -> Any:
    """Return a deep copy of the attribute default."""
    return copy.deepcopy(self._default_cache)

TemplateError

Bases: LatexRenderingError

Raised when a template cannot be loaded or rendered.

TemplateInfo

Bases: BaseModel

Metadata describing the template payload.

attribute_defaults

attribute_defaults() -> dict[str, Any]

Return a deep copy of template attribute defaults.

Source code in src/texsmith/core/templates/manifest.py
841
842
843
def attribute_defaults(self) -> dict[str, Any]:
    """Return a deep copy of template attribute defaults."""
    return copy.deepcopy(self._attribute_defaults)

attribute_owners

attribute_owners() -> dict[str, str]

Return attribute ownership map (name -> owner).

Source code in src/texsmith/core/templates/manifest.py
856
857
858
def attribute_owners(self) -> dict[str, str]:
    """Return attribute ownership map (name -> owner)."""
    return dict(self._attribute_owners)

emit_defaults

emit_defaults() -> dict[str, Any]

Return default attributes emitted by the template.

Source code in src/texsmith/core/templates/manifest.py
845
846
847
def emit_defaults(self) -> dict[str, Any]:
    """Return default attributes emitted by the template."""
    return copy.deepcopy(self.emit)

resolve_attributes

resolve_attributes(
    overrides: Mapping[str, Any] | None = None,
) -> dict[str, Any]

Return defaults merged with overrides using the attribute specification.

Source code in src/texsmith/core/templates/manifest.py
852
853
854
def resolve_attributes(self, overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
    """Return defaults merged with overrides using the attribute specification."""
    return self._attribute_resolver.merge(overrides)

resolve_slots

resolve_slots() -> tuple[dict[str, TemplateSlot], str]

Return declared slots ensuring a single default sink exists.

Source code in src/texsmith/core/templates/manifest.py
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
def resolve_slots(self) -> tuple[dict[str, TemplateSlot], str]:
    """Return declared slots ensuring a single default sink exists."""
    resolved = {
        name: slot if isinstance(slot, TemplateSlot) else TemplateSlot.model_validate(slot)
        for name, slot in self.slots.items()
    }

    if "mainmatter" not in resolved:
        resolved["mainmatter"] = TemplateSlot(default=True)

    defaults = [name for name, slot in resolved.items() if slot.default]
    if not defaults:
        resolved["mainmatter"] = resolved["mainmatter"].model_copy(update={"default": True})
        defaults = ["mainmatter"]
    elif len(defaults) > 1:
        formatted = ", ".join(defaults)
        raise TemplateError(f"Multiple default slots declared: {formatted}")

    return resolved, defaults[0]

TemplateManifest

Bases: BaseModel

Structured manifest describing a multi-backend template.

latex is always present (the primary backend); typst is optional and only present for templates that also support the Typst backend.

load classmethod

load(manifest_path: Path) -> TemplateManifest

Load and validate a manifest from disk.

Source code in src/texsmith/core/templates/manifest.py
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
@classmethod
def load(cls, manifest_path: Path) -> TemplateManifest:
    """Load and validate a manifest from disk."""
    try:
        content = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:  # pragma: no cover - sanity check
        raise TemplateError(f"Template manifest is missing: {manifest_path}") from exc
    except OSError as exc:  # pragma: no cover - IO failure
        raise TemplateError(f"Failed to read template manifest: {exc}") from exc
    except tomllib.TOMLDecodeError as exc:
        raise TemplateError(f"Invalid template manifest: {exc}") from exc

    try:
        return cls.model_validate(content)
    except ValidationError as exc:
        raise TemplateError(f"Template manifest validation failed: {exc}") from exc

section

section(backend: str) -> TemplateInfo

Return the :class:TemplateInfo for backend (latex/typst).

Raises :class:TemplateError when a template is asked for a backend it does not declare — explicit, never a silent fallback to .

Source code in src/texsmith/core/templates/manifest.py
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
def section(self, backend: str) -> TemplateInfo:
    """Return the :class:`TemplateInfo` for ``backend`` (``latex``/``typst``).

    Raises :class:`TemplateError` when a template is asked for a backend it
    does not declare — explicit, never a silent fallback to LaTeX.
    """
    if backend == "latex":
        return self.latex.template
    if backend == "typst":
        if self.typst is None:
            raise TemplateError(
                f"Template '{self.latex.template.name}' does not declare a "
                "[typst.template] section; it cannot be rendered with --format typst."
            )
        return self.typst.template
    raise TemplateError(f"Unknown template backend '{backend}'.")

TemplateSlot

Bases: BaseModel

Configuration describing how content is injected into a template slot.

resolve_level

resolve_level(fallback: int) -> int

Return the base level applied to rendered headings for this slot.

Source code in src/texsmith/core/templates/manifest.py
92
93
94
95
96
97
98
99
def resolve_level(self, fallback: int) -> int:
    """Return the base level applied to rendered headings for this slot."""
    base = fallback
    if self.base_level is not None:
        base = self.base_level
    elif self.depth is not None:
        base = fallback + LATEX_HEADING_LEVELS[self.depth]
    return base + self.offset

TypstSection

Bases: BaseModel

Section grouping Typst-specific manifest settings.

Mirrors :class:LatexSection: it reuses the backend-agnostic :class:TemplateInfo (attributes / slots) so a single template package can declare a parallel [typst.template] block driving the Typst backend.

register_attribute_normaliser

register_attribute_normaliser(
    name: str,
    func: Callable[[Any, TemplateAttributeSpec, Any], Any],
    *,
    override: bool = False,
) -> None

Register a custom attribute normaliser under a short name.

Public counterpart of the internal _register_attribute_normaliser decorator. Template packages may call this at import time so that a manifest can refer to the normaliser by name. Registering over an existing built-in name raises TemplateError unless override=True.

Source code in src/texsmith/core/templates/manifest.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def register_attribute_normaliser(
    name: str,
    func: Callable[[Any, TemplateAttributeSpec, Any], Any],
    *,
    override: bool = False,
) -> None:
    """Register a custom attribute normaliser under a short ``name``.

    Public counterpart of the internal ``_register_attribute_normaliser``
    decorator. Template packages may call this at import time so that a
    manifest can refer to the normaliser by ``name``. Registering over an
    existing built-in name raises ``TemplateError`` unless ``override=True``.
    """
    if name in _BUILTIN_NORMALISER_NAMES and not override:
        raise TemplateError(
            f"Attribute normaliser '{name}' shadows a built-in normaliser. "
            f"Pass override=True to replace it intentionally."
        )
    if name in _ATTRIBUTE_NORMALISERS and not override:
        warnings.warn(
            f"Overriding already-registered attribute normaliser '{name}'.",
            stacklevel=2,
        )
    _ATTRIBUTE_NORMALISERS[name] = func

Runtime helpers for binding templates to rendered documents.

TemplateBinding dataclass

TemplateBinding(
    runtime: TemplateRuntime | None,
    instance: WrappableTemplate | None,
    name: str | None,
    engine: str | None,
    requires_shell_escape: bool,
    formatter_overrides: dict[str, Path],
    slots: dict[str, TemplateSlot],
    default_slot: str,
    base_level: int | None,
    required_partials: set[str] = set(),
)

Binding between slot requests and a template.

apply_formatter_overrides

apply_formatter_overrides(
    formatter: LaTeXFormatter,
) -> None

Apply template-provided overrides to a formatter.

Source code in src/texsmith/core/templates/runtime.py
60
61
62
63
def apply_formatter_overrides(self, formatter: LaTeXFormatter) -> None:
    """Apply template-provided overrides to a formatter."""
    for key, override_path in self.formatter_overrides.items():
        formatter.override_template(key, override_path)

slot_levels

slot_levels(*, offset: int = 0) -> dict[str, int]

Return the resolved base level for each slot.

Source code in src/texsmith/core/templates/runtime.py
55
56
57
58
def slot_levels(self, *, offset: int = 0) -> dict[str, int]:
    """Return the resolved base level for each slot."""
    base = (self.base_level or 0) + offset
    return {name: slot.resolve_level(base) for name, slot in self.slots.items()}

TemplateRuntime dataclass

TemplateRuntime(
    instance: WrappableTemplate,
    name: str,
    engine: str | None,
    requires_shell_escape: bool,
    slots: dict[str, TemplateSlot],
    default_slot: str,
    formatter_overrides: dict[str, Path],
    base_level: int | None,
    required_partials: set[str] = set(),
    extras: dict[str, Any] = dict(),
)

Resolved template metadata reused across conversions.

build_template_overrides

build_template_overrides(
    front_matter: Mapping[str, Any] | None,
) -> dict[str, Any]

Build template overrides from front matter while preserving metadata.

Source code in src/texsmith/core/templates/runtime.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def build_template_overrides(front_matter: Mapping[str, Any] | None) -> dict[str, Any]:
    """Build template overrides from front matter while preserving metadata."""
    if not front_matter or not isinstance(front_matter, Mapping):
        return {}

    overrides = dict(front_matter)
    press_section = overrides.get("press")
    if isinstance(press_section, Mapping):
        overrides["press"] = dict(press_section)

    fragments = overrides.get("fragments")
    if fragments is None and isinstance(press_section, Mapping):
        fragments = press_section.get("fragments")
    if isinstance(fragments, list):
        overrides["fragments"] = list(fragments)

    callouts_section = overrides.get("callouts")
    if callouts_section is None and isinstance(press_section, Mapping):
        callouts_section = press_section.get("callouts")
    if isinstance(callouts_section, Mapping):
        overrides["callouts"] = dict(callouts_section)

    callouts_style = overrides.get("callouts_style")
    if callouts_style is None and isinstance(press_section, Mapping):
        callouts_style = press_section.get("callouts_style")
    if callouts_style is not None:
        overrides["callout_style"] = callouts_style

    base_override = overrides.get("base_level")
    if base_override is None and isinstance(press_section, Mapping):
        base_override = press_section.get("base_level")
    if base_override is not None:
        try:
            overrides["base_level"] = coerce_base_level(base_override)
        except TemplateError:
            overrides["base_level"] = base_override

    return overrides

coerce_base_level

coerce_base_level(
    value: Any, *, allow_none: bool = True
) -> int | None

Normalise base-level metadata to an integer or None.

Source code in src/texsmith/core/templates/runtime.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def coerce_base_level(value: Any, *, allow_none: bool = True) -> int | None:
    """Normalise base-level metadata to an integer or ``None``."""
    if value is None:
        if allow_none:
            return None
        raise TemplateError("Base level value is missing.")

    if isinstance(value, bool):
        raise TemplateError("Base level must be an integer, booleans are not supported.")

    if isinstance(value, (int, float)):
        return int(value)

    if isinstance(value, str):
        candidate = value.strip().lower()
        if not candidate:
            if allow_none:
                return None
            raise TemplateError("Base level value cannot be empty.")
        alias_map = {
            "part": -1,
            "chapter": 0,
            "section": 1,
            "subsection": 2,
        }
        if candidate in alias_map:
            return alias_map[candidate]
        try:
            return int(candidate)
        except ValueError as exc:  # pragma: no cover - defensive
            raise TemplateError(
                f"Invalid base level '{value}'. Expected an integer value or one of "
                f"{', '.join(alias_map)}."
            ) from exc

    raise TemplateError(
        f"Base level should be provided as an integer value, got type '{type(value).__name__}'."
    )

extract_base_level_override

extract_base_level_override(
    overrides: Mapping[str, Any] | None,
) -> Any

Extract a base level override from template metadata overrides.

Source code in src/texsmith/core/templates/runtime.py
106
107
108
109
110
111
112
113
114
115
116
117
def extract_base_level_override(overrides: Mapping[str, Any] | None) -> Any:
    """Extract a base level override from template metadata overrides."""
    if not overrides:
        return None

    press_section = overrides.get("press")
    direct_candidate = overrides.get("base_level")
    if direct_candidate is not None:
        return direct_candidate
    if isinstance(press_section, Mapping):
        return press_section.get("base_level")
    return None

extract_language_from_front_matter

extract_language_from_front_matter(
    front_matter: Mapping[str, Any] | None,
) -> str | None

Inspect front matter for language hints.

Source code in src/texsmith/core/templates/runtime.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def extract_language_from_front_matter(
    front_matter: Mapping[str, Any] | None,
) -> str | None:
    """Inspect front matter for language hints."""
    if not isinstance(front_matter, Mapping):
        return None

    for key in ("language", "lang"):
        value = front_matter.get(key)
        if isinstance(value, str):
            stripped = value.strip()
            if stripped:
                return stripped

    press_entry = front_matter.get("press")
    if isinstance(press_entry, Mapping):
        for key in ("language", "lang"):
            value = press_entry.get(key)
            if isinstance(value, str):
                stripped = value.strip()
                if stripped:
                    return stripped
    return None

load_template_runtime

load_template_runtime(template: str) -> TemplateRuntime

Resolve template metadata for repeated conversions.

Source code in src/texsmith/core/templates/runtime.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def load_template_runtime(template: str) -> TemplateRuntime:
    """Resolve template metadata for repeated conversions."""
    template_instance = load_template(template)

    template_base = coerce_base_level(
        template_instance.info.get_attribute_default("base_level"),
    )

    slots, default_slot = template_instance.info.resolve_slots()
    formatter_overrides = dict(template_instance.iter_formatter_overrides())
    extras_payload = getattr(template_instance, "extras", {}) or {}
    extras = {key: value for key, value in extras_payload.items()}
    declared_fragments = (
        list(template_instance.info.fragments)
        if template_instance.info.fragments is not None
        else None
    )
    extras.setdefault(
        "fragments",
        declared_fragments if declared_fragments is not None else [],
    )
    extras.setdefault("readers", list(template_instance.info.readers or []))
    extras.setdefault("writer", template_instance.info.writer)

    return TemplateRuntime(
        instance=template_instance,
        name=template_instance.info.name,
        engine=template_instance.info.engine,
        requires_shell_escape=bool(template_instance.info.shell_escape),
        slots=slots,
        default_slot=default_slot,
        formatter_overrides=formatter_overrides,
        base_level=template_base,
        required_partials=set(template_instance.info.required_partials or []),
        extras=extras,
    )

normalise_template_language

normalise_template_language(
    value: str | None,
) -> str | None

Normalise language codes and map them through babel aliases when available.

Source code in src/texsmith/core/templates/runtime.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def normalise_template_language(value: str | None) -> str | None:
    """Normalise language codes and map them through babel aliases when available."""
    if value is None:
        return None

    stripped = value.strip()
    if not stripped:
        return None

    lowered = stripped.lower().replace("_", "-")
    alias = _BABEL_LANGUAGE_ALIASES.get(lowered)
    if alias:
        return alias

    primary = lowered.split("-", 1)[0]
    alias = _BABEL_LANGUAGE_ALIASES.get(primary)
    if alias:
        return alias

    if lowered.isalpha():
        return lowered

    return None

resolve_template_binding

resolve_template_binding(
    *,
    template: str | None,
    template_runtime: TemplateRuntime | None,
    template_overrides: Mapping[str, Any],
    slot_requests: Mapping[str, str],
    warn: Callable[[str], None] | None = None,
) -> tuple[TemplateBinding, dict[str, str]]

Resolve template runtime metadata and apply slot overrides.

Source code in src/texsmith/core/templates/runtime.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def resolve_template_binding(
    *,
    template: str | None,
    template_runtime: TemplateRuntime | None,
    template_overrides: Mapping[str, Any],
    slot_requests: Mapping[str, str],
    warn: Callable[[str], None] | None = None,
) -> tuple[TemplateBinding, dict[str, str]]:
    """Resolve template runtime metadata and apply slot overrides."""
    runtime = template_runtime
    if runtime is None and template:
        runtime = load_template_runtime(template)

    if runtime is not None:
        # Adjust base level for book parts when requested via overrides.
        binding_base_level = runtime.base_level
        part_flag = None
        press_section = template_overrides.get("press")
        if isinstance(template_overrides.get("part"), bool):
            part_flag = template_overrides.get("part")
        elif isinstance(press_section, Mapping) and isinstance(press_section.get("part"), bool):
            part_flag = press_section.get("part")
        if part_flag and runtime.name == "book":
            binding_base_level = coerce_base_level("part")

        binding = TemplateBinding(
            runtime=runtime,
            instance=runtime.instance,
            name=runtime.name,
            engine=runtime.engine,
            requires_shell_escape=runtime.requires_shell_escape,
            formatter_overrides=dict(runtime.formatter_overrides),
            slots=runtime.slots,
            default_slot=runtime.default_slot,
            base_level=binding_base_level,
            required_partials=set(runtime.required_partials),
        )
    else:
        binding = TemplateBinding(
            runtime=None,
            instance=None,
            name=None,
            engine=None,
            requires_shell_escape=False,
            formatter_overrides={},
            slots={"mainmatter": TemplateSlot(default=True)},
            default_slot="mainmatter",
            base_level=None,
            required_partials=set(),
        )

    base_override = coerce_base_level(extract_base_level_override(template_overrides))
    if base_override is not None:
        binding.base_level = base_override

    filtered: dict[str, str] = {}
    for slot_name, selector in slot_requests.items():
        if slot_name not in binding.slots:
            if warn is not None:
                template_hint = f"template '{binding.name}'" if binding.name else "the template"
                warn(
                    f"slot '{slot_name}' is not defined by {template_hint}; "
                    f"content will remain in '{binding.default_slot}'."
                )
            continue
        if binding.runtime is None:
            if warn is not None:
                warn(f"slot '{slot_name}' was requested but no template is selected; ignoring.")
            continue
        filtered[slot_name] = selector

    return binding, filtered

resolve_template_language

resolve_template_language(
    explicit: str | None,
    front_matter: Mapping[str, Any] | None,
) -> str

Resolve the effective template language from CLI and front matter inputs.

Source code in src/texsmith/core/templates/runtime.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def resolve_template_language(
    explicit: str | None,
    front_matter: Mapping[str, Any] | None,
) -> str:
    """Resolve the effective template language from CLI and front matter inputs."""
    candidates = (
        normalise_template_language(explicit),
        normalise_template_language(extract_language_from_front_matter(front_matter)),
    )

    for candidate in candidates:
        if candidate:
            return candidate

    return DEFAULT_TEMPLATE_LANGUAGE