Skip to content

docx_plus.controls.read

Read and modify the values of existing content controls. list_controls(doc) returns every control as a list[ControlValue] in document order; read_controls(doc) is the keyed convenience on top of it, returning a dict[str, ControlValue] keyed by tag (default) or alias. set_control_value / clear_control mutate single controls.

Reach for list_controls on Word-authored documents

w:tag is optional and non-unique in OOXML, and Word writes <w:tag w:val=""/> for any control the author did not explicitly tag. read_controls can only report controls that have a usable key, so on a typical Word form it omits most of them. list_controls reports every control, with control_id for identity.

The five typed errors are all dual-base (DocxPlusError plus a stdlib exception) so callers can match either contract — see the error hierarchy.

docx_plus.controls.read

Read and modify content controls (SDTs) in an existing document.

The companion to :mod:docx_plus.controls.builder. Where builder writes w:sdt elements, this module discovers them, reports their values, sets new values, or resets them to placeholder state.

The read side is intentionally schema-tolerant: it works on any document with content controls, not just ones built by :class:FormBuilder. Type detection dispatches on the marker child of w:sdtPr (w:text, w:dropDownList, w:comboBox, w:date, w14:checkbox, and the container/rich-text markers Word writes but this module cannot set a value on).

Reading tolerates what Word actually emits, which is looser than what :class:FormBuilder writes:

  • w:tag is optional and non-unique in OOXML. Word's Developer-ribbon controls are written with <w:tag w:val=""/> unless the author types a tag, so empty and duplicate tags are the norm in real documents. :func:list_controls therefore keys nothing and returns document order; :func:read_controls is the keyed convenience built on top of it.
  • A w:sdt with no type marker at all is a rich-text control (ECMA-376 makes rich text the default), not an unrecognised element.
  • Controls live in headers, footers, footnotes, and endnotes as well as the body, and every function here walks all of them.

ControlType module-attribute

ControlType = Literal[
    "text",
    "dropdown",
    "combobox",
    "date",
    "checkbox",
    "richtext",
    "picture",
    "group",
    "repeating",
    "repeatingitem",
    "docpart",
    "citation",
    "bibliography",
    "equation",
]

WRITABLE_TYPES module-attribute

WRITABLE_TYPES: frozenset[str] = frozenset(
    {"text", "dropdown", "combobox", "date", "checkbox"}
)

ControlValue dataclass

ControlValue(
    tag: str | None,
    alias: str | None,
    control_type: ControlType,
    value: ControlValueT | None,
    is_placeholder: bool,
    control_id: int | None = None,
    index: int = 0,
    location: str = "body",
)

A single content control's identity, type, and current value.

Attributes:

Name Type Description
tag str | None

The control's w:tag value, or None when the control has no w:tag element at all. An empty string means the element is present but its w:val is empty — the shape Word writes for a control the author never tagged. Neither is unique, so a tag is a label, not a primary key; use control_id for identity.

alias str | None

The control's w:alias value (UI label), or None. Also not unique — aliases are human labels and repeat freely.

control_type ControlType

One of the values in :data:ControlType.

value ControlValueT | None

The current value:

  • text/dropdown/combobox: str if filled, None if showing placeholder.
  • date: :class:~datetime.datetime if filled, None otherwise.
  • checkbox: always bool (no placeholder concept).
  • every other type: the control's concatenated text, which for a container control is the text of everything it wraps.
is_placeholder bool

True if the control is showing its placeholder text (w:showingPlcHdr present in sdtPr). Always False for checkboxes.

control_id int | None

The control's w:id value, or None if absent or non-numeric. This is OOXML's actual identity field; pass it to :func:set_control_value to disambiguate a repeated tag. Word does not guarantee uniqueness across a document merge, so treat a collision here as possible but rare.

index int

Zero-based position in :func:list_controls order — document order within :attr:location, parts visited body-first. Stable for a given document, not stable across edits that add controls.

location str

Which story the control lives in: "body", "footnotes", "endnotes", or "header:S:WHICH" / "footer:S:WHICH" where S is the 1-based section index and WHICH is primary, first, or even.

ControlNotFoundError

Bases: DocxPlusError, KeyError

Raised when no content control with the requested tag exists.

Subclasses KeyError so existing except KeyError: clauses still catch it; also subclasses :class:DocxPlusError per SPEC §9.7.

DuplicateTagError

Bases: DocxPlusError, ValueError

Raised when a tag does not identify exactly one control.

Raised by :func:read_controls when two controls share a non-empty key, and by :func:set_control_value / :func:clear_control when the requested tag matches more than one control — writing to an arbitrary one of them would silently corrupt the others' document.

Controls whose w:tag is absent or empty are not a duplicate: they are unkeyable, so :func:read_controls omits them. Use :func:list_controls to see every control regardless of tag, and pass control_id to the writers to target one unambiguously.

ValueNotInListError

Bases: DocxPlusError, ValueError

Raised by :func:set_control_value when a dropdown value has no match.

ControlTypeError

Bases: DocxPlusError, TypeError

Raised when a value's Python type does not match the control's type.

list_controls

list_controls(doc: Document) -> list[ControlValue]

Return every content control in doc, in document order.

The unkeyed primitive that :func:read_controls is built on. Nothing is dropped and nothing can collide, so this is the function to reach for on documents Word produced rather than :class:FormBuilder: it reports controls with an empty tag, with no tag element, of rich-text or container type, and in headers, footers, footnotes, and endnotes.

Parameters:

Name Type Description Default
doc Document

The python-docx Document to inspect.

required

Returns:

Type Description
list[ControlValue]

A list of :class:ControlValue, one per w:sdt, ordered by story

list[ControlValue]

(body first, then headers/footers by section, then notes) and by

list[ControlValue]

document order within each story. Each entry's index matches its

list[ControlValue]

position in this list.

Source code in docx_plus/controls/read.py
def list_controls(doc: Document) -> list[ControlValue]:
    """Return every content control in ``doc``, in document order.

    The unkeyed primitive that :func:`read_controls` is built on. Nothing is
    dropped and nothing can collide, so this is the function to reach for on
    documents Word produced rather than :class:`FormBuilder`: it reports
    controls with an empty tag, with no tag element, of rich-text or container
    type, and in headers, footers, footnotes, and endnotes.

    Args:
        doc: The python-docx Document to inspect.

    Returns:
        A list of :class:`ControlValue`, one per ``w:sdt``, ordered by story
        (body first, then headers/footers by section, then notes) and by
        document order within each story. Each entry's ``index`` matches its
        position in this list.
    """
    out: list[ControlValue] = []
    for sdt, location in _iter_sdts(doc):
        info = _read_sdt(sdt, index=len(out), location=location)
        if info is None:
            continue
        out.append(info)
    return out

read_controls

read_controls(
    doc: Document, *, by: Literal["tag", "alias"] = "tag"
) -> dict[str, ControlValue]

Return the content controls in doc that have a usable key.

A convenience wrapper over :func:list_controls for the common case of a form whose controls carry deliberate, distinct tags — the shape :class:FormBuilder writes.

Controls without a usable key are omitted. In OOXML w:tag is neither required nor unique, and Word writes <w:tag w:val=""/> for any control the author did not explicitly tag, so on a real Word document this can omit most of them. Use :func:list_controls when you need every control, or by="alias" when the document labels controls rather than tagging them.

Parameters:

Name Type Description Default
doc Document

The python-docx Document to inspect.

required
by Literal['tag', 'alias']

Either "tag" (default) — key on w:tag — or "alias" — key on w:alias. Either way, controls whose key is absent or empty are skipped.

'tag'

Returns:

Type Description
dict[str, ControlValue]

Mapping from key to :class:ControlValue.

Raises:

Type Description
DuplicateTagError

If two controls share the same non-empty key. This is genuine ambiguity, unlike an absent key, so it is reported rather than silently resolved.

Source code in docx_plus/controls/read.py
def read_controls(
    doc: Document,
    *,
    by: Literal["tag", "alias"] = "tag",
) -> dict[str, ControlValue]:
    """Return the content controls in ``doc`` that have a usable key.

    A convenience wrapper over :func:`list_controls` for the common case of a
    form whose controls carry deliberate, distinct tags — the shape
    :class:`FormBuilder` writes.

    **Controls without a usable key are omitted.** In OOXML ``w:tag`` is
    neither required nor unique, and Word writes ``<w:tag w:val=""/>`` for any
    control the author did not explicitly tag, so on a real Word document this
    can omit most of them. Use :func:`list_controls` when you need every
    control, or ``by="alias"`` when the document labels controls rather than
    tagging them.

    Args:
        doc: The python-docx Document to inspect.
        by: Either ``"tag"`` (default) — key on ``w:tag`` — or ``"alias"`` —
            key on ``w:alias``. Either way, controls whose key is absent or
            empty are skipped.

    Returns:
        Mapping from key to :class:`ControlValue`.

    Raises:
        DuplicateTagError: If two controls share the same non-empty key. This
            is genuine ambiguity, unlike an absent key, so it is reported
            rather than silently resolved.
    """
    out: dict[str, ControlValue] = {}
    for info in list_controls(doc):
        key = info.tag if by == "tag" else info.alias
        if not key:
            continue
        if key in out:
            raise DuplicateTagError(
                f"duplicate {by} {key!r} encountered while reading controls; "
                f"use list_controls() to read a document with repeated {by}s",
            )
        out[key] = info
    return out

set_control_value

set_control_value(
    doc: Document,
    tag: str | None,
    value: ControlValueT,
    *,
    control_id: int | None = None,
) -> None

Set the value of a control identified by tag (or control_id).

Parameters:

Name Type Description Default
doc Document

The python-docx Document to modify.

required
tag str | None

The control's w:tag value. May be None when control_id is given.

required
value ControlValueT

The new value. Type must match the control type:

  • text: str
  • dropdown / combobox: str
  • date: :class:~datetime.datetime
  • checkbox: bool
required
control_id int | None

The control's w:id value, from :attr:ControlValue.control_id. Selects one control directly and ignores tag — the way to write to a control whose tag is empty, absent, or shared with others.

None

Raises:

Type Description
ControlNotFoundError

If no control matches.

DuplicateTagError

If tag matches more than one control and no control_id was given to disambiguate.

ControlTypeError

If value's type does not match the control type, or the control is one of the rich-text/container types this module cannot set a scalar value on.

ValueNotInListError

For a dropdown when value matches neither w:value nor w:displayText of any list item.

Source code in docx_plus/controls/read.py
def set_control_value(
    doc: Document,
    tag: str | None,
    value: ControlValueT,
    *,
    control_id: int | None = None,
) -> None:
    """Set the value of a control identified by ``tag`` (or ``control_id``).

    Args:
        doc: The python-docx Document to modify.
        tag: The control's ``w:tag`` value. May be ``None`` when ``control_id``
            is given.
        value: The new value. Type must match the control type:

            - text: ``str``
            - dropdown / combobox: ``str``
            - date: :class:`~datetime.datetime`
            - checkbox: ``bool``

        control_id: The control's ``w:id`` value, from
            :attr:`ControlValue.control_id`. Selects one control directly and
            ignores ``tag`` — the way to write to a control whose tag is
            empty, absent, or shared with others.

    Raises:
        ControlNotFoundError: If no control matches.
        DuplicateTagError: If ``tag`` matches more than one control and no
            ``control_id`` was given to disambiguate.
        ControlTypeError: If ``value``'s type does not match the control type,
            or the control is one of the rich-text/container types this
            module cannot set a scalar value on.
        ValueNotInListError: For a dropdown when ``value`` matches neither
            ``w:value`` nor ``w:displayText`` of any list item.
    """
    sdt = _select_sdt(doc, tag, control_id)
    sdt_pr = _sdt_pr(sdt)
    sdt_content = _sdt_content(sdt)
    control_type = _require_writable(sdt, tag, control_id)

    if control_type == "checkbox":
        if not isinstance(value, bool):
            raise ControlTypeError(
                f"checkbox control {tag!r} requires bool; got {type(value).__name__}",
            )
        _set_checkbox(sdt_pr, sdt_content, checked=value)
        return

    if control_type == "date":
        if not isinstance(value, datetime):
            raise ControlTypeError(
                f"date control {tag!r} requires datetime; got {type(value).__name__}",
            )
        _set_date(sdt_pr, sdt_content, value)
        _clear_placeholder_flag(sdt_pr)
        return

    # text / dropdown / combobox
    if not isinstance(value, str):
        raise ControlTypeError(
            f"{control_type} control {tag!r} requires str; got {type(value).__name__}",
        )

    if control_type == "text":
        _replace_sdt_content_text(sdt_content, value)
    elif control_type == "dropdown":
        display = _resolve_dropdown_value(sdt_pr, value, allow_freeform=False, tag=tag)
        _replace_sdt_content_text(sdt_content, display)
    else:  # combobox
        display = _resolve_dropdown_value(sdt_pr, value, allow_freeform=True, tag=tag)
        _replace_sdt_content_text(sdt_content, display)

    _clear_placeholder_flag(sdt_pr)

clear_control

clear_control(
    doc: Document, tag: str | None, *, control_id: int | None = None
) -> None

Reset a control to its placeholder state.

For text/dropdown/combobox/date: re-adds w:showingPlcHdr to sdtPr and re-applies the PlaceholderText rStyle to every run in sdtContent. The placeholder text itself is preserved in place (whatever sdtContent currently holds).

For checkbox: resets the checked flag to 0 and the glyph to . Checkboxes have no placeholder mode.

Parameters:

Name Type Description Default
doc Document

The python-docx Document to modify.

required
tag str | None

The control's w:tag value. May be None when control_id is given.

required
control_id int | None

The control's w:id value; selects one control directly and ignores tag. See :func:set_control_value.

None

Raises:

Type Description
ControlNotFoundError

If no control matches.

DuplicateTagError

If tag matches more than one control and no control_id was given to disambiguate.

ControlTypeError

If the control is a rich-text or container type, which has no placeholder state to reset.

Source code in docx_plus/controls/read.py
def clear_control(
    doc: Document,
    tag: str | None,
    *,
    control_id: int | None = None,
) -> None:
    """Reset a control to its placeholder state.

    For text/dropdown/combobox/date: re-adds ``w:showingPlcHdr`` to sdtPr and
    re-applies the ``PlaceholderText`` rStyle to every run in sdtContent. The
    placeholder text itself is preserved in place (whatever sdtContent
    currently holds).

    For checkbox: resets the checked flag to ``0`` and the glyph to
    ``☐``. Checkboxes have no placeholder mode.

    Args:
        doc: The python-docx Document to modify.
        tag: The control's ``w:tag`` value. May be ``None`` when ``control_id``
            is given.
        control_id: The control's ``w:id`` value; selects one control directly
            and ignores ``tag``. See :func:`set_control_value`.

    Raises:
        ControlNotFoundError: If no control matches.
        DuplicateTagError: If ``tag`` matches more than one control and no
            ``control_id`` was given to disambiguate.
        ControlTypeError: If the control is a rich-text or container type,
            which has no placeholder state to reset.
    """
    sdt = _select_sdt(doc, tag, control_id)
    sdt_pr = _sdt_pr(sdt)
    sdt_content = _sdt_content(sdt)
    control_type = _require_writable(sdt, tag, control_id)

    if control_type == "checkbox":
        _set_checkbox(sdt_pr, sdt_content, checked=False)
        return

    _set_placeholder_flag(sdt_pr)
    for run in sdt_content.findall(qn("w:r")):
        rpr = run.find(qn("w:rPr"))
        if rpr is None:
            rpr = el("w:rPr")
            run.insert(0, rpr)
        for existing in rpr.findall(qn("w:rStyle")):
            remove(existing)
        rstyle = el("w:rStyle", **{"w:val": _PLACEHOLDER_STYLE_ID})
        rpr.insert(0, rstyle)