Skip to content

docx_plus.core.ids

Per-document id allocators. One registry per namespace per edit session — OOXML reuses w:id across several disjoint uniqueness domains, and bookmark id 7 does not collide with comment id 7.

Most namespaces get their registry in the capability package that owns them (comments.CommentIdRegistry, notes.FootnoteIdRegistry, numbering.NumIdRegistry). Three live here instead:

  • IdRegistry — SDT content-control ids, the original v0.1 case.
  • ParaIdRegistryw14:paraId, which is hex-rendered and unique across the whole package rather than within one part, because threaded comments key their parent/child links off it.
  • BookmarkIdRegistry / BookmarkNameRegistry — moved here in v0.5 because two capability packages need them: bookmarks owns add_bookmark, and publishing has to bookmark a caption to make it referenceable. SPEC §9.1 forbids the sibling import. Both are re-exported from docx_plus.bookmarks.registry.

Allocation comes in two flavours. next() mints a random 31-bit value, which is right for an opaque handle. next_sequential() takes the lowest free integer, which is what Word and python-docx do for numbering — a numbering.xml full of nine-digit ids is needlessly unreadable. _MIN_ID exists because w:abstractNumId legitimately starts at 0, unlike every w:id.

docx_plus.core.ids

Per-document registries of issued w:id values.

OOXML uses w:id for several disjoint namespaces — SDT controls, bookmarks, comments, footnotes, endnotes. Each namespace has its own uniqueness requirement; bookmark id 7 does not collide with comment id 7. v0.1 only minted SDT ids and shipped :class:IdRegistry for that purpose. v0.2 adds further namespaces (comments, bookmarks, notes) and refactors the shared next/reserve/issued mechanics into :class:_IdRegistryBase. Each namespace-specific registry is a tiny subclass that overrides :meth:_seed_from_document with the right discovery query.

:class:ParaIdRegistry is the one registry not backed by w:id: it mints w14:paraId values, which are hex-rendered and unique across the whole package rather than within a single part. It reuses the same 31-bit allocator because that happens to be exactly the range Word accepts for a paraId.

:class:BookmarkNameRegistry is the odd one out entirely — bookmarks are addressed by name, and nothing else in the format is. It lives here because two capability packages need it: bookmarks owns the public add_bookmark, and publishing has to bookmark a caption to make it referenceable, since a REF field can only point at a bookmark. BookmarkIdRegistry sits alongside it for the same reason (it was in bookmarks/registry.py through v0.4 and is still re-exported there).

SPEC §3, IMPLEMENTATION.md §7.

IdRegistry

IdRegistry(doc: Document)

Bases: _IdRegistryBase

Tracks issued SDT w:id values for one document-edit session.

On construction, the registry scans the document body and settings part for existing w:id values on w:sdt descendants and seeds itself with them, so :meth:next cannot collide with values already in the file.

Source code in docx_plus/core/ids.py
def __init__(self, doc: Document) -> None:
    """Scan ``doc`` for IDs already issued in this namespace.

    Args:
        doc: A python-docx :class:`~docx.document.Document`.
    """
    self._issued: set[int] = set()
    self._seed_from_document(doc)

ParaIdRegistry

ParaIdRegistry(doc: Document)

Bases: _IdRegistryBase

Tracks issued w14:paraId values for one document-edit session.

w14:paraId identifies a paragraph across the whole package rather than within one part: threaded comments key their parent/child links off the paraId of a comment body's last paragraph (w15:commentEx), so a collision between a body paragraph and a comment paragraph would corrupt the thread graph. The registry therefore seeds from the document body and every part that can carry paragraphs with a paraId — comments, footnotes, endnotes.

Word writes paraId as 8 uppercase hex digits — the rendering :meth:~_IdRegistryBase.next_hex provides — so this subclass adds only the seeding query.

Source code in docx_plus/core/ids.py
def __init__(self, doc: Document) -> None:
    """Scan ``doc`` for IDs already issued in this namespace.

    Args:
        doc: A python-docx :class:`~docx.document.Document`.
    """
    self._issued: set[int] = set()
    self._seed_from_document(doc)

BookmarkIdRegistry

BookmarkIdRegistry(doc: Document)

Bases: _IdRegistryBase

Tracks issued bookmark w:id values for one document-edit session.

The body-side <w:bookmarkStart> / <w:bookmarkEnd> elements both carry the id on a direct @w:id attribute, not as a <w:id w:val=...> child the way SDTs do.

Source code in docx_plus/core/ids.py
def __init__(self, doc: Document) -> None:
    """Scan ``doc`` for IDs already issued in this namespace.

    Args:
        doc: A python-docx :class:`~docx.document.Document`.
    """
    self._issued: set[int] = set()
    self._seed_from_document(doc)

BookmarkNameRegistry

BookmarkNameRegistry(doc: Document)

Tracks bookmark names in use, and mints Word-style hidden ones.

Bookmarks are the one thing in the format addressed by name rather than by id, and nothing prevents a document from carrying two with the same w:name. That is worth guarding: a duplicate makes a REF ambiguous and makes :func:~docx_plus.bookmarks.delete_bookmark remove both.

Deliberately not an :class:_IdRegistryBase subclass — the keys are strings, and most of them (sec_1, intro) have no numeric part to allocate from at all.

Lifecycle: one instance per document-edit session, like every other registry here (SPEC §9.4).

Scan doc for bookmark names already in use.

Parameters:

Name Type Description Default
doc Document

A python-docx :class:~docx.document.Document.

required
Source code in docx_plus/core/ids.py
def __init__(self, doc: Document) -> None:
    """Scan ``doc`` for bookmark names already in use.

    Args:
        doc: A python-docx :class:`~docx.document.Document`.
    """
    self._names: set[str] = set()
    for start in xpath(doc.element.body, ".//w:bookmarkStart"):
        name = start.get(qn("w:name"))
        if name is not None:
            self._names.add(str(name))

__contains__

__contains__(name: str) -> bool

Whether name is already in use.

Source code in docx_plus/core/ids.py
def __contains__(self, name: str) -> bool:
    """Whether ``name`` is already in use."""
    return name in self._names

reserve

reserve(name: str) -> str

Claim name, asserting it is not already taken.

Parameters:

Name Type Description Default
name str

The bookmark name to claim.

required

Returns:

Type Description
str

name (echoed so the call composes inline).

Raises:

Type Description
DuplicateBookmarkNameError

If name is already in use.

Source code in docx_plus/core/ids.py
def reserve(self, name: str) -> str:
    """Claim ``name``, asserting it is not already taken.

    Args:
        name: The bookmark name to claim.

    Returns:
        ``name`` (echoed so the call composes inline).

    Raises:
        DuplicateBookmarkNameError: If ``name`` is already in use.
    """
    if name in self._names:
        raise DuplicateBookmarkNameError(f"bookmark name {name!r} is already in use")
    self._names.add(name)
    return name

next_ref_name

next_ref_name() -> str

Mint an unused hidden name in Word's _Ref + 9-digit form.

Use this for anchors the user never names themselves — a caption being made referenceable, for instance. The leading underscore keeps it out of Word's Bookmark dialog.

Returns:

Type Description
str

A fresh name such as "_Ref418320715".

Raises:

Type Description
RegistryExhaustedError

If the space is exhausted (effectively impossible — included for completeness).

Source code in docx_plus/core/ids.py
def next_ref_name(self) -> str:
    """Mint an unused hidden name in Word's ``_Ref`` + 9-digit form.

    Use this for anchors the user never names themselves — a caption
    being made referenceable, for instance. The leading underscore
    keeps it out of Word's Bookmark dialog.

    Returns:
        A fresh name such as ``"_Ref418320715"``.

    Raises:
        RegistryExhaustedError: If the space is exhausted (effectively
            impossible — included for completeness).
    """
    for _ in range(64):
        candidate = f"_Ref{secrets.randbelow(_MAX_REF_SUFFIX) + 1:0{_REF_NAME_DIGITS}d}"
        if candidate not in self._names:
            self._names.add(candidate)
            return candidate
    for suffix in range(1, _MAX_REF_SUFFIX + 1):  # pragma: no cover - unreachable
        candidate = f"_Ref{suffix:0{_REF_NAME_DIGITS}d}"
        if candidate not in self._names:
            self._names.add(candidate)
            return candidate
    raise RegistryExhaustedError("bookmark name registry exhausted the _Ref space")

issued

issued() -> frozenset[str]

Return an immutable snapshot of every name in use.

Source code in docx_plus/core/ids.py
def issued(self) -> frozenset[str]:
    """Return an immutable snapshot of every name in use."""
    return frozenset(self._names)

DuplicateIdError

Bases: DocxPlusError, ValueError

Raised when an ID is reserved twice.

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

IdRangeError

Bases: DocxPlusError, ValueError

Raised when a reserved ID falls outside the 31-bit positive range.

Subclasses ValueError for backward compatibility; also subclasses :class:DocxPlusError per SPEC §9.7.

DuplicateBookmarkNameError

Bases: DocxPlusError, ValueError

Raised when a bookmark name is already in use in the document.

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