Skip to content

Python templates

A Python template can do what a folder cannot: build the header and footer from the title, version or -O options, extend a built-in template instead of copying its CSS, and change the Markdown or HTML of the document.

The Template class

from obsidian_pdf_exporter.templates import Decorations, ExportContext, Template


class MyTemplate(Template):
    name = "mytemplate"                  # the --template name, must be unique
    description = "Shown by list-templates"

    def get_css(self) -> str: ...                                   # required in practice
    def assets(self) -> dict[str, bytes]: return {}                 # name -> bytes
    def decorations(self, context: ExportContext) -> Decorations | None: return None
    def mermaid_config(self, context: ExportContext) -> dict[str, object]: return {}
    def process_markdown(self, content: str, context: ExportContext) -> str: return content
    def process_html(self, html: str, context: ExportContext) -> str: return html
Member Called Notes
name Identifier for --template.
description One line for list-templates.
get_css() Before the export, without context The CSS source. Written to <build>/<name>.css.
assets() Before the export Files written into the build folder, for relative url(...) in the CSS.
decorations(ctx) After the HTML hooks Running header and footer HTML and page CSS. None for none.
mermaid_config(ctx) Before Mermaid runs Base Mermaid config. -O mermaid.config is merged over it.
process_markdown(content, ctx) Right before pandoc The assembled Markdown of the whole document, after all steps and plugins.
process_html(html, ctx) After pandoc and the HTML steps The full HTML document, before decorations.

One instance is made per export. Do not keep state on the class.

get_css() has no context. Anything that depends on the title or -O options goes into decorations, process_markdown or process_html.

ExportContext

@dataclass
class ExportContext:
    title: str                 # --title or the folder name
    subtitle: str = ""         # --subtitle
    version: str = ""          # --version (export only)
    date: str = ""             # --date or today, ISO
    label: str = ""            # version, short commit hash, "Redline: a -> b" or empty
    output_path: Path          # the PDF
    build_dir: Path            # the build folder, for scratch files
    options: dict[str, str]    # the -O key=value pairs

    @property
    def formatted_date(self) -> str: ...   # "7 May, 2026" for an ISO date

Treat it as read-only.

Decorations

@dataclass
class Decorations:
    running_html: str = ""     # put right after <body>
    page_css: str = ""         # put into <head>

The mechanics are described in Header and footer. Strings from the context must be escaped: HTML escaping for running_html, CSS string escaping for content: "...".

Extend a built-in template

Subclass DefaultTemplate to keep its CSS, header and footer and add to them. This template adds a confidentiality label from -O confidentiality=...:

confidential.py
"""A template that adds a confidentiality label from ``-O confidentiality=...``.

It extends the built-in default template, so it keeps all of its CSS and
its header and footer, and only adds a label at the top right of each page.

    ope export ./Notes -T confidential -O confidentiality=Internal
"""

from __future__ import annotations

from obsidian_pdf_exporter.templates import Decorations
from obsidian_pdf_exporter.templates import ExportContext
from obsidian_pdf_exporter.templates.builtin.default import DefaultTemplate


class ConfidentialTemplate(DefaultTemplate):
    """The default template plus a confidentiality label."""

    name = "confidential"
    description = "Default template with a confidentiality label"

    def decorations(self, context: ExportContext) -> Decorations | None:
        base = super().decorations(context) or Decorations()
        label = context.options.get("confidentiality", "")
        if not label:
            return base
        escaped = label.replace("&", "&amp;").replace("<", "&lt;")
        running_html = f'<span class="confidential-label">{escaped}</span>\n'
        page_css = """
.confidential-label {
    position: running(confidential);
    font-family: Arial, Helvetica, sans-serif;
    font-size: 8pt;
    font-weight: bold;
    text-transform: uppercase;
    color: #c53030;
    border: 1.5pt solid #c53030;
    padding: 2pt 6pt;
}
@page {
    @top-right { content: element(confidential); vertical-align: bottom; padding-bottom: 12pt; }
}
"""
        return Decorations(
            running_html=base.running_html + running_html,
            page_css=base.page_css + page_css,
        )


#: Picked up by scripts/render_examples.py for the docs screenshots.
TEMPLATE = ConfidentialTemplate

The result is in the recipes.

Change Markdown or HTML

Use these hooks only when CSS cannot do it:

def process_markdown(self, content: str, context: ExportContext) -> str:
    if context.options.get("draft") == "yes":
        return "::: {.draft-banner}\nDraft, not approved\n:::\n\n" + content
    return content

def process_html(self, html: str, context: ExportContext) -> str:
    return html.replace('class="title"', 'class="title brand-title"')

process_markdown gets the Markdown after the steps and plugins: Dataview tables and Meta Bind values are already in place. Decorations are added after process_html, so it cannot change them. If the syntax of the notes is the problem, write a plugin instead.

Assets

from importlib import resources
from pathlib import Path

from obsidian_pdf_exporter.templates.base import asset_data_uri


def assets(self) -> dict[str, bytes]:
    return {"logo.svg": (resources.files(__package__) / "logo.svg").read_bytes()}

# For an image in running_html, a data URI avoids WeasyPrint's URL rules in margin boxes:
logo = asset_data_uri(Path(__file__).with_name("logo.svg"))
running_html = f'<img class="brand-logo" src="{logo}">'

Publish as a package

Register the class under the entry point group obsidian_pdf_exporter.templates:

pyproject.toml
[project]
name = "acme-pdf-template"
dependencies = ["obsidian-pdf-exporter"]

[project.entry-points."obsidian_pdf_exporter.templates"]
confidential = "acme_pdf_template:ConfidentialTemplate"
uv tool install obsidian-pdf-exporter --with acme-pdf-template   # or with a local path
ope list-templates
ope export ./Vault -T confidential -O confidentiality=Internal

Register at runtime

When ope is used as a library:

from obsidian_pdf_exporter.main import app
from obsidian_pdf_exporter.templates import register_template

register_template(MyTemplate)               # under MyTemplate.name
register_template(MyTemplate, name="alt")   # or another name
app(["export", "./Vault", "-T", "mytemplate"], standalone_mode=False)

Runtime templates win over entry points of the same name. This is how the docs screenshots of the confidentiality recipe are made, see scripts/render_examples.py.

Options

-O key=value reaches the template as context.options. By convention a template uses <template>.<key> for its own options and a bare key for options shared with plugins.

A template is Python code and can do anything Python can. Review third-party templates like any other dependency.