This directory is a language-agnostic conformance test suite for three pipeline stages used to implement read-aloud/TTS on top of HTML content:
- HTML parsing — reading a document or fragment of HTML/XHTML.
- Guided Navigation (GND) document generation — mapping the parsed HTML
onto the Guided Navigation
role vocabulary defined in
roles.md. - Utterance extraction — turning a GND document into an ordered list of read-aloud utterances, following the patterns documented in the read-aloud examples.
Fixtures here are plain files on disk (HTML + JSON) — not code. Any platform (TypeScript, Kotlin, Swift, ...) can consume them with nothing more than a JSON parser and a file reader. There is no query language, template system, or fixture-specific code to learn.
fixtures/
README.md this file
manifest.json flat index of every fixture, for discovery without a filesystem walk (generated — do not hand-edit)
ROLES_COVERAGE.md flat list of every role covered and its fixtures (generated — do not hand-edit)
<fixture-id>/
meta.json fixture metadata
input.html the input markup as HTML (a fragment or a full document, see meta.json.inputKind);
or input.xhtml when the fixture's content is XHTML (all *-epub-type fixtures)
gnd.json the expected Guided Navigation document produced from the input file
utterances.json the expected utterances from gnd.json — a default case per
format, plus any option combination that diverges from it —
see "Utterance extraction options" below
manifest.json and ROLES_COVERAGE.md are both generated from the
meta.json files on disk by scripts/build-fixtures-manifest.js
(npm run generate-fixtures-manifest) — the filesystem is the source of
truth, not either of these files. Never hand-edit them; run the script
instead.
roles.md
was the seed used to originally populate this suite — it is not something
this suite continues to track itself against. Its tier/table categorization
does not appear anywhere in meta.json, manifest.json, or
ROLES_COVERAGE.md — only role does, since that's the actual content type
being tested (a "footnote", a "list", ...), not a classification scheme.
Each fixture directory is fully self-contained and independently loadable.
One fixture = one distinct, scoped behavior. Concretely:
- One fixture per role, per distinct HTML encoding. roles.md documents
up to three ways of expressing a role: a native HTML element, an explicit
ARIA role (
role="doc-..."or a plain ARIA role), and an EPUBepub:typeattribute. Every one of these is a separate code path through stage 1 (HTML parsing), even when two encodings produce an identical GND result — so each gets its own fixture, distinguished by its id/description and visible directly in itsinput.html(e.g.list-html-nativevs.list-role-ariavs.list-epub-type). This isn't tracked as a field inmeta.json— it's not information a consumer needs to look up, just a reason more than one fixture exists for the same role. - Trivial repetition is bundled into one fixture.
heading1...heading6are the same pathway repeated at six depths, not six distinct behaviors — they live together in oneheading-*fixture. Multiple<li>inside one<ul>is onelist-*fixture, not one per item.
role is a roles.md role name for the vast majority of fixtures — one
covering a stage-2/3 (extraction-option) concern that cuts across roles
rather than targeting one, such as language resolution, instead uses a
short pseudo-role naming that concern (e.g. "language") not found in
roles.md. rolesCovered still lists the real roles.md role(s) the
fixture's markup exercises, so that coverage tracking in
ROLES_COVERAGE.md stays accurate either way.
Stage 2 (GND → utterances) takes options controlling how a fixed GND tree
is turned into utterances — the tree itself never changes shape based on
these; only the resulting utterance list does. utterances.json is a flat
list of cases, each pairing an utterance list with every full
ExtractUtterancesOptions object (format included) that produces it —
option-sets that produce identical output share one case instead of
repeating the payload:
{
"cases": [
{
"options": [{ "format": "plain" }],
"utterances": [ /* the default: no skip/contextualize/language/inlineContextualization */ ]
},
{
"options": [
{ "format": "plain", "skip": ["footnote"] },
{ "format": "plain", "skip": ["footnote"], "language": "none" }
],
"utterances": [ /* shared result for both option-sets above, differs from the default */ ]
}
]
}A case is only present when its options produce output different from
that fixture's default — the bare { format } call, always the first
case for each format (as a single-element options array). An option
combination within scope (below) that appears in no case's options is
understood to equal the default: absence is a positive claim, not a gap.
Outside that scope, a fixture makes no claim either way.
Scope, per format: language × inlineContextualization × segmentation.mode
× every subset of (this fixture's roles ∩ [roles.md's skippable-roles list])
× every subset of (this fixture's roles ∩ roles with a
contextualization-catalog entry) × every subset of (this fixture's roles ∩
roles that switch between inline and block contextualization by verbosity).
Every point in that space is either an explicit case or implicitly the
default — nothing in between. segmentation.suppressions is out of scope,
same as contextualization.contextualizations/params below — it needs a
fixture-specific abbreviation list to matter, not a fixed combination that
generalizes across fixtures.
The options:
format: "plain" | "ssml"(default"plain") — stated explicitly on every case, since a fixture needs both variants covered and omitting it on the"ssml"case would leave it indistinguishable from"plain".skip: GndRole[]— omit roles (and their whole subtree) from the output. See roles.md#list-of-skippable-roles.contextualize: GndRole[]— which roles' synthesized contextualizations (pagebreak, footnote start/end, ...) are spoken, independent of the underlying content (whichskipwould instead omit entirely). Nothing contextualizes by default.contextualization: { shapes: Partial<Record<GndRole, "inline" | "block">> }—shapesis a per-role override of contextualization shape:"inline"reads the catalog'sinlineentry;"block"(default when a role is absent here) reads itsstart/endpair. Only has an effect on roles whose contextualization varies by verbosity (table), and only withincontextualize. Grouped undercontextualizationalongsidecontextualizations/params(unused by these fixtures) — see Utterance Extraction.inlineContextualization: boolean— whether a pagebreak/footnote reference that falls mid-sentence splits the sentence at that exact point, instead of after the whole sentence finishes (the default).language: "none" | "block-level" | "always"— how a language shift between adjacent text is rendered: dropped entirely; kept as separate single-language utterances forplain, or merged into one utterance with embedded<lang>tags forssml. Omitted means unset.segmentation: { mode: "structure" | "sentence" }—"structure"(default, omitted) is one utterance per structural unit;"sentence"splits/reconstructs at real sentence boundaries instead, including across sibling nodes when a sentence genuinely spans them.suppressionsis out of scope — see above.
epub:type is only meaningful in XHTML with the epub namespace declared on
the document root — it is not a plain-HTML attribute, and there is no
"parse it as a literal attribute name in an HTML5 document" fallback: real
EPUB reading systems parse content documents as namespace-aware XHTML. Every
*-epub-type fixture therefore has inputKind: "document" and its
input.xhtml is a complete document:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><meta charset="utf-8"/><title>Fixture</title></head>
<body>
<div epub:type="chapter">...</div>
</body>
</html>By contrast, role-aria and html-native fixtures stay as bare fragments
(inputKind: "fragment") since ARIA roles and native HTML elements don't
require any namespace declaration.
Roles whose roles.md definition is "a list of links to X" or "a collection
of X" (landmarks, loa, loi, lot, lov, pagelist, toc, index,
bibliography, glossary, endnotes, credits) are modeled as an actual
list/listItem structure, not a flat text blob — because that's what they
are. A listItem that links to a target elsewhere in the work carries a
textref alongside its visible text, reusing the same field the
footnote/endnote fixtures already use for cross-references:
{ "role": ["listItem"], "text": "Cover", "textref": "cover.xhtml" }A flat JSON array, one entry per fixture:
[
{
"id": "footnote-epub-type",
"dir": "footnote-epub-type",
"role": "footnote",
"description": "Footnote encoded via epub:type, referenced by a noteref",
"files": {
"input": "input.xhtml",
"gnd": "gnd.json",
"utterances": "utterances.json"
}
}
]Any test runner, in any language, reads this one file to discover every fixture and resolve its file paths — no directory listing required.
- Read
manifest.json, iterate its entries. - For each entry, read
files.input(input.htmlorinput.xhtml) and run it through your implementation of stage 1 (HTML → GND) — compare the result tognd.json. - Run the resulting GND document through your implementation of stage 2
(GND → utterances) with
{ format: "plain" }, then{ format: "ssml" }— each must matchutterances.json's first case for that format (the default; see "Utterance extraction options" above). - For any other option combination you want to test: look for a case in
utterances.jsonwhoseoptionsmatches it exactly. If found, compare your result to it. If not found, and the combination is within the documented scope, your result must match the default case instead — there's no separate case for it precisely because it produces the same output. - A fixture "passes" when every comparison it has data for (explicit or default-inferred) matches exactly.
mkdir fixtures/<id>and hand-writeinput.html(orinput.xhtmlfor anepub:typefixture, sinceepub:typerequires XHTML — see above) for the specific markup being tested.- Hand-author
gnd.json— the ground truth implementations must match. - Hand-author
utterances.json— the ground truth implementations must match. See "Utterance extraction options" above for the case shape and scope. - Write
meta.json. - Run
npm run generate-fixtures-manifestto regeneratemanifest.jsonandROLES_COVERAGE.mdfrom what's now on disk.
New roles, new encodings, and new real-world edge cases get added to
fixtures/ as they're encountered — this is a living suite, not a fixed
checklist against any external document.
{ "id": "footnote-epub-type", // must match the directory name "description": "Footnote encoded via epub:type, referenced by a noteref", "role": "footnote", // the roles.md role name this fixture targets "rolesCovered": ["footnote", "noteref"],// every role name this fixture's markup exercises "sourceRef": "https://github.com/readium/guided-navigation/blob/main/roles.md", "inputKind": "document" // "fragment" | "document" }