Skip to content

Tables, merges and shading

Repeating rows

A {%tr %} tag is scoped to the row it sits in, and — like every prefixed tag — removes that row. So the loop tags go in rows of their own, bracketing the rows that repeat:

Item Qty Price
{%tr for line in lines %}
{{ line.name }} {{ line.qty }} {{ line.price }}
{%tr endfor %}
tpl.render({"lines": [{"name": "Widget", "qty": 2, "price": "10.00"}]})

Both tag rows disappear; the row between them appears once per item. A loop over an empty list leaves the table with its header and nothing else.

Anything Jinja2 can do works here: {%tr if line.visible %}, {%tr for %} over a filtered list, and loops inside loops.

"My table came out empty"

This is the mistake, and it is worth naming because it looks exactly like a bug in the library:

{%tr for line in lines %}{{ line.name }}{%tr endfor %}

Both loop tags are in the row that was supposed to repeat. A prefixed tag removes the element its prefix names — that is the whole rule — so the row is removed, correctly, and takes the loop body with it. The table renders with no rows at all.

Give each tag a row of its own:

{%tr for line in lines %}
{{ line.name }}
{%tr endfor %}

The two tag rows disappear and the middle one repeats. Same for {% if %}, and same at every level of nesting.

Loops inside loops

Each tag still gets a row. Five rows for two nested loops:

Group Member
{%tr for group in groups %}
{%tr for member in group.members %}
{{ group.name }} {{ member }}
{%tr endfor %}
{%tr endfor %}
tpl.render(
    {
        "groups": [
            {"name": "A", "members": ["x", "y"]},
            {"name": "B", "members": ["z"]},
        ],
    },
)
A x
A y
B z

The inner tags read as ordinary Jinja2 once the rows are gone, so the closing tags must be in the reverse order of the opening ones — innermost {%tr endfor %} first. A group whose inner list is empty produces no rows at all, not a blank one.

Rows and columns both from the data

A {%tc %} loop inside a {%tr %} one gives a table whose shape is the data's (upstream #339). The {%tc %} tags go in cells of their own, for the same reason the {%tr %} tags go in rows of their own:

{%tr for row in matrix %}
{%tc for cell in row %} {{ cell }} {%tc endfor %}
{%tr endfor %}
tpl.render({"matrix": [["a", "b", "c", "d"], ["e", "f", "g", "h"]]})

produces a two-by-four table — and the w:tblGrid, which declares the columns once outside any row, is matched to it afterwards.

The examples above are a test

They are tests/fixtures/build/build_fixtures.py::build_table_nested_loops, rendered and asserted on in tests/test_tables.py, so the documentation cannot drift from what the code does. The "came out empty" case is a test too — it asserts the table really does come out empty, so nobody has to take the explanation on faith.

Repeating cells

{%tc %} does the same for a cell, which is how a table grows sideways:

{%tc for month in months %}``{{ month.name }}

The w:tblGrid follows: a table declares its columns once, outside any row, so a loop that produces five cells in a three-column table would otherwise leave a row wider than its own grid — which Word draws squeezed into part of the page. The grid is matched to the widest row after the loops have run.

A column the grid had to add takes the width of the last one the table already had, since there is nothing else to infer it from. When that is not the width you meant, say what is — see column widths below.

Cell directives

Five directives are resolved against the finished table, after the loops have run — which is the only time their meaning is known: a vertical merge spans however many rows the loop produced.

Directive Effect
{% colspan n %} the cell spans n grid columns
{% colwidth w %} the column the cell starts in is w wide
{% hm %} merge horizontally with the cell to the left
{% vm %} merge vertically with the cell above
{% cellbg rrggbb %} shade the cell

Each takes a variable, not a literal, so the value can come from the data:

tpl.render(
    {
        "rows": [
            {"label": "Total", "span": 3, "shade": "ffcc00"},
            {"label": "Net", "span": 1, "shade": "ffffff"},
        ],
    },
)
{%tr for row in rows %}``{% colspan row.span %}{% cellbg row.shade %}{{ row.label }}
{%tr endfor %}

Put the directive in the cell it applies to; it produces no text of its own.

Column widths

{% colwidth %} states the width of the column the cell sits in — including a column a {%tc %} loop has just produced, which is the case the grid could otherwise only guess at:

{%tc for column in columns %} {% colwidth column.width %}{{ column.name }} {%tc endfor %}
tpl.render(
    {
        "columns": [
            {"name": "Description", "width": "80mm"},
            {"name": "Qty", "width": "20mm"},
            {"name": "Price", "width": "1in"},
        ],
    },
)

The width is written to both the w:tblGrid column and the w:tcW of every cell that covers it, so the two never disagree.

Units. A number with a unit — 40mm, 3cm, 1.5in, 12pt — or a bare number, which is twips (a twentieth of a point), the unit OOXML itself counts in.

A cell that spans several columns names the first one it covers. w:gridCol is a column and a spanning cell is not one, so the directive has to pick.

A width means nothing under autofit

Word's default for a table is to recompute the column widths from the content and ignore the grid. {% colwidth %} deliberately does not change that: a directive on one cell should not silently change the layout mode of the whole table.

Set the table to fixed widths yourself — in Word, Table Layout → AutoFit → Fixed Column Width; in Python, table.autofit = False when you build the template.

docx.shared lengths do not survive the trip

A directive argument arrives as rendered text, and Mm(40) is an int in EMU, so it renders as 1440000 and would be read as twips. Passing one is reported rather than silently producing a column wider than a page — but the thing to write is the string: "40mm".

Merging within a loop

{% hm %} and {% vm %} exist for the case a colspan cannot express: a merge whose extent is decided per row.

Region Country Sales
{%tr for row in rows %}``{{ row.region }} {{ row.country }} {{ row.sales }}
{%tr endfor %}

Give the region cell {% vm %} when it repeats the row above:

{% if row.region == previous %}{% vm %}{% else %}{{ row.region }}{% endif %}

The cell above starts the merge chain automatically. You mark only the continuation, which is the whole point: the top of the chain is not knowable until the loop has run.

What is enforced

Breaking any of these produces a file Word opens with "we found a problem with some content", so they are checked rather than trusted:

  • a w:gridSpan may not push a row past the column count the w:tblGrid declares. A {% colspan 9 %} in a three-column table spans three;
  • the cells a span absorbs are removed, and their widths added to the cell that absorbed them;
  • a w:vMerge chain gets exactly one restart at the top and continue below, and the continuation cells stay in the document — a merge that deleted them is the classic repair prompt;
  • w:shd is written where the schema's sequence puts it, whatever else the cell already carries.

The test suite validates every rendered fixture against the ISO/IEC 29500 schemas for this reason.