Skip to content

Readers & Writers

TeXSmith converts a document through a typed intermediate representation (IR):

read(HTML) → IR (texsmith.ir) → write(IR) → LaTeX

A reader lowers BeautifulSoup nodes into backend-agnostic IR nodes, and a writer emits a backend () from that IR. Custom constructs are added in two halves that mirror how the built-in constructs work.

Reader lowerings — @reads

A reader lowering is a callable decorated with @reads(*tags, level, priority, name). It returns an IR node (never mutates the tree). Returning NotHandled lets the next candidate — or the generic fallback — run.

from bs4 import Tag

from texsmith.ir import nodes as ir
from texsmith.readers.html.registry import NotHandled, ReadLevel, reads


@reads("span", level=ReadLevel.INLINE, name="data_counter", priority=50)
def read_data_counter(tag: Tag, ctx) -> ir.Span | object:
    classes = tag.get("class") or []
    tokens = {classes} if isinstance(classes, str) else set(classes)
    if "data-counter" not in tokens:
        return NotHandled
    return ir.Span(content=(), attrs=(("role", "counter"),))

Writer emitters — @writes

A writer emitter is a method decorated with @writes(NodeType) on a LaTeXWriter subclass. Dispatch is typed by node class via the MRO; a node without an emitter raises a clear LaTeXWriteError.

from texsmith.ir import nodes as ir
from texsmith.writers.latex import LaTeXWriter, writes


class CountingWriter(LaTeXWriter):
    @writes(ir.Span)
    def _counter_span(self, node: ir.Span) -> str:
        if dict(node.attrs).get("role") == "counter":
            value = self.state.state.next_counter("data-counter")
            return f"\\counter{{{value}}}"
        return super()._span(node)

See examples/custom-render/counter.py for a complete, runnable extension wiring both halves into a LaTeXRenderer.

Tip

Keep the IR semantic and backend-neutral: encode hints via Span/Div attrs (e.g. ("role", "counter")) rather than backend strings. Only the writer knows about .

Shipping readers & writers in a template

When custom constructs belong to a specific template (an exam, a thesis, a poster…), declare the reader modules and the writer subclass in that template's [latex.template] manifest section. TeXSmith resolves them when the template is selected and applies them to the renderer for that template only — other templates keep the default bundled read→write path.

[latex.template]
name = "exam"
version = "1.0.0"
entrypoint = "template/template.tex"
engine = "lualatex"

# Modules whose @reads lowerings are layered on top of the bundled registry.
# A higher-priority handler may return NotHandled to fall through to a core one.
readers = ["my_exam_pkg.reader"]

# A "module:Class" reference to a LaTeXWriter subclass adding/overriding @writes.
writer = "my_exam_pkg.writer:ExamLaTeXWriter"
  • readers — a list of importable module paths. Every @reads-decorated callable found in each module is registered, layered on top of the bundled HTML→IR lowerings (texsmith.readers.html.build_reader_registry).
  • writer — a "module:Class" reference to a LaTeXWriter subclass. It is validated at render time (must subclass LaTeXWriter), then used as the template's writer so its @writes emitters and overrides take effect.

Both are resolved with the same import machinery as attribute normalisers and fragment entrypoints, so a typo or a bad reference fails early with an actionable TemplateError. A template that declares neither is unaffected.

Scope

These hooks are template-scoped by design. Because exam-style rules (e.g. mapping every h1 to \question) would corrupt unrelated documents, they are never registered globally — only while the declaring template renders. There is no global reader/writer entry point.

Inside an extension, read template front-matter overrides from self.state.runtime["template_overrides"] (already populated by the pipeline); keep cross-node state on self.state.state (the DocumentState) and harvest it in a pre-pass over the IR (texsmith.ir.visitor.walk) rather than mutating shared state during emission.

Reference

Lowering registry — the @reads decorator and its registry.

@reads declares a handler that returns an IR node (or a sequence of IR nodes) for a given HTML tag — never mutating the BeautifulSoup tree. The registry keeps the mapping extensible so extensions can register their own lowering without touching the core reader.

A lowering is selected by (level, tag): the reader walks the tree at a known level (BLOCK while collecting top-level/structural nodes, INLINE while gathering phrasing content) and asks the registry for the most specific handler. Handlers are tried in registration order — earlier registrations win — and a handler signals "not mine" by returning :data:NotHandled, letting the reader fall through to the next candidate (and ultimately to a generic fallback that never drops content silently).

NotHandled module-attribute

NotHandled = _NotHandledType()

Singleton sentinel; return it from a lowering to decline an element.

ReadLevel

Bases: Enum

The structural level a lowering produces.

A handler is consulted only at its own level: BLOCK handlers build block nodes (paragraphs, lists, figures, tables…), INLINE handlers build phrasing nodes (emphasis, links, code spans…). A few constructs are valid at either level (e.g. images, math, margin notes) and register for :data:ReadLevel.ANY.

ReaderRegistry dataclass

ReaderRegistry(_rules: list[ReaderRule] = list())

Collects :class:ReaderRule instances indexed by (level, tag).

Lookups return every candidate for a tag (at the requested level plus the ANY level), highest priority first then registration order. The reader walks the candidates, applying the first that does not return :data:NotHandled.

candidates

candidates(
    tag: str, level: ReadLevel
) -> tuple[ReaderRule, ...]

Return lowerings for tag applicable at level (best first).

Source code in src/texsmith/readers/html/registry.py
115
116
117
118
119
120
121
122
123
def candidates(self, tag: str, level: ReadLevel) -> tuple[ReaderRule, ...]:
    """Return lowerings for ``tag`` applicable at ``level`` (best first)."""
    matched = [
        rule
        for rule in self._rules
        if tag in rule.tags and (rule.level is level or rule.level is ReadLevel.ANY)
    ]
    matched.sort(key=lambda rule: -rule.priority)
    return tuple(matched)

collect_from

collect_from(owner: object) -> None

Register every @reads-decorated callable found on owner.

owner is typically a module; its public callables are scanned for the descriptor installed by :func:reads.

Source code in src/texsmith/readers/html/registry.py
101
102
103
104
105
106
107
108
109
110
111
112
113
def collect_from(self, owner: object) -> None:
    """Register every ``@reads``-decorated callable found on ``owner``.

    ``owner`` is typically a module; its public callables are scanned for
    the descriptor installed by :func:`reads`.
    """
    for attribute in dir(owner):
        handler = getattr(owner, attribute, None)
        if not callable(handler):
            continue
        definition = getattr(handler, "__reader_rule__", None)
        if isinstance(definition, _ReaderDefinition):
            self.register(definition.bind(handler))

register

register(rule: ReaderRule) -> None

Append rule to the registry (registration order is stable).

Source code in src/texsmith/readers/html/registry.py
97
98
99
def register(self, rule: ReaderRule) -> None:
    """Append ``rule`` to the registry (registration order is stable)."""
    self._rules.append(rule)

ReaderRule dataclass

ReaderRule(
    level: ReadLevel,
    tags: tuple[str, ...],
    handler: Lowering,
    name: str,
    priority: int = 0,
)

A concrete lowering bound to one or more tags at a given level.

reads

reads(
    *tags: str,
    level: ReadLevel = ReadLevel.BLOCK,
    priority: int = 0,
    name: str | None = None,
) -> Callable[[Lowering], Lowering]

Declare handler as the lowering for tags at level.

The decorated callable returns an IR node (or sequence/None/ :data:NotHandled) instead of mutating a soup. Higher priority wins when several handlers target the same tag.

Source code in src/texsmith/readers/html/registry.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def reads(
    *tags: str,
    level: ReadLevel = ReadLevel.BLOCK,
    priority: int = 0,
    name: str | None = None,
) -> Callable[[Lowering], Lowering]:
    """Declare ``handler`` as the lowering for ``tags`` at ``level``.

    The decorated callable *returns* an IR node (or sequence/``None``/
    :data:`NotHandled`) instead of mutating a soup. Higher ``priority`` wins
    when several handlers target the same tag.
    """
    definition = _ReaderDefinition(
        level=level,
        tags=tuple(tags),
        priority=priority,
        name=name,
    )

    def decorator(handler: Lowering) -> Lowering:
        handler.__reader_rule__ = definition  # type: ignore[attr-defined]
        return handler

    return decorator

The :class:HtmlReader — lower a BeautifulSoup tree into IR.

The reader is the inverse of the old mutate-and-flatten pipeline: instead of rewriting the DOM into strings and calling soup.get_text(), it constructs a :class:texsmith.ir.Document. It never emits a backend string.

Dispatch model

Two recursive passes share one registry (:mod:.registry):

  • :meth:lower_blocks collects block-level IR from a run of siblings. Loose inline content between block tags is gathered and wrapped in a Para.
  • :meth:lower_inline collects phrasing IR from a run of siblings, turning text nodes into Str / Space and recursing into inline tags.

For each element tag the reader asks the registry for candidate lowerings at the active level (plus the level-agnostic ones). The first candidate that does not return :data:~.registry.NotHandled wins.

Fallback (no construct is ever dropped silently)

If no lowering claims a tag, the reader emits a diagnostic warning and a generic :class:~texsmith.ir.Div (block level) or :class:~texsmith.ir.Span (inline level) that preserves the tag name and its classes in attrs and keeps the recursively-lowered children. Unknown content is therefore always represented and traceable, never lost.

HtmlReader

HtmlReader(
    *,
    registry: ReaderRegistry | None = None,
    diagnostics: DiagnosticEmitter | None = None,
    parser: str = "html.parser",
)

Lower HTML (string or parsed tree) into a :class:texsmith.ir.Document.

Source code in src/texsmith/readers/html/reader.py
131
132
133
134
135
136
137
138
139
140
def __init__(
    self,
    *,
    registry: ReaderRegistry | None = None,
    diagnostics: DiagnosticEmitter | None = None,
    parser: str = "html.parser",
) -> None:
    self._registry = registry or _build_registry()
    self._parser = parser
    self._context = ReadContext(self, diagnostics or NullEmitter())

lower_blocks

lower_blocks(
    children: Iterable[PageElement],
) -> tuple[ir.Block, ...]

Lower a run of siblings into block IR, wrapping loose inline runs.

Source code in src/texsmith/readers/html/reader.py
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
def lower_blocks(self, children: Iterable[PageElement]) -> tuple[ir.Block, ...]:
    """Lower a run of siblings into block IR, wrapping loose inline runs."""
    result: list[ir.Block] = []
    pending: list[PageElement] = []

    def flush() -> None:
        if not pending:
            return
        inlines = self._lower_inline_run(pending)
        pending.clear()
        inlines = _strip_edges(inlines)
        if inlines:
            result.append(ir.Para(content=inlines))

    for child in children:
        if isinstance(child, Comment):
            continue
        if isinstance(child, NavigableString):
            if str(child).strip():
                pending.append(child)
            continue
        if not isinstance(child, Tag):
            continue
        if self._is_block_element(child):
            flush()
            result.extend(self._lower_block_tag(child))
        else:
            pending.append(child)

    flush()
    return tuple(result)

lower_inline

lower_inline(
    children: Iterable[PageElement],
) -> tuple[ir.Inline, ...]

Lower a run of siblings into inline IR.

Source code in src/texsmith/readers/html/reader.py
189
190
191
def lower_inline(self, children: Iterable[PageElement]) -> tuple[ir.Inline, ...]:
    """Lower a run of siblings into inline IR."""
    return self._lower_inline_run(list(children))

read

read(html: str) -> ir.Document

Parse an HTML string and return the document IR.

Source code in src/texsmith/readers/html/reader.py
144
145
146
147
def read(self, html: str) -> ir.Document:
    """Parse an HTML string and return the document IR."""
    soup = BeautifulSoup(html, self._parser)
    return self.read_tree(soup)

read_tree

read_tree(root: Tag) -> ir.Document

Lower an already-parsed BeautifulSoup tree into a Document.

Source code in src/texsmith/readers/html/reader.py
149
150
151
152
153
def read_tree(self, root: Tag) -> ir.Document:
    """Lower an already-parsed BeautifulSoup tree into a ``Document``."""
    body = root.find("body")
    container = body if isinstance(body, Tag) else root
    return ir.Document(content=self.lower_blocks(container.children))

build_reader_registry

build_reader_registry(
    extra_modules: Iterable[object] = (),
) -> ReaderRegistry

Assemble a registry from the bundled lowering modules plus extra_modules.

The bundled modules are collected first; any extra_modules (e.g. a template's @reads module) are layered on top. Because :meth:ReaderRegistry.candidates orders by descending priority (stable on registration order), an extra handler declaring a higher priority than a bundled one for the same tag is tried first and may return NotHandled to fall through to the bundled handler.

Source code in src/texsmith/readers/html/reader.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def build_reader_registry(extra_modules: Iterable[object] = ()) -> ReaderRegistry:
    """Assemble a registry from the bundled lowering modules plus ``extra_modules``.

    The bundled modules are collected first; any ``extra_modules`` (e.g. a
    template's ``@reads`` module) are layered on top. Because
    :meth:`ReaderRegistry.candidates` orders by descending priority (stable on
    registration order), an extra handler declaring a higher ``priority`` than a
    bundled one for the same tag is tried first and may return ``NotHandled`` to
    fall through to the bundled handler.
    """
    registry = ReaderRegistry()
    for module in (_inline, _blocks, _extensions, *extra_modules):
        registry.collect_from(module)
    return registry

writer package: emit from the TeXSmith IR.

LaTeXWriteError

LaTeXWriteError(node: object)

Bases: RuntimeError

Raised when an IR node type has no emitter registered.

Source code in src/texsmith/writers/latex/writer.py
1193
1194
1195
1196
1197
def __init__(self, node: object) -> None:
    super().__init__(
        f"No LaTeX emitter registered for IR node {type(node).__name__!r} (backend: latex)."
    )
    self.node = node

LaTeXWriter

LaTeXWriter(state: WriterState)

Visitor that turns an IR document into a string.

Source code in src/texsmith/writers/latex/writer.py
60
61
62
63
64
65
66
67
68
69
70
71
def __init__(self, state: WriterState) -> None:
    self.state = state
    self._invalid_footnotes: set[str] = set()
    cls = type(self)
    # One registry per concrete class (a subclass that adds ``@writes``
    # emitters gets its own, not the base class's cached one).
    registry = cls.__dict__.get("_registry")
    if registry is None:
        registry = WriterRegistry()
        registry.collect_from_class(cls)
        cls._registry = registry  # type: ignore[attr-defined]
    self.registry = registry

emit

emit(node: Node) -> str

Emit a single node, dispatching by type.

Source code in src/texsmith/writers/latex/writer.py
122
123
124
125
126
127
def emit(self, node: ir.Node) -> str:
    """Emit a single node, dispatching by type."""
    method = self.registry.method_for(node)
    if method is None:
        raise LaTeXWriteError(node)
    return getattr(self, method)(node)

render_inlines

render_inlines(inlines: Sequence[Inline]) -> str

Public inline-rendering entry point (used by the table emitter).

Source code in src/texsmith/writers/latex/writer.py
170
171
172
def render_inlines(self, inlines: Sequence[ir.Inline]) -> str:
    """Public inline-rendering entry point (used by the table emitter)."""
    return self._inlines(inlines)

write

write(document: Document) -> str

Render a full document IR to .

Source code in src/texsmith/writers/latex/writer.py
75
76
77
78
def write(self, document: ir.Document) -> str:
    """Render a full document IR to LaTeX."""
    self._collect_footnotes(document)
    return self._join_blocks(document.content)

WriterRegistry

WriterRegistry()

Collects @writes-decorated methods of a writer class.

Source code in src/texsmith/writers/registry.py
53
54
def __init__(self) -> None:
    self._by_type: dict[type, str] = {}

collect_from_class

collect_from_class(cls: type) -> None

Register every @writes method declared on cls (and bases).

Walk the MRO from base to derived so a subclass emitter overrides the base emitter for the same node type.

Source code in src/texsmith/writers/registry.py
56
57
58
59
60
61
62
63
64
65
66
def collect_from_class(self, cls: type) -> None:
    """Register every ``@writes`` method declared on ``cls`` (and bases).

    Walk the MRO from base to derived so a subclass emitter overrides the
    base emitter for the same node type.
    """
    for klass in reversed(cls.__mro__):
        for name, attr in vars(klass).items():
            node_type = getattr(attr, _EMITTER_ATTR, None)
            if node_type is not None:
                self._by_type[node_type] = name

method_for

method_for(node: object) -> str | None

Return the emitter method name for node (by exact type, then MRO).

Source code in src/texsmith/writers/registry.py
68
69
70
71
72
73
74
def method_for(self, node: object) -> str | None:
    """Return the emitter method name for ``node`` (by exact type, then MRO)."""
    for klass in type(node).__mro__:
        method = self._by_type.get(klass)
        if method is not None:
            return method
    return None

WriterState

WriterState(
    *,
    state: DocumentState,
    config: BookConfig,
    formatter: LaTeXFormatter,
    assets: AssetRegistry,
    runtime: dict[str, Any],
)

All transverse state the writer threads through a traversal.

Source code in src/texsmith/writers/latex/state.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def __init__(
    self,
    *,
    state: DocumentState,
    config: BookConfig,
    formatter: LaTeXFormatter,
    assets: AssetRegistry,
    runtime: dict[str, Any],
) -> None:
    self.state = state
    self.config = config
    self.formatter = formatter
    self.assets = assets
    self.runtime = runtime

legacy_accents property

legacy_accents: bool

Whether unicode accents should be encoded as legacy macros.

output_root property

output_root: Path

Asset output root (used by figure/image emitters).

escape_latex_chars

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

Escape special characters leveraging pylatexenc.

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

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

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

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

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

    return "".join(parts)

writes

writes(
    node_type: type[Node],
) -> Callable[
    [Callable[[WriterT, NodeT], str]],
    Callable[[WriterT, NodeT], str],
]

Mark a writer method as the emitter for node_type.

Usage::

@writes(ir.Para)
def _para(self, node: ir.Para) -> str: ...
Source code in src/texsmith/writers/registry.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def writes(
    node_type: type[Node],
) -> Callable[[Callable[[WriterT, NodeT], str]], Callable[[WriterT, NodeT], str]]:
    """Mark a writer method as the emitter for ``node_type``.

    Usage::

        @writes(ir.Para)
        def _para(self, node: ir.Para) -> str: ...
    """

    def decorate(func: Callable[[WriterT, NodeT], str]) -> Callable[[WriterT, NodeT], str]:
        setattr(func, _EMITTER_ATTR, node_type)
        return func

    return decorate