Skip to content

docx_plus.styles.sweep

The document-wide cascade sweep. Resolves every paragraph and run against a single shared cache, in document order.

resolve_effective_formatting answers "what does this paragraph render as", rebuilding the theme, the styles part, and each basedOn chain on every call. iter_resolved_paragraphs answers the same question about every paragraph at once, resolving those document-level inputs once for the whole walk — roughly 5x faster per target on a text-heavy document, and the read half any whole-document analysis needs.

Pass include_baseline=True to also resolve each target with its own direct formatting excluded, populating .baseline. That is the comparison behind "is this direct property doing anything?" — see stop_below for what the baseline is a resolve of.

docx_plus.styles.sweep

Document-wide cascade sweep: iter_resolved_paragraphs.

:func:~docx_plus.styles.resolve_effective_formatting resolves one target at a time and rebuilds every document-level lookup on each call — the theme, the styles part, each basedOn chain. That is the right shape for asking about a single paragraph and the wrong one for asking about all of them: profiling put load_theme alone at 39% of per-call cost.

This module walks a whole document against one shared cache, in document order, yielding each paragraph's resolved formatting alongside its runs'. It is the read half any whole-document analysis needs — the effective formatting of everything, cheaply enough to then compare targets against each other.

ResolvedParagraph dataclass

ResolvedParagraph(
    paragraph: Paragraph,
    index: int,
    formatting: ResolvedFormatting,
    runs: tuple[ResolvedRun, ...],
    table_depth: int,
    baseline: ResolvedFormatting | None = None,
    spacing: ParagraphSpacing | None = None,
    part: SweepPart = "body",
)

A paragraph's resolved formatting, its runs', and where it sits.

Attributes:

Name Type Description
paragraph Paragraph

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

index int

0-based position in the sweep's own ordering — see the note on :func:iter_resolved_paragraphs about how this relates to doc.paragraphs.

formatting ResolvedFormatting

The paragraph's fully-resolved formatting. Run-level properties here reflect the paragraph mark, not any one run.

runs tuple[ResolvedRun, ...]

One :class:ResolvedRun per run, in order, including runs inside a <w:hyperlink> — which Paragraph.runs omits. Together they cover exactly the text :attr:text reports. Empty when the sweep was run with include_runs=False, and for an empty paragraph.

table_depth int

0 for a body-level paragraph, 1 inside a table, 2 inside a table nested in a table, and so on.

baseline ResolvedFormatting | None

The same paragraph resolved with stop_below="directParagraph", which is what makes "this direct override deviates from the style" answerable. None unless the sweep was run with include_baseline=True.

Note this is not the same as deleting the paragraph's <w:pPr>: the numbering layer sits below directParagraph, so a direct <w:numPr> still supplies num_id and the indents it implies. The baseline excludes the direct paragraph layer, not the whole element.

spacing ParagraphSpacing | None

The paragraph's effective vertical spacing against its neighbours, as :func:~docx_plus.styles.resolve_paragraph_spacing reports it. None unless the sweep was run with include_spacing=True.

part SweepPart

Which part of the document the paragraph came from. Always "body" unless the sweep was run with parts=.

in_table property

in_table: bool

True if this paragraph sits inside a table cell.

text property

text: str

The paragraph's text, for convenience when scanning content.

Covers exactly the runs in :attr:runs — both include the inside of a <w:hyperlink> and both exclude <w:ins> / <w:del> / <w:sdt>, matching python-docx's own Paragraph.text. A rule may therefore index into this string and expect a run to answer for every offset.

ResolvedRun dataclass

ResolvedRun(
    run: Run,
    index: int,
    formatting: ResolvedFormatting,
    baseline: ResolvedFormatting | None = None,
)

A run's resolved formatting, with its position in the paragraph.

Attributes:

Name Type Description
run Run

The python-docx :class:~docx.text.run.Run.

index int

0-based position within the owning paragraph.

formatting ResolvedFormatting

The run's fully-resolved formatting.

baseline ResolvedFormatting | None

The same run resolved with stop_below="directRun" — what it would render as if its own <w:rPr> were deleted, character style and all. None unless the sweep was run with include_baseline=True.

iter_resolved_paragraphs

iter_resolved_paragraphs(
    doc: Document,
    *,
    include_provenance: bool = False,
    include_runs: bool = True,
    include_tables: bool = True,
    include_baseline: bool = False,
    include_spacing: bool = False,
    parts: Iterable[SweepPart] | Literal["all"] = ("body",),
) -> Iterator[ResolvedParagraph]

Resolve every paragraph in doc, sharing one cascade cache.

Yields lazily in document order, so a caller can stop early or stream a large document without materialising every result. Wrap in list() for the whole set.

Equivalent to calling :func:~docx_plus.styles.resolve_effective_formatting on each paragraph and run — the same walk over the same code path — but with the theme, the styles part, and every basedOn chain resolved once for the whole document rather than once per target.

Parameters:

Name Type Description Default
doc Document

The python-docx :class:~docx.document.Document to sweep.

required
include_provenance bool

Populate each result's .provenance. Costs extra work per target, so it is off by default.

False
include_runs bool

Resolve each paragraph's runs as well. Set False when only paragraph-level properties matter — runs are usually the bulk of the work in a text-heavy document.

True
include_tables bool

Descend into table cells (including nested tables). Set False to sweep body-level paragraphs only.

True
include_baseline bool

Also resolve each target with its own direct formatting layer excluded, populating .baseline. Roughly doubles the resolve work, so it is off by default; turn it on for the "is this direct override doing anything?" question.

False
include_spacing bool

Also compute each paragraph's :class:~docx_plus.styles.ParagraphSpacing — the same answer as :func:~docx_plus.styles.resolve_paragraph_spacing, but against the shared cache, which is several times cheaper per paragraph. Populates .spacing; off by default because it resolves each paragraph's neighbours as well.

False
parts Iterable[SweepPart] | Literal['all']

Which parts to sweep, in :data:SweepPart terms. The default is the main body only; "all" adds every header, footer, footnote, endnote, and comment, in that order after the body. Each yielded paragraph says where it came from in .part.

('body',)

Yields:

Name Type Description
One ResolvedParagraph

class:ResolvedParagraph per paragraph, in document order.

Raises:

Type Description
StyleCascadeError

If a basedOn chain has a cycle or exceeds Word's depth limit of 11. Raised from the first paragraph that reaches the bad chain, so a partial sweep may already have been yielded.

Note

index counts the paragraphs this sweep yields, in document order. With include_tables=True (the default) that includes table-cell paragraphs, which doc.paragraphs omits — so the two indexings diverge at the first table. Pass include_tables=False to get indices that line up with doc.paragraphs, and note the CLI's inspect command numbers from 1 rather than 0.

Note

Headers, footers, footnotes, endnotes, and comments are swept only when asked for via parts. A paragraph from a footnotes, endnotes, or comments part is wrapped in a :class:~docx.text.paragraph.Paragraph whose .part is that side part rather than a python-docx story part, so its .style accessor does not work; .text, .runs and the resolved formatting do.

Raises:

Type Description
InvalidSweepPartError

If parts names an unknown part.

Example

from docx import Document from docx_plus.styles import iter_resolved_paragraphs doc = Document() _ = doc.add_paragraph("Hello") for resolved in iter_resolved_paragraphs(doc): ... print(resolved.index, resolved.formatting.font_size) 0 11.0

Source code in docx_plus/styles/sweep.py
def iter_resolved_paragraphs(
    doc: Document,
    *,
    include_provenance: bool = False,
    include_runs: bool = True,
    include_tables: bool = True,
    include_baseline: bool = False,
    include_spacing: bool = False,
    parts: Iterable[SweepPart] | Literal["all"] = ("body",),
) -> Iterator[ResolvedParagraph]:
    """Resolve every paragraph in ``doc``, sharing one cascade cache.

    Yields lazily in document order, so a caller can stop early or stream a
    large document without materialising every result. Wrap in ``list()``
    for the whole set.

    Equivalent to calling
    :func:`~docx_plus.styles.resolve_effective_formatting` on each paragraph
    and run — the same walk over the same code path — but with the theme,
    the styles part, and every ``basedOn`` chain resolved once for the whole
    document rather than once per target.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to sweep.
        include_provenance: Populate each result's ``.provenance``. Costs
            extra work per target, so it is off by default.
        include_runs: Resolve each paragraph's runs as well. Set False when
            only paragraph-level properties matter — runs are usually the
            bulk of the work in a text-heavy document.
        include_tables: Descend into table cells (including nested tables).
            Set False to sweep body-level paragraphs only.
        include_baseline: Also resolve each target with its own direct
            formatting layer excluded, populating ``.baseline``. Roughly
            doubles the resolve work, so it is off by default; turn it on
            for the "is this direct override doing anything?" question.
        include_spacing: Also compute each paragraph's
            :class:`~docx_plus.styles.ParagraphSpacing` — the same answer
            as :func:`~docx_plus.styles.resolve_paragraph_spacing`, but
            against the shared cache, which is several times cheaper per
            paragraph. Populates ``.spacing``; off by default because it
            resolves each paragraph's neighbours as well.
        parts: Which parts to sweep, in :data:`SweepPart` terms. The
            default is the main body only; ``"all"`` adds every header,
            footer, footnote, endnote, and comment, in that order after
            the body. Each yielded paragraph says where it came from in
            ``.part``.

    Yields:
        One :class:`ResolvedParagraph` per paragraph, in document order.

    Raises:
        StyleCascadeError: If a ``basedOn`` chain has a cycle or exceeds
            Word's depth limit of 11. Raised from the first paragraph that
            reaches the bad chain, so a partial sweep may already have been
            yielded.

    Note:
        ``index`` counts the paragraphs **this sweep yields**, in document
        order. With ``include_tables=True`` (the default) that includes
        table-cell paragraphs, which ``doc.paragraphs`` omits — so the two
        indexings diverge at the first table. Pass ``include_tables=False``
        to get indices that line up with ``doc.paragraphs``, and note the
        CLI's ``inspect`` command numbers from 1 rather than 0.

    Note:
        Headers, footers, footnotes, endnotes, and comments are swept only
        when asked for via ``parts``. A paragraph from a footnotes,
        endnotes, or comments part is wrapped in a
        :class:`~docx.text.paragraph.Paragraph` whose ``.part`` is that
        side part rather than a python-docx story part, so its
        ``.style`` accessor does not work; ``.text``, ``.runs`` and the
        resolved formatting do.

    Raises:
        InvalidSweepPartError: If ``parts`` names an unknown part.

    Example:
        >>> from docx import Document
        >>> from docx_plus.styles import iter_resolved_paragraphs
        >>> doc = Document()
        >>> _ = doc.add_paragraph("Hello")
        >>> for resolved in iter_resolved_paragraphs(doc):
        ...     print(resolved.index, resolved.formatting.font_size)
        0 11.0
    """
    cache = _ResolverCache.for_document(doc)
    counter = 0

    for part, paragraph, depth in _walk_parts(doc, parts, include_tables=include_tables):
        runs: tuple[ResolvedRun, ...] = ()
        if include_runs:
            runs = tuple(
                ResolvedRun(
                    run=run,
                    index=run_index,
                    formatting=_resolve_with_cache(
                        cache, run, include_provenance=include_provenance
                    ),
                    baseline=(
                        _resolve_with_cache(cache, run, stop_below="directRun")
                        if include_baseline
                        else None
                    ),
                )
                for run_index, run in enumerate(_iter_runs(paragraph))
            )
        yield ResolvedParagraph(
            paragraph=paragraph,
            index=counter,
            formatting=_resolve_with_cache(cache, paragraph, include_provenance=include_provenance),
            runs=runs,
            table_depth=depth,
            baseline=(
                _resolve_with_cache(cache, paragraph, stop_below="directParagraph")
                if include_baseline
                else None
            ),
            spacing=_resolve_spacing_with_cache(cache, paragraph) if include_spacing else None,
            part=part,
        )
        counter += 1