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
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
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.

__getattr__

__getattr__(method: str) -> Callable[..., str]

Proxy calls to templates or custom handlers.

Source code in src/texsmith/adapters/latex/formatter.py
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
def __getattr__(self, method: str) -> Callable[..., str]:
    """Proxy calls to templates or custom handlers."""
    mangled = f"handle_{method}"
    try:
        handler = object.__getattribute__(self, mangled)
    except AttributeError:
        handler = None
    if handler is not None:
        return handler  # type: ignore[return-value]

    try:
        template = self._get_template(method)
    except KeyError:
        raise AttributeError(f"Object has no template for '{method}'") from None

    def render_template(*args: Any, **kwargs: Any) -> str:
        """Render the template with optional positional shorthand."""
        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)

    return render_template

get_cover

get_cover(name: str, **kwargs: Any) -> str

Render a named cover template populated with book metadata.

Source code in src/texsmith/adapters/latex/formatter.py
268
269
270
271
272
273
274
275
276
277
278
279
def get_cover(self, name: str, **kwargs: Any) -> str:
    """Render a named cover template populated with book metadata."""
    template = self._get_template(f"cover/{name}")
    return template.render(
        title=self.config.title,  # type: ignore[attr-defined]
        author=self.config.author,  # type: ignore[attr-defined]
        subtitle=self.config.subtitle,  # type: ignore[attr-defined]
        email=self.config.email,  # type: ignore[attr-defined]
        year=self.config.year,  # type: ignore[attr-defined]
        **self.config.cover.model_dump(),  # type: ignore[attr-defined]
        **kwargs,
    )

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
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
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
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
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
138
139
140
141
142
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
218
219
220
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
222
223
224
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
80
81
82
83
@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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
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

svg

svg(svg: str | Path) -> str

Render an SVG image by converting it to PDF first.

Source code in src/texsmith/adapters/latex/formatter.py
261
262
263
264
265
266
def svg(self, svg: str | Path) -> str:
    """Render an SVG image by converting it to PDF first."""
    from ..transformers import svg2pdf

    pdfpath = svg2pdf(svg, self.output_path)  # type: ignore[attr-defined]
    return f"\\includegraphics[width=1em]{{{pdfpath}}}"

url

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

Render a URL, escaping special characters.

Source code in src/texsmith/adapters/latex/formatter.py
213
214
215
216
def url(self, text: str, url: str) -> str:
    """Render a URL, escaping special LaTeX characters."""
    safe_url = self._escape_url(url)
    return self._get_template("url").render(text=text, url=safe_url)

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,
)
Source code in src/texsmith/adapters/latex/renderer.py
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 formatter in sync with runtime environment
    self.formatter.config = self.config  # type: ignore[assignment]
    self.formatter.output_path = self.assets_root  # type: ignore[assignment]
    self.formatter.legacy_latex_accents = self.config.legacy_latex_accents

    self.engine = RenderEngine()
    self._register_builtin_handlers()
    self._register_entry_point_handlers()
    register_all_renderers(self)

describe_registered_rules

describe_registered_rules() -> list[dict[str, object]]

Return detailed metadata about registered rules.

Source code in src/texsmith/adapters/latex/renderer.py
215
216
217
def describe_registered_rules(self) -> list[dict[str, object]]:
    """Return detailed metadata about registered rules."""
    return self.engine.registry.describe()

iter_registered_rules

iter_registered_rules() -> Iterable[
    tuple[RenderPhase, str]
]

Expose currently registered rules for debugging/reporting.

Source code in src/texsmith/adapters/latex/renderer.py
209
210
211
212
213
def iter_registered_rules(self) -> Iterable[tuple[RenderPhase, str]]:
    """Expose currently registered rules for debugging/reporting."""
    for phase in RenderPhase:
        for rule in self.engine.registry.iter_phase(phase):
            yield phase, rule.name

register

register(handler: Any) -> None

Register additional handlers on demand.

Arguments can be callables decorated with :func:renders or modules/classes exposing decorated attributes.

Source code in src/texsmith/adapters/latex/renderer.py
88
89
90
91
92
93
94
95
96
97
98
99
def register(self, handler: Any) -> None:
    """Register additional handlers on demand.

    Arguments can be callables decorated with :func:`renders` or modules/classes
    exposing decorated attributes.
    """
    definition = getattr(handler, "__render_rule__", None)
    if definition is not None:
        self.engine.register(handler)
        return

    self.engine.collect_from(handler)

render

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

Render an HTML fragment into .

Source code in src/texsmith/adapters/latex/renderer.py
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
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."""
    active_emitter = emitter or NullEmitter()
    try:
        soup = BeautifulSoup(html, self.parser_backend)
    except FeatureNotFound:
        if self.parser_backend == "html.parser":
            raise
        # Fall back to the built-in parser when the preferred backend is missing.
        from texsmith.core.conversion.debug import record_event

        record_event(
            active_emitter,
            "parser_fallback",
            {"preferred": self.parser_backend, "fallback": "html.parser"},
        )
        soup = BeautifulSoup(html, "html.parser")
        self.parser_backend = "html.parser"
    document_state = state or DocumentState()

    context = RenderContext(
        config=self.config,
        formatter=self.formatter,
        document=soup,
        assets=self.assets,
        state=document_state,
    )

    context.attach_runtime(
        copy_assets=self.copy_assets,
        convert_assets=self.convert_assets,
        hash_assets=self.hash_assets,
        emitter=active_emitter,
    )
    if runtime:
        context.attach_runtime(**runtime)

    try:
        self.engine.run(soup, context)
    except Exception as exc:  # pragma: no cover - defensive
        raise LatexRenderingError("LaTeX rendering failed") from exc

    return self._collect_output(soup)

escape_latex_chars

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

Escape special characters leveraging pylatexenc.

Source code in src/texsmith/adapters/latex/utils.py
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
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:
        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
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
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.

__getattr__

__getattr__(method: str) -> Callable[..., str]

Proxy calls to templates or custom handlers.

Source code in src/texsmith/adapters/latex/formatter.py
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
def __getattr__(self, method: str) -> Callable[..., str]:
    """Proxy calls to templates or custom handlers."""
    mangled = f"handle_{method}"
    try:
        handler = object.__getattribute__(self, mangled)
    except AttributeError:
        handler = None
    if handler is not None:
        return handler  # type: ignore[return-value]

    try:
        template = self._get_template(method)
    except KeyError:
        raise AttributeError(f"Object has no template for '{method}'") from None

    def render_template(*args: Any, **kwargs: Any) -> str:
        """Render the template with optional positional shorthand."""
        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)

    return render_template

get_cover

get_cover(name: str, **kwargs: Any) -> str

Render a named cover template populated with book metadata.

Source code in src/texsmith/adapters/latex/formatter.py
268
269
270
271
272
273
274
275
276
277
278
279
def get_cover(self, name: str, **kwargs: Any) -> str:
    """Render a named cover template populated with book metadata."""
    template = self._get_template(f"cover/{name}")
    return template.render(
        title=self.config.title,  # type: ignore[attr-defined]
        author=self.config.author,  # type: ignore[attr-defined]
        subtitle=self.config.subtitle,  # type: ignore[attr-defined]
        email=self.config.email,  # type: ignore[attr-defined]
        year=self.config.year,  # type: ignore[attr-defined]
        **self.config.cover.model_dump(),  # type: ignore[attr-defined]
        **kwargs,
    )

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
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
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
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
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
138
139
140
141
142
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
218
219
220
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
222
223
224
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
80
81
82
83
@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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
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

svg

svg(svg: str | Path) -> str

Render an SVG image by converting it to PDF first.

Source code in src/texsmith/adapters/latex/formatter.py
261
262
263
264
265
266
def svg(self, svg: str | Path) -> str:
    """Render an SVG image by converting it to PDF first."""
    from ..transformers import svg2pdf

    pdfpath = svg2pdf(svg, self.output_path)  # type: ignore[attr-defined]
    return f"\\includegraphics[width=1em]{{{pdfpath}}}"

url

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

Render a URL, escaping special characters.

Source code in src/texsmith/adapters/latex/formatter.py
213
214
215
216
def url(self, text: str, url: str) -> str:
    """Render a URL, escaping special LaTeX characters."""
    safe_url = self._escape_url(url)
    return self._get_template("url").render(text=text, url=safe_url)

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 to renderer based on the modular pipeline.

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,
)
Source code in src/texsmith/adapters/latex/renderer.py
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 formatter in sync with runtime environment
    self.formatter.config = self.config  # type: ignore[assignment]
    self.formatter.output_path = self.assets_root  # type: ignore[assignment]
    self.formatter.legacy_latex_accents = self.config.legacy_latex_accents

    self.engine = RenderEngine()
    self._register_builtin_handlers()
    self._register_entry_point_handlers()
    register_all_renderers(self)

describe_registered_rules

describe_registered_rules() -> list[dict[str, object]]

Return detailed metadata about registered rules.

Source code in src/texsmith/adapters/latex/renderer.py
215
216
217
def describe_registered_rules(self) -> list[dict[str, object]]:
    """Return detailed metadata about registered rules."""
    return self.engine.registry.describe()

iter_registered_rules

iter_registered_rules() -> Iterable[
    tuple[RenderPhase, str]
]

Expose currently registered rules for debugging/reporting.

Source code in src/texsmith/adapters/latex/renderer.py
209
210
211
212
213
def iter_registered_rules(self) -> Iterable[tuple[RenderPhase, str]]:
    """Expose currently registered rules for debugging/reporting."""
    for phase in RenderPhase:
        for rule in self.engine.registry.iter_phase(phase):
            yield phase, rule.name

register

register(handler: Any) -> None

Register additional handlers on demand.

Arguments can be callables decorated with :func:renders or modules/classes exposing decorated attributes.

Source code in src/texsmith/adapters/latex/renderer.py
88
89
90
91
92
93
94
95
96
97
98
99
def register(self, handler: Any) -> None:
    """Register additional handlers on demand.

    Arguments can be callables decorated with :func:`renders` or modules/classes
    exposing decorated attributes.
    """
    definition = getattr(handler, "__render_rule__", None)
    if definition is not None:
        self.engine.register(handler)
        return

    self.engine.collect_from(handler)

render

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

Render an HTML fragment into .

Source code in src/texsmith/adapters/latex/renderer.py
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
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."""
    active_emitter = emitter or NullEmitter()
    try:
        soup = BeautifulSoup(html, self.parser_backend)
    except FeatureNotFound:
        if self.parser_backend == "html.parser":
            raise
        # Fall back to the built-in parser when the preferred backend is missing.
        from texsmith.core.conversion.debug import record_event

        record_event(
            active_emitter,
            "parser_fallback",
            {"preferred": self.parser_backend, "fallback": "html.parser"},
        )
        soup = BeautifulSoup(html, "html.parser")
        self.parser_backend = "html.parser"
    document_state = state or DocumentState()

    context = RenderContext(
        config=self.config,
        formatter=self.formatter,
        document=soup,
        assets=self.assets,
        state=document_state,
    )

    context.attach_runtime(
        copy_assets=self.copy_assets,
        convert_assets=self.convert_assets,
        hash_assets=self.hash_assets,
        emitter=active_emitter,
    )
    if runtime:
        context.attach_runtime(**runtime)

    try:
        self.engine.run(soup, context)
    except Exception as exc:  # pragma: no cover - defensive
        raise LatexRenderingError("LaTeX rendering failed") from exc

    return self._collect_output(soup)

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
107
108
109
110
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
166
167
168
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
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
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

LatexLogRenderer

LatexLogRenderer(console: Console)

Render structured messages to a Rich console.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
417
418
419
420
421
422
423
424
425
def __init__(self, console: Console) -> None:
    self.console = console
    self.messages: list[LatexMessage] = []
    self._current_messages: list[LatexMessage] = []
    self._pending: LatexMessage | None = None
    self._pending_heading: bool = False
    self._branch_stack: list[bool] = []
    self._heading_open = False
    self._heading_next_bold = False

consume

consume(message: LatexMessage) -> None

Display a single message, queueing it for tree-aware formatting.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def consume(self, message: LatexMessage) -> None:
    """Display a single message, queueing it for tree-aware formatting."""
    heading_for_message = False
    heading_state = self._split_heading_line(message.summary)
    if heading_state is not None:
        kind, remainder = heading_state
        if kind == "rule":
            self._heading_open = not self._heading_open
            self._heading_next_bold = self._heading_open
            return
        if remainder:
            heading_for_message = True
            message = replace(message, summary=remainder)
            if not self._heading_open:
                self._heading_open = True
        self._heading_next_bold = False

    if self._heading_next_bold:
        heading_for_message = True
        self._heading_next_bold = False

    if self._is_run_boundary(message):
        self._current_messages.clear()
    self.messages.append(message)
    self._current_messages.append(message)
    next_indent = message.indent
    self._emit_pending(next_indent)
    self._pending = message
    self._pending_heading = heading_for_message

summarize

summarize() -> None

Print a summary of processed messages grouped by severity.

Source code in src/texsmith/adapters/latex/engines/latex/log.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def summarize(self) -> None:
    """Print a summary of processed messages grouped by severity."""
    self._emit_pending(None)
    warnings = sum(
        1 for msg in self._current_messages if msg.severity is LatexMessageSeverity.WARNING
    )
    errors = sum(
        1 for msg in self._current_messages if msg.severity is LatexMessageSeverity.ERROR
    )
    info = sum(1 for msg in self._current_messages if msg.severity is LatexMessageSeverity.INFO)
    summary_parts = [
        f"errors: {errors}",
        f"warnings: {warnings}",
    ]
    if info:
        summary_parts.append(f"info: {info}")
    style = "green" if errors == 0 else "bold red"
    self.console.print(Text("Summary — " + ", ".join(summary_parts), style=style))

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
711
712
713
714
715
716
717
718
719
720
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
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
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."""
    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)

Utility helpers specific to rendering.

escape_latex_chars

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

Escape special characters leveraging pylatexenc.

Source code in src/texsmith/adapters/latex/utils.py
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
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:
        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
66
67
68
69
70
71
72
73
74
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
76
77
78
79
80
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
82
83
84
85
86
87
88
89
90
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
511
512
513
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
61
62
63
64
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
56
57
58
59
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
785
786
787
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
800
801
802
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
789
790
791
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
796
797
798
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
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
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 template.

load classmethod

load(manifest_path: Path) -> TemplateManifest

Load and validate a manifest from disk.

Source code in src/texsmith/core/templates/manifest.py
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
@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

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
123
124
125
126
127
128
129
130
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
66
67
68
69
70
71
72
73
74
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
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
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 as exc:  # 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
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
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
 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
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
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
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
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
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
 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
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
 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
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
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
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:
        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"]))

    for candidate in _iter_local_candidates("*placeholder*"):
        pass

    local_candidates: list[Path] = []
    for slug in set():
        pass

    for candidate in _iter_local_candidates("*placeholder*"):
        pass

    def _record(origin: str, name: str, root: Path) -> None:
        key = (name, str(root))
        if key in seen_paths:
            return
        seen_paths.add(key)
        entries.append({"name": name, "origin": origin, "root": str(root)})

    # Re-run local with concrete names.
    visited: set[str] = set()
    cwd = Path.cwd().resolve()
    for candidate in _iter_local_candidates(""):
        if _looks_like_template_root(candidate):
            _record("local", candidate.name, candidate)

    home_root = get_user_dir().data_dir("templates", create=False)
    if home_root.exists():
        for child in 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
107
108
109
110
111
112
113
114
115
116
117
118
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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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
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
64
65
66
67
68
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
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 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 [],
    )

    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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
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
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
328
329
330
331
332
333
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
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
 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
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.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]
    template_context["acronyms"] = document_state.acronyms.copy()
    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
66
67
68
69
70
71
72
73
74
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
76
77
78
79
80
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
82
83
84
85
86
87
88
89
90
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
66
67
68
69
70
71
72
73
74
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
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
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 as exc:  # 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
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
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
 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
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
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
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
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
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
 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
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
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
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:
        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"]))

    for candidate in _iter_local_candidates("*placeholder*"):
        pass

    local_candidates: list[Path] = []
    for slug in set():
        pass

    for candidate in _iter_local_candidates("*placeholder*"):
        pass

    def _record(origin: str, name: str, root: Path) -> None:
        key = (name, str(root))
        if key in seen_paths:
            return
        seen_paths.add(key)
        entries.append({"name": name, "origin": origin, "root": str(root)})

    # Re-run local with concrete names.
    visited: set[str] = set()
    cwd = Path.cwd().resolve()
    for candidate in _iter_local_candidates(""):
        if _looks_like_template_root(candidate):
            _record("local", candidate.name, candidate)

    home_root = get_user_dir().data_dir("templates", create=False)
    if home_root.exists():
        for child in 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
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
64
65
66
67
68
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
657
658
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
511
512
513
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
785
786
787
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
800
801
802
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
789
790
791
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
796
797
798
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
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
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 template.

load classmethod

load(manifest_path: Path) -> TemplateManifest

Load and validate a manifest from disk.

Source code in src/texsmith/core/templates/manifest.py
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
@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

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
123
124
125
126
127
128
129
130
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

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
61
62
63
64
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
56
57
58
59
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
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
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
 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
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
107
108
109
110
111
112
113
114
115
116
117
118
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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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
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 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 [],
    )

    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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
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
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
328
329
330
331
332
333
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
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