Accessibility · Semantics and the A11y Tree

Static Text Objects and the Text Interface

Why paragraphs become single text objects, how character offsets and range queries work, and what happens when a text run is split by inline structure.

A screen reader in browse mode does not read elements. It reads text, and it needs to be able to ask questions about text: where the caret is, which word is at offset 412, what the bounding rectangle of characters 300 through 340 is, whether this character is part of a link. Those questions are answered by a text interface attached to objects that carry text, and the shape of the accessibility tree determines how many such objects there are and how the offsets behave.

The primary object type is static text. It has no role beyond being text, no interaction, and a single string of characters. Every platform has an equivalent: ROLE_SYSTEM_STATICTEXT with IAccessibleText on MSAA, AXStaticText with AXValue and range attributes on macOS, ATK_ROLE_STATIC with the AtkText interface on AT-SPI. A consumer obtains the string, then issues range queries against it.

One object per block, not per node

Consecutive inline content inside a single block container collapses into one static text object. A paragraph of prose is one object containing the whole sentence run, regardless of how many span elements the markup uses to apply styling. A table cell is one object. A list item is one object. The block boundary is what splits runs, and the block boundary is determined by the box tree, not by the tag: an inline element with display: block starts a new run, and a block element with display: inline does not.

The aggregation exists because the alternative is unaffordable. A page of prose with one span per word would produce tens of thousands of objects, each with its own rectangle, name, and state flags. Collapsing to one object per block reduces that to hundreds, and the per-word detail is preserved inside the string rather than in the tree.

The collapse has a limit: an inline element that is itself exposed splits the run. A link inside a paragraph becomes a child object of its own, with its own text. So <p>See the <a href="/x">retry policy</a> for details.</p> produces a paragraph object with two static text children and a link object between them, and the text “See the ” belongs to the first child while “retry policy” belongs to the link’s own static text child. A screen reader navigating by character must therefore step across object boundaries, and the character index it reports is per-object, not per-paragraph. This is why character navigation announces positions that do not obviously correspond to the visible paragraph.

The split has a second cause: a br. A hard line break ends the run, so a two-line address in one paragraph is two static text objects. A wbr does not split; it is a break opportunity with no semantics.

<p>Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy.</p>
<!--
  Objects:
    static text "Press "
    kbd -> generic (or role text) containing static text "Ctrl"
    static text "+"
    kbd -> generic containing static text "C"
    static text " to copy."
  A character query at offset 3 resolves against the first object only.
-->

Offsets, and why they are not what authors expect

Every text interface addresses characters by integer offset into its own string. The offsets are code-unit based, not code-point based, in most implementations: a character outside the Basic Multilingual Plane occupies two offsets in a UTF-16 backed string. A consumer that treats offsets as code points desynchronises on the first emoji or mathematical symbol. The specification does not settle this, and platforms differ, which is why the safest consumer behaviour is to query text by range rather than to assume a character width.

Whitespace is part of the string. A paragraph rendered with white-space: pre preserves newlines in the exposed text; a normal paragraph collapses runs of whitespace to a single space. Leading and trailing whitespace is usually trimmed at the object boundary. The trim is visible to consumers: a paragraph that ends with a space before a link has that space in the first text object’s string, and a paragraph whose final character is a space has it removed.

Offsets are also unstable under mutation. Inserting a character at the start of a paragraph shifts every subsequent offset by one, and any consumer that cached an offset now points at the wrong character. Platforms handle this by emitting a text-changed event with the affected range, and consumers re-query rather than adjust. A virtual buffer built from the initial tree, the classic screen reader model, must therefore be rebuilt or patched on every text event, which is the main cost of a fast-changing page.

Range queries and their cost

A range query takes a start offset, an end offset, and a granularity: character, word, sentence, line, or paragraph. The implementation answers by walking the string and applying boundary rules. Word boundaries are locale-dependent and typically follow the Unicode text segmentation rules; sentence boundaries likewise. Line boundaries are not textual at all: they require layout, so a line query on a long paragraph forces the engine to consult the line box tree rather than the string.

That is the expensive case. Character and word queries are linear in the length of the requested range, which is trivial. A line-range query across a paragraph of ten thousand characters must map offsets to line boxes, and the line box structure is only valid after layout. Engines cache the mapping for the current layout and invalidate it when the text or the width changes; a resize invalidates it for the whole document. Screen readers that navigate by line, the default granularity in most browse modes, issue this query constantly, which is why reading a very long single paragraph is slower than reading the same text split into blocks.

The bounding rectangle query has the same dependency. getBoundingBoxes for a character range returns one rectangle per line box the range touches, not one rectangle for the whole range. A selection spanning four lines yields four rectangles. The offsets those rectangles answer to are computed per object, not per paragraph, for the reasons given in How a Document Becomes an Accessibility Tree. Consumers use the count to detect line breaks, and an implementation that returns a single merged rectangle breaks that detection.

Embedded objects and the object replacement character

Text interfaces can contain embedded objects: an image, a form control, or any element exposed as a child inside a text run. The convention is that the embedded object occupies exactly one offset in the parent string, and the character at that offset is U+FFFC, the object replacement character. A consumer reading the string therefore sees a placeholder where the image is, and must look up the child object at that offset to learn what it is.

Not every engine follows the convention. Some omit the placeholder entirely, so the parent string is shorter than the visual text and offsets after the image are shifted. Some substitute a descriptive string such as the image’s alt text, which makes the parent string self-describing but means a consumer that also announces the child object says the alt text twice. The variation is one reason alt text placement is contentious: on an <img> inside a link inside a paragraph, the same string may be exposed as the image’s name, as the link’s name, and as the parent paragraph’s text, and which of the three is announced depends on navigation granularity.

Where text objects are absent

A canvas has no text objects. Its pixels are opaque to the accessibility tree unless the author supplies fallback content or an overlay of positioned elements that mirror the drawn text. A CSS background-image carrying text likewise produces nothing. SVG text is exposed, but whether it appears as static text objects or as a single object with a name depends on the engine and on whether the text element is inside a svg with a role.

A pseudo-element’s generated content is exposed as text in some engines and not in others, and when it is exposed, it may be appended to the parent’s run or given its own object. A content: " *" marker on a required field is sometimes announced as part of the label and sometimes not, which is the mechanism-level reason the convention is to mark required fields with a real attribute rather than with decorative glyphs.

The final limitation is that text attributes (font, weight, colour, and the semantic attributes that ARIA maps onto them) are optional. An engine may expose a string with no attribute runs at all. A consumer that depends on attributes to detect emphasis or code spans will find nothing to detect, and the markup that would have carried the semantics has already been folded into the run. That collapse is the same one described for names in The Order of Sources in Accessible Name Computation, applied to a different output: the traversal flattens structure, and only what the engine chooses to record survives as an attribute.

References

  1. Core Accessibility API Mappings 1.2 — Text — W3C
  2. AT-SPI 2 — Text interface — GNOME Accessibility project
  3. IAccessibleText — Microsoft Active Accessibility documentation
  4. NSAccessibility Protocol — Apple developer documentation