Skip to content

transform

The high-level transform API. Import these to run a compiled transform module against parsed XML.

opm.transform

Core transform API — import this to drive transforms from your own Python scripts.

Most callers want opm.Project.transform, which also compiles the ODD and keeps the loaded module and registers between calls. The functions here are the layer below it, at increasing levels of abstraction.

run_transform(mod, element, ...) is the lowest level. The caller supplies an already-loaded module and an already-selected lxml element; the function serializes and optionally wraps the result in the Jinja2 document template:

from opm.odd_cache import ensure_compiled_module
from opm.resources import packaged_odd
from opm.transform import load_transform_module, run_transform
from lxml import etree

path, _ = ensure_compiled_module(packaged_odd('teipublisher'))
mod = load_transform_module(path)
root = etree.parse('document.xml').getroot()

html     = run_transform(mod, root)                 # full document
fragment = run_transform(mod, root.find('.//{*}div'))  # single element

transform_node(script_path, root, *, xpath=None, ...) is the mid level. It loads the module from script_path and, if xpath is given, selects the target element before transforming:

from opm.odd_cache import ensure_compiled_module
from opm.resources import packaged_odd
from opm.transform import transform_node
from lxml import etree

path, _ = ensure_compiled_module(packaged_odd('teipublisher'))
root = etree.parse('document.xml').getroot()
html = transform_node(path, root, xpath='//body/div[1]')

transform_file(module, xml_path, *, xpath=None, ...) is the highest level, and what opm transform runs. It also parses the XML file and takes everything else from opm.toml: parameters, registers, extensions, web components, template:

from opm.odd_cache import ensure_compiled_module
from opm.resources import packaged_odd
from opm.transform import transform_file

path, _ = ensure_compiled_module(packaged_odd('teipublisher'))
html = transform_file(path, Path('document.xml'), xpath='//body/div[1]')

xpath_select(root, expr, ...) evaluates XPath against a parsed document without any namespace bookkeeping: unprefixed names automatically match the document's namespace:

from opm.transform import xpath_select
from lxml import etree

root = etree.parse('document.xml').getroot()
chapters = xpath_select(root, '//body/div')

TemplateArguments

Bases: TypedDict

The run_transform argument each kind of template goes to.

template_arguments

template_arguments(
    mode: OutputMode,
    config: ProjectConfig,
    override: Path | None = None,
) -> TemplateArguments

The template argument run_transform takes for a run in mode.

override (--template) wins over the project's [transform.<type>] template. Without either, an HTML or Typst shell falls back to its packaged default inside run_transform, and DOCX to the packaged Word style template. Modes that take no template get none.

Source code in src/opm/transform.py
def template_arguments(
    mode: OutputMode,
    config: ProjectConfig,
    override: Path | None = None,
) -> TemplateArguments:
    """The template argument [`run_transform`][opm.transform.run_transform] takes for a run in *mode*.

    *override* (``--template``) wins over the project's
    ``[transform.<type>] template``. Without either, an HTML or Typst shell
    falls back to its packaged default inside [`run_transform`][opm.transform.run_transform], and DOCX
    to the packaged Word style template. Modes that take no template get none.
    """
    if mode.template is None or mode.template_setting is None:
        return {}
    chosen = override if override is not None else getattr(config, mode.template_setting)
    if chosen is None and mode.template == 'docx':
        from opm.resources import packaged_default_docx

        chosen = packaged_default_docx()
    if mode.template == 'typst':
        return {'typst_template_path': chosen}
    if mode.template == 'docx':
        return {'docx_template': chosen}
    return {'template_path': chosen}

template_assets

template_assets(
    mode: OutputMode, config: ProjectConfig
) -> tuple[Path, ...]

The files mode's template references, e.g. [transform.web] assets.

Whoever writes the output copies them into an assets/ directory beside it (see copy_assets); the template links them from there.

Source code in src/opm/transform.py
def template_assets(mode: OutputMode, config: ProjectConfig) -> tuple[Path, ...]:
    """The files *mode*'s template references, e.g. ``[transform.web] assets``.

    Whoever writes the output copies them into an ``assets/`` directory beside
    it (see [`copy_assets`][opm.assets.copy_assets]); the template links them
    from there.
    """
    return tuple(getattr(config, mode.assets_setting)) if mode.assets_setting else ()

load_transform_module

load_transform_module(script_path: Path) -> ModuleType

Load a .py file that defines transform() and OUTPUT_MODE.

Source code in src/opm/transform.py
def load_transform_module(script_path: Path) -> ModuleType:
    """Load a ``.py`` file that defines ``transform()`` and ``OUTPUT_MODE``."""
    path = script_path.resolve()
    if not path.is_file():
        raise FileNotFoundError(f'Not a file: {path}')
    name = f'tei_transform_{path.stem}'
    spec = importlib.util.spec_from_file_location(name, path)
    if spec is None or spec.loader is None:
        raise ImportError(f'Could not load module from {path}')
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    if not hasattr(mod, 'transform'):
        raise AttributeError(
            f'{path} has no transform() — expected a TEI Publisher transform module',
        )
    if not hasattr(mod, 'OUTPUT_MODE'):
        raise AttributeError(
            f'{path} has no OUTPUT_MODE — expected a compiled ODD transform module',
        )
    return mod

xpath_select

xpath_select(
    root: _Element,
    expr: str,
    params: dict[str, str] | None = None,
    xpath_extensions: Sequence[str] | None = None,
    xpath_base_uri: str | None = None,
    xpath_documents: dict[str, Any] | None = None,
    xpath_collections: dict[str, list] | None = None,
    xpath_variables: dict[str, Any] | None = None,
    xpath_namespaces: dict[str, str] | None = None,
    *,
    xpath_env: XPathEnvironment | None = None,
) -> list

Evaluate XPath 3.1 expr against root, returning a plain list.

The document's default namespace URI (taken from root's nsmap) is set as the XPath default element namespace, so unprefixed element names match without any prefix mapping:

chapters = xpath_select(root, '//body/div')          # TEI, DocBook, …
titles   = xpath_select(root, '//div/head/string()')  # atomic results

Element results are returned as lxml _Element objects. Atomic expressions (count(…), string(…)) return the corresponding Python scalar.

Parameters:

Name Type Description Default
root _Element

Document root element; its namespace determines the default element namespace.

required
expr str

XPath 3.1 expression with unprefixed element names.

required
params dict[str, str] | None

Values bound as the XPath $parameters map.

None
xpath_extensions Sequence[str] | None

Dotted module paths for custom XPath functions in the tp: namespace.

None
xpath_env XPathEnvironment | None

Evaluate in this environment instead of one built from the other arguments; its cached document trees are then reused.

None
Source code in src/opm/transform.py
def xpath_select(
    root: etree._Element,
    expr: str,
    params: dict[str, str] | None = None,
    xpath_extensions: Sequence[str] | None = None,
    xpath_base_uri: str | None = None,
    xpath_documents: dict[str, Any] | None = None,
    xpath_collections: dict[str, list] | None = None,
    xpath_variables: dict[str, Any] | None = None,
    xpath_namespaces: dict[str, str] | None = None,
    *,
    xpath_env: XPathEnvironment | None = None,
) -> list:
    """Evaluate XPath 3.1 *expr* against *root*, returning a plain list.

    The document's default namespace URI (taken from *root*'s ``nsmap``) is set
    as the XPath default element namespace, so unprefixed element names match
    without any prefix mapping:

    ```python
    chapters = xpath_select(root, '//body/div')          # TEI, DocBook, …
    titles   = xpath_select(root, '//div/head/string()')  # atomic results
    ```

    Element results are returned as lxml `_Element` objects.
    Atomic expressions (``count(…)``, ``string(…)``) return the corresponding
    Python scalar.

    Args:
        root: Document root element; its namespace determines the default element namespace.
        expr: XPath 3.1 expression with unprefixed element names.
        params: Values bound as the XPath ``$parameters`` map.
        xpath_extensions: Dotted module paths for custom XPath functions in the ``tp:`` namespace.
        xpath_env: Evaluate in this environment instead of one built from the
            other arguments; its cached document trees are then reused.
    """
    env = xpath_env if xpath_env is not None else XPathEnvironment(
        base_uri=xpath_base_uri,
        documents=xpath_documents,
        collections=xpath_collections,
        variables=xpath_variables,
        namespaces=xpath_namespaces,
        extensions=xpath_extensions,
    )
    if params:
        env = env.with_parameters(params)
    return env.select_all(root, expr)

load_xpath_documents

load_xpath_documents(
    paths: Sequence[Path],
) -> dict[str, Any]

Parse configured XPath documents keyed by their absolute file URI.

Each tree is wrapped in its elementpath node tree once, here, rather than left as a bare _ElementTree. XPathContext.__init__ runs get_node_tree() over every entry of documents on each construction, and for a bare lxml tree that means a full build_lxml_node_tree() walk every time — with a context built per doc()-using predicate, the register documents were being re-wrapped thousands of times per chunked file. get_node_tree() short-circuits on an already-wrapped DocumentNode, so pre-wrapping turns that back into a dict lookup. Mirrors what wrapped does for the main document.

Source code in src/opm/transform.py
def load_xpath_documents(paths: Sequence[Path]) -> dict[str, Any]:
    """Parse configured XPath documents keyed by their absolute file URI.

    Each tree is wrapped in its elementpath node tree once, here, rather than
    left as a bare ``_ElementTree``. ``XPathContext.__init__`` runs
    ``get_node_tree()`` over every entry of ``documents`` on *each*
    construction, and for a bare lxml tree that means a full
    ``build_lxml_node_tree()`` walk every time — with a context built per
    ``doc()``-using predicate, the register documents were being re-wrapped
    thousands of times per chunked file. ``get_node_tree()`` short-circuits on
    an already-wrapped ``DocumentNode``, so pre-wrapping turns that back into a
    dict lookup. Mirrors what [`wrapped`][opm.runtime.xpath_env.XPathEnvironment.wrapped]
    does for the main document.
    """
    documents: dict[str, Any] = {}
    for path in paths:
        resolved = path.resolve()
        uri = resolved.as_uri()
        documents[uri] = get_node_tree(parse_xml(str(resolved)), None, uri)
    return documents

load_xpath_collections

load_xpath_collections(
    collections: Sequence[CollectionConfig],
    documents: dict[str, Any] | None = None,
) -> tuple[dict[str, list], dict[str, Any]]

Return (collections, documents) maps for the XPath dynamic context.

Each member document is parsed and wrapped once (see load_xpath_documents) and registered in both returned maps. The second registration is not redundant: fn:id resolves its target document through XPathContext.get_root(), which searches root and documents but never collections. Without it, collection($uri)/id($key) returns the empty sequence — silently, with no error — which is the shape most register lookups take.

documents is merged into (and takes precedence in) the returned document map, so a file listed both in [transform] documents and in a collection is parsed once and shared as the same node object.

Source code in src/opm/transform.py
def load_xpath_collections(
    collections: Sequence[CollectionConfig],
    documents: dict[str, Any] | None = None,
) -> tuple[dict[str, list], dict[str, Any]]:
    """Return ``(collections, documents)`` maps for the XPath dynamic context.

    Each member document is parsed and wrapped once (see
    [`load_xpath_documents`][opm.transform.load_xpath_documents]) and registered in *both* returned maps. The
    second registration is not redundant: ``fn:id`` resolves its target document
    through ``XPathContext.get_root()``, which searches ``root`` and
    ``documents`` but never ``collections``. Without it,
    ``collection($uri)/id($key)`` returns the empty sequence — silently, with no
    error — which is the shape most register lookups take.

    *documents* is merged into (and takes precedence in) the returned document
    map, so a file listed both in ``[transform] documents`` and in a collection
    is parsed once and shared as the same node object.
    """
    merged_documents: dict[str, Any] = dict(documents or {})
    result: dict[str, list] = {}
    for entry in collections:
        members: list = []
        for path in entry.documents:
            resolved = path.resolve()
            uri = resolved.as_uri()
            node = merged_documents.get(uri)
            if node is None:
                node = get_node_tree(parse_xml(str(resolved)), None, uri)
                merged_documents[uri] = node
            members.append(node)
        result.setdefault(entry.uri, []).extend(members)
    return result, merged_documents

load_project_documents

load_project_documents(
    config: ProjectConfig,
) -> tuple[dict[str, Any], dict[str, list]]

(documents, collections) for the XPath dynamic context, from config.

Parses [transform] documents and every [[transform.collections]] member once. Pass the result to project_xpath_env to share the parsed registers across several documents.

Source code in src/opm/transform.py
def load_project_documents(
    config: ProjectConfig,
) -> tuple[dict[str, Any], dict[str, list]]:
    """``(documents, collections)`` for the XPath dynamic context, from *config*.

    Parses ``[transform] documents`` and every ``[[transform.collections]]``
    member once. Pass the result to [`project_xpath_env`][opm.transform.project_xpath_env] to share the
    parsed registers across several documents.
    """
    documents = load_xpath_documents(config.xpath_documents)
    collections, documents = load_xpath_collections(config.xpath_collections, documents)
    return documents, collections

project_xpath_env

project_xpath_env(
    config: ProjectConfig,
    xml_path: Path | None = None,
    *,
    extensions: Sequence[str] | None = None,
    documents: tuple[dict[str, Any], dict[str, list]]
    | None = None,
) -> XPathEnvironment

The XPath environment a run over xml_path evaluates in.

Registers, collections, variables, namespaces and extension modules all come from config; extensions replaces the configured modules when given. xml_path is the base URI doc() resolves against. documents is a load_project_documents result to reuse instead of parsing the registers again.

The config's [project] pythonpath goes on sys.path first, so its extension modules import from the Python API as they do from the CLI.

Source code in src/opm/transform.py
def project_xpath_env(
    config: ProjectConfig,
    xml_path: Path | None = None,
    *,
    extensions: Sequence[str] | None = None,
    documents: tuple[dict[str, Any], dict[str, list]] | None = None,
) -> XPathEnvironment:
    """The XPath environment a run over *xml_path* evaluates in.

    Registers, collections, variables, namespaces and extension modules all
    come from *config*; *extensions* replaces the configured modules when
    given. *xml_path* is the base URI ``doc()`` resolves against. *documents*
    is a [`load_project_documents`][opm.transform.load_project_documents] result to reuse instead of parsing the
    registers again.

    The config's ``[project] pythonpath`` goes on ``sys.path`` first, so its
    extension modules import from the Python API as they do from the CLI.
    """
    config.extend_sys_path()
    docs, collections = (
        documents if documents is not None else load_project_documents(config)
    )
    return XPathEnvironment(
        base_uri=xml_path.resolve().as_uri() if xml_path is not None else None,
        documents=docs,
        collections=collections,
        variables=dict(config.xpath_variables),
        namespaces=dict(config.xpath_namespaces),
        extensions=tuple(extensions) if extensions is not None else config.xpath_extensions,
    )
pb_links_to_anchors(
    tree: _Element,
    resolve: Callable[[str], str | None] = lambda href: (
        href
    ),
) -> bool

Turn the pb-link elements below tree into plain anchors.

A standalone page has no pb-view listening on the channel a pb-link emits on, so a link that only names its target in xml-id/node-id/hash goes nowhere. Each such target becomes #id, passed through resolve (which may point it at another file, or return None to leave the element alone). A pb-link with no target (the path-based browse form) is left untouched. Returns whether anything changed.

Source code in src/opm/transform.py
def pb_links_to_anchors(
    tree: etree._Element,
    resolve: Callable[[str], str | None] = lambda href: href,
) -> bool:
    """Turn the ``pb-link`` elements below *tree* into plain anchors.

    A standalone page has no ``pb-view`` listening on the channel a
    ``pb-link`` emits on, so a link that only names its target in
    ``xml-id``/``node-id``/``hash`` goes nowhere. Each such target becomes
    ``#id``, passed through *resolve* (which may point it at another file,
    or return ``None`` to leave the element alone). A ``pb-link`` with no
    target (the ``path``-based browse form) is left untouched. Returns
    whether anything changed.
    """
    changed = False
    for element in list(tree.iter('pb-link')):
        href = element.get('href')
        if not href:
            target = next(
                (element.get(attr) for attr in PB_LINK_TARGET_ATTRS if element.get(attr)),
                None,
            )
            if not target:
                continue
            href = resolve(target if target.startswith('#') else f'#{target}')
        if not href:
            continue
        element.tag = 'a'
        element.set('href', href)
        for attr in PB_LINK_DROP_ATTRS:
            element.attrib.pop(attr, None)
        changed = True
    return changed

run_transform

run_transform(
    mod: ModuleType,
    root: _Element,
    *,
    parameters: dict[str, str] | None = None,
    webcomponents: bool = False,
    apply_template: bool = True,
    template_path: Path | None = None,
    template_context: dict[str, Any] | None = None,
    assets: Sequence[Path] = (),
    docx_template: Path | None = None,
    typst_template_path: Path | None = None,
    epub_chunking: ChunkingConfig | None = None,
    epub_css: Path | None = None,
    epub_skip_title: bool = False,
    pages: str | None = None,
    fragments: dict[str, str] | None = None,
    footnote_order: str | Callable | None = None,
    xpath_env: XPathEnvironment | None = None,
) -> str | bytes

Run mod against root and return the serialized output.

For HTML output, if the result contains a full <html> document and apply_template is True, the result is wrapped in the Jinja2 document template. Fragment transforms (e.g. a single <div>) skip this step.

For DOCX / EPUB output, returns raw bytes (the package file content).

Parameters:

Name Type Description Default
mod ModuleType

A loaded transform module (from load_transform_module).

required
root _Element

The lxml element to transform.

required
parameters dict[str, str] | None

XPath $parameters map passed to the transform.

None
webcomponents bool

Enable TEI Publisher web-component mode.

False
apply_template bool

Wrap full-document HTML output in the Jinja2 template.

True
template_path Path | None

Override Jinja2 template (default: packaged template).

None
template_context dict[str, Any] | None

Project [context] values exposed to the Jinja2 template as context (see context_for).

None
assets Sequence[Path]

Files the HTML template references, expected in an assets/ directory beside the output. The template receives assets (the directory's URL) and asset_styles (the stylesheets among them); copying them is up to the caller.

()
docx_template Path | None

Path to a .docx file used as the Word style template.

None
typst_template_path Path | None

Jinja2 template for Typst document shell.

None
epub_chunking ChunkingConfig | None

Chapter selection for -t epub (defaults from TEI/DocBook).

None
epub_css Path | None

Stylesheet appended last to the EPUB package.

None
epub_skip_title bool

Omit the generated EPUB title page.

False
pages str | None

Dotted path of a page splitter ([transform.web] pages). For a full HTML document, the <body> then holds one <section class="opm-page"> per page instead of a single flow.

None
fragments dict[str, str] | None

Rendered fragments handed to the HTML template as fragments (see global_fragments).

None
footnote_order str | Callable | None

Orders each run's footnotes before they are placed ([transform] footnote_order), as a callable or the dotted path of one. Only the output modes that collect footnotes place them themselves; see inject_cached_footnotes.

None
xpath_env XPathEnvironment | None

The XPath environment to evaluate in (see project_xpath_env); an empty one when omitted.

None
Source code in src/opm/transform.py
def run_transform(
    mod: ModuleType,
    root: etree._Element,
    *,
    parameters: dict[str, str] | None = None,
    webcomponents: bool = False,
    apply_template: bool = True,
    template_path: Path | None = None,
    template_context: dict[str, Any] | None = None,
    assets: Sequence[Path] = (),
    docx_template: Path | None = None,
    typst_template_path: Path | None = None,
    epub_chunking: ChunkingConfig | None = None,
    epub_css: Path | None = None,
    epub_skip_title: bool = False,
    pages: str | None = None,
    fragments: dict[str, str] | None = None,
    footnote_order: str | Callable | None = None,
    xpath_env: XPathEnvironment | None = None,
) -> str | bytes:
    """Run *mod* against *root* and return the serialized output.

    For HTML output, if the result contains a full ``<html>`` document and
    *apply_template* is ``True``, the result is wrapped in the Jinja2 document
    template.  Fragment transforms (e.g. a single ``<div>``) skip this step.

    For DOCX / EPUB output, returns raw ``bytes`` (the package file content).

    Args:
        mod: A loaded transform module (from [`load_transform_module`][opm.transform.load_transform_module]).
        root: The lxml element to transform.
        parameters: XPath ``$parameters`` map passed to the transform.
        webcomponents: Enable TEI Publisher web-component mode.
        apply_template: Wrap full-document HTML output in the Jinja2 template.
        template_path: Override Jinja2 template (default: packaged template).
        template_context: Project ``[context]`` values exposed to the Jinja2
            template as ``context`` (see
            [`context_for`][opm.config.ProjectConfig.context_for]).
        assets: Files the HTML template references, expected in an
            ``assets/`` directory beside the output. The template receives
            ``assets`` (the directory's URL) and ``asset_styles`` (the
            stylesheets among them); copying them is up to the caller.
        docx_template: Path to a ``.docx`` file used as the Word style template.
        typst_template_path: Jinja2 template for Typst document shell.
        epub_chunking: Chapter selection for ``-t epub`` (defaults from TEI/DocBook).
        epub_css: Stylesheet appended last to the EPUB package.
        epub_skip_title: Omit the generated EPUB title page.
        pages: Dotted path of a page splitter (``[transform.web] pages``).
            For a full HTML document, the ``<body>`` then holds one
            ``<section class="opm-page">`` per page instead of a single flow.
        fragments: Rendered fragments handed to the HTML template as
            ``fragments`` (see [`global_fragments`][opm.transform.global_fragments]).
        footnote_order: Orders each run's footnotes before they are placed
            (``[transform] footnote_order``), as a callable or the dotted path
            of one. Only the output modes that collect footnotes place them
            themselves; see
            [`inject_cached_footnotes`][opm.runtime.pm_runtime.inject_cached_footnotes].
        xpath_env: The XPath environment to evaluate in (see
            [`project_xpath_env`][opm.transform.project_xpath_env]); an empty one when omitted.
    """
    serialize = getattr(mod, 'serialize', _default_serialize)

    env = xpath_env if xpath_env is not None else XPathEnvironment()
    transform_opts: dict[str, Any] = dict(parameters or {})
    if webcomponents:
        transform_opts['webcomponents'] = True
    if docx_template is not None:
        transform_opts['docx_template'] = docx_template
    if footnote_order is not None:
        transform_opts['footnote_order'] = footnote_order

    mode = module_mode(mod)

    if mode.packaged:
        from opm.epub import build_epub

        input_path = None
        raw_input = (parameters or {}).get('input_path') if parameters else None
        if raw_input:
            input_path = Path(str(raw_input))
        return build_epub(
            mod,
            root,
            chunking=epub_chunking,
            odd_css=getattr(mod, 'ODD_GENERATED_CSS', '') or '',
            project_css=epub_css.read_text(encoding='utf-8') if epub_css else None,
            input_path=input_path,
            transform_opts=transform_opts,
            skip_title=epub_skip_title,
            xpath_env=env,
        )

    metadata: dict = {}
    if mode.collects_metadata:
        transform_opts['metadata'] = metadata

    result = mod.transform(root, transform_opts or None, xpath_env=env)

    # Binary output (e.g. docx) — finish() already packaged everything
    if result and isinstance(result[0], bytes):
        return result[0]

    html_root = next(
        (item for item in result
         if isinstance(item, etree._Element) and etree.QName(item).localname == 'html'),
        None,
    )
    is_document = html_root is not None
    body = html_root.find('body') if html_root is not None else None
    if pages and body is not None and mode.template == 'html':
        # The pages replace the whole body: like a chunk, a page shows the
        # text and its notes, not the header rendered around them.
        for child in list(body):
            body.remove(child)
        body.text = None
        for section in _page_sections(mod, root, pages, transform_opts, env):
            body.append(section)
    if apply_template and is_document and mode.template == 'html':
        # A whole-document page stands alone: no pb-view for pb-links to talk to.
        if body is not None:
            pb_links_to_anchors(body)
        if fragments:
            fragments = {name: _pb_links_to_anchors_html(html) for name, html in fragments.items()}
    out = serialize(result)

    if apply_template and mode.template == 'typst':
        tpl = resolve_template_path(
            typst_template_path,
            default_name=mode.default_template or DEFAULT_TYPST_TEMPLATE_NAME,
        )
        out = render_typst_document_template(
            content_typst=out,
            template_path=tpl,
            odd_typst=getattr(mod, 'ODD_GENERATED_TYPST', ''),
            parameters=parameters or {},
            metadata=metadata,
            context=template_context,
        )
    elif apply_template and is_document and mode.template == 'html':
        tpl = resolve_template_path(
            template_path, default_name=mode.default_template or DEFAULT_TEMPLATE_NAME,
        )
        out = render_document_template(
            serialized_html=out,
            template_path=tpl,
            odd_css=getattr(mod, 'ODD_GENERATED_CSS', ''),
            base_css=_print_base_css() if mode.print_css else None,
            parameters=parameters or {},
            context=template_context,
            assets=ASSETS_DIR if assets else '',
            asset_styles=asset_styles(resolve_assets(Path.cwd(), assets)),
            fragments=fragments,
        )

    return out

transform_node

transform_node(
    script_path: Path | ModuleType,
    root: _Element,
    *,
    xpath: str | None = None,
    parameters: dict[str, str] | None = None,
    webcomponents: bool = False,
    apply_template: bool = True,
    template_path: Path | None = None,
    template_context: dict[str, Any] | None = None,
    assets: Sequence[Path] = (),
    docx_template: Path | None = None,
    typst_template_path: Path | None = None,
    epub_chunking: ChunkingConfig | None = None,
    epub_css: Path | None = None,
    epub_skip_title: bool = False,
    pages: str | None = None,
    fragments: dict[str, str] | None = None,
    footnote_order: str | Callable | None = None,
    xpath_env: XPathEnvironment | None = None,
) -> str | bytes

Load script_path as a transform module and apply it to root.

If xpath is given it is evaluated against root via resolve_element to select the actual element to transform; unprefixed names use the document's default namespace. Without xpath, root itself is the transform target.

Parameters:

Name Type Description Default
script_path Path | ModuleType

Path to the compiled transform .py module, or an already-loaded one — callers that need to inspect the module first (to learn its output channel, say) pass it in rather than paying for a second exec.

required
root _Element

The lxml element that acts as the document root for XPath evaluation and (when xpath is None) as the transform target.

required
xpath str | None

XPath 3.1 expression selecting a single child element to transform instead of root.

None
parameters dict[str, str] | None

XPath $parameters map passed to the transform.

None
webcomponents bool

Enable TEI Publisher web-component mode.

False
apply_template bool

Wrap full-document HTML output in the Jinja2 template.

True
template_path Path | None

Override Jinja2 template (default: packaged template).

None
template_context dict[str, Any] | None

Project [context] values exposed to the Jinja2 template as context.

None
assets Sequence[Path]

Files the HTML template references (see run_transform).

()
pages str | None

Page splitter for HTML output (see run_transform).

None
fragments dict[str, str] | None

Rendered fragments for the HTML template (see run_transform).

None
footnote_order str | Callable | None

Footnote ordering for each run (see run_transform).

None
xpath_env XPathEnvironment | None

The XPath environment to evaluate in (see project_xpath_env); an empty one when omitted.

None
Source code in src/opm/transform.py
def transform_node(
    script_path: Path | ModuleType,
    root: etree._Element,
    *,
    xpath: str | None = None,
    parameters: dict[str, str] | None = None,
    webcomponents: bool = False,
    apply_template: bool = True,
    template_path: Path | None = None,
    template_context: dict[str, Any] | None = None,
    assets: Sequence[Path] = (),
    docx_template: Path | None = None,
    typst_template_path: Path | None = None,
    epub_chunking: ChunkingConfig | None = None,
    epub_css: Path | None = None,
    epub_skip_title: bool = False,
    pages: str | None = None,
    fragments: dict[str, str] | None = None,
    footnote_order: str | Callable | None = None,
    xpath_env: XPathEnvironment | None = None,
) -> str | bytes:
    """Load *script_path* as a transform module and apply it to *root*.

    If *xpath* is given it is evaluated against *root* via
    [`resolve_element`][opm.runtime.xpath_env.XPathEnvironment.resolve_element] to select the
    actual element to transform; unprefixed names use the document's default
    namespace.  Without *xpath*, *root* itself is the transform target.

    Args:
        script_path: Path to the compiled transform ``.py`` module, or an
            already-loaded one — callers that need to inspect the module first
            (to learn its output channel, say) pass it in rather than paying
            for a second exec.
        root: The lxml element that acts as the document root for XPath
            evaluation and (when *xpath* is ``None``) as the transform target.
        xpath: XPath 3.1 expression selecting a single child element to
            transform instead of *root*.
        parameters: XPath ``$parameters`` map passed to the transform.
        webcomponents: Enable TEI Publisher web-component mode.
        apply_template: Wrap full-document HTML output in the Jinja2 template.
        template_path: Override Jinja2 template (default: packaged template).
        template_context: Project ``[context]`` values exposed to the Jinja2
            template as ``context``.
        assets: Files the HTML template references (see
            [`run_transform`][opm.transform.run_transform]).
        pages: Page splitter for HTML output (see
            [`run_transform`][opm.transform.run_transform]).
        fragments: Rendered fragments for the HTML template (see
            [`run_transform`][opm.transform.run_transform]).
        footnote_order: Footnote ordering for each run (see
            [`run_transform`][opm.transform.run_transform]).
        xpath_env: The XPath environment to evaluate in (see
            [`project_xpath_env`][opm.transform.project_xpath_env]); an empty one when omitted.
    """
    mod = (
        script_path
        if isinstance(script_path, ModuleType)
        else load_transform_module(script_path)
    )
    env = xpath_env if xpath_env is not None else XPathEnvironment()
    element = (
        env.with_parameters(parameters).resolve_element(root, xpath)
        if xpath
        else root
    )
    return run_transform(
        mod,
        element,
        parameters=parameters,
        webcomponents=webcomponents,
        apply_template=apply_template,
        template_path=template_path,
        template_context=template_context,
        assets=assets,
        docx_template=docx_template,
        typst_template_path=typst_template_path,
        epub_chunking=epub_chunking,
        epub_css=epub_css,
        epub_skip_title=epub_skip_title,
        pages=pages,
        fragments=fragments,
        footnote_order=footnote_order,
        xpath_env=env,
    )

transform_file

transform_file(
    module: Path | ModuleType,
    xml_path: Path,
    *,
    xpath: str | None = None,
    parameters: dict[str, str] | None = None,
    xpath_extensions: Sequence[str] | None = None,
    webcomponents: bool | None = None,
    template: Path | None = None,
    config: ProjectConfig | None = None,
    documents: tuple[dict[str, Any], dict[str, list]]
    | None = None,
) -> str | bytes

Transform xml_path (or an XPath-selected element within it) with the project's settings.

This is what opm transform runs. Everything the arguments leave open comes from the project config: $parameters, registers and collections, XPath variables and extensions, web components, the template and its context, and the EPUB chapter selection.

Parameters:

Name Type Description Default
module Path | ModuleType

The compiled transform module, or the path to its .py file.

required
xml_path Path

Path to the XML input file.

required
xpath str | None

XPath 3.1 expression selecting the transform root element. Unprefixed names use the document's default namespace.

None
parameters dict[str, str] | None

XPath $parameters, over [transform.parameters]. input_path defaults to xml_path.

None
xpath_extensions Sequence[str] | None

Dotted module paths for custom XPath functions. None uses [transform] xpath_extensions from config.

None
webcomponents bool | None

Enable web-component mode. None uses [transform.web] webcomponents from config. Modes without web components (print, EPUB, JSON) ignore it.

None
template Path | None

Template override (see template_arguments).

None
config ProjectConfig | None

Pre-loaded ProjectConfig. When None, opm.toml is loaded from the CWD.

None
documents tuple[dict[str, Any], dict[str, list]] | None

A load_project_documents result to reuse instead of parsing the registers again.

None

Returns str for text output modes (HTML, Markdown, Typst) and bytes for binary ones (DOCX, EPUB). To get a PDF, pass Typst output to opm.typst_compile.compile_pdf.

Source code in src/opm/transform.py
def transform_file(
    module: Path | ModuleType,
    xml_path: Path,
    *,
    xpath: str | None = None,
    parameters: dict[str, str] | None = None,
    xpath_extensions: Sequence[str] | None = None,
    webcomponents: bool | None = None,
    template: Path | None = None,
    config: ProjectConfig | None = None,
    documents: tuple[dict[str, Any], dict[str, list]] | None = None,
) -> str | bytes:
    """Transform *xml_path* (or an XPath-selected element within it) with the project's settings.

    This is what ``opm transform`` runs. Everything the arguments leave open
    comes from the project config: ``$parameters``, registers and collections,
    XPath variables and extensions, web components, the template and its
    ``context``, and the EPUB chapter selection.

    Args:
        module: The compiled transform module, or the path to its ``.py`` file.
        xml_path: Path to the XML input file.
        xpath: XPath 3.1 expression selecting the transform root element.
            Unprefixed names use the document's default namespace.
        parameters: XPath ``$parameters``, over ``[transform.parameters]``.
            ``input_path`` defaults to *xml_path*.
        xpath_extensions: Dotted module paths for custom XPath functions.
            ``None`` uses ``[transform] xpath_extensions`` from config.
        webcomponents: Enable web-component mode.
            ``None`` uses ``[transform.web] webcomponents`` from config.
            Modes without web components (print, EPUB, JSON) ignore it.
        template: Template override (see [`template_arguments`][opm.transform.template_arguments]).
        config: Pre-loaded [`ProjectConfig`][opm.config.ProjectConfig].
            When ``None``, ``opm.toml`` is loaded from the CWD.
        documents: A [`load_project_documents`][opm.transform.load_project_documents] result to reuse instead
            of parsing the registers again.

    Returns ``str`` for text output modes (HTML, Markdown, Typst) and ``bytes``
    for binary ones (DOCX, EPUB). To get a PDF, pass Typst output to
    [`opm.typst_compile.compile_pdf`][opm.typst_compile.compile_pdf].
    """
    return transform_with_config(
        module if isinstance(module, ModuleType) else load_transform_module(module),
        parse_xml(str(xml_path)).getroot(),
        xml_path,
        config if config is not None else load_project_config(),
        xpath=xpath,
        parameters=parameters,
        xpath_extensions=xpath_extensions,
        webcomponents=webcomponents,
        template=template,
        documents=documents,
    )

transform_with_config

transform_with_config(
    mod: ModuleType,
    root: _Element,
    xml_path: Path | None,
    config: ProjectConfig,
    *,
    xpath: str | None = None,
    parameters: dict[str, str] | None = None,
    xpath_extensions: Sequence[str] | None = None,
    webcomponents: bool | None = None,
    template: Path | None = None,
    documents: tuple[dict[str, Any], dict[str, list]]
    | None = None,
) -> str | bytes

Transform the already parsed root with config's settings.

The part of transform_file after parsing, shared with opm.project.Project.transform. xml_path is the file root was read from, if any: doc() resolves against it and it becomes $parameters?input_path.

Source code in src/opm/transform.py
def transform_with_config(
    mod: ModuleType,
    root: etree._Element,
    xml_path: Path | None,
    config: ProjectConfig,
    *,
    xpath: str | None = None,
    parameters: dict[str, str] | None = None,
    xpath_extensions: Sequence[str] | None = None,
    webcomponents: bool | None = None,
    template: Path | None = None,
    documents: tuple[dict[str, Any], dict[str, list]] | None = None,
) -> str | bytes:
    """Transform the already parsed *root* with *config*'s settings.

    The part of [`transform_file`][opm.transform.transform_file] after parsing, shared with
    [`opm.project.Project.transform`][opm.project.Project.transform]. *xml_path* is the file *root* was
    read from, if any: ``doc()`` resolves against it and it becomes
    ``$parameters?input_path``.
    """
    mode = module_mode(mod)

    enabled = webcomponents if webcomponents is not None else bool(config.webcomponents_enabled)
    effective_webcomponents = enabled and mode.webcomponents
    merged_parameters = expand_placeholders(
        {**config.parameters, **(parameters or {})}, xml_path,
    )
    if xml_path is not None:
        merged_parameters.setdefault('input_path', str(xml_path))
    env = project_xpath_env(config, xml_path, extensions=xpath_extensions, documents=documents)
    is_web = mode.name == 'web'

    return transform_node(
        mod,
        root,
        xpath=xpath,
        parameters=merged_parameters,
        webcomponents=effective_webcomponents,
        template_context=config.context_for(mode.name, webcomponents=effective_webcomponents),
        **template_arguments(mode, config, template),
        assets=template_assets(mode, config),
        xpath_env=env,
        epub_chunking=config.epub_chunking,
        epub_css=config.epub_css,
        epub_skip_title=config.epub_skip_title,
        pages=config.web_pages if is_web else None,
        footnote_order=config.footnote_order if mode.collects_footnotes else None,
        fragments=(
            global_fragments(
                mod, root, config, merged_parameters, env,
                webcomponents=effective_webcomponents,
                footnote_order=config.footnote_order,
            )
            if is_web and xpath is None else None
        ),
    )

expand_placeholders

expand_placeholders(
    params: dict[str, Any], xml_path: Path | None
) -> dict[str, Any]

Fill in the {…} placeholders a whole-document run can resolve.

{prefix} is the path back to the directory holding assets/: empty, since opm transform writes its output right beside it. {doc} and {doc_stem} name the source file. Chunk-only placeholders ({file}, {stem}) are left as they are, as is any other brace.

Source code in src/opm/transform.py
def expand_placeholders(params: dict[str, Any], xml_path: Path | None) -> dict[str, Any]:
    """Fill in the ``{…}`` placeholders a whole-document run can resolve.

    ``{prefix}`` is the path back to the directory holding ``assets/``: empty,
    since ``opm transform`` writes its output right beside it. ``{doc}`` and
    ``{doc_stem}`` name the source file. Chunk-only placeholders (``{file}``,
    ``{stem}``) are left as they are, as is any other brace.
    """
    values = {'prefix': ''}
    if xml_path is not None:
        values |= {'doc': xml_path.name, 'doc_stem': xml_path.stem}
    expanded: dict[str, Any] = {}
    for key, value in params.items():
        if isinstance(value, str):
            for name, replacement in values.items():
                value = value.replace(f'{{{name}}}', replacement)
        expanded[key] = value
    return expanded

global_fragments

global_fragments(
    mod: ModuleType,
    root: _Element,
    config: ProjectConfig,
    parameters: dict[str, Any],
    env: XPathEnvironment,
    *,
    webcomponents: bool = False,
    footnote_order: str | Callable | None = None,
) -> dict[str, str]

Render the scope = "global" fragments of [chunking] for root.

These describe the document as a whole (its title, a table of contents), so a whole-document page can show them just as every chunk page does. A fragment with an odd of its own is rendered with that ODD, otherwise with mod. Only the first [[chunking]] run's fragments are used.

Source code in src/opm/transform.py
def global_fragments(
    mod: ModuleType,
    root: etree._Element,
    config: ProjectConfig,
    parameters: dict[str, Any],
    env: XPathEnvironment,
    *,
    webcomponents: bool = False,
    footnote_order: str | Callable | None = None,
) -> dict[str, str]:
    """Render the ``scope = "global"`` fragments of ``[chunking]`` for *root*.

    These describe the document as a whole (its title, a table of contents),
    so a whole-document page can show them just as every chunk page does. A
    fragment with an ``odd`` of its own is rendered with that ODD, otherwise
    with *mod*. Only the first ``[[chunking]]`` run's fragments are used.
    """
    chunking = config.chunking
    if chunking is None or not chunking.fragments:
        return {}
    rendered: dict[str, str] = {}
    for fragment in chunking.fragments:
        if fragment.scope != 'global':
            continue
        params = expand_placeholders({**parameters, **(fragment.parameters or {})}, None)
        found = xpath_select(root, fragment.xpath, xpath_env=env.with_parameters(params))
        if isinstance(found, list):
            found = found[0] if len(found) == 1 else None
        if isinstance(found, str):
            rendered[fragment.name] = found
            continue
        if not isinstance(found, etree._Element):
            continue
        fragment_mod = mod
        if fragment.module is not None or fragment.odd is not None:
            from opm.odd_cache import resolve_transform_module

            resolved = resolve_transform_module(
                module=fragment.module,
                odd=fragment.odd,
                output_mode=fragment.mode,
                use_packaged_default=False,
                base_css=resolve_base_css(config.document_css, Path.cwd()),
            )
            fragment_mod = load_transform_module(resolved.module_path)
        rendered[fragment.name] = str(run_transform(
            fragment_mod,
            found,
            parameters=params,
            webcomponents=webcomponents,
            apply_template=False,
            footnote_order=footnote_order,
            xpath_env=env,
        ))
    return rendered

Templates

Rendering transform output through Jinja2 templates, and the frontmatter those templates may carry.

opm.template_rendering

Helpers for wrapping transform output in Jinja2 templates (HTML and Typst).

TrimmedBlocks

TrimmedBlocks(template_ref: Any)

A template's blocks, each rendered on first use and trimmed.

Templates get one through the trimmed global: {% set blocks = trimmed(self) %}, then blocks.before is the rendered before block, an empty string when it holds only whitespace. That lets a layout test whether a region has content before it writes the region's wrapper.

Source code in src/opm/template_rendering.py
def __init__(self, template_ref: Any) -> None:
    self._ref = template_ref
    self._rendered: dict[str, str] = {}

default_template_path

default_template_path(
    template_name: str = DEFAULT_TEMPLATE_NAME,
) -> Path

Return the path to a packaged default document template.

Source code in src/opm/template_rendering.py
def default_template_path(template_name: str = DEFAULT_TEMPLATE_NAME) -> Path:
    """Return the path to a packaged default document template."""
    return Path(
        str(resources.files('opm').joinpath(f'resources/templates/{template_name}'))
    )

resolve_template_path

resolve_template_path(
    template_path: Path | None,
    *,
    default_name: str = DEFAULT_TEMPLATE_NAME,
) -> Path

Resolve override template path or packaged default template.

Source code in src/opm/template_rendering.py
def resolve_template_path(
    template_path: Path | None,
    *,
    default_name: str = DEFAULT_TEMPLATE_NAME,
) -> Path:
    """Resolve override template path or packaged default template."""
    path = (
        template_path
        if template_path is not None
        else default_template_path(default_name)
    )
    if not path.is_file():
        raise FileNotFoundError(f'Template not found: {path}')
    return path

layout_css

layout_css(templates_dir: Path | None = None) -> str

The stylesheet behind opm/layout.html.j2.

Chunking writes it to css/opm-layout.css and passes layout_css_url; a single transformed page has no such file, so the layout inlines it. A copy at opm/opm-layout.css under templates_dir wins over the packaged one, as the template loader does for the inlined case.

Source code in src/opm/template_rendering.py
def layout_css(templates_dir: Path | None = None) -> str:
    """The stylesheet behind ``opm/layout.html.j2``.

    Chunking writes it to ``css/opm-layout.css`` and passes ``layout_css_url``;
    a single transformed page has no such file, so the layout inlines it.
    A copy at ``opm/opm-layout.css`` under *templates_dir* wins over the
    packaged one, as the template loader does for the inlined case.
    """
    if templates_dir is not None:
        local = templates_dir / 'opm' / LAYOUT_CSS_NAME
        if local.is_file():
            return local.read_text(encoding='utf-8')
    return (packaged_templates_dir() / 'opm' / LAYOUT_CSS_NAME).read_text(encoding='utf-8')

packaged_templates_dir

packaged_templates_dir() -> Path

The directory holding the packaged templates, opm/ layouts included.

Source code in src/opm/template_rendering.py
def packaged_templates_dir() -> Path:
    """The directory holding the packaged templates, ``opm/`` layouts included."""
    return default_template_path().parent

template_environment

template_environment(template_path: Path) -> Environment

A Jinja2 environment for rendering template_path.

Templates are looked up in template_path's directory first, then among the packaged ones, so a project template can {% extends "opm/layout.html.j2" %} and can replace a packaged file by giving its own the same relative path. Frontmatter is removed from every source and feeds the template context (see template_context). The global trimmed gives access to a template's rendered blocks (see TrimmedBlocks).

Source code in src/opm/template_rendering.py
def template_environment(template_path: Path) -> Environment:
    """A Jinja2 environment for rendering *template_path*.

    Templates are looked up in *template_path*'s directory first, then among
    the packaged ones, so a project template can ``{% extends
    "opm/layout.html.j2" %}`` and can replace a packaged file by giving its
    own the same relative path. Frontmatter is removed from every source and
    feeds the template ``context`` (see
    [`template_context`][opm.template_frontmatter.template_context]). The
    global ``trimmed`` gives access to a template's rendered blocks (see
    [`TrimmedBlocks`][opm.template_rendering.TrimmedBlocks]).
    """
    searchpaths = [str(template_path.parent)]
    packaged = str(packaged_templates_dir())
    if packaged not in searchpaths:
        searchpaths.append(packaged)
    env = Environment(loader=FrontmatterLoader(searchpaths), autoescape=False)
    env.globals['trimmed'] = TrimmedBlocks
    return env

load_template

load_template(
    env: Environment,
    template_path: Path,
    label: str = "Template",
)

Load template_path from env, raising FileNotFoundError if missing.

Source code in src/opm/template_rendering.py
def load_template(env: Environment, template_path: Path, label: str = 'Template'):
    """Load *template_path* from *env*, raising ``FileNotFoundError`` if missing."""
    try:
        template = env.get_template(template_path.name)
        if isinstance(env.loader, FrontmatterLoader):
            # Resolve the extends chain now, so a missing parent is reported
            # here rather than as a Jinja2 error halfway through rendering.
            env.loader.chain(env, template_path.name)
        return template
    except TemplateNotFound as e:
        missing = '' if e.name == template_path.name else f' (needed by {template_path.name})'
        raise FileNotFoundError(f'{label} not found: {e.name}{missing}') from e

is_full_html_document

is_full_html_document(serialized_html: str) -> bool

Whether serialized output contains a full <html> document element.

Source code in src/opm/template_rendering.py
def is_full_html_document(serialized_html: str) -> bool:
    """Whether serialized output contains a full ``<html>`` document element."""
    return _first_html_root(serialized_html) is not None

render_document_template

render_document_template(
    *,
    serialized_html: str,
    template_path: Path,
    odd_css: str | None,
    parameters: dict[str, str],
    context: dict | None = None,
    base_css: str | None = None,
    assets: str = "",
    asset_styles: list[str] | None = None,
    fragments: dict[str, str] | None = None,
) -> str

Render a full HTML document through the selected Jinja2 template.

context is the project's [context] table (see context_for), exposed to the template under the single name context so a project key can never shadow a built-in like content_html.

base_css is a packaged baseline stylesheet (e.g. the print channel's paged-media rules) rendered via its own {{ base_css }} template slot, kept separate from odd_css rather than concatenated into it — so the <style> that HtmlOutputFunctions.document() embedded in the document's own <head> still matches odd_css exactly. That element is removed from head_html; the template gets the stylesheet as odd_css instead and decides where it goes.

assets and asset_styles are the URL prefix of the copied [transform.web] assets and the stylesheets among them, the same values chunk pages receive.

fragments are the rendered global fragments of [chunking] (the title, a table of contents), under the same fragments name chunk pages get them, so one template serves both.

Source code in src/opm/template_rendering.py
def render_document_template(
    *,
    serialized_html: str,
    template_path: Path,
    odd_css: str | None,
    parameters: dict[str, str],
    context: dict | None = None,
    base_css: str | None = None,
    assets: str = '',
    asset_styles: list[str] | None = None,
    fragments: dict[str, str] | None = None,
) -> str:
    """Render a full HTML document through the selected Jinja2 template.

    *context* is the project's ``[context]`` table (see
    [`context_for`][opm.config.ProjectConfig.context_for]), exposed to the template
    under the single name ``context`` so a project key can never shadow a
    built-in like ``content_html``.

    *base_css* is a packaged baseline stylesheet (e.g. the print channel's
    paged-media rules) rendered via its own ``{{ base_css }}`` template slot,
    kept separate from *odd_css* rather than concatenated into it — so the
    ``<style>`` that ``HtmlOutputFunctions.document()`` embedded in the
    document's own ``<head>`` still matches *odd_css* exactly. That element is
    removed from ``head_html``; the template gets the stylesheet as
    ``odd_css`` instead and decides where it goes.

    *assets* and *asset_styles* are the URL prefix of the copied
    ``[transform.web] assets`` and the stylesheets among them, the same
    values chunk pages receive.

    *fragments* are the rendered global fragments of ``[chunking]`` (the
    title, a table of contents), under the same ``fragments`` name chunk pages
    get them, so one template serves both.
    """
    html_root = _first_html_root(serialized_html)
    if html_root is None:
        return serialized_html

    head = html_root.find('head')
    body = html_root.find('body')
    # HtmlOutputFunctions.document() already puts the ODD CSS in <head>. Take
    # it out again and hand it over as odd_css, so a template places it where
    # it belongs in the cascade (opm/base.html.j2: after the page's own
    # stylesheets) and a template rendering both variables does not repeat it.
    if head is not None and odd_css:
        for style in head.findall('style'):
            if (style.text or '').strip() == odd_css.strip():
                head.remove(style)
    head_content = _inner_html(head)
    effective_odd_css = odd_css or ''
    env = template_environment(template_path)
    tpl = load_template(env, template_path)
    return tpl.render(
        head_html=head_content,
        content_html=_inner_html(body),
        odd_css=effective_odd_css,
        base_css=base_css or '',
        parameters=parameters or {},
        lang=html_root.get('lang', ''),
        context=template_context(env, template_path.name, context),
        assets=assets,
        asset_styles=asset_styles or [],
        fragments=fragments or {},
    )

render_index_template

render_index_template(
    *,
    entries: list,
    template_path: Path,
    title: str,
    odd_css: str | None = None,
    odd_css_url: str = "",
    assets: str = "",
    asset_styles: list[str] | None = None,
    layout_css_url: str = "",
    context: dict | None = None,
) -> str

Render a collection index listing chunked documents.

The template receives documents (a list of IndexEntry), title, the odd_css string, the odd_css_url file chunking writes, and — when an assets setting is configured — an assets URL prefix plus asset_styles, the stylesheets among those assets in declared order. The index sits at the output root, so those URLs carry no ../ prefix.

Each entry exposes fragments, so a project controls what the index shows by declaring global fragments in opm.toml — no code change needed to add an author or date column.

Source code in src/opm/template_rendering.py
def render_index_template(
    *,
    entries: list,
    template_path: Path,
    title: str,
    odd_css: str | None = None,
    odd_css_url: str = '',
    assets: str = '',
    asset_styles: list[str] | None = None,
    layout_css_url: str = '',
    context: dict | None = None,
) -> str:
    """Render a collection index listing chunked documents.

    The template receives ``documents`` (a list of
    [`IndexEntry`][opm.chunking.IndexEntry]), ``title``, the ``odd_css`` string, the
    ``odd_css_url`` file chunking writes, and — when
    an assets setting is configured — an ``assets`` URL prefix plus
    ``asset_styles``, the stylesheets among those assets in declared order. The
    index sits at the output root, so those URLs carry no ``../`` prefix.

    Each entry exposes ``fragments``, so a project controls what the index shows
    by declaring global fragments in ``opm.toml`` — no code change needed to add
    an author or date column.
    """
    env = template_environment(template_path)
    tpl = load_template(env, template_path)
    return tpl.render(
        documents=entries,
        title=title,
        odd_css=odd_css or '',
        odd_css_url=odd_css_url,
        assets=assets,
        asset_styles=asset_styles or [],
        layout_css_url=layout_css_url,
        context=template_context(env, template_path.name, context),
    )

render_typst_document_template

render_typst_document_template(
    *,
    content_typst: str,
    template_path: Path,
    odd_typst: str | None,
    parameters: dict[str, str],
    metadata: dict | None = None,
    context: dict | None = None,
) -> str

Render Typst body content through the selected Jinja2 document shell.

Source code in src/opm/template_rendering.py
def render_typst_document_template(
    *,
    content_typst: str,
    template_path: Path,
    odd_typst: str | None,
    parameters: dict[str, str],
    metadata: dict | None = None,
    context: dict | None = None,
) -> str:
    """Render Typst body content through the selected Jinja2 document shell."""
    env = template_environment(template_path)
    tpl = load_template(env, template_path, 'Typst template')
    return tpl.render(
        content_typst=content_typst,
        odd_typst=odd_typst or '',
        parameters=parameters or {},
        metadata=metadata or {},
        context=template_context(env, template_path.name, context),
    )

opm.template_frontmatter

Frontmatter in Jinja2 templates, merged into the template context.

A template may open with a block of JSON or TOML between two --- lines, as in jinks::

{# SPDX header comments may come first #}
---toml
styles = ["https://fonts.googleapis.com/css2?family=Belleza"]
[layout.after]
width = "22rem"
---
{% extends "opm/layout.html.j2" %}

The block is removed before Jinja2 sees the source. Its values are merged over the packaged defaults (opm/defaults.toml) and the project's [context] along the template's {% extends %} chain: the root layout first, the rendered template last, so a child overrides its parent and frontmatter overrides opm.toml. See merge_deep for how values combine.

FrontmatterLoader

FrontmatterLoader(searchpath: Any, **kwargs: Any)

Bases: FileSystemLoader

A FileSystemLoader that removes frontmatter and remembers it.

Source code in src/opm/template_frontmatter.py
def __init__(self, searchpath: Any, **kwargs: Any) -> None:
    super().__init__(searchpath, **kwargs)
    self._frontmatter: dict[str, dict[str, Any]] = {}
    self._contexts: dict[str, dict[str, Any]] = {}

frontmatter

frontmatter(
    environment: Environment, template: str
) -> dict[str, Any]

The frontmatter of template, loading it if need be.

Source code in src/opm/template_frontmatter.py
def frontmatter(self, environment: Environment, template: str) -> dict[str, Any]:
    """The frontmatter of *template*, loading it if need be."""
    if template not in self._frontmatter:
        self.get_source(environment, template)
    return self._frontmatter[template]

chain

chain(environment: Environment, template: str) -> list[str]

template and the templates it extends, root layout first.

Source code in src/opm/template_frontmatter.py
def chain(self, environment: Environment, template: str) -> list[str]:
    """*template* and the templates it extends, root layout first."""
    names: list[str] = []
    current: str | None = template
    while current is not None and current not in names:
        names.append(current)
        source, _, _ = self.get_source(environment, current)
        current = _extended(environment, source)
    return names[::-1]

context

context(
    environment: Environment, template: str
) -> dict[str, Any]

The frontmatter of template's extends chain, merged root first.

Source code in src/opm/template_frontmatter.py
def context(self, environment: Environment, template: str) -> dict[str, Any]:
    """The frontmatter of *template*'s extends chain, merged root first."""
    if template not in self._contexts:
        self._contexts[template] = merge_deep(
            *(self.frontmatter(environment, name) for name in self.chain(environment, template))
        )
    return self._contexts[template]

split_frontmatter

split_frontmatter(
    source: str, name: str = "<template>"
) -> tuple[dict[str, Any], str]

Return the frontmatter of source as a dict, and the source without it.

The block is replaced by a Jinja2 comment spanning as many lines, so it outputs nothing and line numbers in Jinja2 error messages still match the file. ---json and a bare --- hold JSON, ---toml holds TOML. A source without frontmatter comes back unchanged with an empty dict.

Raises:

Type Description
ValueError

The block names another syntax or does not parse.

Source code in src/opm/template_frontmatter.py
def split_frontmatter(source: str, name: str = '<template>') -> tuple[dict[str, Any], str]:
    """Return the frontmatter of *source* as a dict, and the source without it.

    The block is replaced by a Jinja2 comment spanning as many lines, so it
    outputs nothing and line numbers in Jinja2 error messages still match the
    file. ``---json`` and a bare ``---``
    hold JSON, ``---toml`` holds TOML. A source without frontmatter comes back
    unchanged with an empty dict.

    Raises:
        ValueError: The block names another syntax or does not parse.
    """
    match = _FRONTMATTER.match(source)
    if match is None:
        return {}, source
    syntax = match['syntax'].lower() or 'json'
    body = match['body']
    try:
        if syntax == 'json':
            data = json.loads(body)
        elif syntax == 'toml':
            data = tomllib.loads(body)
        else:
            raise ValueError(f'unsupported frontmatter syntax "{syntax}" (use json or toml)')
    except (json.JSONDecodeError, tomllib.TOMLDecodeError) as e:
        raise ValueError(f'{name}: invalid {syntax} frontmatter: {e}') from e
    if not isinstance(data, dict):
        raise ValueError(f'{name}: frontmatter must be an object')
    # A Jinja comment spanning the same lines as the whitespace and block it
    # replaces: line numbers stay, and nothing is output in their place.
    prefix = match['prefix'].rstrip()
    lines = source[len(prefix):match.end()].count('\n')
    stripped = prefix + '{#' + '\n' * lines + '#}' + source[match.end():]
    return data, stripped

merge_deep

merge_deep(
    *maps: Mapping[str, Any] | None,
) -> dict[str, Any]

Merge maps left to right, the way jinks merges context maps.

  • A scalar replaces an earlier value under the same key.
  • Maps merge recursively. A map holding "$replace": true replaces the earlier map instead; "$replace": ["a", "b"] replaces only those keys and merges the rest.
  • Arrays are concatenated with duplicates dropped: maps with an id compare by it, other values by their JSON form.

$replace itself never ends up in the result.

Source code in src/opm/template_frontmatter.py
def merge_deep(*maps: Mapping[str, Any] | None) -> dict[str, Any]:
    """Merge *maps* left to right, the way jinks merges context maps.

    - A scalar replaces an earlier value under the same key.
    - Maps merge recursively. A map holding ``"$replace": true`` replaces the
      earlier map instead; ``"$replace": ["a", "b"]`` replaces only those keys
      and merges the rest.
    - Arrays are concatenated with duplicates dropped: maps with an ``id``
      compare by it, other values by their JSON form.

    ``$replace`` itself never ends up in the result.
    """
    return _merge([m for m in maps if m])

template_context

template_context(
    environment: Environment,
    template: str,
    base: Mapping[str, Any] | None = None,
) -> dict[str, Any]

The context to render template with: base, then its frontmatter.

base is the project's context ([context] overlaid by [transform.<type>.context]). It is merged over the packaged context_defaults, and the frontmatter along the extends chain over both, with merge_deep. An environment whose loader is not a FrontmatterLoader adds neither.

Source code in src/opm/template_frontmatter.py
def template_context(
    environment: Environment,
    template: str,
    base: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
    """The ``context`` to render *template* with: *base*, then its frontmatter.

    *base* is the project's context (``[context]`` overlaid by
    ``[transform.<type>.context]``). It is merged over the packaged
    [`context_defaults`][opm.template_frontmatter.context_defaults], and the
    frontmatter along the extends chain over both, with
    [`merge_deep`][opm.template_frontmatter.merge_deep].
    An environment whose loader is not a
    [`FrontmatterLoader`][opm.template_frontmatter.FrontmatterLoader] adds
    neither.
    """
    loader = environment.loader
    if not isinstance(loader, FrontmatterLoader):
        return dict(base or {})
    try:
        frontmatter = loader.context(environment, template)
    except TemplateNotFound:
        frontmatter = {}
    return merge_deep(context_defaults(), base, frontmatter)

context_defaults

context_defaults() -> dict[str, Any]

The packaged defaults beneath every template context.

Read from opm/defaults.toml among the packaged templates: the settings the packaged layouts understand, such as layout.after.width. A project overrides them in [context] and a template in its frontmatter.

Source code in src/opm/template_frontmatter.py
def context_defaults() -> dict[str, Any]:
    """The packaged defaults beneath every template ``context``.

    Read from ``opm/defaults.toml`` among the packaged templates: the settings
    the packaged layouts understand, such as ``layout.after.width``. A project
    overrides them in ``[context]`` and a template in its frontmatter.
    """
    return merge_deep(_context_defaults())

Assets

opm.assets

Files a web template references, copied next to the pages that use them.

A project lists them under [transform.web] assets (or [chunking] assets for one chunking run). opm chunk and opm transform copy them into an assets/ directory beside their output and hand the template the stylesheets among them as asset_styles.

resolve_assets

resolve_assets(
    root: Path, assets: Sequence[Path]
) -> list[Path]

Expand assets entries to the paths to copy.

An entry holding *, ? or [ is matched against the filesystem, so iiif/* copies every document's directory in one line instead of naming each one — and keeps working when a document is added. Matches are sorted, which fixes the cascade order of any stylesheets among them. Every other entry is taken literally. Relative entries resolve against root.

A literal path that does not exist, or a pattern matching nothing, raises FileNotFoundError. The alternative is output quietly missing a file a template or model expects, which surfaces much later as a 404.

Source code in src/opm/assets.py
def resolve_assets(root: Path, assets: Sequence[Path]) -> list[Path]:
    """Expand ``assets`` entries to the paths to copy.

    An entry holding ``*``, ``?`` or ``[`` is matched against the filesystem, so
    ``iiif/*`` copies every document's directory in one line instead of naming
    each one — and keeps working when a document is added. Matches are sorted,
    which fixes the cascade order of any stylesheets among them. Every other
    entry is taken literally. Relative entries resolve against *root*.

    A literal path that does not exist, or a pattern matching nothing, raises
    ``FileNotFoundError``. The alternative is output quietly missing a file a
    template or model expects, which surfaces much later as a 404.
    """
    resolved: list[Path] = []
    for asset in assets:
        source = asset if asset.is_absolute() else root / asset
        text = str(source)
        if any(char in text for char in '*?['):
            pattern = str(source.relative_to(source.anchor))
            matches = sorted(Path(source.anchor).glob(pattern))
            if not matches:
                raise FileNotFoundError(f'Asset pattern matched nothing: {source}')
            resolved.extend(matches)
        elif source.exists():
            resolved.append(source)
        else:
            raise FileNotFoundError(f'Asset not found: {source}')
    return resolved

copy_assets

copy_assets(
    root: Path, assets: Sequence[Path], output_root: Path
) -> list[Path]

Copy assets into <output_root>/assets/ and return their sources.

Each entry keeps its own name, a directory its whole tree. Copying again over an earlier run is safe.

Source code in src/opm/assets.py
def copy_assets(root: Path, assets: Sequence[Path], output_root: Path) -> list[Path]:
    """Copy *assets* into ``<output_root>/assets/`` and return their sources.

    Each entry keeps its own name, a directory its whole tree. Copying again
    over an earlier run is safe.
    """
    sources = resolve_assets(root, assets)
    if not sources:
        return sources
    target_dir = output_root / ASSETS_DIR
    target_dir.mkdir(parents=True, exist_ok=True)
    for source in sources:
        target = target_dir / source.name
        if source.is_dir():
            shutil.copytree(source, target, dirs_exist_ok=True)
        else:
            shutil.copy2(source, target)
    return sources

asset_styles

asset_styles(
    sources: Sequence[Path], prefix: str = ""
) -> list[str]

URLs of the stylesheets among sources, in the order they were declared.

That is the cascade order, so a template can link them blind. prefix is the path from the page back to the output root (../ for a chunk page).

Source code in src/opm/assets.py
def asset_styles(sources: Sequence[Path], prefix: str = '') -> list[str]:
    """URLs of the stylesheets among *sources*, in the order they were declared.

    That is the cascade order, so a template can link them blind. *prefix* is
    the path from the page back to the output root (``../`` for a chunk page).
    """
    return [
        f'{prefix}{ASSETS_DIR}/{source.name}'
        for source in sources
        if source.suffix.lower() == '.css'
    ]