Skip to content

Repository files navigation

container-writer.lua / container-strip.lua / container-unwrap.lua

testLicense: GPL v3PandocQuarto

Three companion Pandoc Lua filters:

  • container-writer.lua — translates generic Div and Span containers into format-native environments for LaTeX, ConTeXt and Typst, named styles for DOCX/ODT, semantic elements for JATS, and passthrough for HTML/EPUB.
  • container-strip.lua — removes Div and Span elements by class, content and all, for stripping editorial annotations in production builds.
  • container-unwrap.lua — removes Div and Span container elements while preserving their content. Useful as a post-processing step after container-writer.lua to neutralise elements that were not in the whitelist.

Copyright 2026 Pedro Luis Barrio under GPL-3.0-or-later, see LICENSE file for details.

Maintained by plbarrio.

Requirements

Pandoc >= 2.19.1 · Quarto >= 1.4.0 (for Quarto usage)

Usage

Plain Pandoc

pandoc --lua-filter=container-writer.lua input.md -o output.pdf

Quarto

Declare in _quarto.yml:

filters:
- container-writer.lua

How it works

Pandoc renders Div and Span elements with CSS classes natively in HTML/EPUB. In other formats they are invisible — content is emitted but without any wrapping. This filter bridges that gap by wrapping whitelisted containers in the appropriate format command.

ElementLaTeXConTeXtTypstDOCX/ODTJATSHTML/EPUB
Div\begin{name}...\end{name}\startname...\stopname#block[...] <name>paragraph style name<boxed-text content-type="name">unchanged
Span\name{...}\name{...}#[...] <name>character style name<named-content content-type="name">unchanged

Writer variants resolve to their base format, so html5, epub3 and jats_publishing need no configuration of their own — a jats: section in the YAML applies to all four JATS writers.

DOCX and ODT work differently from the other formats: instead of emitting raw markup, the filter sets Pandoc's custom-style attribute and lets Pandoc do the work. Pandoc maps it to a paragraph style for Div and to a character style for Span — the same block/inline split the filter already uses elsewhere, so no extra configuration is needed.

The effective whitelist for a given format is common + the FORMAT-specific list. Containers not in the whitelist are left untouched.

Both common and format-specific keys accept a scalar for a single entry or a list for multiple entries:

container-writer:
common: epigraphcontainer-writer:
common:
- epigraph
- note

Only whitelisted names are processed — unknown containers never cause errors in the output format.

Parent.child entries

Compound entries (parent.child) control how nested elements are wrapped. When a Div or Span with class parent is visited, its children matching class child are wrapped using the child's own class name as environment — mirroring the AST directly:

container-writer:
common:
- note
- note.title # Div.title inside Div.note → \begin{title}

Entries chain to any depth, one level per dot, and each level is wrapped with its own class name:

container-writer:
common:
- note
- note.title
- note.title.icon # Div.icon inside Div.title inside Div.note

A level only wraps if its full chain is whitelisted, so note.title alone leaves a deeper icon untouched.

In HTML/EPUB children are left as-is — rendered natively by Pandoc, styled via CSS descendant selectors (.note .title { ... }). In Typst and ConTeXt the child style can be scoped inside the parent rule. In LaTeX \begin{title} is global — use the remap syntax to give it a per-context name.

Remap entries

A compound entry can remap the child's environment name for specific formats:

container-writer:
common:
- note
- note.title # HTML/EPUB: uses class name 'title'latex:
- note.title: notetitle # LaTeX: uses 'notetitle' insteadcontext:
- note.title: notetitle # ConTeXt: uses 'notetitle' instead

This lets CSS use .note .title naturally while LaTeX/ConTeXt use \begin{notetitle} / \startnotetitle — fully per-context, no global namespace collision.

Remap is also useful to avoid conflicts with existing LaTeX environments. If a class name collides with an environment already defined by your document class or a package, remap it to a different name without changing your source:

container-writer:
latex:
- dedication: mydedication # avoids collision with existing \dedication

Then define mydedication in your preamble instead of dedication.

If the parent is not in the whitelist but parent.child is, the parent passes through unwrapped while its matching children are still processed.

Wrapping flags

By default a Div is wrapped in the format's block form and a Span in its inline form. A flag overrides that for one entry:

FlagEffect
/divblockDiv → block form (default)
/divinlineDiv → inline form
/divcallDiv → the name as a call
/spanblockSpan → block form
/spaninlineSpan → inline form (default)
/spancallSpan → the name as a call
container-writer:
common:
- verselatex:
- poemtitle: /divcall
::: poemtitle
Hamlet
:::
\poemtitle{Hamlet}

A poem title is a block — it stands alone — but the verse package provides it as a command, not an environment. The flag lets the source stay correct while the output matches the package.

The leading slash is what tells a flag from a remap target, so both fit in one entry, along with the flag for the other element type:

latex:
- poemtitle: [/divcall, /spanblock, ptcmd]

If it is a Div, call it; if it is a Span, use the block form; and emit ptcmd either way. At most one div* flag, one span* flag and one remap per entry — conflicting flags are reported and the first wins.

Where a form is not available, the default is used and the filter says so:

[container-writer] jats has no "call" form — using block for "boxed"

Call and inline forms take their content as an argument, so a Div using them is emitted as its paragraph's inlines rather than as a block. That keeps the argument on one line, which TeX requires: \poemtitle{, a blank line and Hamlet fails even if the command is \long, because any non-long command further in stops at the paragraph break. A Div holding more than one paragraph cannot work that way, and is reported.

Markdown syntax

::: epigraph
Content of the epigraph block.
:::
A paragraph with [an inline span]{.sidebar} inside.

Per-block override

The env or environment attribute names the environment for one specific block, and is emitted without consulting the whitelist:

::: {.epigraph env=myepigraph}
Content.
:::

That block becomes \begin{myepigraph} whether or not myepigraph appears in your configuration. Nothing has to be declared to use it.

The asymmetry with classes is deliberate. A class is a shared space — a .note may belong to your CSS, to another filter, or to nothing at all — so the whitelist is what says which classes this filter should act on. env= is this filter's own attribute: if it is there, it was written for it, and there is nothing to disambiguate. A typo shows up as an undefined environment when you compile, spelled exactly as you wrote it.

Which of the four tools to reach for:

wherewhat it does
whitelistYAMLwhich classes the filter acts on
remapYAMLrename systematically, per format — every .verse becomes gmverse in LaTeX only
flagsYAMLchange the wrapping form: /divcall, /spanblock
env=Markdownname the environment for one block, declaring nothing

env= is a remap for a single element. It changes the name and nothing else — the form stays whatever the element type would use, or whatever the class's flag has fixed:

classelementenv=other emits
plain entryDiv\begin{other} — block form
plain entrySpan\other{…} — inline form
entry with /divcallDiv\other{…} — call, the flag is inherited
entry with /divcallSpan\other{…} — inline; the flag was for Div
not in the whitelistDiv\begin{other} — no flags to inherit

So the two answer different questions — what to emit and how to wrap it — and combine rather than compete. Flags live on the class, so they stay in effect when the attribute supplies the name:

latex:
- poemtitle: /divcall
::: poemtitle → \poemtitle{Hamlet}
::: {.poemtitle env=other} → \other{Hamlet}

If the format has no form for what was asked — call in JATS, any form in DOCX or ODT, where Pandoc decides paragraph or character style from the element type — the default is used and the filter says so on stderr.

Output examples

LaTeX

\begin{epigraph}
Content of the epigraph block.
\end{epigraph}
A paragraph with \sidebar{an inline span} inside.

ConTeXt

\startepigraph
Content of the epigraph block.
\stopepigraph
A paragraph with \sidebar{an inline span} inside.

Typst

#block[
Content of the epigraph block.
] <epigraph>
A paragraph with #[an inline span] <sidebar> inside.

HTML

<divclass="epigraph"><p>Content of the epigraph block.</p></div><p>A paragraph with <spanclass="sidebar">an inline span</span> inside.</p>

DOCX

The filter sets custom-style; Pandoc turns it into a real Word style:

<w:pStylew:val="epigraph"/> <!-- from the Div — paragraph style -->
<w:rStylew:val="sidebar"/> <!-- from the Span — character style -->

ODT

<text:ptext:style-name="epigraph">Content of the epigraph block.</text:p>
<text:ptext:style-name="Text_20_body">A paragraph with
<text:spantext:style-name="sidebar">an inline span</text:span> inside.</text:p>

JATS

<boxed-textcontent-type="epigraph">
<p>Content of the epigraph block.</p>
</boxed-text>
<p>A paragraph with <named-contentcontent-type="sidebar">an inline
span</named-content> inside.</p>

Span currently maps to named-content, which marks what something is. JATS also offers styled-content, which marks how something looks, and that suits presentational containers better. Which one applies is a property of the container rather than of the format — the same document may want named-content for a technical term and styled-content for small caps — so the choice waits for per-entry wrapping modes (see ROADMAP).


Usage examples

Editorial margin notes

A practical case for review workflows: annotations visible in draft builds, removed entirely in production by container-strip.lua — no changes to source files needed.

[this scene needs more tension]{.marginnoteopen}
[checked against sources]{.marginnoteclosed}
::: marginnoteopenblock
Longer note spanning multiple lines.
:::

Review build:

pandoc --lua-filter=container-writer.lua input.md -o draft.pdf

Production build:

pandoc --lua-filter=container-strip.lua \
--lua-filter=container-writer.lua \
input.md -o final.pdf
container-strip:
- marginnoteopen
- marginnoteclosed
- marginnoteopenblock
- marginnoteclosedblock

Style files: notes.tex, notes.ctx, notes.typ, notes.css.

Using existing LaTeX packages without writing LaTeX

A whitelisted class name that matches an environment defined by a LaTeX package works out of the box — no \newenvironment needed in your preamble, no raw LaTeX in your source files.

markdown

::: verse
Shall I compare thee to a summer's day?\
Thou art more lovely and more temperate.
:::

yaml

container-writer:
common:
- verse

latex

\usepackage{verse}

The filter emits \begin{verse}...\end{verse} and the package provides the implementation. Your source stays pure Markdown across all output formats — ConTeXt, Typst and HTML use their own definitions independently.

LaTeX output


Typst templates

A container becomes a call, so define a function of the same name:

#letepigraph(body) = block(
inset: (left: 2em, right: 2em),
above: 1em,
below: 1em,
text(style: "italic", body)
)

That is how Typst packages are written — none of showybox, theorion, orange-book, marginalia or octique uses a label as a selector; all of them define functions.

A name with no definition is error: unknown variable, naming the container and its line. LaTeX and ConTeXt already behave this way; the label form was the only one that let a missing definition pass unnoticed.

A function must suit where the container appears. One built from text works anywhere; one holding a block breaks the paragraph when used inline, because a block cannot sit inside a line. If the same name is used for a Div and a Span and they need to look different, use two classes.

Getting the label form back

Before, a Div was emitted as #block[...] <name> and a Span as #[...] <name>, styled with #show <name>. That still works, per entry:

container-writer:
typst:
- epigraph: /divblock # #block[...] <epigraph>
- sidebar: /spaninline # #[...] <sidebar>

Two things to know if you keep it. The label shares a namespace with document identifiers, so emitting one per container collides with a reader's own @name — Typst refuses a label that occurs more than once as soon as it is referenced. And the label hangs off a block, a layout element, so a template's #show block rule reaches containers it never meant to style.

LaTeX setup

Define the environments in your preamble or template:

\usepackage{epigraph}
% or define manually:\newenvironment{epigraph}{\begin{quote}\itshape}{\end{quote}}

ConTeXt setup

\definestartstop[epigraph]
[before={\blank\startnarrow},
after={\stopnarrow\blank}]

DOCX / ODT setup

Styles are not defined in the source — they live in a reference document. Create one, define the styles by name, and pass it when converting:

pandoc --print-default-data-file reference.docx > styles/mydoc.docx

Open it, define a paragraph style named epigraph and a character style named sidebar, save, and then:

pandoc --lua-filter=container-writer.lua \
--reference-doc=styles/mydoc.docx input.md -o output.docx

In Quarto:

format:
docx:
reference-doc: styles/mydoc.docxodt:
reference-doc: styles/mydoc.odt

A whitelisted name with no matching style in the reference document does not break the build. Pandoc creates the definition on the fly, based on the default body style:

<w:stylew:type="paragraph"w:customStyle="1"w:styleId="epigraph">
<w:namew:val="epigraph"/><w:basedOnw:val="BodyText"/><w:qFormat/>
</w:style>

The style then exists in the output and is listed in the word processor, but carries no formatting of its own. Unlike LaTeX, nothing warns you that the definition was missing — the text simply looks unstyled.

container-strip.lua

Removes Div and Span elements by class — content and all. Configure with a separate YAML key. Accepts a scalar for a single class or a list:

container-strip: marginnoteopencontainer-strip:
- marginnoteopen
- marginnoteclosed
- marginnoteopenblock
- marginnoteclosedblock
pandoc --lua-filter=container-strip.lua \
--lua-filter=container-writer.lua \
input.md -o output.pdf

Note: container-strip must run beforecontainer-writer — if writer runs first it converts spans to raw format commands that strip never sees.


container-unwrap.lua

Removes Div and Span container elements while preserving their content. Useful after container-writer.lua to neutralise elements not in the whitelist that would otherwise render differently across Pandoc versions (e.g. #block[] in Typst 3.9 vs nothing in 3.1).

Must run aftercontainer-writer.lua — the writer converts whitelisted elements to raw format commands that unwrap never sees.

pandoc --lua-filter=container-writer.lua \
--lua-filter=container-unwrap.lua \
input.md -t typst

Accepts a scalar or a list. Two reserved keywords control bulk behaviour:

  • all — unwrap every remaining Div/Span regardless of class
  • void — unwrap elements that carry no class at all
container-unwrap: allcontainer-unwrap: voidcontainer-unwrap: sidebarcontainer-unwrap:
- void
- sidebar
- note

Known limitations

  • A container name present in both Div and Span contexts uses the same environment name. If your format requires different names for block and inline, use the env attribute to override per block.

  • LineBlock (|) inside a whitelisted Div is not processed by this filter.

  • Use letters only in container names — no hyphens, no leading digits. Write marginnoteopen, or a short prefix like mnopen, rather than marginnote-open. CSS classes and Typst labels accept hyphens freely, but the TeX formats do not, and the same name is often used for both a Div and a Span.

    Where it actually breaks, if you are wondering how strict this is:

    DivSpan
    LaTeXworks — \begin{} resolves the name via \csnamefails: Undefined control sequence
    ConTeXtfails — the name is glued to \startfails
    Typst, HTML, DOCX, ODTfinefine

    Both TeX engines report the failure rather than producing a wrong document silently, so a hyphen you get away with in LaTeX will still stop the build in ConTeXt. If a name has to keep its hyphen in HTML or Typst, use a remap entry to give it a TeX-safe name per format:

    container-writer:
    latex:
    - marginnote-open: marginnoteopen

    The filter deliberately does not strip hyphens for you. Doing so would replace a loud failure with a quieter one: the document would compile, call an environment nobody defined, and name it in the error message with a spelling that appears nowhere in your source. It would also let note-title and notetitle collapse into a single environment without saying so.

  • Wrapping discards the element's own attributes. A wrapped container is replaced by raw markup, so whatever Pandoc would have emitted from its attributes is lost: the id, and format-specific properties such as typst:breakable. HTML and EPUB are unaffected, since nothing is wrapped there.

    The id is the one to watch, because the link survives. A [see it](#myid) pointing at a wrapped container still emits \hyperref[myid]{} in LaTeX or #link(<myid>) in Typst, now aimed at an anchor that no longer exists — a warning at compile time and a dead link in the output, rather than an error. Put the anchor inside the container if you need it:

    ::: note
    []{#myid}Content.
    :::
  • container-strip does not support compound entries — blacklist entries are plain class names only. To strip Div.title inside Div.note, list title explicitly (strips all .title elements) or list the specific classes you want removed.

  • ODT needs a recent Pandoc for Spancustom-style on inline elements is ignored by the ODT writer in Pandoc 3.1.3: the character style is dropped and the text comes out unstyled. Verified working in 3.10. DOCX handles both Div and Span correctly in 3.1.3, and ODT handles Div correctly too — only the ODT/Span combination is affected.

  • A Span becomes a character style in DOCX/ODT, never a paragraph style. When a container is semantically a block but written inline — a poem title on its own line, say — the word processor cannot give it spacing or keep with next, because character styles do not carry paragraph properties. Model it as a Div if it needs paragraph-level formatting.

Issues and contributing

Issues and PRs welcome at the project repository.

License

GPL-3.0-or-later. See LICENSE.

References

About

A pair of Pandoc Lua filters that convert generic Div/Span containers into format-specific environments and optionally strip them for production builds.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages