Tables¶
python-docx models table structure well and table appearance not at
all. Rows, columns, cells, cell text, widths, and _Cell.merge all work.
But there is no CT_Border, CT_TblBorders, CT_TcBorders, or CT_Shd
class in the package and none of those tags is registered — so ruling a
table or shading a header row means raw OOXML.
docx_plus.tables fills that in. Structure still comes from python-docx
(doc.add_table, table.cell, cell.text, cell.width); this module
only formats what is already there.
Nothing here is a field, so no mark_fields_dirty.
Borders¶
from docx_plus.core import Border
from docx_plus.tables import set_cell_borders, set_table_borders
table = doc.add_table(rows=4, cols=3)
hairline = Border(style="single", size=4, color="8EAADB")
set_table_borders(
table,
all_edges=Border(style="single", size=12, color="2F5496"), # heavy box
inside_h=hairline, # light rules
inside_v=hairline,
)
# A cell-level border overrides the table's for that cell:
set_cell_borders(table.cell(2, 2),
top=Border(style="double", size=6, color="C00000"))
set_table_borders(table, *, all_edges=None, top=None, bottom=None, left=None, right=None, inside_h=None, inside_v=None)set_cell_borders(cell, *, all_edges=None, top=None, bottom=None, left=None, right=None, tl2br=None, tr2bl=None)—tl2br/tr2blare the diagonals.all_edgescovers the four sides only; a diagonal is a "crossed-out cell" mark, never what "all borders" means.
Both are a full replacement, not a merge
An edge you do not name ends up absent. Naming no edges at all removes the element entirely.
Border.size is in eighths of a point — 4 is 0.5pt, 8 is 1pt,
capped at 96.
Border.space is ignored for tables
Both writers emit w:space="0". The dataclass defaults it to 24, a
page-border value, and Word's own table UI cannot produce anything
but 0. Nothing to do about it — just don't expect the field to
survive a round-trip.
Shading¶
from docx_plus.tables import (
Shading, set_cell_shading, set_row_shading, set_table_shading,
)
set_row_shading(table.rows[0], Shading(fill="2F5496")) # header band
set_cell_shading(table.cell(3, 2), Shading(fill="FFF2CC")) # one call-out cell
set_table_shading(table, Shading(fill="F2F2F2")) # whole-table default
set_cell_shading(table.cell(3, 2), None) # remove
Shading(fill="auto", pattern="clear", color="auto") — fill is the
background and is what you almost always want alone. pattern is an
ST_Shd value drawn over it ("pct25", "thinHorzStripe", "nil");
color is that pattern's foreground and does nothing while pattern is
"clear". All three validate at construction, so fill="red" or
"#FF0000" raises.
Rows have no shading element
CT_TrPr has no w:shd child — there is no row-level shading in the
format. Word implements "shade this row" by writing the same fill into
every cell, and so does set_row_shading. It iterates <w:tc>
elements rather than Row.cells, so a cell spanning several columns is
written once rather than once per column.
Shading is a cell property; contrasting text on top of it is ordinary python-docx run formatting:
from docx.shared import RGBColor
# A fresh cell holds an empty paragraph and no runs, so add one rather than
# indexing into `.runs` — `paragraphs[0].runs[0]` is an IndexError until
# something has written text.
run = table.cell(0, 0).paragraphs[0].add_run("Header")
run.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
Merging and unmerging¶
from docx_plus.tables import merge_cells, unmerge_cell
banner = merge_cells(table.cell(0, 0), table.cell(0, 2)) # returns the top-left cell
banner.text = "Fiscal Year 2026"
unmerge_cell(banner) # the inverse — python-docx has none
merge_cells(start, end)— the two cells are diagonal corners of a rectangular region. Content from every cell moves into the top-left one, which is what is returned and is not necessarilystart. A non-rectangular (L- or T-shaped) selection raisesInvalidMergeError(DocxPlusError+ValueError).unmerge_cell(cell)— works from any cell in the region, including a vertical continuation. Content stays in the anchor; the restored cells are empty, matching Word's "Split Cells". Widths divide evenly, because the originals were summed away by the merge. Idempotent on an unmerged cell.
Legacy w:hMerge tables¶
OOXML has two horizontal-merge encodings and python-docx models only one:
| Encoding | Shape | python-docx |
|---|---|---|
w:gridSpan |
One <w:tc> widened over N columns |
Understood |
w:hMerge |
One <w:tc> per column, followers marked continuation |
Ignored |
Word renders both identically. But on an hMerge table table.cell()
hands back cells that look separate and are not, and row.cells reports a
column count Word never draws. If a table you did not author behaves
strangely, this is a likely cause.
from docx_plus.tables import normalize_horizontal_merges
converted = normalize_horizontal_merges(table) # -> number of regions
converted = normalize_horizontal_merges(table, discard_content=True)
Returns 0 on any table Word wrote recently. It refuses by default to drop
text held in a continuation cell — that text is invisible in Word, so
keeping it would surface hidden content and dropping it silently would lose
data. Pass discard_content=True to choose.
Reading formatting back¶
from docx_plus.tables import read_table_formatting
fmt = read_table_formatting(table)
print(fmt.style, sorted(fmt.borders), fmt.shading)
for cell in fmt.cells:
print(cell.row, cell.column, cell.grid_span, cell.vertical_merge, cell.shading)
Direct formatting only
The table-style cascade (table style → w:tblStylePr conditional
branch → direct w:tcPr) is not resolved here. A Table Grid
table reads back with borders == {} — true of its XML, not of its
appearance.
Don't use this to answer "does this table have borders?". Use it to answer "what did someone write directly on it?".
CellFormatting.column is a grid offset, not an index into
row.cells — cells to the right of a merged one are offset by the span.
There is one entry per <w:tc>, so a merged cell appears once.
End to end¶
from docx import Document
from docx_plus.core import Border
from docx_plus.tables import Shading, merge_cells, set_row_shading, set_table_borders
doc = Document()
table = doc.add_table(rows=4, cols=3)
banner = merge_cells(table.cell(0, 0), table.cell(0, 2))
banner.text = "Fiscal Year 2026"
set_row_shading(table.rows[0], Shading(fill="2F5496"))
hairline = Border(style="single", size=4, color="8EAADB")
set_table_borders(table,
all_edges=Border(style="single", size=12, color="2F5496"),
inside_h=hairline, inside_v=hairline)
doc.save("budget.docx")
See also¶
- How table formatting works — the two merge encodings, row shading, and what the cascade doesn't cover
- Page borders are a different module
- Reference:
tables.borders,tables.shading,tables.merge,tables.read - Example:
table_formatting.py