Skip to content

docx_plus.numbering.define

Authoring list definitions in numbering.xml — the largest remaining python-docx gap. python-docx has no CT_AbstractNum and no CT_Lvl class, so nothing in it can express what a list looks like.

OOXML splits a list in two: a <w:abstractNum> holds up to nine <w:lvl> children describing each depth, and a <w:num> is an instance pointing at one. Paragraphs reference the instance, never the abstract definition — which is what makes restarting possible.

Architecture walkthrough: Custom numbering.

Size the hanging indent to the number

hanging is the width reserved for the number, and the gap between number and text is a tab stop at indent. If the number is wider than hanging the tab collapses and a cumulative outline renders 1.1.1.On-call lead rather than 1.1.1. On-call lead. Deeper levels of a %1.%2.%3. outline need progressively larger values.

docx_plus.numbering.define

Authoring list definitions in numbering.xml.

python-docx cannot write a list definition at all. It ships a NumberingPart, but docx/oxml/numbering.py defines classes only for w:numbering, w:num, w:lvlOverride, and w:numPr — there is no CT_AbstractNum and no CT_Lvl, so nothing in it can express what a list looks like: the number format, the level text, the start value, the indents, the bullet glyph. Callers hand-write XML.

The OOXML model has two halves:

  • <w:abstractNum> — the definition. Up to nine <w:lvl> children, each describing one outline level.
  • <w:num> — an instance pointing at an abstract definition by id. Paragraphs reference this id, never the abstract one.

The indirection is what makes restarting possible: a second w:num over the same w:abstractNum is an independent sequence with identical formatting. See :func:~docx_plus.numbering.restart_list.

This module imports only from docx_plus.core and its sibling docx_plus.numbering.registry (SPEC §9.1).

MAX_LEVELS module-attribute

MAX_LEVELS = 9

LevelDefinition dataclass

LevelDefinition(
    fmt: str = "decimal",
    text: str = "%1.",
    start: int = 1,
    indent: int | None = None,
    hanging: int | None = None,
    justify: Justification = "left",
    suffix: Suffix = "tab",
    restart_after: int | None = None,
    font: str | None = None,
)

One outline level of a list definition — a <w:lvl>.

Attributes:

Name Type Description
fmt str

ECMA-376 17.18.59 ST_NumberFormat name. "decimal", "lowerLetter", "lowerRoman", "upperRoman", and "bullet" cover almost every list; the full enumeration has 60+ entries.

text str

The w:lvlText pattern. %N interpolates the counter for level N (1-based, so level 0's own counter is %1). "%1." gives 1., 2.; "%1.%2." on level 1 gives 1.1, 1.2 — the legal-outline shape. For fmt="bullet" this is the literal glyph, not a pattern.

start int

First value of the counter. Defaults to 1.

indent int | None

Left indent in twips (1/20 pt; 720 = 0.5"). None omits the w:ind, inheriting from the style.

hanging int | None

Hanging indent in twips — the width reserved for the number, measured back from indent. Written only when indent is also set.

Make this wider than the rendered text. The gap between number and text is a tab stop sitting at indent, so when the number is wider than hanging the tab has nowhere to advance to and collapses to nothing — a cumulative outline renders 1.1.1.On-call lead rather than 1.1.1. On-call lead. Deeper levels of a %1.%2.%3. outline therefore need progressively larger values, not the same 360 that suits a single digit.

justify Justification

How the number is aligned within the hanging indent.

suffix Suffix

What separates the number from the text — "tab" (default, what Word writes), "space", or "nothing".

restart_after int | None

The w:lvlRestart value: this level's counter restarts whenever the level with this (1-based) number increments. 0 means never restart. None omits the element, which is Word's implicit "restart after the immediately preceding level".

font str | None

Font applied to the number or bullet glyph only, not the paragraph text. Required in practice for symbol bullets — "Symbol" and "Wingdings" render as Latin letters without it.

Raises:

Type Description
InvalidLevelError

If any field is outside its ECMA-376 type.

__post_init__

__post_init__() -> None

Validate the fields against their ECMA-376 simple types.

Source code in docx_plus/numbering/define.py
def __post_init__(self) -> None:
    """Validate the fields against their ECMA-376 simple types."""
    if self.fmt not in _NUMBER_FORMATS:
        raise InvalidLevelError(
            f"LevelDefinition.fmt must be an ECMA-376 ST_NumberFormat name "
            f"(e.g. 'decimal', 'bullet', 'lowerRoman'); got {self.fmt!r}"
        )
    if self.suffix not in _SUFFIXES:
        raise InvalidLevelError(
            f"LevelDefinition.suffix must be one of {sorted(_SUFFIXES)}; got {self.suffix!r}"
        )
    if self.justify not in _JUSTIFICATIONS:
        raise InvalidLevelError(
            f"LevelDefinition.justify must be one of {sorted(_JUSTIFICATIONS)}; "
            f"got {self.justify!r}"
        )
    if self.start < 0:
        raise InvalidLevelError(
            f"LevelDefinition.start must be non-negative; got {self.start!r}"
        )
    if self.restart_after is not None and not 0 <= self.restart_after <= MAX_LEVELS:
        raise InvalidLevelError(
            f"LevelDefinition.restart_after must be 0 (never) or a 1-based level "
            f"number up to {MAX_LEVELS}; got {self.restart_after!r}"
        )
    if self.hanging is not None and self.indent is None:
        raise InvalidLevelError(
            "LevelDefinition.hanging has no effect without indent; set both or "
            "neither (a w:ind with only w:hanging is ignored by Word)"
        )

InvalidLevelError

Bases: DocxPlusError, ValueError

Raised for a malformed :class:LevelDefinition, level list, or level index.

Also what :func:apply_list / :func:restart_list raise for a level outside 0–8 or a negative start, so one except covers every "that is not a valid level" complaint in this package. Subclasses ValueError so existing except ValueError: clauses still catch it; also subclasses :class:DocxPlusError per SPEC §9.7.

define_list_definition

define_list_definition(
    doc: Document,
    *,
    levels: list[LevelDefinition] | tuple[LevelDefinition, ...],
    name: str | None = None,
    style_link: str | None = None,
    num_style_link: str | None = None,
    multi_level_type: MultiLevelType | None = None,
    num_registry: NumIdRegistry | None = None,
    abstract_registry: AbstractNumIdRegistry | None = None,
) -> int

Write a list definition and return the numId to apply.

Creates one <w:abstractNum> holding levels, plus one <w:num> instance pointing at it. The returned id is the instance's — that is what :func:~docx_plus.numbering.apply_list takes and what a paragraph's w:numPr stores.

numbering.xml is created if the document has none. That is not the same as python-docx's doc.part.numbering_part, which fabricates through an unimplemented stub and raises; see :data:~docx_plus.core.parts.NUMBERING_SPEC.

Parameters:

Name Type Description Default
doc Document

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

required
levels list[LevelDefinition] | tuple[LevelDefinition, ...]

One :class:LevelDefinition per outline level, outermost first. At least one, at most nine.

required
name str | None

Optional w:name for the definition. Cosmetic; Word does not surface it.

None
style_link str | None

Style id this definition is the numbering for — the paired half of a "list style". Mutually exclusive with num_style_link.

None
num_style_link str | None

Style id whose numbering this definition defers to. Mutually exclusive with style_link.

None
multi_level_type MultiLevelType | None

"singleLevel", "multilevel", or "hybridMultilevel". Defaults to "singleLevel" for one level and "multilevel" beyond that, which is what Word writes.

None
num_registry NumIdRegistry | None

Pre-existing w:numId allocator to share across an editing session.

None
abstract_registry AbstractNumIdRegistry | None

Pre-existing w:abstractNumId allocator to share across an editing session.

None

Returns:

Type Description
int

The w:numId of the new instance.

Raises:

Type Description
InvalidLevelError

If levels is empty, longer than nine, or contains a w:lvlText placeholder referencing a deeper level than its own.

ValueError

If both style_link and num_style_link are given.

Example

from docx import Document from docx_plus.numbering import LevelDefinition, apply_list, define_list_definition doc = Document() num = define_list_definition(doc, levels=[ ... LevelDefinition(fmt="decimal", text="%1.", indent=720, hanging=360), ... LevelDefinition(fmt="lowerLetter", text="%2)", indent=1440, hanging=360), ... ]) apply_list(doc.add_paragraph("top level"), num) apply_list(doc.add_paragraph("nested"), num, level=1)

Source code in docx_plus/numbering/define.py
def define_list_definition(
    doc: Document,
    *,
    levels: list[LevelDefinition] | tuple[LevelDefinition, ...],
    name: str | None = None,
    style_link: str | None = None,
    num_style_link: str | None = None,
    multi_level_type: MultiLevelType | None = None,
    num_registry: NumIdRegistry | None = None,
    abstract_registry: AbstractNumIdRegistry | None = None,
) -> int:
    """Write a list definition and return the ``numId`` to apply.

    Creates one ``<w:abstractNum>`` holding ``levels``, plus one
    ``<w:num>`` instance pointing at it. The returned id is the
    instance's — that is what :func:`~docx_plus.numbering.apply_list`
    takes and what a paragraph's ``w:numPr`` stores.

    ``numbering.xml`` is created if the document has none. That is not
    the same as python-docx's ``doc.part.numbering_part``, which
    fabricates through an unimplemented stub and raises; see
    :data:`~docx_plus.core.parts.NUMBERING_SPEC`.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to mutate.
        levels: One :class:`LevelDefinition` per outline level, outermost
            first. At least one, at most nine.
        name: Optional ``w:name`` for the definition. Cosmetic; Word does
            not surface it.
        style_link: Style id this definition is the numbering *for* —
            the paired half of a "list style". Mutually exclusive with
            ``num_style_link``.
        num_style_link: Style id whose numbering this definition
            *defers to*. Mutually exclusive with ``style_link``.
        multi_level_type: ``"singleLevel"``, ``"multilevel"``, or
            ``"hybridMultilevel"``. Defaults to ``"singleLevel"`` for one
            level and ``"multilevel"`` beyond that, which is what Word
            writes.
        num_registry: Pre-existing ``w:numId`` allocator to share across
            an editing session.
        abstract_registry: Pre-existing ``w:abstractNumId`` allocator to
            share across an editing session.

    Returns:
        The ``w:numId`` of the new instance.

    Raises:
        InvalidLevelError: If ``levels`` is empty, longer than nine, or
            contains a ``w:lvlText`` placeholder referencing a deeper
            level than its own.
        ValueError: If both ``style_link`` and ``num_style_link`` are given.

    Example:
        >>> from docx import Document
        >>> from docx_plus.numbering import LevelDefinition, apply_list, define_list_definition
        >>> doc = Document()
        >>> num = define_list_definition(doc, levels=[
        ...     LevelDefinition(fmt="decimal", text="%1.", indent=720, hanging=360),
        ...     LevelDefinition(fmt="lowerLetter", text="%2)", indent=1440, hanging=360),
        ... ])
        >>> apply_list(doc.add_paragraph("top level"), num)
        >>> apply_list(doc.add_paragraph("nested"), num, level=1)
    """
    levels = tuple(levels)
    _validate_levels(levels)
    if style_link is not None and num_style_link is not None:
        raise ValueError(
            "style_link and num_style_link are the two halves of a style/numbering "
            "pair and cannot both be set on one definition"
        )

    _, root = get_or_create_part(doc, NUMBERING_SPEC)

    if abstract_registry is None:
        abstract_registry = AbstractNumIdRegistry(doc)
    if num_registry is None:
        num_registry = NumIdRegistry(doc)

    abstract_id = abstract_registry.next_sequential()
    num_id = num_registry.next_sequential()

    if multi_level_type is None:
        multi_level_type = "singleLevel" if len(levels) == 1 else "multilevel"
    elif multi_level_type not in _MULTI_LEVEL_TYPES:
        raise ValueError(
            f"multi_level_type must be one of {sorted(_MULTI_LEVEL_TYPES)}; "
            f"got {multi_level_type!r}"
        )

    abstract_num = _build_abstract_num(
        abstract_id,
        levels,
        name=name,
        style_link=style_link,
        num_style_link=num_style_link,
        multi_level_type=multi_level_type,
    )
    # w:abstractNum must precede every w:num. python-docx's own helpers
    # only ever append, so this ordering is entirely on us.
    insert_before_first_anchor(root, abstract_num, _AFTER_ABSTRACT_NUM)

    num = el("w:num", **{"w:numId": str(num_id)})
    sub(num, "w:abstractNumId", **{"w:val": str(abstract_id)})
    insert_before_first_anchor(root, num, _AFTER_NUM)

    return num_id

define_bullet_list

define_bullet_list(
    doc: Document,
    *,
    levels: int = 1,
    indent_step: int = 720,
    hanging: int = 360,
    num_registry: NumIdRegistry | None = None,
    abstract_registry: AbstractNumIdRegistry | None = None,
) -> int

Define a bulleted list with Word's default glyph cycle.

Word cycles three bullets by depth — a filled round bullet, a hollow o, then a filled square — each needing its own symbol font to render as anything but a Latin letter.

Parameters:

Name Type Description Default
doc Document

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

required
levels int

How many outline levels to define, 1 to 9.

1
indent_step int

Twips of left indent added per level (720 = 0.5").

720
hanging int

Hanging indent in twips for every level.

360
num_registry NumIdRegistry | None

Pre-existing w:numId allocator.

None
abstract_registry AbstractNumIdRegistry | None

Pre-existing w:abstractNumId allocator.

None

Returns:

Type Description
int

The w:numId to pass to

int

func:~docx_plus.numbering.apply_list.

Raises:

Type Description
InvalidLevelError

If levels is outside 1 to 9.

Example

from docx import Document from docx_plus.numbering import apply_list, define_bullet_list doc = Document() bullets = define_bullet_list(doc, levels=2) apply_list(doc.add_paragraph("first"), bullets)

Source code in docx_plus/numbering/define.py
def define_bullet_list(
    doc: Document,
    *,
    levels: int = 1,
    indent_step: int = 720,
    hanging: int = 360,
    num_registry: NumIdRegistry | None = None,
    abstract_registry: AbstractNumIdRegistry | None = None,
) -> int:
    """Define a bulleted list with Word's default glyph cycle.

    Word cycles three bullets by depth — a filled round bullet, a hollow
    ``o``, then a filled square — each needing its own symbol font to
    render as anything but a Latin letter.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to mutate.
        levels: How many outline levels to define, 1 to 9.
        indent_step: Twips of left indent added per level (720 = 0.5").
        hanging: Hanging indent in twips for every level.
        num_registry: Pre-existing ``w:numId`` allocator.
        abstract_registry: Pre-existing ``w:abstractNumId`` allocator.

    Returns:
        The ``w:numId`` to pass to
        :func:`~docx_plus.numbering.apply_list`.

    Raises:
        InvalidLevelError: If ``levels`` is outside 1 to 9.

    Example:
        >>> from docx import Document
        >>> from docx_plus.numbering import apply_list, define_bullet_list
        >>> doc = Document()
        >>> bullets = define_bullet_list(doc, levels=2)
        >>> apply_list(doc.add_paragraph("first"), bullets)
    """
    return define_list_definition(
        doc,
        levels=[
            _preset_level(_BULLET_CYCLE[index % len(_BULLET_CYCLE)], index, indent_step, hanging)
            for index in range(_checked_level_count(levels))
        ],
        num_registry=num_registry,
        abstract_registry=abstract_registry,
    )

define_numbered_list

define_numbered_list(
    doc: Document,
    *,
    levels: int = 1,
    indent_step: int = 720,
    hanging: int = 360,
    num_registry: NumIdRegistry | None = None,
    abstract_registry: AbstractNumIdRegistry | None = None,
) -> int

Define a numbered list with Word's default format cycle.

Word cycles 1.a.i. by depth. Each level's counter stands alone; for the legal-outline shape (1.1, 1.1.1) build the levels yourself with text="%1.%2." and pass them to :func:define_list_definition.

Parameters:

Name Type Description Default
doc Document

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

required
levels int

How many outline levels to define, 1 to 9.

1
indent_step int

Twips of left indent added per level (720 = 0.5").

720
hanging int

Hanging indent in twips for every level.

360
num_registry NumIdRegistry | None

Pre-existing w:numId allocator.

None
abstract_registry AbstractNumIdRegistry | None

Pre-existing w:abstractNumId allocator.

None

Returns:

Type Description
int

The w:numId to pass to

int

func:~docx_plus.numbering.apply_list.

Raises:

Type Description
InvalidLevelError

If levels is outside 1 to 9.

Example

from docx import Document from docx_plus.numbering import apply_list, define_numbered_list doc = Document() steps = define_numbered_list(doc, levels=3) apply_list(doc.add_paragraph("step one"), steps)

Source code in docx_plus/numbering/define.py
def define_numbered_list(
    doc: Document,
    *,
    levels: int = 1,
    indent_step: int = 720,
    hanging: int = 360,
    num_registry: NumIdRegistry | None = None,
    abstract_registry: AbstractNumIdRegistry | None = None,
) -> int:
    """Define a numbered list with Word's default format cycle.

    Word cycles ``1.`` → ``a.`` → ``i.`` by depth. Each level's counter
    stands alone; for the legal-outline shape (``1.1``, ``1.1.1``) build
    the levels yourself with ``text="%1.%2."`` and pass them to
    :func:`define_list_definition`.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to mutate.
        levels: How many outline levels to define, 1 to 9.
        indent_step: Twips of left indent added per level (720 = 0.5").
        hanging: Hanging indent in twips for every level.
        num_registry: Pre-existing ``w:numId`` allocator.
        abstract_registry: Pre-existing ``w:abstractNumId`` allocator.

    Returns:
        The ``w:numId`` to pass to
        :func:`~docx_plus.numbering.apply_list`.

    Raises:
        InvalidLevelError: If ``levels`` is outside 1 to 9.

    Example:
        >>> from docx import Document
        >>> from docx_plus.numbering import apply_list, define_numbered_list
        >>> doc = Document()
        >>> steps = define_numbered_list(doc, levels=3)
        >>> apply_list(doc.add_paragraph("step one"), steps)
    """
    return define_list_definition(
        doc,
        levels=[
            _preset_level(_NUMBER_CYCLE[index % len(_NUMBER_CYCLE)], index, indent_step, hanging)
            for index in range(_checked_level_count(levels))
        ],
        num_registry=num_registry,
        abstract_registry=abstract_registry,
    )