Skip to content

Handlers

Handlers convert BeautifulSoup nodes into fragments. They run inside the core renderer and are grouped by RenderPhase so transformations can hook into the DOM at the right point.

Render phases at a glance

Phase Purpose Typical consumers
RenderPhase.PRE Normalise HTML before layout-sensitive transforms (unwrap unwanted tags, capture math spans, detect inline code). basic.discard_unwanted, inline.inline_code, diagram preprocessors.
RenderPhase.BLOCK Manipulate block-level nodes when structure is stable (convert <figure>, extract tabbed content, manage slot boundaries). blocks.tabbed_content, media.render_mermaid.
RenderPhase.INLINE Render inline formatting once blocks are resolved. inline.inline_emphasis, links.links, inline.abbreviation.
RenderPhase.POST Finalisation pass after children are converted; ideal for numbering, bibliography hooks, or asset emission. blocks.tables, admonitions.render_admonition, media.render_images.

Handlers are regular Python callables decorated with @renders(...). The decorator declares the HTML selectors, phase, and metadata such as priority, nestable, and whether TeXSmith should auto-mark the node as processed.

Example: add a custom inline macro

from bs4 import Tag

from texsmith.core.context import RenderContext
from texsmith.core.rules import RenderPhase, renders


@renders("span", phase=RenderPhase.INLINE, priority=10, name="callout_chips")
def render_callout_chips(element: Tag, context: RenderContext) -> None:
    if "data-callout" not in element.attrs:
        return
    label = element.get_text(strip=True)
    latex = r"\CalloutChip{%s}" % context.escape(label)
    context.write(latex)
    context.mark_processed(element)

Drop the module anywhere on PYTHONPATH and import it before calling texsmith.render. For MkDocs sites, add the import inside a mkdocs plugin or docs/hooks/mkdocs_hooks.py. For programmatic runs, import the module ahead of convert_documents so the decorator executes at import time.

Tip

Handlers should call context.mark_processed(element) when they fully consume a node. Leave the node untouched to let lower-priority handlers run.

Reference

Built-in handler collections.

Internal helpers shared across handler modules.

coerce_attribute

coerce_attribute(value: Any) -> str | None

Normalise a BeautifulSoup attribute value to a string when possible.

Source code in src/texsmith/adapters/handlers/_helpers.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def coerce_attribute(value: Any) -> str | None:
    """Normalise a BeautifulSoup attribute value to a string when possible."""
    if isinstance(value, str):
        return value
    if isinstance(value, bytes):
        try:
            return value.decode("utf-8")
        except UnicodeDecodeError:
            return None
    if isinstance(value, Iterable):
        for item in value:
            if isinstance(item, str):
                return item
    return None

gather_classes

gather_classes(value: Any) -> list[str]

Return a list of classes extracted from a BeautifulSoup attribute.

Source code in src/texsmith/adapters/handlers/_helpers.py
38
39
40
41
42
43
44
def gather_classes(value: Any) -> list[str]:
    """Return a list of classes extracted from a BeautifulSoup attribute."""
    if isinstance(value, str):
        return [value]
    if isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray)):
        return [cast(str, item) for item in value if isinstance(item, str)]
    return []

is_valid_url

is_valid_url(url: str) -> bool

Check whether a URL string has a valid scheme/netloc combination.

Source code in src/texsmith/adapters/handlers/_helpers.py
56
57
58
59
60
61
62
def is_valid_url(url: str) -> bool:
    """Check whether a URL string has a valid scheme/netloc combination."""
    try:
        result = urlparse(url)
    except ValueError:
        return False
    return bool(result.scheme and result.netloc)

mark_processed

mark_processed(node: NodeT) -> NodeT

Mark a BeautifulSoup node as processed and return it for chaining.

Source code in src/texsmith/adapters/handlers/_helpers.py
16
17
18
19
def mark_processed(node: NodeT) -> NodeT:
    """Mark a BeautifulSoup node as processed and return it for chaining."""
    cast(Any, node).processed = True  # type: ignore[attr-defined]
    return node

resolve_asset_path

resolve_asset_path(
    file_path: Path, path: str | Path
) -> Path | None

Resolve an asset path relative to a Markdown source file.

Source code in src/texsmith/adapters/handlers/_helpers.py
47
48
49
50
51
52
53
def resolve_asset_path(file_path: Path, path: str | Path) -> Path | None:
    """Resolve an asset path relative to a Markdown source file."""
    origin = Path(file_path)
    if origin.name == "index.md":
        origin = origin.parent
    target = (origin / path).resolve()
    return target if target.exists() else None

Handlers for generic admonition and callout markup.

render_blockquote_callouts

render_blockquote_callouts(
    element: Tag, context: RenderContext
) -> None

Handle Obsidian/Docusaurus style blockquote callouts.

Source code in src/texsmith/adapters/handlers/admonitions.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
@renders(
    "blockquote",
    phase=RenderPhase.POST,
    priority=15,
    name="blockquote_callouts",
    nestable=True,
    auto_mark=False,
)
def render_blockquote_callouts(element: Tag, context: RenderContext) -> None:
    """Handle Obsidian/Docusaurus style blockquote callouts."""
    first_paragraph = element.find("p")
    if first_paragraph is None:
        return

    first_text_node = next(
        (child for child in first_paragraph.contents if isinstance(child, NavigableString)),
        None,
    )
    if first_text_node is None:
        return

    raw_text = str(first_text_node)
    stripped = raw_text.lstrip()
    offset = len(raw_text) - len(stripped)
    match = CALLOUT_PATTERN.match(stripped)
    if not match:
        return

    callout_type = match.group("kind").lower()
    remainder = match.group("content") or ""
    remainder = remainder.lstrip()

    first_text_node.replace_with(NavigableString(raw_text[:offset] + remainder))

    lines_with_endings = remainder.splitlines(keepends=True)
    lines = [line.strip() for line in remainder.splitlines() if line.strip()]
    title = lines[0] if lines else callout_type.capitalize()

    if len(lines_with_endings) > 1:
        prefix_length = len(raw_text[:offset]) + len(lines_with_endings[0])
        _trim_paragraph_prefix(first_paragraph, prefix_length)
        first_text = next(
            (child for child in first_paragraph.contents if isinstance(child, NavigableString)),
            None,
        )
        if first_text is not None:
            first_text.replace_with(NavigableString(str(first_text).lstrip()))
    else:
        first_paragraph.decompose()

    classes = gather_classes(element.get("class"))
    preserved = [cls for cls in classes if cls != "admonition"]
    new_classes = ["admonition", callout_type]
    for cls in preserved:
        if cls not in new_classes:
            new_classes.append(cls)
    element["class"] = new_classes
    _promote_callout(element, context, classes=new_classes, title=title)

render_details_admonitions

render_details_admonitions(
    element: Tag, context: RenderContext
) -> None

Handle collapsible callouts converted from MkDocs Material's details blocks.

Source code in src/texsmith/adapters/handlers/admonitions.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
@renders(
    "details",
    phase=RenderPhase.POST,
    priority=55,
    name="details_admonitions",
    nestable=True,
    auto_mark=False,
)
def render_details_admonitions(element: Tag, context: RenderContext) -> None:
    """Handle collapsible callouts converted from MkDocs Material's details blocks."""
    classes = gather_classes(element.get("class"))
    if "exercise" in classes:
        return

    title = ""
    if summary := element.find("summary"):
        title = summary.get_text(strip=True)
        summary.decompose()

    _promote_callout(element, context, classes=classes, title=title or "")

render_div_admonitions

render_div_admonitions(
    element: Tag, context: RenderContext
) -> None

Handle MkDocs Material admonition blocks.

Source code in src/texsmith/adapters/handlers/admonitions.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@renders(
    "div",
    phase=RenderPhase.POST,
    priority=50,
    name="admonitions",
    nestable=True,
    auto_mark=False,
)
def render_div_admonitions(element: Tag, context: RenderContext) -> None:
    """Handle MkDocs Material admonition blocks."""
    classes = gather_classes(element.get("class"))
    if "admonition" not in classes:
        return
    if "exercise" in classes:
        return

    title = _extract_title(element.find("p", class_="admonition-title"))
    _promote_callout(element, context, classes=classes, title=title)

render_texsmith_callouts

render_texsmith_callouts(
    element: Tag, context: RenderContext
) -> None

Convert promoted callout nodes once their children have rendered.

Source code in src/texsmith/adapters/handlers/admonitions.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
@renders(
    "texsmith-callout",
    phase=RenderPhase.POST,
    priority=140,
    name="finalize_callouts",
    nestable=False,
    auto_mark=False,
    after_children=True,
)
def render_texsmith_callouts(element: Tag, context: RenderContext) -> None:
    """Convert promoted callout nodes once their children have rendered."""
    classes = gather_classes(element.get("class"))
    title = element.attrs.pop("data-callout-title", "")
    _render_admonition(element, context, classes=classes, title=title)

Built-in baseline handlers used by the renderer.

discard_unwanted

discard_unwanted(root: Tag, context: RenderContext) -> None

Discard or unwrap nodes that must not reach later phases.

Source code in src/texsmith/adapters/handlers/basic.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@renders(phase=RenderPhase.PRE, auto_mark=False, name="discard_unwanted")
def discard_unwanted(root: Tag, context: RenderContext) -> None:
    """Discard or unwrap nodes that must not reach later phases."""
    for tag_name, classes, mode in _merge_strip_rules(context):
        class_filter = list(classes)
        candidates = (
            root.find_all(tag_name, class_=class_filter)
            if class_filter
            else root.find_all(tag_name)
        )
        for node in candidates:
            if mode == "unwrap":
                node.unwrap()
            elif mode == "extract":
                node.extract()
            elif mode == "decompose":
                node.decompose()

render_headings

render_headings(
    element: Tag, context: RenderContext
) -> None

Convert HTML headings to sectioning commands.

Source code in src/texsmith/adapters/handlers/basic.py
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
@renders(
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    phase=RenderPhase.POST,
    name="render_headings",
)
def render_headings(element: Tag, context: RenderContext) -> None:
    """Convert HTML headings to LaTeX sectioning commands."""
    # Drop anchor tags within headings
    for anchor in element.find_all("a"):
        anchor.unwrap()

    drop_title = context.runtime.get("drop_title")
    if drop_title:
        context.runtime["drop_title"] = False
        latex = context.formatter.pagestyle(text="plain")
        element.replace_with(mark_processed(NavigableString(latex)))
        return

    raw_text = element.get_text(strip=False)
    text = render_moving_text(
        raw_text,
        context,
        legacy_accents=getattr(context.config, "legacy_latex_accents", False),
        escape="\\" not in raw_text,
        wrap_scripts=True,
    )
    plain_text = element.get_text(strip=True)
    level = int(element.name[1:])
    base_level = context.runtime.get("base_level", 0)
    rendered_level = level + base_level - 1
    ref = coerce_attribute(element.get("id"))
    if not ref:
        slug = slugify(plain_text, separator="-")
        ref = slug or None
    numbered = context.runtime.get("numbered", True)

    latex = context.formatter.heading(
        text=text,
        level=rendered_level,
        ref=ref,
        numbered=numbered,
    )

    element.replace_with(mark_processed(NavigableString(latex)))

    context.state.add_heading(level=rendered_level, text=plain_text, ref=ref)

render_horizontal_rule

render_horizontal_rule(
    element: Tag, context: RenderContext
) -> None

Render <hr> nodes as horizontal rules.

Source code in src/texsmith/adapters/handlers/basic.py
78
79
80
81
82
@renders("hr", phase=RenderPhase.BLOCK, name="render_horizontal_rule")
def render_horizontal_rule(element: Tag, context: RenderContext) -> None:
    """Render ``<hr>`` nodes as LaTeX horizontal rules."""
    latex = context.formatter.horizontal_rule()
    element.replace_with(mark_processed(NavigableString(latex)))

render_inline_deletion

render_inline_deletion(
    element: Tag, context: RenderContext
) -> None

Render <del> tags using strikethrough template.

Source code in src/texsmith/adapters/handlers/basic.py
127
128
129
130
131
132
@renders("del", phase=RenderPhase.INLINE, name="inline_deletion", after_children=True)
def render_inline_deletion(element: Tag, context: RenderContext) -> None:
    """Render ``<del>`` tags using strikethrough template."""
    text = element.get_text(strip=False)
    latex = context.formatter.strikethrough(text=text)
    element.replace_with(NavigableString(latex))

render_inline_emphasis

render_inline_emphasis(
    element: Tag, context: RenderContext
) -> None

Render <em> tags using emphasis template.

Source code in src/texsmith/adapters/handlers/basic.py
119
120
121
122
123
124
@renders("em", phase=RenderPhase.INLINE, name="inline_emphasis", after_children=True)
def render_inline_emphasis(element: Tag, context: RenderContext) -> None:
    """Render ``<em>`` tags using emphasis template."""
    text = element.get_text(strip=False)
    latex = context.formatter.italic(text=text)
    element.replace_with(NavigableString(latex))

render_inline_mark

render_inline_mark(
    element: Tag, context: RenderContext
) -> None

Render <mark> tags using highlight template.

Source code in src/texsmith/adapters/handlers/basic.py
135
136
137
138
139
140
@renders("mark", phase=RenderPhase.INLINE, name="inline_mark", after_children=True)
def render_inline_mark(element: Tag, context: RenderContext) -> None:
    """Render ``<mark>`` tags using highlight template."""
    text = element.get_text(strip=False)
    latex = context.formatter.highlight(text=text)
    element.replace_with(NavigableString(latex))

render_inline_quote

render_inline_quote(
    element: Tag, context: RenderContext
) -> None

Render inline quotations using \enquote{}.

Source code in src/texsmith/adapters/handlers/basic.py
162
163
164
165
166
167
@renders("q", phase=RenderPhase.INLINE, name="inline_quote", after_children=True)
def render_inline_quote(element: Tag, context: RenderContext) -> None:
    """Render inline quotations using \\enquote{}."""
    text = element.get_text(strip=False)
    latex = context.formatter.enquote(text=text)
    element.replace_with(NavigableString(latex))

render_inline_smallcaps

render_inline_smallcaps(
    element: Tag, context: RenderContext
) -> None

Render <span class="texsmith-smallcaps"> nodes as small capitals.

Source code in src/texsmith/adapters/handlers/basic.py
 99
100
101
102
103
104
105
106
107
108
@renders("span", phase=RenderPhase.INLINE, name="inline_smallcaps", auto_mark=False)
def render_inline_smallcaps(element: Tag, context: RenderContext) -> None:
    """Render ``<span class=\"texsmith-smallcaps\">`` nodes as small capitals."""
    classes = gather_classes(element.get("class"))
    if "texsmith-smallcaps" not in classes:
        return
    text = element.get_text(strip=False)
    latex = context.formatter.smallcaps(text=text)
    element.replace_with(NavigableString(latex))
    context.mark_processed(element)

render_inline_strong

render_inline_strong(
    element: Tag, context: RenderContext
) -> None

Render <strong> tags using bold template.

Source code in src/texsmith/adapters/handlers/basic.py
111
112
113
114
115
116
@renders("strong", phase=RenderPhase.INLINE, name="inline_strong", after_children=True)
def render_inline_strong(element: Tag, context: RenderContext) -> None:
    """Render ``<strong>`` tags using bold template."""
    text = element.get_text(strip=False)
    latex = context.formatter.strong(text=text)
    element.replace_with(NavigableString(latex))

render_inline_subscript

render_inline_subscript(
    element: Tag, context: RenderContext
) -> None

Render <sub> tags.

Source code in src/texsmith/adapters/handlers/basic.py
143
144
145
146
147
148
@renders("sub", phase=RenderPhase.INLINE, name="inline_subscript", after_children=True)
def render_inline_subscript(element: Tag, context: RenderContext) -> None:
    """Render ``<sub>`` tags."""
    text = element.get_text(strip=False)
    latex = context.formatter.subscript(text=text)
    element.replace_with(NavigableString(latex))

render_inline_superscript

render_inline_superscript(
    element: Tag, context: RenderContext
) -> None

Render <sup> tags, skipping footnote references.

Source code in src/texsmith/adapters/handlers/basic.py
151
152
153
154
155
156
157
158
159
@renders("sup", phase=RenderPhase.INLINE, name="inline_superscript", after_children=True)
def render_inline_superscript(element: Tag, context: RenderContext) -> None:
    """Render ``<sup>`` tags, skipping footnote references."""
    if element.get("id"):
        return

    text = element.get_text(strip=False)
    latex = context.formatter.superscript(text=text)
    element.replace_with(NavigableString(latex))

render_inline_underline

render_inline_underline(
    element: Tag, context: RenderContext
) -> None

Render <ins> tags using the formatter.

Source code in src/texsmith/adapters/handlers/basic.py
91
92
93
94
95
96
@renders("ins", phase=RenderPhase.INLINE, name="inline_underline", after_children=True)
def render_inline_underline(element: Tag, context: RenderContext) -> None:
    """Render ``<ins>`` tags using the formatter."""
    text = element.get_text(strip=False)
    latex = context.formatter.underline(text=text)
    element.replace_with(NavigableString(latex))

replace_line_breaks

replace_line_breaks(
    element: Tag, _context: RenderContext
) -> None

Convert <br> tags into explicit line breaks.

Source code in src/texsmith/adapters/handlers/basic.py
85
86
87
88
@renders("br", phase=RenderPhase.INLINE, name="line_breaks")
def replace_line_breaks(element: Tag, _context: RenderContext) -> None:
    """Convert ``<br>`` tags into explicit LaTeX line breaks."""
    element.replace_with(mark_processed(NavigableString("\\")))

unwrap_grid_cards

unwrap_grid_cards(
    element: Tag, _context: RenderContext
) -> None

Unwrap div.grid-cards containers.

Source code in src/texsmith/adapters/handlers/basic.py
170
171
172
173
174
175
@renders("div", phase=RenderPhase.BLOCK, name="grid_cards", auto_mark=False)
def unwrap_grid_cards(element: Tag, _context: RenderContext) -> None:
    """Unwrap ``div.grid-cards`` containers."""
    classes = element.get("class") or []
    if "grid-cards" in classes:
        element.unwrap()

Block-level handlers for structural HTML elements.

cleanup_tabbed_content

cleanup_tabbed_content(
    element: Tag, _context: RenderContext
) -> None

Remove tabbed container wrappers after children are processed.

Source code in src/texsmith/adapters/handlers/blocks.py
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
@renders(
    "div",
    phase=RenderPhase.PRE,
    priority=130,
    name="tabbed_cleanup",
    auto_mark=False,
    after_children=True,
)
def cleanup_tabbed_content(element: Tag, _context: RenderContext) -> None:
    """Remove tabbed container wrappers after children are processed."""
    classes = gather_classes(element.get("class"))
    if "tabbed-set" not in classes:
        return

    containers = element.find_all("div", class_="tabbed-content", recursive=False)
    if not containers:
        containers = element.find_all("div", class_="tabbed-content")
    if not containers:
        element.unwrap()
        return

    for container in containers:
        inner_blocks = container.find_all("div", class_="tabbed-block", recursive=False)
        if inner_blocks:
            for block in inner_blocks:
                block.unwrap()
        container.unwrap()
    element.unwrap()

render_blockquotes

render_blockquotes(
    element: Tag, context: RenderContext
) -> None

Convert blockquote elements into blockquote environments.

Source code in src/texsmith/adapters/handlers/blocks.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
@renders(
    "blockquote",
    phase=RenderPhase.POST,
    priority=200,
    name="blockquotes",
    nestable=False,
    after_children=True,
)
def render_blockquotes(element: Tag, context: RenderContext) -> None:
    """Convert blockquote elements into LaTeX blockquote environments."""
    classes = element.get("class") or []
    if "epigraph" in classes:
        return

    text = element.get_text(strip=False)
    latex = context.formatter.blockquote(text)
    element.replace_with(mark_processed(NavigableString(latex)))

render_columns

render_columns(
    element: Tag, context: RenderContext
) -> None

Render lists specially marked as multi-column blocks.

Source code in src/texsmith/adapters/handlers/blocks.py
711
712
713
714
715
716
717
718
719
720
721
722
723
724
@renders("div", phase=RenderPhase.POST, priority=60, name="multicolumns", nestable=False)
def render_columns(element: Tag, context: RenderContext) -> None:
    """Render lists specially marked as multi-column blocks."""
    classes = gather_classes(element.get("class"))
    if "two-column-list" in classes:
        columns = 2
    elif "three-column-list" in classes:
        columns = 3
    else:
        return

    text = element.get_text(strip=False)
    latex = context.formatter.multicolumn(text, columns=columns)
    element.replace_with(mark_processed(NavigableString(latex)))

render_description_lists

render_description_lists(
    root: Tag, context: RenderContext
) -> None

Render

elements.

Source code in src/texsmith/adapters/handlers/blocks.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
@renders(phase=RenderPhase.POST, priority=15, name="description_lists", auto_mark=False)
def render_description_lists(root: Tag, context: RenderContext) -> None:
    """Render <dl> elements."""
    for dl in _iter_reversed(root.find_all("dl")):
        _prepare_rich_text_content(dl, context)
        items: list[tuple[str | None, str]] = []
        current_term: str | None = None

        for child in dl.find_all(["dt", "dd"], recursive=False):
            if child.name == "dt":
                term = child.get_text(strip=False).strip()
                current_term = term or None
            elif child.name == "dd":
                content = child.get_text(strip=False).strip()
                if not content and current_term is None:
                    continue
                items.append((current_term, content))

        if not items:
            warnings.warn("Discarding empty description list.", stacklevel=2)
            dl.decompose()
            continue

        latex = context.formatter.description_list(items=items)
        dl.replace_with(mark_processed(NavigableString(latex)))

render_figures

render_figures(
    element: Tag, context: RenderContext
) -> None

Render

elements and manage associated assets.

Source code in src/texsmith/adapters/handlers/blocks.py
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
@renders("figure", phase=RenderPhase.POST, priority=30, name="figures", nestable=False)
def render_figures(element: Tag, context: RenderContext) -> None:
    """Render <figure> elements and manage associated assets."""
    classes = gather_classes(element.get("class"))
    if "mermaid-figure" in classes:
        return

    image = element.find("img")
    if image is None:
        table = element.find("table")
        if table is not None:
            identifier = coerce_attribute(element.get("id"))
            if identifier and not table.get("id"):
                table["id"] = identifier
            figcaption = element.find("figcaption")
            if figcaption is not None and table.find("caption") is None:
                caption = context.document.new_tag("caption")
                _strip_caption_prefix(figcaption)
                caption.string = figcaption.get_text(strip=False)
                table.insert(0, caption)
                figcaption.decompose()
            render_tables(table, context)
            element.unwrap()
            return
        raise InvalidNodeError("Figure missing <img> element")

    src = coerce_attribute(image.get("src"))
    if not src:
        raise InvalidNodeError("Figure image missing 'src' attribute")

    width = coerce_attribute(image.get("width")) or None
    alt_text = coerce_attribute(image.get("alt")) or None
    if not context.runtime.get("copy_assets", True):
        caption_node = element.find("figcaption")
        caption_text = caption_node.get_text(strip=False).strip() if caption_node else None
        placeholder = caption_text or alt_text or "[figure]"
        element.replace_with(mark_processed(NavigableString(placeholder)))
        return

    if is_valid_url(src):
        stored_path = store_remote_image_asset(context, src)
    else:
        resolved = _resolve_source_path(context, src)
        if resolved is None:
            raise AssetMissingError(f"Unable to resolve figure asset '{src}'")

        stored_path = store_local_image_asset(context, resolved)

    caption_text = None
    short_caption = alt_text

    if figcaption := element.find("figcaption"):
        _strip_caption_prefix(figcaption)
        caption_text = figcaption.get_text(strip=False).strip()
        figcaption.decompose()

    if short_caption and caption_text and len(caption_text) > len(short_caption):
        short_caption = None

    label = coerce_attribute(element.get("id"))

    template_name = _figure_template_for(element, context)
    formatter = getattr(context.formatter, template_name)
    asset_path = context.assets.latex_path(stored_path)
    latex = formatter(
        path=asset_path,
        caption=caption_text or short_caption,
        shortcaption=short_caption,
        label=label,
        width=width,
    )

    element.replace_with(mark_processed(NavigableString(latex)))

render_footnotes

render_footnotes(root: Tag, context: RenderContext) -> None

Extract and render footnote references.

Source code in src/texsmith/adapters/handlers/blocks.py
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
@renders(phase=RenderPhase.POST, priority=-10, name="footnotes", auto_mark=False)
def render_footnotes(root: Tag, context: RenderContext) -> None:
    """Extract and render footnote references."""
    footnotes: dict[str, str] = {}
    bibliography = context.state.bibliography

    def _normalise_footnote_id(value: str | None) -> str:
        if not value:
            return ""
        text = str(value).strip()
        if ":" in text:
            prefix, suffix = text.split(":", 1)
            if prefix.startswith("fnref") or prefix.startswith("fn"):
                return suffix
        return text or ""

    def _replace_with_latex(node: Tag, latex: str) -> None:
        replacement = mark_processed(NavigableString(latex))
        node.replace_with(replacement)

    _citation_payload_pattern = re.compile(
        rf"^\s*({_CITATION_KEY_PATTERN}(?:\s*,\s*{_CITATION_KEY_PATTERN})*)\s*$"
    )

    def _citation_keys_from_payload(text: str | None) -> list[str]:
        if not text:
            return []
        match = _citation_payload_pattern.match(text)
        if not match:
            return []
        keys = [part.strip() for part in match.group(1).split(",")]
        return [key for key in keys if key]

    def _render_citation(node: Tag, keys: list[str]) -> bool:
        if not keys:
            return False
        _ensure_doi_entries(keys, context)
        missing = [key for key in keys if key not in bibliography]
        if missing:
            return False
        for key in keys:
            context.state.record_citation(key)
        latex = context.formatter.citation(key=",".join(keys))
        _replace_with_latex(node, latex)
        return True

    citation_footnotes: dict[str, list[str]] = {}
    invalid_footnotes: set[str] = set()

    for container in root.find_all("div", class_="footnote"):
        for li in container.find_all("li"):
            footnote_id = _normalise_footnote_id(coerce_attribute(li.get("id")))
            if not footnote_id:
                raise InvalidNodeError("Footnote item missing identifier")
            text = li.get_text(strip=False)
            if _is_multiline_footnote(text):
                warnings.warn(
                    f"Footnote '{footnote_id}' spans multiple lines and cannot be rendered; dropping it.",
                    stacklevel=2,
                )
                invalid_footnotes.add(footnote_id)
                continue
            text = text.strip()
            footnotes[footnote_id] = text
            recovered = _citation_keys_from_payload(text)
            if recovered:
                citation_footnotes[footnote_id] = recovered
        container.decompose()

    if footnotes:
        context.state.footnotes.update(footnotes)

    for sup in root.find_all("sup", id=True):
        footnote_id = _normalise_footnote_id(coerce_attribute(sup.get("id")))
        if footnote_id in invalid_footnotes:
            sup.decompose()
            continue
        citation_keys = citation_footnotes.get(footnote_id)
        if citation_keys and _render_citation(sup, citation_keys):
            continue
        payload = footnotes.get(footnote_id)
        if payload is None:
            payload = context.state.footnotes.get(footnote_id)
        if payload is None:
            citation_keys = _split_citation_keys(footnote_id)
            if citation_keys and _render_citation(sup, citation_keys):
                continue
            # Fall back to default handling/warnings for unresolved citations.
        if footnote_id and footnote_id in bibliography:
            placeholder_note = bool(payload) and _is_bibliography_placeholder(payload)
            if payload and not placeholder_note:
                warnings.warn(
                    f"Conflicting bibliography definition for '{footnote_id}'.",
                    stacklevel=2,
                )
            context.state.record_citation(footnote_id)
            latex = context.formatter.citation(key=footnote_id)
            _replace_with_latex(sup, latex)
            continue

        if payload is None:
            if footnote_id and footnote_id not in bibliography:
                warnings.warn(
                    f"Reference to '{footnote_id}' is not in your bibliography...",
                    stacklevel=2,
                )
            continue

        latex = context.formatter.footnote(payload)
        _replace_with_latex(sup, latex)

    for placeholder in root.find_all("texsmith-missing-footnote"):
        identifier_attr = coerce_attribute(placeholder.get("data-footnote-id"))
        identifier = identifier_attr or placeholder.get_text(strip=True)
        footnote_id = identifier.strip() if identifier else ""
        if not footnote_id:
            placeholder.decompose()
            continue
        if footnote_id in invalid_footnotes:
            placeholder.decompose()
            continue

        citation_keys = citation_footnotes.get(footnote_id)
        if citation_keys and _render_citation(placeholder, citation_keys):
            continue

        citation_keys = _split_citation_keys(footnote_id)
        if citation_keys and _render_citation(placeholder, citation_keys):
            continue
        # Fall back to default handling for unresolved citations.

        if footnote_id in bibliography:
            context.state.record_citation(footnote_id)
            latex = context.formatter.citation(key=footnote_id)
            _replace_with_latex(placeholder, latex)
        else:
            payload = context.state.footnotes.get(footnote_id)
            if payload:
                latex = context.formatter.footnote(payload)
                _replace_with_latex(placeholder, latex)
                continue
            warnings.warn(
                f"Reference to '{footnote_id}' is not in your bibliography...",
                stacklevel=2,
            )
            replacement = mark_processed(NavigableString(footnote_id))
            placeholder.replace_with(replacement)

render_latex_raw

render_latex_raw(
    element: Tag, _context: RenderContext
) -> None

Preserve raw payloads embedded in hidden paragraphs.

Source code in src/texsmith/adapters/handlers/blocks.py
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
@renders(
    "p",
    "span",
    phase=RenderPhase.POST,
    priority=100,
    name="latex_raw",
    nestable=False,
)
def render_latex_raw(element: Tag, _context: RenderContext) -> None:
    """Preserve raw LaTeX payloads embedded in hidden paragraphs."""
    classes = gather_classes(element.get("class"))
    if "latex-raw" not in classes:
        return

    text = element.get_text(strip=False)
    replacement = mark_processed(NavigableString(text))
    element.replace_with(replacement)

render_lists

render_lists(root: Tag, context: RenderContext) -> None

Render ordered and unordered lists.

Source code in src/texsmith/adapters/handlers/blocks.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
@renders(phase=RenderPhase.POST, name="lists", auto_mark=False)
def render_lists(root: Tag, context: RenderContext) -> None:
    """Render ordered and unordered lists."""
    for element in _iter_reversed(root.find_all(["ol", "ul"])):
        _prepare_rich_text_content(element, context)
        items: list[str] = []
        checkboxes: list[int] = []

        for li in element.find_all("li", recursive=False):
            checkbox_input = li.find("input", attrs={"type": "checkbox"})
            if checkbox_input is not None:
                is_checked = checkbox_input.has_attr("checked")
                checkboxes.append(1 if is_checked else -1)
                checkbox_input.extract()
                text = li.get_text(strip=False).strip()
            else:
                text = li.get_text(strip=False).strip()
                if text.startswith("[ ]"):
                    checkboxes.append(-1)
                    text = text[3:].strip()
                elif text.startswith("[x]") or text.startswith("[X]"):
                    checkboxes.append(1)
                    text = text[3:].strip()
                else:
                    checkboxes.append(0)
            items.append(text)

        has_checkbox = any(checkboxes) or bool(gather_classes(element.get("class")))
        latex: str

        if element.name == "ol":
            latex = context.formatter.ordered_list(items=items)
        else:
            if has_checkbox:
                choices = list(zip((c > 0 for c in checkboxes), items, strict=False))
                latex = context.formatter.choices(items=choices)
            else:
                latex = context.formatter.unordered_list(items=items)

        element.replace_with(mark_processed(NavigableString(latex)))

render_paragraphs

render_paragraphs(
    element: Tag, context: RenderContext
) -> None

Render plain paragraphs with script-aware wrapping.

Source code in src/texsmith/adapters/handlers/blocks.py
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
@renders("p", phase=RenderPhase.POST, priority=90, name="paragraphs", nestable=False)
def render_paragraphs(element: Tag, context: RenderContext) -> None:
    """Render plain paragraphs with script-aware wrapping."""
    if _render_script_paragraphs(element, context):
        return
    if element.get("data-texsmith-latex") == "true":
        content = element.get_text(strip=False)
        element.replace_with(mark_processed(NavigableString(f"{content}\n")))
        return
    if element.get("class"):
        return

    raw_text = element.get_text(strip=False).strip("\n")
    if not raw_text.strip():
        element.decompose()
        return

    legacy_accents = getattr(context.config, "legacy_latex_accents", False)
    contains_math = bool(_MATH_PAYLOAD_PATTERN.search(raw_text))
    escape_text = "\\" not in raw_text and not contains_math
    rendered = render_moving_text(
        raw_text,
        context,
        legacy_accents=legacy_accents,
        include_whitespace=True,
        wrap_scripts=escape_text,
        escape=escape_text,
    )
    element.replace_with(mark_processed(NavigableString(f"{rendered}\n")))

render_remaining_code_blocks

render_remaining_code_blocks(
    root: Tag, context: RenderContext
) -> None

Convert any remaining MkDocs highlight blocks that escaped earlier passes.

Source code in src/texsmith/adapters/handlers/blocks.py
503
504
505
506
507
508
509
@renders(phase=RenderPhase.POST, priority=5, name="fallback_highlight_blocks", auto_mark=False)
def render_remaining_code_blocks(root: Tag, context: RenderContext) -> None:
    """Convert any remaining MkDocs highlight blocks that escaped earlier passes."""
    for highlight in _iter_reversed(root.find_all("div", class_="highlight")):
        if context.is_processed(highlight):
            continue
        _render_code_block(highlight, context)

render_tabbed_content

render_tabbed_content(
    element: Tag, context: RenderContext
) -> None

Unwrap MkDocs tabbed content structures.

Source code in src/texsmith/adapters/handlers/blocks.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
@renders("div", phase=RenderPhase.PRE, priority=120, name="tabbed_content", auto_mark=False)
def render_tabbed_content(element: Tag, context: RenderContext) -> None:
    """Unwrap MkDocs tabbed content structures."""
    classes = gather_classes(element.get("class"))
    if "tabbed-set" not in classes:
        return

    titles: list[str] = []
    if labels := element.find("div", class_="tabbed-labels"):
        for label in labels.find_all("label"):
            titles.append(label.get_text(strip=True))
        labels.extract()
    else:
        fallback_labels = element.find_all("label", recursive=False)
        for label in fallback_labels:
            titles.append(label.get_text(strip=True))
            label.extract()

    for input_node in element.find_all("input", recursive=False):
        input_node.extract()

    content_containers = element.find_all("div", class_="tabbed-content", recursive=False)
    if not content_containers:
        candidate = element.find("div", class_="tabbed-content")
        if candidate is None:
            raise InvalidNodeError("Missing tabbed-content container inside tabbed-set")
        content_containers = [candidate]

    blocks: list[Tag] = []
    for container in content_containers:
        inner_blocks = container.find_all("div", class_="tabbed-block", recursive=False)
        if inner_blocks:
            blocks.extend(inner_blocks)
        else:
            blocks.append(container)

    soup = element.soup

    for index, block in enumerate(blocks):
        title = titles[index] if index < len(titles) else ""
        if soup is None:
            heading = mark_processed(NavigableString(f"\\textbf{{{title}}}\\par\n"))
        else:
            heading = soup.new_tag("p")
            strong = soup.new_tag("strong")
            strong.string = title
            heading.append(strong)
        block.insert_before(heading)

        for highlight in block.find_all("div"):
            highlight_classes = gather_classes(highlight.get("class"))
            if "highlight" not in highlight_classes:
                continue
            if context.is_processed(highlight):
                continue
            parent_before = highlight.parent
            _render_code_block(highlight, context)
            if highlight.parent is not None and highlight.parent is parent_before:
                code_element = highlight.find("code")
                code_text = (
                    code_element.get_text(strip=False)
                    if code_element is not None
                    else highlight.get_text(strip=False)
                )
                if code_text and not code_text.endswith("\n"):
                    code_text += "\n"
                fallback = mark_processed(
                    NavigableString(
                        context.formatter.codeblock(
                            code=code_text,
                            language="text",
                            lineno=False,
                            filename=None,
                            highlight=[],
                            baselinestretch=None,
                        )
                    )
                )
                highlight.replace_with(fallback)
            context.mark_processed(highlight)

render_tables

render_tables(element: Tag, context: RenderContext) -> None

Render HTML tables to .

Source code in src/texsmith/adapters/handlers/blocks.py
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
@renders("table", phase=RenderPhase.POST, priority=40, name="tables", nestable=False)
def render_tables(element: Tag, context: RenderContext) -> None:
    """Render HTML tables to LaTeX."""
    caption = None
    if caption_node := element.find("caption"):
        _strip_caption_prefix(caption_node)
        caption = caption_node.get_text(strip=False).strip()
        caption_node.decompose()

    label = coerce_attribute(element.get("id"))

    table_rows: list[list[str]] = []
    styles: list[list[str]] = []
    is_large = False

    for row in element.find_all("tr"):
        row_values: list[str] = []
        row_styles: list[str] = []
        for cell in row.find_all(["th", "td"]):
            content = cell.get_text(strip=False).strip()
            row_values.append(content)
            row_styles.append(_cell_alignment(cell))
        table_rows.append(row_values)
        styles.append(row_styles)

        stripped = "".join(
            re.sub(r"\\href\{[^\}]+?\}|\\\w{3,}|[\{\}|]", "", col) for col in row_values
        )
        if len(stripped) > 50:
            is_large = True

    columns = styles[0] if styles else []
    latex = context.formatter.table(
        columns=columns,
        rows=table_rows,
        caption=caption,
        label=label,
        is_large=is_large,
    )

    element.replace_with(mark_processed(NavigableString(latex)))

Code-related handlers for the renderer.

render_code_blocks

render_code_blocks(
    element: Tag, context: RenderContext
) -> None

Render MkDocs-highlighted code blocks.

Source code in src/texsmith/adapters/handlers/code.py
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
@renders("div", phase=RenderPhase.PRE, priority=40, name="code_blocks", nestable=True)
def render_code_blocks(element: Tag, context: RenderContext) -> None:
    """Render MkDocs-highlighted code blocks."""
    classes = gather_classes(element.get("class"))
    if "highlight" not in classes:
        return
    if "mermaid" in classes:
        return

    code_element = element.find("code")
    if code_element is None:
        raise InvalidNodeError("Missing <code> element inside highlighted block")
    code_classes = gather_classes(code_element.get("class"))
    if any(cls in {"language-mermaid", "mermaid"} for cls in code_classes):
        return

    if _looks_like_mermaid(code_element.get_text(strip=False)):
        return

    language = _extract_language(code_element)
    if language == "text":
        language = _extract_language(element)
    lineno = element.find(class_="linenos") is not None

    filename = None
    if filename_el := element.find(class_="filename"):
        filename = filename_el.get_text(strip=True)

    code_text, highlight = _collect_code_listing(code_element)

    engine = _resolve_code_engine(context)
    baselinestretch = 0.5 if _is_ascii_art(code_text) else None
    if not code_text.endswith("\n"):
        code_text += "\n"

    context.state.requires_shell_escape = context.state.requires_shell_escape or engine == "minted"
    latex = context.formatter.codeblock(
        code=code_text,
        language=language,
        lineno=lineno,
        filename=filename,
        highlight=highlight,
        baselinestretch=baselinestretch,
        engine=engine,
        state=context.state,
    )

    context.suppress_children(element)
    element.replace_with(mark_processed(NavigableString(latex)))

render_pre_code_blocks

render_pre_code_blocks(
    element: Tag, context: RenderContext
) -> None

Render

 blocks that escaped earlier handlers.

Source code in src/texsmith/adapters/handlers/code.py
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
@renders("pre", phase=RenderPhase.PRE, priority=38, name="pre_code_blocks", nestable=False)
def render_pre_code_blocks(element: Tag, context: RenderContext) -> None:
    """Render <pre><code> blocks that escaped earlier handlers."""
    code_element = element.find("code")
    if code_element is None:
        return

    code_classes = gather_classes(code_element.get("class"))
    if any(cls in {"language-mermaid", "mermaid"} for cls in code_classes):
        return

    code_text, highlight = _collect_code_listing(code_element)
    if _looks_like_mermaid(code_text):
        return

    language = _extract_language(code_element) or _extract_language(element)
    engine = _resolve_code_engine(context)
    baselinestretch = 0.5 if _is_ascii_art(code_text) else None
    if not code_text.endswith("\n"):
        code_text += "\n"

    context.state.requires_shell_escape = context.state.requires_shell_escape or engine == "minted"
    latex = context.formatter.codeblock(
        code=code_text,
        language=language,
        lineno=False,
        filename=None,
        highlight=highlight,
        baselinestretch=baselinestretch,
        engine=engine,
        state=context.state,
    )

    context.mark_processed(element)
    element.replace_with(mark_processed(NavigableString(latex)))

render_preformatted_code

render_preformatted_code(
    element: Tag, context: RenderContext
) -> None

Render plain

 blocks that wrap a  element.

Source code in src/texsmith/adapters/handlers/code.py
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
@renders("pre", phase=RenderPhase.PRE, priority=45, name="preformatted_code", nestable=True)
def render_preformatted_code(element: Tag, context: RenderContext) -> None:
    """Render plain <pre> blocks that wrap a <code> element."""
    classes = gather_classes(element.get("class"))
    if "mermaid" in classes:
        return

    parent = element.parent
    parent_classes = gather_classes(getattr(parent, "get", lambda *_: None)("class"))
    if any(cls in {"highlight", "codehilite"} for cls in parent_classes):
        return

    code_element = element.find("code", recursive=False)
    code_classes = gather_classes(code_element.get("class")) if code_element else []
    if any(cls in {"language-mermaid", "mermaid"} for cls in code_classes):
        return

    if code_element is not None and _looks_like_mermaid(code_element.get_text(strip=False)):
        return

    language = _extract_language(code_element) if code_element else "text"
    engine = _resolve_code_engine(context)
    code_text = (
        code_element.get_text(strip=False) if code_element else element.get_text(strip=False)
    )

    if not code_text.strip():
        return

    baselinestretch = 0.5 if _is_ascii_art(code_text) else None
    if not code_text.endswith("\n"):
        code_text += "\n"

    context.state.requires_shell_escape = context.state.requires_shell_escape or engine == "minted"
    latex = context.formatter.codeblock(
        code=code_text,
        language=language,
        lineno=False,
        filename=None,
        highlight=[],
        baselinestretch=baselinestretch,
        engine=engine,
        state=context.state,
    )

    context.suppress_children(element)
    element.replace_with(mark_processed(NavigableString(latex)))

render_standalone_code_blocks

render_standalone_code_blocks(
    element: Tag, context: RenderContext
) -> None

Render elements that include multiline content as block code.

Source code in src/texsmith/adapters/handlers/code.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
259
260
261
262
263
264
265
266
267
268
269
270
271
272
@renders(
    "code",
    phase=RenderPhase.PRE,
    priority=40,
    name="standalone_code_blocks",
    auto_mark=False,
)
def render_standalone_code_blocks(element: Tag, context: RenderContext) -> None:
    """Render <code> elements that include multiline content as block code."""
    if element.find_parent("pre"):
        return

    classes = element.get("class") or []
    if any(cls in {"language-mermaid", "mermaid"} for cls in classes):
        return

    code_text = element.get_text(strip=False)
    if "\n" not in code_text:
        return

    if _looks_like_mermaid(code_text):
        return

    language = _extract_language(element)
    if language == "text":
        hint, adjusted = _extract_language_hint(code_text)
        if hint:
            language = hint
            code_text = adjusted

    engine = _resolve_code_engine(context)
    if engine == "minted":
        code_text = code_text.replace("{", r"\{").replace("}", r"\}")
    if not code_text.strip():
        return
    if not code_text.endswith("\n"):
        code_text += "\n"

    baselinestretch = 0.5 if _is_ascii_art(code_text) else None

    context.state.requires_shell_escape = context.state.requires_shell_escape or engine == "minted"
    latex = context.formatter.codeblock(
        code=code_text,
        language=language,
        lineno=False,
        filename=None,
        highlight=[],
        baselinestretch=baselinestretch,
        engine=engine,
        state=context.state,
    )

    node = mark_processed(NavigableString(latex))

    if element.parent and element.parent.name == "p" and _is_only_meaningful_child(element):
        element.parent.replace_with(node)
        context.mark_processed(element.parent)
    else:
        element.replace_with(node)
    context.mark_processed(element)
    context.suppress_children(element)

Advanced inline handlers ported from the legacy renderer.

escape_plain_text

escape_plain_text(
    root: Tag, context: RenderContext
) -> None

Escape characters on plain text nodes outside code blocks.

Source code in src/texsmith/adapters/handlers/inline.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
@renders(phase=RenderPhase.PRE, name="escape_plain_text", auto_mark=False)
def escape_plain_text(root: Tag, context: RenderContext) -> None:
    """Escape LaTeX characters on plain text nodes outside code blocks."""
    legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
    for node in list(root.find_all(string=True)):
        if getattr(node, "processed", False):
            continue
        if _has_ancestor(node, "code", "script"):
            continue
        ancestor = getattr(node, "parent", None)
        skip_plain_text = False
        while ancestor is not None:
            classes = gather_classes(getattr(ancestor, "get", lambda *_: None)("class"))
            if "latex-raw" in classes or "arithmatex" in classes:
                skip_plain_text = True
                break
            ancestor = getattr(ancestor, "parent", None)
        if skip_plain_text:
            continue
        text = str(node)
        if not text:
            continue
        if "\\keystroke{" in text or "\\keystrokes{" in text:
            node.replace_with(mark_processed(NavigableString(text)))
            continue
        matches = list(_MATH_PAYLOAD_PATTERN.finditer(text))
        if not matches:
            escaped = _escape_text_segment(text, context, legacy_latex_accents=legacy_latex_accents)
            if escaped != text:
                node.replace_with(mark_processed(NavigableString(escaped)))
            continue

        parts: list[str] = []
        cursor = 0
        for match in matches:
            if match.start() > cursor:
                segment = text[cursor : match.start()]
                if segment:
                    escaped = _escape_text_segment(
                        segment,
                        context,
                        legacy_latex_accents=legacy_latex_accents,
                    )
                    parts.append(escaped)
            parts.append(match.group(0))
            cursor = match.end()
        if cursor < len(text):
            tail = text[cursor:]
            if tail:
                escaped = _escape_text_segment(
                    tail,
                    context,
                    legacy_latex_accents=legacy_latex_accents,
                )
                parts.append(escaped)

        replacement = mark_processed(NavigableString("".join(parts)))
        node.replace_with(replacement)

render_abbreviation

render_abbreviation(
    element: Tag, context: RenderContext
) -> None

Register and render abbreviations.

Source code in src/texsmith/adapters/handlers/inline.py
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
@renders("abbr", phase=RenderPhase.INLINE, priority=30, name="abbreviation", nestable=False)
def render_abbreviation(element: Tag, context: RenderContext) -> None:
    """Register and render abbreviations."""
    title_attr = element.get("title")
    description = title_attr.strip() if isinstance(title_attr, str) else ""
    term = element.get_text(strip=True)

    if not term:
        return

    if not description:
        legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
        latex_text = escape_latex_chars(term, legacy_accents=legacy_latex_accents)
        element.replace_with(mark_processed(NavigableString(latex_text)))
        return

    key = context.state.remember_abbreviation(term, description)
    if not key:
        key = term

    latex = f"\\acrshort{{{key}}}"
    element.replace_with(mark_processed(NavigableString(latex)))

render_critic_additions

render_critic_additions(
    element: Tag, context: RenderContext
) -> None

Convert critic-marked insertions into review macros.

Source code in src/texsmith/adapters/handlers/inline.py
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
@renders(
    "ins",
    phase=RenderPhase.INLINE,
    priority=35,
    name="critic_additions",
    auto_mark=False,
)
def render_critic_additions(element: Tag, context: RenderContext) -> None:
    """Convert critic-marked insertions into LaTeX review macros."""
    classes = gather_classes(element.get("class"))
    if "critic" not in classes:
        return

    text = element.get_text(strip=False)
    latex = context.formatter.addition(text=text)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_critic_comments

render_critic_comments(
    element: Tag, context: RenderContext
) -> None

Render critic comments as inline annotations.

Source code in src/texsmith/adapters/handlers/inline.py
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
@renders(
    "span",
    phase=RenderPhase.INLINE,
    priority=35,
    name="critic_comments",
    auto_mark=False,
)
def render_critic_comments(element: Tag, context: RenderContext) -> None:
    """Render critic comments as inline LaTeX annotations."""
    classes = gather_classes(element.get("class"))
    if "critic" not in classes or "comment" not in classes:
        return

    text = element.get_text(strip=False)
    latex = context.formatter.comment(text=text)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_critic_deletions

render_critic_deletions(
    element: Tag, context: RenderContext
) -> None

Convert critic-marked deletions into review macros.

Source code in src/texsmith/adapters/handlers/inline.py
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
@renders(
    "del",
    phase=RenderPhase.INLINE,
    priority=35,
    name="critic_deletions",
    auto_mark=False,
)
def render_critic_deletions(element: Tag, context: RenderContext) -> None:
    """Convert critic-marked deletions into LaTeX review macros."""
    classes = gather_classes(element.get("class"))
    if "critic" not in classes:
        return

    text = element.get_text(strip=False)
    latex = context.formatter.deletion(text=text)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_critic_highlight

render_critic_highlight(
    element: Tag, context: RenderContext
) -> None

Render critic highlights using the formatter highlighting helper.

Source code in src/texsmith/adapters/handlers/inline.py
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
@renders(
    "mark",
    phase=RenderPhase.INLINE,
    priority=35,
    name="critic_highlight",
    auto_mark=False,
)
def render_critic_highlight(element: Tag, context: RenderContext) -> None:
    """Render critic highlights using the formatter highlighting helper."""
    classes = gather_classes(element.get("class"))
    if "critic" not in classes:
        return

    text = element.get_text(strip=False)
    latex = context.formatter.highlight(text=text)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_critic_substitution

render_critic_substitution(
    element: Tag, context: RenderContext
) -> None

Render critic substitutions as paired deletion/addition markup.

Source code in src/texsmith/adapters/handlers/inline.py
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
@renders(
    "span",
    phase=RenderPhase.INLINE,
    priority=-10,
    name="critic_substitution",
    auto_mark=False,
)
def render_critic_substitution(element: Tag, context: RenderContext) -> None:
    """Render critic substitutions as paired deletion/addition markup."""
    classes = gather_classes(element.get("class"))
    if "critic" not in classes or "subst" not in classes:
        return

    deleted = element.find("del")
    inserted = element.find("ins")
    if deleted is None or inserted is None:
        raise InvalidNodeError("Critic substitution requires both <del> and <ins> children")

    original = deleted.get_text(strip=False)
    replacement = inserted.get_text(strip=False)

    latex = context.formatter.substitution(original=original, replacement=replacement)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_index_entry

render_index_entry(
    element: Tag, context: RenderContext
) -> None

Render inline index term annotations.

Source code in src/texsmith/adapters/handlers/inline.py
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
@renders(
    "span",
    "a",
    phase=RenderPhase.INLINE,
    priority=45,
    name="index_entries",
    nestable=False,
    auto_mark=False,
)
def render_index_entry(element: Tag, context: RenderContext) -> None:
    """Render inline index term annotations."""
    tag_name = coerce_attribute(element.get("data-tag-name"))
    if not tag_name:
        return

    raw_entry = str(tag_name)
    parts = [segment.strip() for segment in raw_entry.split(",") if segment.strip()]
    if not parts:
        return

    legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
    escaped_fragments = [
        render_moving_text(part, context, legacy_accents=legacy_latex_accents, wrap_scripts=True)
        or ""
        for part in parts
    ]
    escaped_entry = "!".join(fragment for fragment in escaped_fragments if fragment)
    style_value = coerce_attribute(element.get("data-tag-style"))
    style_key = style_value.strip().lower() if style_value else ""
    if style_key not in {"b", "i", "bi"}:
        style_key = ""

    display_text = element.get_text(strip=False) or ""
    escaped_text = (
        render_moving_text(
            display_text, context, legacy_accents=legacy_latex_accents, wrap_scripts=True
        )
        or ""
    )

    latex = context.formatter.index(escaped_text, entry=escaped_entry, style=style_key)
    node = mark_processed(NavigableString(latex))
    context.state.has_index_entries = True
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_inline_code

render_inline_code(
    element: Tag, context: RenderContext
) -> None

Render inline code elements using the formatter.

Source code in src/texsmith/adapters/handlers/inline.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
@renders("code", phase=RenderPhase.PRE, priority=50, name="inline_code", nestable=False)
def render_inline_code(element: Tag, context: RenderContext) -> None:
    """Render inline code elements using the formatter."""
    if element.find_parent("pre"):
        return

    classes = gather_classes(element.get("class"))
    code = _extract_code_text(element)
    if "\n" in code:
        return

    engine = _resolve_code_engine(context)
    language_hint = None
    if code.startswith("#!"):
        shebang_parts = code[2:].strip().split(None, 1)
        if shebang_parts:
            language_hint = shebang_parts[0]
            code = shebang_parts[1] if len(shebang_parts) > 1 else ""

    has_language = any(cls.startswith("language-") for cls in classes)
    language = None
    if has_language or "highlight" in classes:
        language = next(
            (cls[len("language-") :] or "text" for cls in classes if cls.startswith("language-")),
            "text",
        )
    if language_hint and not language:
        language = language_hint

    if language:
        delimiter = _pick_mintinline_delimiter(code)
        if delimiter and engine == "minted":
            context.state.requires_shell_escape = (
                context.state.requires_shell_escape or engine == "minted"
            )
            latex = context.formatter.codeinline(
                language=language or "text",
                text=code,
                engine=engine,
            )
            element.replace_with(mark_processed(NavigableString(latex)))
            return
        latex = context.formatter.codeinline(
            language=language or "text",
            text=code,
            engine=engine,
            state=context.state,
        )
        element.replace_with(mark_processed(NavigableString(latex)))
        return

    latex = context.formatter.codeinlinett(code)
    element.replace_with(mark_processed(NavigableString(latex)))

render_inline_code_fallback

render_inline_code_fallback(
    root: Tag, context: RenderContext
) -> None

Convert lingering inline code nodes that escaped the PRE phase.

Source code in src/texsmith/adapters/handlers/inline.py
929
930
931
932
933
934
935
936
937
@renders(phase=RenderPhase.POST, priority=5, name="inline_code_fallback", auto_mark=False)
def render_inline_code_fallback(root: Tag, context: RenderContext) -> None:
    """Convert lingering inline code nodes that escaped the PRE phase."""
    for code in list(root.find_all("code")):
        if code.find_parent("pre"):
            continue
        if context.is_processed(code):
            continue
        render_inline_code(code, context)

render_keystrokes

render_keystrokes(
    element: Tag, context: RenderContext
) -> None

Render keyboard shortcut markup.

Source code in src/texsmith/adapters/handlers/inline.py
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
@renders("span", phase=RenderPhase.INLINE, priority=40, name="keystrokes", nestable=False)
def render_keystrokes(element: Tag, context: RenderContext) -> None:
    """Render keyboard shortcut markup."""
    classes = gather_classes(element.get("class"))
    if "keys" not in classes:
        return

    keys: list[str] = []
    for key in element.find_all("kbd"):
        key_classes = gather_classes(key.get("class"))
        matched: Iterable[str] = (cls[4:] for cls in key_classes if cls.startswith("key-"))
        value = next(matched, None)
        if value:
            keys.append(value)
        else:
            keys.append(key.get_text(strip=True))

    latex = context.formatter.keystroke(keys)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)

render_latex_text_span

render_latex_text_span(
    element: Tag, context: RenderContext
) -> None

Render the custom latex-text span into canonical .

Source code in src/texsmith/adapters/handlers/inline.py
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
@renders(
    "span",
    phase=RenderPhase.INLINE,
    priority=30,
    name="latex_text",
    nestable=False,
    auto_mark=False,
)
def render_latex_text_span(element: Tag, context: RenderContext) -> None:
    """Render the custom ``latex-text`` span into canonical LaTeX."""
    classes = gather_classes(element.get("class"))
    if "latex-text" not in classes:
        return

    latex = mark_processed(NavigableString(r"\LaTeX{}"))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(latex)

render_math_block

render_math_block(
    element: Tag, _context: RenderContext
) -> None

Preserve block math payloads.

Source code in src/texsmith/adapters/handlers/inline.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
@renders(
    "div",
    phase=RenderPhase.PRE,
    priority=30,
    name="math_block",
    auto_mark=False,
)
def render_math_block(element: Tag, _context: RenderContext) -> None:
    """Preserve block math payloads."""
    classes = gather_classes(element.get("class"))
    if "arithmatex" not in classes:
        return
    text = element.get_text(strip=False)
    stripped = text.strip()

    match = _DISPLAY_MATH_PATTERN.match(stripped)
    if match:
        inner = match.group(1)
        if _payload_is_block_environment(inner):
            # align/equation environments already provide display math.
            latex = f"\n{inner.strip()}\n"
            element.replace_with(mark_processed(NavigableString(latex)))
            return

    element.replace_with(mark_processed(NavigableString(f"\n{text}\n")))

render_math_inline

render_math_inline(
    element: Tag, _context: RenderContext
) -> None

Preserve inline math payloads untouched.

Source code in src/texsmith/adapters/handlers/inline.py
493
494
495
496
497
498
499
500
501
502
503
504
505
506
@renders(
    "span",
    phase=RenderPhase.PRE,
    priority=60,
    name="inline_math",
    auto_mark=False,
)
def render_math_inline(element: Tag, _context: RenderContext) -> None:
    """Preserve inline math payloads untouched."""
    classes = gather_classes(element.get("class"))
    if "arithmatex" not in classes:
        return
    text = element.get_text(strip=False)
    element.replace_with(mark_processed(NavigableString(text)))

render_math_script

render_math_script(
    element: Tag, _context: RenderContext
) -> None

Preserve math payloads generated via script tags (e.g. mdx_math).

Source code in src/texsmith/adapters/handlers/inline.py
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
@renders(
    "script",
    phase=RenderPhase.PRE,
    priority=65,
    name="math_script",
    nestable=False,
    auto_mark=False,
)
def render_math_script(element: Tag, _context: RenderContext) -> None:
    """Preserve math payloads generated via script tags (e.g. mdx_math)."""
    type_attr = coerce_attribute(element.get("type"))
    if type_attr is None:
        return
    if not type_attr.startswith("math/tex"):
        return

    payload = element.get_text(strip=False)
    if payload is None:
        payload = ""
    payload = payload.strip()
    is_display = "mode=display" in type_attr

    if not payload:
        node = NavigableString("")
    elif is_display:
        if _payload_is_block_environment(payload):
            node = NavigableString(f"\n{payload}\n")
        else:
            node = NavigableString(f"\n$$\n{payload}\n$$\n")
    else:
        node = NavigableString(f"${payload}$")

    element.replace_with(mark_processed(node))
render_regex_link(
    element: Tag, context: RenderContext
) -> None

Render custom regex helper links.

Source code in src/texsmith/adapters/handlers/inline.py
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
@renders("a", phase=RenderPhase.PRE, priority=70, name="regex_links", nestable=False)
def render_regex_link(element: Tag, context: RenderContext) -> None:
    """Render custom regex helper links."""
    classes = gather_classes(element.get("class"))
    if "ycr-regex" not in classes:
        return

    code = element.get_text(strip=False)
    if code_tag := element.find("code"):
        code = code_tag.get_text(strip=False)
    code = code.replace("&", "\\&").replace("#", "\\#")

    href = coerce_attribute(element.get("href")) or ""
    latex = context.formatter.regex(code, url=requote_url(href))
    element.replace_with(mark_processed(NavigableString(latex)))

render_script_spans

render_script_spans(
    element: Tag, context: RenderContext
) -> None

Render spans tagged with data-script into explicit text commands.

Source code in src/texsmith/adapters/handlers/inline.py
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
@renders(
    "span",
    phase=RenderPhase.INLINE,
    priority=30,
    name="script_spans",
    nestable=False,
    auto_mark=False,
)
def render_script_spans(element: Tag, context: RenderContext) -> None:
    """Render spans tagged with data-script into explicit text commands."""
    slug = coerce_attribute(element.get("data-script"))
    if not slug:
        return

    raw_text = element.get_text(strip=False)
    if not raw_text:
        element.decompose()
        return

    record_script_usage_for_slug(slug, raw_text, context)
    legacy_accents = getattr(context.config, "legacy_latex_accents", False)
    payload = escape_latex_chars(raw_text, legacy_accents=legacy_accents)
    latex = f"\\text{slug}{{{payload}}}"
    parent = element.parent
    if parent is not None and getattr(parent, "attrs", None) is not None:
        parent.attrs["data-texsmith-latex"] = "true"
    context.mark_processed(element)
    element.replace_with(mark_processed(NavigableString(latex)))

render_twemoji_image

render_twemoji_image(
    element: Tag, context: RenderContext
) -> None

Render Twitter emoji images as inline icons.

Source code in src/texsmith/adapters/handlers/inline.py
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
@renders("img", phase=RenderPhase.INLINE, priority=20, name="twemoji_images", nestable=False)
def render_twemoji_image(element: Tag, context: RenderContext) -> None:
    """Render Twitter emoji images as inline icons."""
    classes = gather_classes(element.get("class"))
    if not {"twemoji", "emojione"}.intersection(classes):
        return
    emoji_mode = _get_emoji_mode(context)
    if emoji_mode != "artifact":
        token = _extract_emoji_token(element)
        latex = _render_font_emoji(token, _get_emoji_command(context))
        element.replace_with(mark_processed(NavigableString(latex)))
        return
    if not context.runtime.get("copy_assets", True):
        placeholder = (
            coerce_attribute(element.get("alt")) or coerce_attribute(element.get("title")) or ""
        )
        element.replace_with(mark_processed(NavigableString(placeholder)))
        return

    src = coerce_attribute(element.get("src"))
    if not src:
        raise InvalidNodeError("Twemoji image without 'src' attribute")
    if not is_valid_url(src):
        raise InvalidNodeError("Twemoji images must reference remote assets")

    artefact = fetch_image(src, output_dir=context.assets.output_root)
    stored_path = context.assets.register(src, artefact)
    asset_path = context.assets.latex_path(stored_path)

    latex = context.formatter.icon(asset_path)
    element.replace_with(mark_processed(NavigableString(latex)))

render_twemoji_span

render_twemoji_span(
    element: Tag, context: RenderContext
) -> None

Render inline SVG emoji payloads.

Source code in src/texsmith/adapters/handlers/inline.py
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
@renders(
    "span",
    phase=RenderPhase.INLINE,
    priority=25,
    name="twemoji_svg",
    nestable=False,
    auto_mark=False,
)
def render_twemoji_span(element: Tag, context: RenderContext) -> None:
    """Render inline SVG emoji payloads."""
    classes = gather_classes(element.get("class"))
    if "twemoji" not in classes:
        return
    emoji_mode = _get_emoji_mode(context)
    if emoji_mode != "artifact":
        token = _extract_emoji_token(element)
        latex = _render_font_emoji(token, _get_emoji_command(context))
        element.replace_with(mark_processed(NavigableString(latex)))
        return
    if not context.runtime.get("copy_assets", True):
        placeholder = coerce_attribute(element.get("title")) or element.get_text(strip=True) or ""
        element.replace_with(mark_processed(NavigableString(placeholder)))
        return

    svg = element.find("svg")
    if svg is None:
        raise InvalidNodeError("Expected inline SVG inside span.twemoji")

    svg_payload = str(svg)
    artefact = svg2pdf(svg_payload, output_dir=context.assets.output_root)
    digest = hashlib.sha256(svg_payload.encode("utf-8")).hexdigest()
    stored_path = context.assets.register(f"twemoji::{digest}", artefact)
    asset_path = context.assets.latex_path(stored_path)

    latex = context.formatter.icon(asset_path)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)
render_unicode_link(
    element: Tag, context: RenderContext
) -> None

Render Unicode helper links.

Source code in src/texsmith/adapters/handlers/inline.py
394
395
396
397
398
399
400
401
402
403
404
@renders("a", phase=RenderPhase.PRE, priority=80, name="unicode_links", nestable=False)
def render_unicode_link(element: Tag, context: RenderContext) -> None:
    """Render Unicode helper links."""
    classes = gather_classes(element.get("class"))
    if "ycr-unicode" not in classes:
        return

    code = element.get_text(strip=True)
    href = coerce_attribute(element.get("href")) or ""
    latex = context.formatter.href(text=f"U+{code}", url=requote_url(href))
    element.replace_with(mark_processed(NavigableString(latex)))

Link handling utilities.

render_autoref

render_autoref(
    element: Tag, context: RenderContext
) -> None

Render custom tags.

Source code in src/texsmith/adapters/handlers/links.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
@renders(
    "autoref",
    phase=RenderPhase.INLINE,
    priority=10,
    name="autoref_tags",
    nestable=False,
)
def render_autoref(element: Tag, context: RenderContext) -> None:
    """Render <autoref> custom tags."""
    identifier = coerce_attribute(element.get("identifier"))
    if not identifier:
        legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
        literal = f"<{element.name}>"
        latex = escape_latex_chars(literal, legacy_accents=legacy_latex_accents)
        element.replace_with(mark_processed(NavigableString(latex)))
        return
    text = element.get_text(strip=False)

    latex = context.formatter.ref(text, ref=identifier)
    element.replace_with(mark_processed(NavigableString(latex)))

render_autoref_spans

render_autoref_spans(
    element: Tag, context: RenderContext
) -> None

Render MkDocs autoref span placeholders.

Source code in src/texsmith/adapters/handlers/links.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@renders(
    "span",
    phase=RenderPhase.INLINE,
    priority=15,
    name="autoref_spans",
    nestable=False,
    auto_mark=False,
)
def render_autoref_spans(element: Tag, context: RenderContext) -> None:
    """Render MkDocs autoref span placeholders."""
    identifier = coerce_attribute(element.get("data-autorefs-identifier"))
    if not identifier:
        return
    text = element.get_text(strip=False)

    latex = context.formatter.ref(text, ref=identifier)
    node = mark_processed(NavigableString(latex))
    context.mark_processed(element)
    context.suppress_children(element)
    element.replace_with(node)
render_links(element: Tag, context: RenderContext) -> None

Render hyperlinks and internal references.

Source code in src/texsmith/adapters/handlers/links.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
@renders("a", phase=RenderPhase.INLINE, priority=60, name="links", nestable=False)
def render_links(element: Tag, context: RenderContext) -> None:
    """Render hyperlinks and internal references."""
    href = coerce_attribute(element.get("href")) or ""
    element_id = coerce_attribute(element.get("id"))
    text = element.get_text(strip=False)

    # Already handled in preprocessing modules
    if element.name != "a":
        return

    parsed_href = urlparse(href)
    scheme = (parsed_href.scheme or "").lower()
    fragment = parsed_href.fragment.strip() if parsed_href.fragment else ""

    if scheme in {"http", "https"}:
        latex = context.formatter.href(text=text, url=requote_url(href))
    elif scheme:
        raise InvalidNodeError(f"Unsupported link scheme '{scheme}' for '{href}'.")
    elif href.startswith("#"):
        latex = context.formatter.ref(text, ref=href[1:])
    elif href == "" and element_id:
        latex = context.formatter.label(element_id)
    elif href:
        resolved = _resolve_local_target(context, href)
        if resolved is None:
            raise AssetMissingError(f"Unable to resolve link target '{href}'")
        target_ref = fragment or _infer_heading_reference(resolved)
        if target_ref:
            latex = context.formatter.ref(text or "", ref=target_ref)
        else:
            content = resolved.read_bytes()
            digest = sha256(content).hexdigest()
            reference = f"snippet:{digest}"
            context.state.register_snippet(
                reference,
                {
                    "path": resolved,
                    "content": content,
                    "format": resolved.suffix[1:] if resolved.suffix else "",
                },
            )
            latex = context.formatter.ref(text or "extrait", ref=reference)
    else:
        legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
        latex = escape_latex_chars(text, legacy_accents=legacy_latex_accents)

    element.replace_with(mark_processed(NavigableString(latex)))

Handlers responsible for assets such as images and diagrams.

render_images

render_images(element: Tag, context: RenderContext) -> None

Convert nodes into figures and manage assets.

Source code in src/texsmith/adapters/handlers/media.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
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
@renders("img", phase=RenderPhase.BLOCK, name="render_images", nestable=False)
def render_images(element: Tag, context: RenderContext) -> None:
    """Convert <img> nodes into LaTeX figures and manage assets."""
    if not context.runtime.get("copy_assets", True):
        alt_text = (
            coerce_attribute(element.get("alt")) or coerce_attribute(element.get("title")) or ""
        )
        placeholder = alt_text.strip() or "[image]"
        element.replace_with(mark_processed(NavigableString(placeholder)))
        return

    src = coerce_attribute(element.get("src"))
    if not src:
        raise InvalidNodeError("Image tag without 'src' attribute")

    src = _strip_mkdocs_theme_variant(src)

    classes = gather_classes(element.get("class"))
    if {"twemoji", "emojione"}.intersection(classes):
        return

    if element.find_parent("figure"):
        return

    raw_alt = coerce_attribute(element.get("alt"))
    alt_text = raw_alt.strip() if raw_alt else None
    raw_title = coerce_attribute(element.get("title"))
    caption_text = raw_title.strip() if raw_title else None
    width = coerce_attribute(element.get("width")) or None
    template = _figure_template_for(element)

    link_wrapper = None
    link_target = None
    parent = element.parent
    if isinstance(parent, Tag) and parent.name == "a":
        candidates = [
            child
            for child in parent.contents
            if not (isinstance(child, NavigableString) and not child.strip())
        ]
        if len(candidates) == 1 and candidates[0] is element:
            link_wrapper = parent
            link_target = coerce_attribute(parent.get("href"))

    mermaid_payload = _load_mermaid_diagram(context, src)
    if mermaid_payload is not None:
        diagram, _ = mermaid_payload
        figure_node = _render_mermaid_diagram(
            context,
            diagram,
            template=template,
            caption=caption_text,
        )
        if figure_node is None:
            raise InvalidNodeError(f"Mermaid source '{src}' does not contain a valid diagram")
        element.replace_with(figure_node)
        return

    if not caption_text:
        caption_text = alt_text

    if is_valid_url(src):
        stored_path = store_remote_image_asset(context, src)
    else:
        resolved = _resolve_source_path(context, src)
        if resolved is None:
            raise AssetMissingError(f"Unable to resolve image asset '{src}'")

        stored_path = store_local_image_asset(context, resolved)

    figure_node = _apply_figure_template(
        context,
        path=stored_path,
        caption=caption_text,
        alt=alt_text,
        label=None,
        width=width,
        template=template,
        adjustbox=False,
        link=link_target,
    )
    if link_wrapper:
        link_wrapper.replace_with(figure_node)
    else:
        element.replace_with(figure_node)

render_mermaid

render_mermaid(
    element: Tag, context: RenderContext
) -> None

Convert Mermaid code blocks inside highlight containers.

Source code in src/texsmith/adapters/handlers/media.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
@renders("div", phase=RenderPhase.BLOCK, name="render_mermaid", nestable=False)
def render_mermaid(element: Tag, context: RenderContext) -> None:
    """Convert Mermaid code blocks inside highlight containers."""
    classes = gather_classes(element.get("class"))
    if "highlight" not in classes and "mermaid" not in classes:
        return

    code = element.find("code")
    if code is None:
        return

    diagram = code.get_text()
    width = coerce_attribute(code.get("width") or element.get("width"))
    template = _figure_template_for(element)
    figure_node = _render_mermaid_diagram(context, diagram, template=template, width=width)
    if figure_node is None:
        return

    element.replace_with(figure_node)

render_mermaid_pre

render_mermaid_pre(
    element: Tag, context: RenderContext
) -> None

Handle

 blocks.

Source code in src/texsmith/adapters/handlers/media.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
@renders("pre", phase=RenderPhase.BLOCK, name="render_mermaid_pre", nestable=False)
def render_mermaid_pre(element: Tag, context: RenderContext) -> None:
    """Handle <pre class=\"mermaid\"> blocks."""
    classes = gather_classes(element.get("class"))
    if "mermaid" not in classes:
        return

    diagram: str | None = None
    source_hint = coerce_attribute(element.get("data-mermaid-source"))
    if source_hint:
        payload = _load_mermaid_diagram(context, source_hint)
        if payload is not None:
            diagram, _ = payload
    if diagram is None:
        diagram = element.get_text()
    width = coerce_attribute(element.get("width"))

    template = _figure_template_for(element)
    figure_node = _render_mermaid_diagram(context, diagram, template=template, width=width)
    if figure_node is None:
        return

    element.replace_with(figure_node)