speech

Guided Navigation

Guided Navigation (GND) is the JSON tree this library extracts from HTML/XHTML, before turning it into utterances (see Utterance Extraction).

Building a GND document

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.

Text references (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? }

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).

GndObject

type 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[];
}

Footnotes and pagebreaks

Both are read out of narrative order, so the converter handles them specially:

Roles

Three independent sources, mapped in src/gnd/roles.ts:

  1. Element type — <h1> → heading1, <nav> → navigation, <blockquote> → blockquote, etc.
  2. ARIA role — role="doc-chapter" → chapter, role="figure" → figure, etc. role="heading" reads its level from aria-level (default 2).
  3. epub:type — epub:type="chapter" → chapter, epub:type="pagebreak" → pagebreak, etc. (XHTML only, see below).

epub:type requires XHTML

epub: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

fixtures/ is a language-agnostic conformance suite for this stage plus utterance extraction — see fixtures/README.md and Testing.