Styles and the cascade¶
docx_plus.styles has two halves. Inspection answers "what does this
actually look like, and why?" — the question python-docx cannot answer,
because most formatting is inherited rather than declared. Modification
creates, applies, and changes styles the Word-native way.
Why is this paragraph 13pt and blue?¶
from docx import Document
from docx_plus.styles import resolve_effective_formatting
doc = Document("report.docx")
r = resolve_effective_formatting(doc.paragraphs[0])
print(r.style_id, r.style_name) # "Heading2", "heading 2"
print(r.font_size) # 13.0 (points)
print(r.bold) # True
print(r.color_rgb) # "2F5496"
None for a field means "not set at any layer" — it inherits Word's own
default.
Add include_provenance=True and each resolved field also tells you which
cascade layer set it:
r = resolve_effective_formatting(doc.paragraphs[0], include_provenance=True)
src = r.provenance["font_size"]
print(src.layer) # "paragraphStyle"
print(src.style_id) # "Heading2" — the style that *resolved* it,
# not necessarily the one applied
print(src.chain_depth) # 0 — how many basedOn hops away
layer is one of the eight Layer names, lowest first: docDefaults,
tableStyle, paragraphStyle, styleNumbering, numbering,
directParagraph, runStyle, directRun.
The target can be a Paragraph, a Run, or a table _Cell. For a Run
you get run-direct formatting included; for a Paragraph it stops at
paragraph level.
Is this direct formatting doing anything?¶
stop_below resolves the target as if that layer and everything above it
were absent — the "what would this look like without its own override?"
question:
full = resolve_effective_formatting(run)
base = resolve_effective_formatting(run, stop_below="directRun")
if full.font_size == base.font_size:
print("the direct size is redundant — the style already says that")
This is the primitive the linter is built on.
Spacing needs a second call¶
spacing_before / spacing_after are what the cascade declares. Whether
either is actually applied depends on the paragraph's neighbours, so ask
separately:
from docx_plus.styles import resolve_paragraph_spacing
s = resolve_paragraph_spacing(p)
s.space_above, s.space_below # twips actually applied, both edges
s.declared_before, s.declared_after
s.contextual_spacing
s.before_suppressed, s.after_suppressed
Do not add space-after to the next paragraph's space-before
Word tops the first up to the second rather than summing them, so a
pair of paragraphs sits max(after, before) apart. And
<w:contextualSpacing> drops a paragraph's space on any edge where the
neighbour has the same styleId — which affects any document using
Word's built-in list styles, since fourteen of them carry the flag.
Both rules were measured against live Word. See the concept page.
Sweeping a whole document¶
resolve_effective_formatting rebuilds every document-level lookup on each
call — the theme, the styles part, each basedOn chain. That is right for
one paragraph and wrong for all of them. iter_resolved_paragraphs
resolves the whole document against one shared cache:
from docx_plus.styles import iter_resolved_paragraphs
for resolved in iter_resolved_paragraphs(doc, include_baseline=True):
print(resolved.index, resolved.formatting.style_id, resolved.text)
for run in resolved.runs:
if run.baseline and run.formatting.bold != run.baseline.bold:
print(" run", run.index, "sets bold directly")
include_baseline=True resolves each target a second time with its own
direct layer excluded, which is what makes "is this override doing
anything?" answerable across a whole document. It roughly doubles the work,
so it is off by default. include_spacing=True likewise populates
resolved.spacing with the same ParagraphSpacing that
resolve_paragraph_spacing returns, but against the shared cache — several
times cheaper per paragraph, and off by default because it resolves each
paragraph's neighbours as well.
index is not an index into doc.paragraphs
The sweep descends into table cells, which doc.paragraphs omits, so
the two diverge at the first table. Pass include_tables=False to make
them line up.
The sweep is body only by default — headers, footers, footnotes,
endnotes, and comments live in separate parts. Pass parts="all", or a
tuple of SweepPart names ("body", "header", "footer",
"footnotes", "endnotes", "comments"), to visit them after the
body; each ResolvedParagraph.part says where it came from. A
paragraph from a footnotes, endnotes, or comments part is wrapped in a
Paragraph whose .part is that side part, so its .style accessor
does not work — .text, .runs, and the resolved formatting do. An
unknown name raises InvalidSweepPartError.
resolved.runs includes runs inside a <w:hyperlink>, which
Paragraph.runs omits; together they cover exactly the text .text
reports.
Creating and applying styles¶
The Word-native workflow: define a style once, apply it, and later change the style to restyle every paragraph that uses it.
from docx import Document
from docx_plus.styles import apply_style, create_style
doc = Document()
create_style(
doc, "BrandHeading",
style_type="paragraph", # required: "paragraph" | "character" | "table"
based_on="Heading1",
name="Brand Heading", # the UI name; defaults to the style_id
font_name="Inter",
font_size=18.0,
color_rgb="2F5496",
bold=True,
spacing_after=240,
)
apply_style(doc.add_paragraph("Hello"), "BrandHeading")
doc.save("out.docx")
create_style(doc, style_id, *, style_type, name=None, based_on=None, next_style=None, linked_style=None, ui_priority=None, q_format=None, custom=None, **properties)— raisesStyleExistsErrorif the id is taken.modify_style(doc, style_id, *, if_missing="raise", **properties)—w:ind/w:spacing/w:rFontsmerge rather than replace. Passif_missing="create"to define the style instead of raising.apply_style(target, style_id)— aParagraph,Run, or_Cell.delete_style(doc, style_id, *, force=False)— raisesStyleInUseErrorif the style is still referenced.
Which properties are accepted¶
Field names match ResolvedFormatting, so resolved output round-trips
straight back into the modifier.
- Paragraph:
alignment,indent_left,indent_right,indent_first_line,spacing_before,spacing_after,line_spacing,line_spacing_rule,keep_with_next,keep_lines,page_break_before,outline_level,num_id,num_level - Run:
font_name,font_size,bold,italic,underline,strike,color_rgb,highlight,caps,small_caps,vanish,vert_align, and the decorative / complex-script togglescs_bold,cs_italic,emboss,imprint,outline,shadow
num_id / num_level link a style to a numbering definition
(w:pPr/w:numPr). num_id is the w:numId that define_list_definition (or a preset) returned;
0 is the schema's "explicitly not numbered" sentinel, which a style based
on a list style uses to opt out, and None removes the link. num_level
is the 0-based w:ilvl (0–8) and defaults to 0 when only num_id is
given; setting it on a style that links no definition, like a negative
num_id or an out-of-range level, raises InvalidStylePropertyError.
contextual_spacing is reported by the resolver but is not a writable
property.
Restyling an existing document¶
Change the style, not the paragraphs:
from docx import Document
from docx_plus.styles import ensure_style, modify_style
doc = Document("report.docx")
ensure_style(doc, "Heading1")
modify_style(doc, "Heading1", color_rgb="C00000", font_size=20.0,
spacing_before=480)
doc.save("restyled.docx") # every Heading1 paragraph re-renders on open
Materialising latent built-in styles¶
Word's built-ins (Heading1–Heading9, Title, Quote, TOC1–TOC9,
Caption, FootnoteText, …) are latent: defined by Word's defaults but
absent from styles.xml until something uses them. ensure_style knows
107 of them with real Word-extracted defaults, and is idempotent:
from docx_plus.styles import ensure_style
ensure_style(doc, "Heading1") # materialise if absent; no-op if present
ensure_style(doc, "TOC2")
ensure_style(doc, "Caption")
ensure_style(doc, "Quote", color_rgb="404040") # override, only when creating
ensure_style never overwrites
If the style is already defined — including the Word-2007-vintage
definitions python-docx's own template ships — the existing definition
is returned unchanged. It is a "the style is absent, here is what Word
would have written" fallback, not a "force my defaults" mechanism. For
that, use modify_style.
The full tiered table is on the styles concept page.
Working with someone else's document¶
The same logical style shows up as Heading1 in one document, Heading 1
in another, and HeadingOne in a third. Code that calls
apply_style(p, "Heading1") fails against the second — not because the
style is missing, but because the id doesn't match.
from docx_plus.styles import find_matching_style, list_styles, remap_styles
# Case- and space-insensitive lookup. Safe to call unconditionally.
proxy = find_matching_style(doc, "Heading1") # StyleProxy | None
# Bulk-normalise the document's body references onto canonical ids:
remap_styles(doc, mapping={"Heading 1": "Heading1"})
for s in list_styles(doc, style_type="paragraph", include_latent=True):
print(s.style_id, s.name, s.is_latent)
remap_styles(doc, *, targets=None, mapping=None, create_missing=False)
rewrites body references only (w:pStyle, w:rStyle, w:tblStyle).
Style-to-style references inside styles.xml are deliberately left alone —
see why.
The same job from a shell is docx-plus restyle.
Theme colours (read-only)¶
from docx_plus.styles.theme import load_theme, resolve_theme_color, resolve_theme_font
theme = load_theme(doc) # None if the part is missing or malformed
if theme:
accent = resolve_theme_color(theme, "accent1", tint="99") # -> "RRGGBB"
body = resolve_theme_font(theme, "minorHAnsi") # e.g. "Calibri"
tint and shade are hex byte strings "00"–"FF", not ints: tint
lightens toward white ("FF" is a no-op), shade darkens toward black.
Name-to-slot resolution is per-document, not a fixed alias table — a
document can remap text1 to white via settings.xml. Theme writing is
out of scope.
Errors¶
All subclass DocxPlusError: StyleExistsError, StyleNotFoundError,
StyleInUseError, StyleCascadeError (a basedOn cycle, or a chain
deeper than 11), UnknownStylePropertyError (also TypeError),
InvalidColorError, InvalidStylePropertyError, InvalidLayerError
(a stop_below that names no layer), InvalidSweepPartError (all three
also ValueError), ThemeError.
See also¶
- How the cascade works — the six layers, the toggle rule, spacing arithmetic, provenance
- Remapping and built-ins — the design behind
remap_stylesand the 107-style table - Reference:
styles.inspect,styles.modify,styles.sweep,styles.theme - Examples:
inspect_document.py,restyle_existing.py