Architecture¶
Rendering runs in four phases. Only one of them sees text.
coalesce.py → compile.py → jinja2 → reparse.py → postprocess.py
→ tables.py
typed tree typed tree text typed tree
The reason for the split is that every structural decision — where a loop
repeats, how far a merge spans, where a paragraph breaks — is made where the
schema is known. A w:gridSpan that exceeds the w:tblGrid, or a w:vMerge
chain missing its restart, produces a file Word opens with a repair prompt,
and string patching does not catch that reliably.
Phase 1 — coalesce¶
coalesce.py
Word splits a paragraph into runs for reasons unrelated to formatting: a
spell-check boundary, a revision-save id, where the cursor happened to be. So a
tag the author typed as {{ customer.name }} may be stored as
and nothing downstream can find a tag that is still in pieces. This phase merges
adjacent runs whose w:rPr is semantically equivalent — comparing serialised
XML is not enough, since attribute order and omitted defaults differ between
runs Word considers identical.
Merging is conservative. A run carrying anything other than w:t content — a
break, a drawing, a field, a footnote reference — is never merged away, because
losing one silently corrupts the document. Neither is a run holding a whole
{%r %} tag: merging that one into its neighbour would widen what the tag
removes, so Status: {%r if urgent %} would lose its label.
That leaves the tags merging must not fix. An author who bolded half of
{{ total }} while typing it has it spread over runs that genuinely differ, and
merging those would change how the document looks. join_split_tags() takes the
other route: the tag's own characters — which rendering consumes anyway — move
into the run holding the opening delimiter, and the runs keep their formatting.
Both stop at the paragraph they are working on. A text box is a w:p inside a
w:pict inside a w:r of another paragraph, so "every w:t beneath this
paragraph" reaches text drawn somewhere else entirely; owns() in oxml.py
states that boundary once and everything that walks a paragraph respects it.
Phase 2 — compile¶
compile.py, syntax.py
Four things happen before the tree is serialised:
- The part is checked for characters this package reserves. Phases 2 to 4
talk to each other through private-use characters (see
markers.py), and an icon font is free to use that area too. A document that already contains one is reported, naming the code point, rather than having a glyph turn into a line break later. - Control tags are hoisted.
{%tr for line in lines %}typed into a cell is moved so that it brackets the wholew:tr. Jinja2 then repeats the row's markup instead of emitting the loop's literal text inside one cell. A tag naming a scope that does not enclose it is an error here, not a malformed document later. - Table directives become sentinels.
{% colspan %},{% cellbg %},{% hm %}and{% vm %}cannot be resolved yet — what they mean depends on the table that exists after the loops run. They are carried through rendering as opaque tokens. - The tree is serialised once and handed to Jinja2 — with every tag
replaced by a token first, because serialising escapes text and a tag holding
<would otherwise reach Jinja2 as{% if a < b %}. The tokens are swapped back for the real tag source on the serialised string, which is the only moment anything is edited as text.
The rule the hoisting follows is docxtpl's, and is one rule rather than four:
a prefixed tag replaces the element its prefix names. It is why the loop
tags of a repeating row belong in rows of their own.
Phase 3 — render¶
environment.py
Jinja2, with escaping to XML rather than HTML. This phase substitutes values and does nothing else.
The escaping is done with Jinja2's finalize hook rather than its autoescape,
because it has to be this package's own — the five predefined entities, plus the
control characters XML 1.0 cannot represent at all — and because it has to apply
to values only. The template body here is document markup; escaping that would
produce a document of angle brackets.
The environment is never shared between templates: filters a caller registers for one document must not leak into another. A caller's own environment is overlaid rather than modified, so their other templates keep behaving as they did.
Phase 4 — rebuild¶
reparse.py, postprocess.py, tables.py
The rendered string is parsed with python-docx-ng's parser, so the result is
typed elements and the caller can go on using the normal document API. A parse
failure here almost always means a control tag was hoisted to the wrong element,
so the error names the part and the line rather than letting a bare
XMLSyntaxError escape.
Then postprocess.py, in this order:
promote_misplaced_content()repairs the two ways a value can land where the schema does not allow it. AnImagesubstituted at an inline{{ }}tag is aw:rinside aw:t; a plain string at a{{r }}tag — where aTextwas expected — is bare characters inside aw:p. Neither can be prevented at compile time, because what the value is is not known until it has been rendered. The run is divided around promoted markup, block content divides the paragraph, and stray text is given the run or paragraph it needs.expand_control_escapes()turns the control characters a Python string can carry but Word has no character for into elements:\n→w:br,\t→w:tab,\f→ a page break,\a→ a paragraph split that inherits the source paragraph'sw:pPrand leaves aw:sectPrwith the last of them.strip_empty_artifacts()removes runs and paragraphs left empty by a tag that rendered to nothing — only ones this pipeline created. The provenance is a marker the compiler wrote into any text it emptied of tags, so a paragraph the author left blank on purpose is never touched.resolve_escaped_delimiters()turns{_{and its relatives into the literal delimiters. After rendering, never before: one inside a loop body would otherwise be resolved once and read as a real tag on the next iteration.
Finally tables.py applies the sentinel directives to the real CT_Tbl,
enforcing the ISO 29500 rules on w:gridSpan, w:vMerge chains and w:tcPr
child order. The test suite validates rendered parts against those schemas, for
the reason this whole phase exists: the failure that matters is not an
exception, it is a document Word offers to repair.
What gets rendered¶
parts.py
Rendering only the main document body is the most common bug in this class of
library. A {{ customer }} in a page header is just as much a tag as one in the
body, and so is one in a footnote or a core property.
parts.py yields the body, then every header and footer of every section, then
footnotes and endnotes, then comments, then the core, extended and custom
properties — in that fixed order, so a failure reports the same part every run
and relationship ids are allocated reproducibly.
Each part also declares where its text lives: w:t for a wordprocessing
part, the leaf element itself for a properties part, where the text of
dc:title is the title. That was inferred from the root element's namespace
once, and inferring it is how the custom properties came to be missing without a
single test noticing.
Rendering the same template twice¶
Template._prepare, and benchmarks/render.py
Phases 1 and 2 walk the tree and produce a string; Jinja2 then compiles that string to Python bytecode. None of it depends on the context, so all of it can be kept between renders — which is what a caller producing one document per customer needs.
Which part of it is worth keeping was measured, not guessed. On a document
with twelve thousand variables, Jinja2's compilation is around 86% of a render
and phases 1 and 2 together are about 5%. Keeping the compiled program takes
repeated renders from ~3500 ms to ~340 ms; keeping only the phases would have
bought almost nothing. benchmarks/render.py is that measurement, checked in so
the next person does not have to take it on trust.
The cache is keyed on a hash of the part's serialised XML, not on whether
reload() was called. That is the difference between a cache and a bug:
reaching through Template.document to edit the tree between renders is
supported, and nothing here can be told about it. Any edit changes the hash. The
one extra serialisation per part costs a few per cent of the first render and
buys the guarantee.
An environment the caller supplied is never a cache key — it is theirs to change between renders, and a filter added after the first one has to work.
The two public surfaces¶
template.py and content/ are the modern API. compat.py is the
docxtpl-source-compatible one, implemented as thin subclasses of the modern
classes.
Where the two disagree — render()'s positional jinja_env, autoescape
defaulting to off — the compatibility surface keeps docxtpl's behaviour,
because code depending on it is code we cannot change.
The clean-room rule¶
This package is an independent implementation of a feature set published by
docxtpl, which is LGPL-2.1-only; this package is MIT. Contributions must be
derived from docxtpl's public documentation and observable behaviour, never
from its source. Reproducing the public API names and the tag dialect is
intentional and is not the same thing — an interface is what users depend on.
See CLAUDE.md in the repository root for the full rule.