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
basedOnchain 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.
docDefaultsis 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 whosew:defaultis on, falling back to the style whose id isNormal. - It substitutes whenever
w:pStylefails to resolve — absent, dangling, or naming a style of the wrong type. Aw:pStylepointing 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
Normaldeclaring 20pt wins over a table style declaring 36pt. - It is an ordinary toggle level: a bold
Normalplus 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 withfirstRowcleared takes nofirstRowformatting, 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:tblStyleRowBandSizeorw: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.
wholeTabledoes 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 samestyleId— andstyleIdidentity is the whole test. Numbering plays no part (twoListParagraphparagraphs in unrelated lists still collapse), and abasedOnchild 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_provenancenames the layer each value came from.stop_belowre-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 samestyleId. 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-basedOnstyles 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, notafter + 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/firstColconditional actually paints that line, in which case it starts at 1. ThetblLookflag 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 |
is_last_row |
bool
|
Cell is in the last |
is_first_col |
bool
|
Cell is the first |
is_last_col |
bool
|
Cell is the last |
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 |
last_row_enabled |
bool
|
As above for |
first_col_enabled |
bool
|
As above for |
last_col_enabled |
bool
|
As above for |
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: |
required |
include_provenance
|
bool
|
If True, populate |
False
|
table_context
|
TableContext | None
|
Optional override for the cell's position within
its table. When |
None
|
stop_below
|
Layer | None
|
Stop the walk below this :data: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
ResolvedFormatting
|
class: |
Raises:
| Type | Description |
|---|---|
StyleCascadeError
|
If the basedOn chain has a cycle or exceeds Word's depth limit of 11. |
ValueError
|
If |
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
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 | |
resolve_paragraph_spacing ¶
Resolve the vertical space actually applied around paragraph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
paragraph
|
Paragraph
|
A python-docx :class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
ParagraphSpacing
|
class: |
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.