Skip to content

docx_plus.styles.inspect

The cascade resolver. Walks the OOXML formatting layers and returns a fully-resolved ResolvedFormatting plus optional per-field provenance.

See The cascade resolver for the algorithm walkthrough and the toggle semantics.

Toggle properties do not override

bold, italic and the ten other ECMA-376 17.7.3 toggles combine by a rule that catches most people out, so it is worth stating up front:

  • A basedOn chain is one level and flattens by plain override. A child style restating its parent's <w:b/> stays bold.
  • A paragraph style and a character style are two levels, and two levels both asking for bold cancel. This is the spec's own example.
  • docDefaults is the base, not a level, and a style restating the base is inert.
  • Direct formatting on a run is absolute — it states the value rather than flipping it.

The rule was settled by measuring live Word, because the spec prose admits several incompatible readings and this library previously shipped one of the wrong ones. tests/test_cascade_word_verified.py holds the measurements.

A paragraph with no style still has one

Most paragraphs carry no w:pStyle, and they are not unstyled — Word gives them the default paragraph style, so resolve_effective_formatting reports it as their style_id and applies everything it declares.

  • It is chosen as the last w:type="paragraph" style whose w:default is on, falling back to the style whose id is Normal.
  • It substitutes whenever w:pStyle fails to resolve — absent, dangling, or naming a style of the wrong type. A w:pStyle pointing at a style the document never defines is reported as the default style, not as the name it wrote.
  • It sits at the paragraph-style layer, so it beats the table style — a Normal declaring 20pt wins over a table style declaring 36pt.
  • It is an ordinary toggle level: a bold Normal plus a bold character style cancel.

The default character and table styles are not the same story — they never apply to anything. Only w:pStyle has a fallback.

A style reference also has to match the style's w:type: w:rStyle naming a paragraph style, or w:basedOn crossing between the two, contributes nothing. Measurements: tests/test_default_styles_word_verified.py.

Conditional table formatting is gated, not positional

A table style's firstRow / firstCol / banding branches do not apply just because a cell sits in the matching place:

  • The table's <w:tblLook> decides which branches are wanted at all — Word's Header Row / First Column / Banded Rows tick-boxes. A cell in row 0 of a table with firstRow cleared takes no firstRow formatting, and a corner branch needs both of its axes enabled. No <w:tblLook> at all means everything is enabled.
  • Banding needs a declared band size (w:tblStyleRowBandSize or w:tblStyleColBandSize, on the table or in its style chain). Absent means no banding, not a band size of one.
  • Precedence is not the order ECMA-376 17.7.6.5 lists. A vertical band beats a horizontal one; a row branch beats a column branch; the corners beat everything. wholeTable does nothing at all — Word discards the branch on load and drops it on save.

Like the toggle rule, this was settled by measuring live Word, because the spec's prose and Word's behaviour disagree. The measurements live in tests/test_tables_word_verified.py.

Spacing needs a second call

Spacing is the one property the cascade cannot answer on its own, and the resolver deliberately does not pretend otherwise. ResolvedFormatting.spacing_before / spacing_after are what the cascade declares; contextual_spacing carries the resolved <w:contextualSpacing> flag. Whether either value is actually applied depends on the paragraph's neighbours, so resolve_paragraph_spacing answers that separately.

Two measured facts it folds in:

  • <w:contextualSpacing> drops a paragraph's space on the side where the neighbour carries the same styleId — and styleId identity is the whole test. Numbering plays no part (two ListParagraph paragraphs in unrelated lists still collapse), and a basedOn child counts as a different style. Each edge answers only to its own paragraph's flag.
  • Word does not add space-after to the next paragraph's space-before. It tops the first up to the second, so an ordinary pair sits max(after, before) apart. The top-up is measured from the declared space-after even when that space-after was suppressed, so a contextual paragraph with 20pt after followed by a plain one with 30pt before leaves 10pt, not 30pt.

A table between two paragraphs stops the suppression; a content control does not — <w:sdt> is transparent. Measurements: tests/test_contextual_spacing_word_verified.py.

include_provenance vs stop_below

Two features answer different questions and are easy to confuse:

  • include_provenance names the layer each value came from.
  • stop_below re-resolves with a layer excluded, giving the value that would have surfaced without it.

Provenance alone cannot answer the second: knowing a run's size came from directRun does not say what deleting that <w:rPr> would leave. Together they are what lets a caller tell a direct property that changes nothing from one that genuinely overrides its style — the basis of every consistency rule in docx_plus.lint.

docx_plus.styles.inspect

Cascade resolver: resolve_effective_formatting.

Walks the layers of OOXML formatting precedence (SPEC §4) and returns a fully-resolved :class:ResolvedFormatting describing what a paragraph, run, or cell would render with right now. Later layers override earlier ones, except toggle properties (bold, italic, etc.), which follow the rule of ECMA-376 17.7.3 — see :func:_resolve_toggle.

Provenance tracking is plumbed through the same walk gated by the include_provenance flag; with the flag off, the resolver's value output is identical (verified by test_provenance_does_not_change_values).

ResolvedFormatting dataclass

ResolvedFormatting(
    style_id: str | None = None,
    style_name: str | None = None,
    alignment: str | None = None,
    indent_left: int | None = None,
    indent_right: int | None = None,
    indent_first_line: int | None = None,
    spacing_before: int | None = None,
    spacing_after: int | None = None,
    line_spacing: float | None = None,
    line_spacing_rule: str | None = None,
    contextual_spacing: bool | None = None,
    keep_with_next: bool | None = None,
    keep_lines: bool | None = None,
    page_break_before: bool | None = None,
    outline_level: int | None = None,
    font_name: str | None = None,
    font_size: float | None = None,
    bold: bool | None = None,
    italic: bool | None = None,
    cs_bold: bool | None = None,
    cs_italic: bool | None = None,
    underline: str | None = None,
    strike: bool | None = None,
    double_strike: bool | None = None,
    color_rgb: str | None = None,
    highlight: str | None = None,
    caps: bool | None = None,
    small_caps: bool | None = None,
    vanish: bool | None = None,
    emboss: bool | None = None,
    imprint: bool | None = None,
    outline: bool | None = None,
    shadow: bool | None = None,
    vert_align: str | None = None,
    lang: str | None = None,
    num_id: int | None = None,
    num_level: int | None = None,
    partial: bool = False,
    provenance: dict[str, FormattingSource] | None = None,
)

The effective formatting for a paragraph, run, or table cell.

Every field is None until some layer of the cascade sets it. Toggle properties carry the value the ECMA-376 17.7.3 rule produces. SPEC §4 specifies the fields.

All twelve ECMA-376 17.7.3 toggle properties are surfaced: the six base toggles (bold, italic, caps, small_caps, strike, vanish) and the six complex-script / decorative variants (cs_bold, cs_italic, emboss, imprint, outline, shadow). All combine by the same rule — see :func:_resolve_toggle.

ParagraphSpacing dataclass

ParagraphSpacing(
    declared_before: int,
    declared_after: int,
    contextual_spacing: bool,
    before_suppressed: bool,
    after_suppressed: bool,
    space_above: int,
    space_below: int,
)

How much vertical space Word actually puts above and below a paragraph.

:class:ResolvedFormatting answers what the cascade declares. That is not the whole story for spacing, for two reasons measured against Word rather than inferred:

  • <w:contextualSpacing> makes a paragraph drop its own space before/after when the neighbour on that side carries the same styleId. Only the paragraph's own flag governs its own edges, and numbering plays no part — two paragraphs in different lists still suppress, and two related-by-basedOn styles do not.
  • Word does not add one paragraph's space-after to the next one's space-before. It lays down the space-after, then tops it up to the space-before if that is larger — so an unsuppressed pair sits max(after, before) apart, not after + before.

The two interact: the top-up is measured against the declared space-after even when that space-after was itself suppressed. A contextual paragraph with after=20pt followed by a non-contextual one with before=30pt leaves 10pt, not 30pt.

space_above and space_below fold all of that together, so space_below of one paragraph always equals space_above of the next. Attributes are twips.

TableContext dataclass

TableContext(
    is_first_row: bool = False,
    is_last_row: bool = False,
    is_first_col: bool = False,
    is_last_col: bool = False,
    is_band_row: bool = False,
    is_band_col: bool = False,
    is_band2_row: bool = False,
    is_band2_col: bool = False,
    first_row_enabled: bool = True,
    last_row_enabled: bool = True,
    first_col_enabled: bool = True,
    last_col_enabled: bool = True,
)

A cell's position within its table — for conditional table-style formatting.

ECMA-376 17.7.6.5 lets a <w:style w:type="table"> carry conditional formatting branches (<w:tblStylePr w:type="firstRow"/>, "lastRow", "firstCol", "lastCol", "band1Horz", "band1Vert", "band2Horz", "band2Vert", "nwCell" / "neCell" / "swCell" / "seCell"). To pick the right branches the cascade resolver needs to know where in the table the target lives.

Construct manually for an out-of-band query, or pass a _Cell to :func:resolve_effective_formatting to derive the context automatically from the cell's parent row / table.

Position is not enough. A table carries a <w:tblLook> saying which conditional branches it wants — the "Header Row" / "First Column" / "Banded Rows" tick-boxes in Word's Table Design tab. A cell in the first row of a table whose tblLook clears firstRow gets no firstRow formatting at all. The four *_enabled attributes carry those flags; the derived context reads them from the table, and they default to True so a hand-built context behaves like a table with no <w:tblLook> (which Word treats as "everything enabled").

Banding is folded into the is_band* attributes rather than exposed as flags, because deciding them needs the whole table:

  • Bands exist only when a <w:tblStyleRowBandSize> / <w:tblStyleColBandSize> is declared, on the table instance or anywhere in its style chain. Absent means no banding — it is not a band size of 1. Instance beats style.
  • The stripe sequence starts at row / column 0 unless the matching firstRow / firstCol conditional actually paints that line, in which case it starts at 1. The tblLook flag alone does not shift it; the style must also define the branch.

Scope: this context selects which <w:tblStylePr> branches apply, but only their run / paragraph properties are resolved. Cell-, row-, and table-level properties (<w:tcPr> / <w:trPr> / <w:tblPr>) from a table style are not surfaced — see the :func:resolve_effective_formatting note.

Auto-derivation limitation: when a row wraps its cells in a <w:sdt> (a content control around table cells), the derived column index cannot be computed and an empty (all-False) :class:TableContext is returned. Pass an explicit context in that case. Nested tables resolve against the inner cell's position.

Attributes:

Name Type Description
is_first_row bool

Cell is in the first <w:tr> of its table.

is_last_row bool

Cell is in the last <w:tr>.

is_first_col bool

Cell is the first <w:tc> of its row.

is_last_col bool

Cell is the last <w:tc> of its row.

is_band_row bool

Cell is in a "band1" horizontal stripe.

is_band_col bool

Cell is in a "band1" vertical stripe.

is_band2_row bool

Cell is in a "band2" horizontal stripe.

is_band2_col bool

Cell is in a "band2" vertical stripe.

first_row_enabled bool

The table's tblLook asks for firstRow formatting. When False the firstRow (and nwCell / neCell) branches are suppressed even in row 0.

last_row_enabled bool

As above for lastRow / swCell / seCell.

first_col_enabled bool

As above for firstCol / nwCell / swCell.

last_col_enabled bool

As above for lastCol / neCell / seCell.

FormattingSource dataclass

FormattingSource(
    layer: Layer,
    style_id: str | None = None,
    is_toggle_resolved: bool = False,
    chain_depth: int | None = None,
)

Identifies the cascade layer that contributed a resolved property.

layer is the cascade layer the value came from. For style layers, style_id names the specific style (the lowest one in the basedOn chain that set the value); chain_depth records how many basedOn hops away that style was from the target. is_toggle_resolved is True when the value was computed by the ECMA-376 17.7.3 toggle rule across more than one contributing layer, rather than stated by one of them.

StyleCascadeError

Bases: DocxPlusError

Raised when the basedOn chain cycles or exceeds Word's depth limit.

MissingPartError

Bases: DocxPlusError

Raised when a referenced document part is absent.

Nothing in the cascade resolver raises this today, and :func:resolve_effective_formatting used to promise it for a numPr whose numbering.xml is missing. That promise was wrong: an unresolvable numbering reference is normal in real documents and the resolver degrades instead — see that function's Note.

Retained as a public symbol because it has been exported since v0.1 and callers may name it in an except clause. New code should not expect it from the cascade.

resolve_effective_formatting

resolve_effective_formatting(
    target: Paragraph | Run | _Cell,
    *,
    include_provenance: bool = False,
    table_context: TableContext | None = None,
    stop_below: Layer | None = None,
) -> ResolvedFormatting

Resolve the effective formatting for target.

Walks the cascade layers in precedence order, returning a fully resolved :class:ResolvedFormatting. Toggle properties combine per ECMA-376 17.7.3 rather than overriding — see :func:_resolve_toggle. Theme colors are resolved against the document's theme part; if the theme is missing or malformed, the result's partial flag is set and unresolved theme names are returned in place of hex values.

When target is in a table cell, table-style conditional formatting (<w:tblStylePr> branches: firstRow, lastRow, firstCol, lastCol, the four band branches and the four corners) is applied on top of the base table style — but only the branches the table's <w:tblLook> asks for. See :class:TableContext for how that gating and band membership are worked out.

Note

Only run- and paragraph-level properties are resolved (the <w:rPr> / <w:pPr> carried by a style's base and its <w:tblStylePr> branches). Cell-, row-, and table-level properties (<w:tcPr> cell shading and margins, <w:trPr> row heights, <w:tblPr> table defaults) declared by a table style are not surfaced on :class:ResolvedFormatting — that belongs to a separate cell-formatting resolver deferred to v0.3+.

Parameters:

Name Type Description Default
target Paragraph | Run | _Cell

A python-docx :class:~docx.text.paragraph.Paragraph, :class:~docx.text.run.Run, or :class:~docx.table._Cell.

required
include_provenance bool

If True, populate .provenance with the cascade layer that set each field. Default False.

False
table_context TableContext | None

Optional override for the cell's position within its table. When None (default), the resolver derives it from the target's parent <w:tr> / <w:tbl> chain; pass an explicit :class:TableContext to query a hypothetical position (e.g. "what would the formatting be if this cell were in the first row?").

None
stop_below Layer | None

Stop the walk below this :data:Layer, so the named layer and everything above it contribute nothing. None (default) walks the whole cascade. See the note below.

None

Returns:

Name Type Description
A ResolvedFormatting

class:ResolvedFormatting snapshot.

Raises:

Type Description
StyleCascadeError

If the basedOn chain has a cycle or exceeds Word's depth limit of 11.

ValueError

If stop_below is not one of the :data:Layer names.

Note

A paragraph carrying no w:pStyle is not unstyled: Word gives it the document's default paragraph style, and so does this. The same fallback covers a w:pStyle that dangles or names a style of the wrong w:type — Word treats those as no reference at all rather than as an empty style. style_id therefore reports the style Word actually applies, which for a dangling reference is not the id written in the XML.

That layer sits above the table style, so a default style declaring a property beats a table style declaring it. The default character and table styles have no equivalent: neither is ever applied. See SPEC "Default styles".

Note

A paragraph whose w:numPr references a numbering id that cannot be resolved — because numbering.xml is absent, or the numId is dangling — is not an error. num_id and num_level are still reported; only the formatting the numbering level would have contributed is missing. Word behaves the same way, and documents in the wild routinely carry a numPr with no matching definition.

Note

Numbering resolves through the style chain, not just the paragraph's own w:numPr: a paragraph styled List Bullet reports the num_id that style supplies. w:numId and w:ilvl resolve independently, so a paragraph overriding only the level keeps its style's list. A resolved num_id of 0 is the ECMA-376 sentinel for explicitly not numbered, distinct from None for "no numbering information at all"; with include_provenance, the num_id layer is "numbering" for a direct reference and "styleNumbering" for an inherited one.

Note

stop_below answers "what would this look like without that layer?", which provenance alone cannot: provenance names the layer that won, not the value that would have surfaced in its absence. Resolving a run with stop_below="directRun" gives exactly what it would render as if its own <w:rPr> were deleted — character style and all — so a caller can tell direct formatting that changes nothing from direct formatting that overrides the style. That comparison is the basis of every consistency rule in :mod:docx_plus.lint.

style_id and style_name are identity rather than formatting, so they are reported regardless of where the walk stops — a caller resolving beneath the paragraph style still needs to know which style it excluded.

Example

from docx import Document from docx_plus.styles.inspect import resolve_effective_formatting doc = Document() p = doc.add_paragraph("Hello") resolved = resolve_effective_formatting(p) resolved.font_size # e.g. 11.0 from docDefaults 11.0

A run whose direct bold merely restates its style:

run = p.add_run("bold") run.bold = True resolve_effective_formatting(run).bold True resolve_effective_formatting(run, stop_below="directRun").bold is None True

Source code in docx_plus/styles/inspect.py
def resolve_effective_formatting(
    target: Paragraph | Run | _Cell,
    *,
    include_provenance: bool = False,
    table_context: TableContext | None = None,
    stop_below: Layer | None = None,
) -> ResolvedFormatting:
    """Resolve the effective formatting for ``target``.

    Walks the cascade layers in precedence order, returning a fully
    resolved :class:`ResolvedFormatting`. Toggle properties combine per
    ECMA-376 17.7.3 rather than overriding — see
    :func:`_resolve_toggle`. Theme colors are resolved against the
    document's theme part; if the theme is missing or malformed, the result's
    ``partial`` flag is set and unresolved theme names are returned in place
    of hex values.

    When ``target`` is in a table cell, table-style **conditional
    formatting** (``<w:tblStylePr>`` branches: ``firstRow``, ``lastRow``,
    ``firstCol``, ``lastCol``, the four band branches and the four
    corners) is applied on top of the base table style — but only the
    branches the table's ``<w:tblLook>`` asks for. See
    :class:`TableContext` for how that gating and band membership are
    worked out.

    Note:
        Only **run- and paragraph-level** properties are resolved (the
        ``<w:rPr>`` / ``<w:pPr>`` carried by a style's base and its
        ``<w:tblStylePr>`` branches). Cell-, row-, and table-level
        properties (``<w:tcPr>`` cell shading and margins, ``<w:trPr>``
        row heights, ``<w:tblPr>`` table defaults) declared by a table
        style are **not** surfaced on :class:`ResolvedFormatting` — that
        belongs to a separate cell-formatting resolver deferred to v0.3+.

    Args:
        target: A python-docx :class:`~docx.text.paragraph.Paragraph`,
            :class:`~docx.text.run.Run`, or :class:`~docx.table._Cell`.
        include_provenance: If True, populate ``.provenance`` with the cascade
            layer that set each field. Default False.
        table_context: Optional override for the cell's position within
            its table. When ``None`` (default), the resolver derives it
            from the target's parent ``<w:tr>`` / ``<w:tbl>`` chain;
            pass an explicit :class:`TableContext` to query a hypothetical
            position (e.g. "what would the formatting be if this cell
            were in the first row?").
        stop_below: Stop the walk *below* this :data:`Layer`, so the named
            layer and everything above it contribute nothing. ``None``
            (default) walks the whole cascade. See the note below.

    Returns:
        A :class:`ResolvedFormatting` snapshot.

    Raises:
        StyleCascadeError: If the basedOn chain has a cycle or exceeds Word's
            depth limit of 11.
        ValueError: If ``stop_below`` is not one of the :data:`Layer` names.

    Note:
        A paragraph carrying no ``w:pStyle`` is **not** unstyled: Word
        gives it the document's default paragraph style, and so does this.
        The same fallback covers a ``w:pStyle`` that dangles or names a
        style of the wrong ``w:type`` — Word treats those as no reference
        at all rather than as an empty style. ``style_id`` therefore
        reports the style Word actually applies, which for a dangling
        reference is not the id written in the XML.

        That layer sits above the table style, so a default style
        declaring a property beats a table style declaring it. The default
        *character* and *table* styles have no equivalent: neither is ever
        applied. See SPEC "Default styles".

    Note:
        A paragraph whose ``w:numPr`` references a numbering id that
        cannot be resolved — because ``numbering.xml`` is absent, or the
        ``numId`` is dangling — is **not** an error. ``num_id`` and
        ``num_level`` are still reported; only the formatting the
        numbering level would have contributed is missing. Word behaves
        the same way, and documents in the wild routinely carry a
        ``numPr`` with no matching definition.

    Note:
        Numbering resolves through the **style chain**, not just the
        paragraph's own ``w:numPr``: a paragraph styled ``List Bullet``
        reports the ``num_id`` that style supplies. ``w:numId`` and
        ``w:ilvl`` resolve independently, so a paragraph overriding only
        the level keeps its style's list. A resolved ``num_id`` of ``0``
        is the ECMA-376 sentinel for *explicitly not numbered*, distinct
        from ``None`` for "no numbering information at all"; with
        ``include_provenance``, the ``num_id`` layer is ``"numbering"``
        for a direct reference and ``"styleNumbering"`` for an inherited
        one.

    Note:
        ``stop_below`` answers **"what would this look like without that
        layer?"**, which provenance alone cannot: provenance names the
        layer that *won*, not the value that would have surfaced in its
        absence. Resolving a run with ``stop_below="directRun"`` gives
        exactly what it would render as if its own ``<w:rPr>`` were
        deleted — character style and all — so a caller can tell direct
        formatting that changes nothing from direct formatting that
        overrides the style. That comparison is the basis of every
        consistency rule in :mod:`docx_plus.lint`.

        ``style_id`` and ``style_name`` are identity rather than
        formatting, so they are reported regardless of where the walk
        stops — a caller resolving beneath the paragraph style still needs
        to know which style it excluded.

    Example:
        >>> from docx import Document
        >>> from docx_plus.styles.inspect import resolve_effective_formatting
        >>> doc = Document()
        >>> p = doc.add_paragraph("Hello")
        >>> resolved = resolve_effective_formatting(p)
        >>> resolved.font_size  # e.g. 11.0 from docDefaults
        11.0

        A run whose direct bold merely restates its style:

        >>> run = p.add_run("bold")
        >>> run.bold = True
        >>> resolve_effective_formatting(run).bold
        True
        >>> resolve_effective_formatting(run, stop_below="directRun").bold is None
        True
    """
    # Classify first so a wrong-typed target raises TypeError from
    # _classify_target, before _document_of reaches for ``.part`` and turns
    # it into an AttributeError. _resolve_with_cache classifies again; three
    # isinstance checks are nothing against a full cascade walk.
    _classify_target(target)
    if stop_below is not None and stop_below not in _LAYER_ORDER:
        raise InvalidLayerError(
            f"stop_below must be one of {', '.join(_LAYER_ORDER)}; got {stop_below!r}"
        )
    return _resolve_with_cache(
        _ResolverCache.for_document(_document_of(target)),
        target,
        include_provenance=include_provenance,
        table_context=table_context,
        stop_below=stop_below,
    )

resolve_paragraph_spacing

resolve_paragraph_spacing(paragraph: Paragraph) -> ParagraphSpacing

Resolve the vertical space actually applied around paragraph.

Parameters:

Name Type Description Default
paragraph Paragraph

A python-docx :class:~docx.text.paragraph.Paragraph.

required

Returns:

Name Type Description
A ParagraphSpacing

class:ParagraphSpacing snapshot, in twips.

Note

A paragraph with no neighbour on a side keeps its declared space there — nothing can suppress it. Word's separate rule about space-before at the top of a page is layout the resolver does not model, since it depends on pagination.

Source code in docx_plus/styles/inspect.py
def resolve_paragraph_spacing(paragraph: Paragraph) -> ParagraphSpacing:
    """Resolve the vertical space actually applied around ``paragraph``.

    Args:
        paragraph: A python-docx :class:`~docx.text.paragraph.Paragraph`.

    Returns:
        A :class:`ParagraphSpacing` snapshot, in twips.

    Note:
        A paragraph with no neighbour on a side keeps its declared space
        there — nothing can suppress it. Word's separate rule about
        space-before at the top of a *page* is layout the resolver does not
        model, since it depends on pagination.
    """
    cache = _ResolverCache.for_document(_document_of(paragraph))
    return _resolve_spacing_with_cache(cache, paragraph)