Guided Navigation (GND) is the JSON tree this library extracts from HTML/XHTML, before turning it into utterances (see Utterance Extraction).
import { makeGnd } from "@readium/speech";
const gnd = makeGnd(`
<section epub:type="chapter">
<h1>Chapter One</h1>
<p>It was a dark and stormy night.</p>
</section>
`);
// gnd.guided: GndObject[]
function makeGnd(input: string | Element, mediaType?: GndMediaType, options?: GndGenerationOptions): GndDocument;
interface GndDocument {
links?: unknown[];
guided: GndObject[];
}
input is either raw markup, or a live, already-rendered element to convert in place — see domRange below for why that distinction matters. mediaType is "text/html" | "application/xhtml+xml". Omit it to sniff from input (a string: XML declaration, xmlns:epub, XHTML doctype → XHTML, else HTML; an element: its own document’s content type).
Skip the GndDocument wrapper by calling parseMarkup(input, mediaType?, options?): GndObject[] directly.
Parsing uses the native DOMParser — no HTML/XML library bundled or loaded at runtime.
textrefs)Off by default — it costs extra compute to generate. When on, every node with a role gets a textref: a link back to the element it came from, either #id (if it has one) or #css(<selector>). It’s just a URI reference — not a Readium Locator yet. See Highlighting for how it becomes one.
interface GndGenerationOptions {
textrefs?: boolean | GndRole[] | TextrefOptions;
}
interface TextrefOptions {
roles?: boolean | GndRole[]; // which roles get a reference; true = every role
domRange?: boolean; // also compute exact textNodeIndex/charOffset
textFragment?: boolean; // also append a WICG Text Fragment directive
}
makeGnd(html, undefined, { textrefs: true }); // every role
makeGnd(html, undefined, { textrefs: ["heading1", "paragraph"] }); // just these roles
domRange makes the reference more precise: #domrange(...), pointing at the exact text node and character offset, not just the element. This only works if you pass a live DOM element as input (not an HTML string) — pass a string and domRange is silently skipped, because a string gets parsed into a detached copy you can’t point back to:
makeGnd(document.querySelector("article")!, undefined, {
textrefs: { roles: true, domRange: true },
});
textFragment adds a WICG Text Fragment directive on top of whatever reference you already have, e.g. #css(p.foo):~:text=It%20was.... It only needs the text, so — unlike domRange — it works fine with a plain HTML string:
makeGnd(html, undefined, { textrefs: { roles: true, textFragment: true } });
The directive is generated by Google’s text-fragments-polyfill (via @readium/helpers), not by this library. If the node’s text is unique in the document, that text is the whole directive. If it’s repeated (e.g. the same heading twice), the polyfill adds a bit of surrounding text so it matches only one spot. If it still can’t make it unique, no directive is generated.
If the node’s text is too long, the polyfill only keeps the first few words and the last few words (textStart and textEnd), instead of the whole thing.
Use decodeTextref({ id, textref }) to read a textref back. It returns:
{ cssSelector?, domRange?, text?, fragment? }
text: { highlight, before?, after? }textStart/textEnd case above) → fragment (the raw :~:text=... string)It returns undefined if the textref isn’t one of ours (e.g. it’s a plain link’s href). The object it returns can be passed straight to createLocator()/decorate() — see Highlighting.
decodeTextref covers the whole textref (#css(...)/#domrange(...) plus an optional :~:text=... suffix) in one call. The lower-level pieces it’s built from are exported too, for a caller building/reading just one part directly: encodeCssSelectorFragment(selector)/decodeCssSelectorFragment(textref) for the plain #css(...) form, and encodeDomRangeFragment(domRange)/decodeDomRangeFragment(textref) for #domrange(...) (a DomRangeJSON: { start: { cssSelector, textNodeIndex, charOffset? }, end?: {...} }, the RWPM shape behind @readium/shared’s DomRange).
GndObjecttype GndRole = string; // open-ended, see roles.ts
interface GndText {
language: string;
plain?: string;
ssml?: string;
}
interface GndObject {
role?: GndRole[];
text?: string | GndText;
description?: string;
imgref?: string;
audioref?: string;
videoref?: string;
textref?: string;
id?: string;
children?: GndObject[];
}
role can have multiple entries: tag name, ARIA role, epub:type all contribute, in that order (e.g. <section epub:type="chapter"> → ["section", "chapter"]). role="presentation"/"none" overrides everything to ["presentation"].text is a plain string when unformatted, a GndText when it needs SSML (inline formatting, a language shift, or an embedded footnote/pagebreak/image mid-sentence). ssml marks embedded objects with a <readium:noteref id="..." />-style placeholder whose id matches a sibling in children.imgref/audioref/videoref are a media element’s src. textref is an href — reused for navigational-list items (toc, index…), noteref/backlink/biblioref/glossref, and plain links.description is a node’s accessible name (aria-label, alt, <figcaption>…) when it differs from its visible text.aria-hidden/hidden content and role-less wrapper <div>s are dropped from the tree, not kept as empty nodes.<p><em>Cover</em></p> → { role: ["paragraph"], text: "Cover" }, not a nested anonymous child. A child carrying its own ref (textref/imgref/audioref/videoref) is never hoisted, since merging it would either discard that ref or silently overwrite the parent’s own: <p><a href="...">Cover</a></p> → { role: ["paragraph"], children: [{ text: "Cover", textref: "..." }] }.Both are read out of narrative order, so the converter handles them specially:
noteref (<a role="doc-noteref" href="#note1">) resolves its href and embeds the target’s whole subtree as children — no second lookup needed. Unresolvable hrefs get a plain textref child instead. The footnote is suppressed from also appearing at its original location — except when that location is inside an endnotes section, where it’s kept so the section’s own list isn’t left silent.pagebreak (<span epub:type="pagebreak" title="42">) carries its label as text. Mid-sentence, it’s a <readium:pagebreak id="..." /> placeholder in that sentence’s ssml, with the pagebreak node as a sibling children entry.Three independent sources, mapped in src/gnd/roles.ts:
<h1> → heading1, <nav> → navigation, <blockquote> → blockquote, etc.role="doc-chapter" → chapter, role="figure" → figure, etc. role="heading" reads its level from aria-level (default 2).epub:type — epub:type="chapter" → chapter, epub:type="pagebreak" → pagebreak, etc. (XHTML only, see below).epub:type requires XHTMLepub:type only means anything in namespace-aware XHTML. Pass a complete XHTML document (xmlns:epub on the root):
<?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>...</title></head>
<body>
<section epub:type="chapter">...</section>
</body>
</html>
ARIA roles and native elements work as plain HTML fragments.
fixtures/ is a language-agnostic conformance suite for this stage plus utterance extraction — see fixtures/README.md and Testing.