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):
- the text of the elements
aria-labelledbypoints at; aria-label;- what HTML labels the element with: an image’s
alt, a control’s<label>(withfor, or around it), a button’svalue, afieldset’s<legend>, atable’s<caption>, afigure’s<figcaption>, aninput’splaceholder; - its content, for the roles that are named by it (links, buttons, headings, cells, tabs, menu items…);
- 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)
- Caps Lock+Right Arrow reads the page in order; each heading is announced as a heading with its level, a landmark as its kind (main, navigation, search), and an image by its description.
- H and Shift+H jump by heading, D by landmark, K by link, T by table; Enter on a link follows it.
- In a table, Ctrl+Alt+Arrow moves between cells and Narrator reads the column and row header with each cell, and “row 2 of 3, column 1 of 2”.
- Space on a check box toggles it and Narrator says the new state; Tab reaches it and a text field, and the focus ring is visible on both.
- In a text field, typing, Backspace, the arrow keys and Ctrl+Arrow work and Narrator echoes the characters; Caps Lock+Tab reads the field’s name and text. An input method (the Japanese or Chinese keyboard) shows its candidate window next to the caret - the first thing to look at, because the position is estimated.
- Tab past the last stop and the focus leaves the page for the next control, and Shift+Tab returns to the last.
Android, Android view with TalkBack (volume keys held for three seconds, or Settings > Accessibility)
- Swipe right and left reads the page in order, with headings, links, buttons and images announced as what they are.
- Drag one finger over the page: TalkBack reads what is under it (touch exploration), and lifting the finger leaves the focus there.
- The reading controls (swipe up then down) switch to character, word and paragraph: a text node is read by each in turn.
- Double-tap a check box toggles it and TalkBack says “checked”; a progress bar or slider is announced with its percentage, and a slider adjusts with a swipe up or down.
- Double-tap a text field: the keyboard opens and the caret is where the tap was. Type, delete and compose (a keyboard that suggests words, and a Chinese or Japanese one): composing text replaces the underlined text and does not add to it.
- With a hardware keyboard: Tab and Shift+Tab step through the page’s tab stops, and arrow keys move the caret in a field.
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
- A name that comes from an element that is not shown (
display: none) is not read, because it is never laid out;aria-labelledbypointing at one gives nothing, and no relation can point at one. - Content moved into the tree by
display: contentsis not in it yet. - A table header or footer that repeats on every page is in the tree once, with the rectangle it has on the first page.
- An inline SVG is one node: the shapes and text inside it are not separate nodes, and a
<title>or<desc>of an element inside it is not read. Amathelement is one node too, and its spoken form is plain English for common formulas, not a full speech-rule system (no language but English, no ClearSpeak or MathSpeak style settings). - A
thwith neitherscopenor aheaderslink is taken to head its column, andscope=colgroupandscope=rowgroupare not read. - Radio buttons are not counted as a set by their
name.