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 (LATEX) 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 LATEX.
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 aLaTeXWritersubclass. It is validated at render time (must subclassLaTeXWriter), then used as the template's writer so its@writesemitters 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 | |
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 | |
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 | |
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 | |
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 LATEX 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_blockscollects block-level IR from a run of siblings. Loose inline content between block tags is gathered and wrapped in aPara. - :meth:
lower_inlinecollects phrasing IR from a run of siblings, turning text nodes intoStr/Spaceand 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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
LATEX writer package: emit LATEX from the TeXSmith IR.
LaTeXWriteError ¶
LaTeXWriteError(node: object)
Bases: RuntimeError
Raised when an IR node type has no LATEX emitter registered.
Source code in src/texsmith/writers/latex/writer.py
1193 1194 1195 1196 1197 | |
LaTeXWriter ¶
LaTeXWriter(state: WriterState)
Visitor that turns an IR document into a LATEX string.
Source code in src/texsmith/writers/latex/writer.py
60 61 62 63 64 65 66 67 68 69 70 71 | |
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 | |
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 | |
write ¶
write(document: Document) -> str
Render a full document IR to LATEX.
Source code in src/texsmith/writers/latex/writer.py
75 76 77 78 | |
WriterRegistry ¶
WriterRegistry()
Collects @writes-decorated methods of a writer class.
Source code in src/texsmith/writers/registry.py
53 54 | |
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 | |
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 | |
WriterState ¶
WriterState(
*,
state: DocumentState,
config: BookConfig,
formatter: LaTeXFormatter,
assets: AssetRegistry,
runtime: dict[str, Any],
)
All transverse state the LATEX 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 | |
escape_latex_chars ¶
escape_latex_chars(
text: str, *, legacy_accents: bool = False
) -> str
Escape LATEX 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 | |
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 | |