Skip to content

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

<w:r><w:t>{{ cust</w:t></w:r><w:r><w:t>omer.name }}</w:t></w:r>

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:

  1. 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.
  2. Control tags are hoisted. {%tr for line in lines %} typed into a cell is moved so that it brackets the whole w: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.
  3. 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.
  4. 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 &lt; 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. An Image substituted at an inline {{ }} tag is a w:r inside a w:t; a plain string at a {{r }} tag — where a Text was expected — is bare characters inside a w: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: \nw:br, \tw:tab, \f → a page break, \a → a paragraph split that inherits the source paragraph's w:pPr and leaves a w:sectPr with 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.