Accessibility

PeachBrackets describes a document to an assistive technology - a screen reader, a switch, a braille display - as an accessibility tree: the parts of the page that mean something to a reader, each with the role it has, the name it is called by, and what state it is in. A host such as the WinUI or Android view builds the tree its platform wants (UI Automation, an accessibility node provider) from this one.

var document = await HtmlDocument.LoadAsync(html, renderContext);
await document.LayoutAsync(canvas);

AccessibilityTree tree = document.GetAccessibilityTree();
foreach (var node in tree.Nodes)
    Console.WriteLine($"{node.Role} \"{node.Name}\"");

AccessibilityNode? underTheFinger = document.GetAccessibilityNodeAt(pageIndex: 0, x: 120, y: 340);

The tree is built when it is first asked for and kept until the document is laid out again. It needs no more than layout: text, bounds and links come from the laid-out page.

The tree

AccessibilityTree.Root is the document (named by its <title>); the nodes below it follow the document. Each AccessibilityNode has:

Member What it is
Role What it is: one of the roles of WAI-ARIA (Heading, Link, List, Table, Button, Navigation…) or Text for text itself
Name, Description The name it is called by, and more about it; see Names
Text The text it holds, white space collapsed
ReadingText, ReadingChildren What a reader says for it, and the children it steps to - an element’s own text is read as part of the element, so its text nodes are left out
Level, Uri, Language, ElementId, TagName The heading level; where a link leads (an address, or #name for a place in the document); the language of its content; the element’s id and name
Checked, Pressed, Expanded, Selected, Disabled, Required, ReadOnly, Invalid, Busy, Modal, Current State, from the element’s attributes and the elements around it
Live, Atomic How changes are announced (aria-live, or what the role implies)
IsFocusable, TabIndex, AccessKey, KeyShortcuts What a keyboard can do with it
RowIndex, ColumnIndex, RowSpan, ColumnSpan, RowCount, ColumnCount, ColumnHeaders, RowHeaders Where a cell or row is in its table or grid (counted from 0), how many rows and columns it covers, how big the table is, and the cells that head a cell; see Tables
PositionInSet, SetSize The place of a list item, option, tab, menu item or tree item among its siblings (counted from 1), and how many there are
Controls, Owns, FlowTo, Details, ErrorMessages, LabelledBy, DescribedBy, ActiveDescendant The nodes that aria-controls, aria-owns, aria-flowto, aria-details, aria-errormessage, aria-labelledby, aria-describedby and aria-activedescendant point at
Bounds One rectangle for each page it is on, in points from the top-left of the sheet
Id A name that is the same each time the document is laid out, so a reader that is on a node stays there

Elements that mean nothing to a reader (div, span, a section that has no name) are not in the tree; their content is. Content that is not shown is not in it either: display: none, visibility: hidden, hidden, inert, and anything inside an aria-hidden="true" element. An element with role="presentation" or role="none" is left out too, unless it can be focused, and so is an image whose alt is empty.

Roles

An element has the role HTML gives it (HTML-AAM): h1-h6 are headings, a[href] links, ul and ol lists, table a table with th column or row headers and td cells, nav, main, aside and search landmarks, header and footer banner and content information landmarks unless they are inside an article, aside, main, nav or section, section and form landmarks only when they have a name, and the form controls are what their type says (input type=checkbox a check box, type=range a slider, type=number a spin button, select a combo box or, with multiple, a list box…).

A role attribute replaces that. When it names several roles the first one that exists is used, as WAI-ARIA lets an author give a fallback.

Changes and live regions

AccessibilityTreeDiff.Compare(before, after) says what differs between two trees of the same document, matching parts by Id: parts that came or went, a changed name, description, number or value text (ValueChanged), and a changed state (checked, pressed, expanded, selected, disabled, required, readonly, invalid, busy, current, modal). A host uses it after a form change or a reflow to raise the platform’s change events for the parts that changed instead of replacing the whole tree. It also says what a screen reader should say for a live region that changed (aria-live="polite" or "assertive", or the roles that imply one): the content that is new in it, or all of it when the region is aria-atomic. A region that is aria-busy, one that is new in the second tree, and a region inside another live region (which is spoken with it) say nothing. An element that gives aria-live keeps a node of its own, a generic one, whatever its role.

Names

The name of a node is the first of these that gives one (the Accessible Name and Description Computation):

  1. the text of the elements aria-labelledby points at;
  2. aria-label;
  3. what HTML labels the element with: an image’s alt, a control’s <label> (with for, or around it), a button’s value, a fieldset’s <legend>, a table’s <caption>, a figure’s <figcaption>, an input’s placeholder;
  4. its content, for the roles that are named by it (links, buttons, headings, cells, tabs, menu items…);
  5. its title.

The description is aria-describedby, then aria-description, then title if the name did not already come from it.

SVG and MathML

An inline <svg> is a GraphicsDocument node (or Image when it says role="img"). Its name is the first of aria-labelledby, aria-label and the text of its own <title> child, and its description is aria-describedby, aria-description or its <desc> child. An SVG with aria-hidden="true" is left out.

A <math> element is a Math node. Its name is the first of aria-labelledby, aria-label, alttext and a text/plain <annotation> inside <semantics>; with none of them the formula is said in English from its structure: x² + 1 is “x squared plus 1”, a/b is “a over b”, a sum with limits is “sum from i equals 1 to n …”, a matrix is “table with 2 rows and 2 columns; row 1: a, b; row 2: c, d”. Fractions, roots, scripts, limits, fences, tables, the usual operators and Greek letters are covered; anything else is read in the order it is written.

Tables

Cells are placed the way HTML lays a table out: left to right in their row, skipping the places a cell from a row above reaches down into, each taking as many places as its colspan and rowspan say. A header or footer row group goes first or last whatever order the source has it in. aria-rowindex, aria-colindex, aria-rowcount and aria-colcount replace the computed numbers for a grid that shows only part of its data.

A data cell’s ColumnHeaders are the column headers above it that overlap its columns, and its RowHeaders the th scope=row cells before it that overlap its rows; a cell with a headers attribute is headed by exactly the cells it names.

Sets and references

The items of a list are numbered among the siblings with the same role; aria-posinset and aria-setsize replace that, and an item without a position follows the one before it. A reference such as aria-controls="panel" is resolved to the node id="panel" is when there is one: an element that is not in the tree (a div or span with no role, or one that is not shown) cannot be pointed at. aria-owns is reported but the tree is not rearranged to match it.

State

aria-checked, aria-pressed, aria-expanded, aria-selected, aria-disabled, aria-required, aria-readonly, aria-invalid, aria-busy, aria-modal and aria-current are read, and so are their native equivalents: checked, disabled (also through a disabled fieldset), required, readonly, selected, and a details element’s open.

PDF/UA

A tagged PDF built from a document carries the same names, languages and table details (see Tagged PDF (PDF/UA) Support), and PdfGenerateConfig.PdfUAConformance makes the file claim PDF/UA: it identifies it in the XMP metadata, requires a title, a language and a creation date, gives every figure alternative text, and shows the title in the viewer. See Generating PDF/UA (accessible) output.

Other formats

HtmlDocument.PaintAccessibility(pageIndex, canvas) hands the tree to any Canvas as AddSemanticRegion calls: one unpainted region per part of a page that has a role (landmarks, headings, links, buttons, lists, table parts…), in reading order, with its name, description, language, heading level and states. PeachDrawing.Svg writes them as <rect role="..." aria-label="...">, so an SVG made from a document is readable by a screen reader; the text itself is still drawn as outlines unless the SVG is made with selectable text. The regions are flat (no nesting). The Skia and Win2D canvases record them in their link map (SkiaLinkMap.Semantics / Win2dLinkMap.Semantics) beside the links, and SemanticAt finds the innermost one under a point, which is what a host’s touch exploration or UI Automation hit test needs.

Form controls

A host that shows the page sets HtmlDocumentOptions.PaintFormControls, and the engine draws every <input> and <select> itself from the control’s own CSS - no native widget is placed over the page - so a control is styled like any other box. The look covers check boxes, radio buttons, text-like fields (value or placeholder), buttons, drop-downs and ranges, a colour input (a block of its colour), a file input (a “Choose File” button and “No file chosen”) and date and time inputs (their value, or the pattern it would take while empty: mm/dd/yyyy, --:-- --…). The text of a text field can be edited through HtmlDocument (see PeachBrackets); a textarea is drawn and edited like a text field (it keeps its line breaks, moves the caret by line and scrolls to keep the caret in view; both hosts route Enter, Up, Down, Home and End to it), a drop-down, a date, time or colour field and a range control are operated through the hosts’ own popups and drag handling, and a file input opens the host’s file picker. The accessibility tree already reports each control’s role, name and state.

Preferences and text spacing

A host reports what the reader has asked of the system with HtmlDocumentOptions.Preferences, so style sheets can answer with prefers-reduced-motion, prefers-contrast, forced-colors, inverted-colors, prefers-reduced-transparency and prefers-reduced-data. The defaults are those of a static document. HtmlDocumentOptions.TextSpacing applies the spacing WCAG 1.4.12 asks a page to survive (TextSpacingOverrides.Wcag) over the document’s own.

Forced colors

With UserPreferences.ForcedColors on, the document is also drawn in the colours of UserPreferences.ForcedPalette (a SystemColorPalette: Canvas, CanvasText, LinkText, ButtonFace… filled from the platform, or SystemColorPalette.Light / .Dark), as a browser does under a Windows high contrast theme: text takes CanvasText (LinkText in a link, ButtonText in a button, FieldText in a field), backgrounds take Canvas (ButtonFace, Field, Mark), borders, outlines and column rules take CanvasText (ButtonBorder on a control), box shadows, text shadows and gradient backgrounds are not drawn (a url() image is kept), and an inline <svg>’s fill and stroke (solid, gradient or pattern) take CanvasText. A fully transparent colour stays transparent. An element opts out with forced-color-adjust none, or keeps just its text colour with preserve-parent-color. The CSS system colour keywords (CanvasText, LinkText…) work in any style sheet; they always have the colours of SystemColorPalette.Light, whatever palette the host passes.

Text size

HtmlDocumentOptions.FontScale scales the root font size (1.5 is 150% of the default), so text the document sizes in em, rem, a percentage or a keyword follows the reader’s text size setting, and text sized in px or pt does not, as in a browser.

Checking with a screen reader

The tests read the accessibility tree and the UI Automation and AccessibilityNodeInfo trees a host builds from it, but they cannot tell what a person hears. Before relying on a host for real readers, walk a page that has headings, landmarks, a link, a table with headers, a check box, a text field, a progress bar and an image with a description through it with the platform’s screen reader. The list below says what to listen for.

Windows, WinUI view with Narrator (Win+Ctrl+Enter starts it)

Android, Android view with TalkBack (volume keys held for three seconds, or Settings > Accessibility)

Anything that is announced wrongly, silently or twice is worth recording with the page and the setting, since the tree it came from can then be dumped (adb shell uiautomator dump on Android, the Accessibility Insights tool on Windows) and compared with what the document means.

Limits of this version