diff --git a/pkg/content/element/attributes.go b/pkg/content/element/attributes.go index 5c4de9d7..c40e09a3 100644 --- a/pkg/content/element/attributes.go +++ b/pkg/content/element/attributes.go @@ -3,6 +3,9 @@ package element type AttributeKey string const AcessibilityLabelAttributeKey AttributeKey = "accessibilityLabel" +const AccessibilityDetailsAttributeKey AttributeKey = "accessibilityDetails" +const AccessibilityLabeledByAttributeKey AttributeKey = "accessibilityLabeledBy" +const AccessibilityDescribedByAttributeKey AttributeKey = "accessibilityDescribedBy" const LanguageAttributeKey AttributeKey = "language" // An attribute is an arbitrary key-value metadata pair. diff --git a/pkg/content/element/element.go b/pkg/content/element/element.go index 6ad7a007..f1cbcefb 100644 --- a/pkg/content/element/element.go +++ b/pkg/content/element/element.go @@ -86,7 +86,7 @@ func (e AudioElement) MarshalJSON() ([]byte, error) { res := ElementToMap(e) res["text"] = e.Text() res["link"] = e.EmbeddedLink() - res["@type"] = "Video" + res["@type"] = "Audio" return json.Marshal(res) } diff --git a/pkg/content/iterator/html_converter.go b/pkg/content/iterator/html_converter.go index a3225582..ef414b33 100644 --- a/pkg/content/iterator/html_converter.go +++ b/pkg/content/iterator/html_converter.go @@ -474,6 +474,7 @@ func (c *HTMLConverter) flushText() { if len(c.breadcrumbs) > 0 { el := c.breadcrumbs[len(c.breadcrumbs)-1].node for _, at := range el.Attr { + // THIS IS WRONG! need epub:type so split the str if at.Namespace == "http://www.idpf.org/2007/ops" && at.Key == "type" && at.Val == "footnote" { bestRole = element.Footnote{} break diff --git a/pkg/guidednavigation/converter/a11y.go b/pkg/guidednavigation/converter/a11y.go new file mode 100644 index 00000000..e405641d --- /dev/null +++ b/pkg/guidednavigation/converter/a11y.go @@ -0,0 +1,196 @@ +package converter + +import ( + "encoding/xml" + "slices" + "strings" + + "github.com/readium/go-toolkit/pkg/guidednavigation" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func getElementByID(n *html.Node, id string) *html.Node { + if n.Type == html.ElementNode { + for _, a := range n.Attr { + if a.Key == "id" && a.Val == id { + return n + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if res := getElementByID(c, id); res != nil { + return res + } + } + return nil +} + +func nodeIsHidden(n *html.Node) bool { + for _, attr := range n.Attr { + if attr.Key == "aria-hidden" && attr.Val == "true" { + return true + } + if attr.Key == "hidden" { + return true + } + } + return false +} + +func nodeText(sb *strings.Builder, n *html.Node) { + var f func(*html.Node) + f = func(n *html.Node) { + if n.Type == html.TextNode { + sb.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(n) +} + +// Normalized (whitespace-coalesced and trimmed) text content of a node's subtree. +func normalizedNodeText(n *html.Node) string { + var raw strings.Builder + nodeText(&raw, n) + var sb strings.Builder + appendNormalizedWhitespace(&sb, raw.String(), true) + return strings.TrimSpace(sb.String()) +} + +// https://www.w3.org/TR/accname/#terminology +// Returns the node's accessibility text if existent, and whether or not the node is visible in the first place. +func ExtractNodeAria(el *html.Node) (*guidednavigation.GuidedNavigationText, bool) { + // 2.A + if nodeIsHidden(el) { + return nil, false + } + + // 2.B + if labelledBy := strings.TrimSpace(getAttr(el, "aria-labelledby")); labelledBy != "" { + rawIds := strings.Fields(labelledBy) + ids := make([]string, 0, len(rawIds)) + for _, v := range rawIds { + if v != "" && !slices.Contains(ids, v) { + ids = append(ids, v) + } + } + + // Traverse up to the root of the document + doc := el + for doc.Parent != nil { + doc = doc.Parent + } + + labelNodes := make([]*html.Node, 0, len(ids)) + for _, v := range ids { + n := getElementByID(doc, v) + if n != nil { + labelNodes = append(labelNodes, n) + } + } + if len(labelNodes) > 0 { + var sb strings.Builder + for i, n := range labelNodes { + if nodeIsHidden(n) { + continue + } + if label := getAttr(n, "aria-label"); label != "" { + sb.WriteString(label) + } else { + nodeText(&sb, n) + } + + if i < len(labelNodes)-1 { + sb.WriteRune(' ') // Add a space at the end + } + } + var normalized strings.Builder + appendNormalizedWhitespace(&normalized, sb.String(), true) + text := strings.TrimSpace(normalized.String()) + if text != "" { + return &guidednavigation.GuidedNavigationText{ + Plain: text, + }, true + } + } + } + + // 2.C + if label := strings.TrimSpace(getAttr(el, "aria-label")); label != "" { + return &guidednavigation.GuidedNavigationText{ + Plain: label, + }, true + } + + // 2.D + // TODO: more support for els + switch el.DataAtom { + case atom.Img: + if alt := strings.TrimSpace(getAttr(el, "alt")); alt != "" { + return &guidednavigation.GuidedNavigationText{ + Plain: alt, + }, true + } + // 2.I fallback for images: the title attribute + if title := strings.TrimSpace(getAttr(el, "title")); title != "" { + return &guidednavigation.GuidedNavigationText{ + Plain: title, + }, true + } + case atom.Svg: + // The accessible name of an SVG comes from its child + if title := childOfType(el, atom.Title, true); title != nil { + if text := normalizedNodeText(title); text != "" { + return &guidednavigation.GuidedNavigationText{ + Plain: text, + }, true + } + } + } + + return nil, true +} + +// ConvertElementToSSMLTag maps an HTML element to the SSML tag its text should be wrapped in. +// https://www.w3.org/TR/speech-synthesis11/#S3.2.2 +func ConvertElementToSSMLTag(a atom.Atom) (string, []xml.Attr) { + switch a { + case atom.Em: + return "emphasis", nil + case atom.B: + return "emphasis", nil + case atom.I: + return "emphasis", []xml.Attr{{ + Name: xml.Name{Local: "level"}, + Value: "reduced", + }} + case atom.Strong: + return "emphasis", []xml.Attr{{ + Name: xml.Name{Local: "level"}, + Value: "strong", + }} + case atom.Br: + return "break", nil + default: + return "", nil + } +} + +// Elements whose entire subtree carries no user-facing content. +var skippedElements = map[atom.Atom]struct{}{ + atom.Script: {}, + atom.Style: {}, + atom.Template: {}, + atom.Noscript: {}, + atom.Textarea: {}, + atom.Select: {}, + atom.Datalist: {}, + atom.Iframe: {}, + // Ruby annotations would duplicate the base text when read aloud + atom.Rt: {}, + atom.Rp: {}, + atom.Rtc: {}, +} diff --git a/pkg/guidednavigation/converter/converter.go b/pkg/guidednavigation/converter/converter.go new file mode 100644 index 00000000..5dde48a7 --- /dev/null +++ b/pkg/guidednavigation/converter/converter.go @@ -0,0 +1,57 @@ +package converter + +import ( + "context" + "strings" + + "github.com/pkg/errors" + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/guidednavigation" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/mediatype" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator) (*guidednavigation.GuidedNavigationDocument, error) { + raw, rerr := fetcher.ReadResourceAsString(ctx, resource) + if rerr != nil { + return nil, errors.Wrap(rerr, "failed reading HTML string of "+resource.Link().Href.String()) + } + + var document *html.Node + xmlParsed := false + if mt := resource.Link().MediaType; mt != nil && mt.Matches(&mediatype.XHTML) { + // XHTML is XML: parse it as such, so that e.g. self-closing elements are + // handled correctly. Ill-formed documents fall back to the HTML parser. + if doc, err := ParseXHTML(strings.NewReader(raw)); err == nil && childOfType(doc, atom.Body, true) != nil { + document = doc + xmlParsed = true + } + } + if document == nil { + var err error + document, err = html.ParseWithOptions( + strings.NewReader(raw), + html.ParseOptionEnableScripting(false), + ) + if err != nil { + return nil, errors.Wrap(err, "failed parsing HTML of "+resource.Link().Href.String()) + } + } + + body := childOfType(document, atom.Body, true) + if body == nil { + return nil, errors.New("HTML of " + resource.Link().Href.String() + " doesn't have a <body>") + } + + contentConverter := NewHTMLConverter(locator) + contentConverter.xmlParsed = xmlParsed + + // Traverse the document's HTML + contentConverter.Convert(body) + + return &guidednavigation.GuidedNavigationDocument{ + Guided: contentConverter.Result(), + }, nil +} diff --git a/pkg/guidednavigation/converter/converter_test.go b/pkg/guidednavigation/converter/converter_test.go new file mode 100644 index 00000000..c974f614 --- /dev/null +++ b/pkg/guidednavigation/converter/converter_test.go @@ -0,0 +1,738 @@ +package converter + +import ( + "context" + "encoding/json" + "testing" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/mediatype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType) string { + t.Helper() + f := fetcher.NewBytesResource(manifest.Link{ + Href: manifest.MustNewHREFFromString("hello.xhtml", false), + MediaType: mt, + }, func() []byte { + return []byte(doc) + }) + + nav, err := Do(context.Background(), f, manifest.Locator{ + Href: f.Link().Href.Resolve(nil, nil), + }) + require.NoError(t, err) + bin, err := json.Marshal(nav) + require.NoError(t, err) + return string(bin) +} + +func wrapXHTML(bodyAttrs, body string) string { + return `<?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><title>Test +` + body + ` +` +} + +// Convert an XHTML body (through the XML parsing path) to a guided navigation JSON string. +func convertBody(t *testing.T, body string) string { + t.Helper() + return convertDoc(t, wrapXHTML("", body), &mediatype.XHTML) +} + +// The expected guided navigation document JSON for the given children of . +func guidedJSON(children string) string { + res := `{"guided":[{"role":["body"],"textref":"hello.xhtml"` + if children != "" { + res += `,"children":[` + children + `]` + } + return res + `}]}` +} + +// Canonical example from the specification's read-aloud examples: sections and headers. +func TestConvertSectionAndHeading(t *testing.T) { + res := convertBody(t, ` +
+

Title of the chapter

+
`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["section", "chapter"], + "children": [ + {"role": ["heading1"], "text": "Title of the chapter"} + ] + }`), res) +} + +func TestConvertHeadingLevels(t *testing.T) { + res := convertBody(t, ` +

Two

+
Six
+

Four

+
Default
`) + assert.JSONEq(t, guidedJSON(` + {"role": ["heading2"], "text": "Two"}, + {"role": ["heading6"], "text": "Six"}, + {"role": ["paragraph", "heading4"], "text": "Four"}, + {"role": ["heading2"], "text": "Default"}`), res) +} + +// Canonical example from the specification: a simple paragraph. +func TestConvertParagraph(t *testing.T) { + res := convertBody(t, `

It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife." + }`), res) +} + +// Canonical example from the specification: multilingual text using SSML. +func TestConvertMultilingualText(t *testing.T) { + res := convertDoc(t, ` + + Test + +

This job requires a certain savoir faire that can only be acquired over time.

+

This job requires a certain savoir faire that can only be acquired over time.

+ + `, &mediatype.XHTML) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "This job requires a certain savoir faire that can only be acquired over time.", + "ssml": "This job requires a certain savoir faire that can only be acquired over time.", + "language": "en" + } + }, + { + "role": ["paragraph"], + "text": { + "plain": "This job requires a certain savoir faire that can only be acquired over time.", + "ssml": "This job requires a certain savoir faire that can only be acquired over time.", + "language": "en" + } + }`), res) +} + +// A paragraph entirely in another language needs no SSML, just a language. +func TestConvertSingleForeignLanguage(t *testing.T) { + res := convertBody(t, `

Tout à fait.

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": {"plain": "Tout à fait.", "language": "fr"} + }`), res) +} + +// Regression test: emphasis must wrap the emphasized segment, not the text before it, +// and nested emphasis elements with the same SSML mapping merge into one. +func TestConvertEmphasis(t *testing.T) { + res := convertBody(t, ` +

This is a paragraph with some very-strong bold text!

+

Reduced paragraph

+

Very strong words

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "This is a paragraph with some very-strong bold text!", + "ssml": "This is a paragraph with some very-strong bold text!" + } + }, + { + "role": ["paragraph"], + "text": { + "plain": "Reduced paragraph", + "ssml": "Reduced paragraph" + } + }, + { + "role": ["paragraph"], + "text": { + "plain": "Very strong words", + "ssml": "Very strong words" + } + }`), res) +} + +// Canonical example from the specification: images. +func TestConvertImages(t *testing.T) { + res := convertBody(t, ` + Alternative text using the alt attribute + + + + + + + +
+
+			 /\_/\
+			( o.o )
+			   ^
+			
+
+ ASCII Art of a cat face +
+
`) + assert.JSONEq(t, guidedJSON(` + {"role": ["image"], "imgref": "image1.avif", "description": "Alternative text using the alt attribute"}, + {"role": ["image"], "description": "Rating: 4 out of 5 stars"}, + {"role": ["figure"], "description": "ASCII Art of a cat face"}`), res) +} + +// An image in the middle of a sentence becomes a child object referenced +// from the text with a custom SSML tag. +func TestConvertInlineImage(t *testing.T) { + res := convertBody(t, `

Paragraphe avec image: A cool image

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Paragraphe avec image:", + "ssml": "Paragraphe avec image: ", + "language": "fr" + }, + "children": [ + {"role": ["image"], "id": "image1", "imgref": "src/image.jpg", "description": "A cool image"} + ] + }`), res) +} + +func TestConvertMultipleInlineImages(t *testing.T) { + res := convertBody(t, ` +

Paragraphe avec image #1 A cool image et #2 A second cool image!

+

The coolest image et The boring image

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Paragraphe avec image #1 et #2!", + "ssml": "Paragraphe avec image #1 et #2 !", + "language": "fr" + }, + "children": [ + {"role": ["image"], "id": "image1", "imgref": "src/image.jpg", "description": "A cool image"}, + {"role": ["image"], "id": "image2", "imgref": "src/image.jpg", "description": "A second cool image"} + ] + }, + { + "role": ["paragraph"], + "text": { + "plain": "et", + "ssml": " et ", + "language": "fr" + }, + "children": [ + {"role": ["image"], "id": "image3", "imgref": "src/image.jpg", "description": "The coolest image"}, + {"role": ["image"], "id": "image4", "imgref": "src/image.jpg", "description": "The boring image"} + ] + }`), res) +} + +// Decorative images don't produce any output. +func TestConvertDecorativeImages(t *testing.T) { + res := convertBody(t, ` +

Before after

+ ignored`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": "Before after" + }`), res) +} + +// A figure without an accessible name keeps its content, including the caption. +func TestConvertUnlabelledFigure(t *testing.T) { + res := convertBody(t, ` +
+ A chart +
Sales over time
+
`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["figure"], + "children": [ + {"role": ["image"], "imgref": "chart.png", "description": "A chart"}, + {"role": ["caption"], "text": "Sales over time"} + ] + }`), res) +} + +// A labelled figure keeps media children, but its text content is +// replaced by the description. +func TestConvertLabelledFigureKeepsImage(t *testing.T) { + res := convertBody(t, ` +
+ A chart +
Dropped caption
+
`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["figure"], + "description": "A described figure", + "children": [ + {"role": ["image"], "imgref": "chart.png", "description": "A chart"} + ] + }`), res) +} + +// Canonical example from the specification's read-aloud examples: pagebreaks, +// standalone and in the middle of a sentence. Uses self-closing , which +// exercises the XML parsing path. +func TestConvertPagebreaks(t *testing.T) { + res := convertBody(t, ` + +

And the next pagebreak is in the middle of a sentence.

`) + assert.JSONEq(t, guidedJSON(` + {"role": ["pagebreak"], "text": "4", "textref": "hello.xhtml#pg04"}, + { + "role": ["paragraph"], + "text": { + "plain": "And the next pagebreak is in the middle of a sentence.", + "ssml": "And the next pagebreak is in the middle of a sentence." + }, + "children": [ + {"role": ["pagebreak"], "id": "pg05", "text": "5", "textref": "hello.xhtml#pg05"} + ] + }`), res) +} + +// The same self-closing pagebreak parsed as HTML swallows the following +// content into the span: the pagebreak object must still be emitted and the +// swallowed content must not be lost. +func TestConvertPagebreaksHTMLParser(t *testing.T) { + res := convertDoc(t, ` + Test + +
+ +

And the next pagebreak is in the middle of a sentence.

+
+ + `, &mediatype.HTML) + assert.JSONEq(t, guidedJSON(` + {"role": ["pagebreak"], "text": "4", "textref": "hello.xhtml#pg04"}, + { + "role": ["paragraph"], + "text": { + "plain": "And the next pagebreak is in the middle of a sentence.", + "ssml": "And the next pagebreak is in the middle of a sentence." + }, + "children": [ + {"role": ["pagebreak"], "id": "pg05", "text": "5", "textref": "hello.xhtml#pg05"} + ] + }`), res) +} + +// Canonical example from the specification's read-aloud examples: footnotes and endnotes. +// The same-resource footnote is embedded in the noteref and removed from the normal flow; +// the endnote in another resource is referenced by textref. +func TestConvertNoterefs(t *testing.T) { + res := convertBody(t, ` +

This text has a footnote in the same resource [1] and an endnote [2].

+

This is a paragraph without a footnote.

+ `) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "This text has a footnote in the same resource and an endnote.", + "ssml": "This text has a footnote in the same resource and an endnote ." + }, + "children": [ + { + "role": ["noteref"], + "id": "note1", + "text": "[1]", + "children": [ + {"role": ["aside", "footnote"], "text": "Text of the footnote", "textref": "hello.xhtml#note1"} + ] + }, + { + "role": ["noteref"], + "id": "note2", + "text": "[2]", + "children": [ + {"textref": "endnote.xhtml#note2"} + ] + } + ] + }, + { + "role": ["paragraph"], + "text": "This is a paragraph without a footnote." + }`), res) +} + +// EPUB 3 footnotes are commonly hidden (they're meant to be shown in popups): +// they must still be embedded in their noteref. +func TestConvertHiddenFootnote(t *testing.T) { + res := convertBody(t, ` +

Some text1.

+ `) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Some text.", + "ssml": "Some text." + }, + "children": [ + { + "role": ["noteref"], + "id": "note1", + "text": "1", + "children": [ + { + "role": ["aside", "footnote"], + "textref": "hello.xhtml#note1", + "children": [{"role": ["paragraph"], "text": "Hidden footnote"}] + } + ] + } + ] + }`), res) +} + +// Canonical example from the specification's read-aloud examples: lists. +func TestConvertList(t *testing.T) { + res := convertBody(t, ` +
    +
  • First item
  • +
  • Second item
  • +
  • Third item
  • +
`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["list"], + "children": [ + {"role": ["listItem"], "text": "First item"}, + {"role": ["listItem"], "text": "Second item"}, + {"role": ["listItem"], "text": "Third item"} + ] + }`), res) +} + +func TestConvertLineBreaks(t *testing.T) { + res := convertBody(t, `

Just
testing
some
breaks! And useless elements...

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Just testing some breaks! And useless elements...", + "ssml": "Justtestingsomebreaks! And useless elements..." + } + }`), res) +} + +func TestConvertHiddenContent(t *testing.T) { + res := convertBody(t, ` + + +

Visible text

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": "Visible text" + }`), res) +} + +// Text inside after

+ +

kan(

`) + assert.JSONEq(t, guidedJSON(` + {"role": ["paragraph"], "text": "Before after"}, + {"role": ["paragraph"], "text": "漢字"}`), res) +} + +// Wrappers without any semantics (
, ...) are dissolved into their parent. +func TestConvertWrapperSplicing(t *testing.T) { + res := convertBody(t, ` +
+
+

Deep paragraph

+
+

Another one

+
`) + assert.JSONEq(t, guidedJSON(` + {"role": ["paragraph"], "text": "Deep paragraph"}, + {"role": ["paragraph"], "text": "Another one"}`), res) +} + +// A wrapper with plain text can't be dissolved: its text is kept as an anonymous object. +func TestConvertDivWithMixedContent(t *testing.T) { + res := convertBody(t, `
Intro text

Paragraph

outro text
`) + assert.JSONEq(t, guidedJSON(` + {"text": "Intro text"}, + {"role": ["paragraph"], "text": "Paragraph"}, + {"text": "outro text"}`), res) +} + +func TestConvertTable(t *testing.T) { + res := convertBody(t, ` + + + +
NameAge
Alice42
`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["table"], + "children": [ + {"role": ["row"], "children": [ + {"role": ["columnheader"], "text": "Name"}, + {"role": ["columnheader"], "text": "Age"} + ]}, + {"role": ["row"], "children": [ + {"role": ["cell"], "text": "Alice"}, + {"role": ["cell"], "text": "42"} + ]} + ] + }`), res) +} + +func TestConvertAudioVideo(t *testing.T) { + res := convertBody(t, ` +

Listen: now

+ `) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Listen: now", + "ssml": "Listen: now" + }, + "children": [ + {"role": ["audio"], "id": "audio1", "audioref": "audio.mp3"} + ] + }, + {"role": ["video"], "videoref": "movie.mp4"}`), res) +} + +// Multiple values in a single epub:type attribute. +func TestConvertMultiValueEpubType(t *testing.T) { + res := convertBody(t, `

Content

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["section", "chapter"], + "children": [{"role": ["paragraph"], "text": "Content"}] + }`), res) +} + +// HTML entities in XHTML content are resolved, and non-breaking spaces normalized. +func TestConvertEntities(t *testing.T) { + res := convertBody(t, `

Café & lait <3

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": "Café & lait <3" + }`), res) +} + +// Special characters must be escaped in SSML but not in plain text. +func TestConvertSSMLEscaping(t *testing.T) { + res := convertBody(t, `

A&B <tag>

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "A&B ", + "ssml": "A&B <tag>" + } + }`), res) +} + +// Elements with roles and an id get a textref pointing into the resource. +func TestConvertTextRefFragments(t *testing.T) { + res := convertBody(t, `

Text

More
`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["section", "chapter"], + "textref": "hello.xhtml#ch1", + "children": [ + {"role": ["paragraph"], "textref": "hello.xhtml#par1", "text": "Text"}, + {"text": "More"} + ] + }`), res) +} + +// MathML with alternative text. +func TestConvertMath(t *testing.T) { + res := convertBody(t, `

Consider x2 here.

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Consider here.", + "ssml": "Consider here." + }, + "children": [ + {"role": ["math"], "id": "math1", "text": "x squared"} + ] + }`), res) +} + +// Inline SVG with a is exposed as an image described by its title. +func TestConvertSVG(t *testing.T) { + res := convertBody(t, `<p>Diagram: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>A blue square

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Diagram:", + "ssml": "Diagram: " + }, + "children": [ + {"role": ["image"], "id": "image1", "description": "A blue square"} + ] + }`), res) +} + +// Definition lists and blockquotes. +func TestConvertMiscRoles(t *testing.T) { + res := convertBody(t, ` +
+
Term
+
Definition
+
+
Quoted text
`) + assert.JSONEq(t, guidedJSON(` + {"role": ["term"], "text": "Term"}, + {"role": ["definition"], "text": "Definition"}, + {"role": ["blockquote"], "text": "Quoted text"}`), res) +} + +// The document language propagates to text objects. +func TestConvertDocumentLanguage(t *testing.T) { + res := convertDoc(t, ` + Test +

English text

+ `, &mediatype.HTML) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": {"plain": "English text", "language": "en"} + }`), res) +} + +// A block-level pagebreak with content must not unbalance the tree: +// all content stays nested in the body object. +func TestConvertBlockPagebreakWithContent(t *testing.T) { + // XML path: content becomes the page label + res := convertBody(t, `
4

after

`) + assert.JSONEq(t, guidedJSON(` + {"role": ["pagebreak"], "text": "4"}, + {"role": ["paragraph"], "text": "after"}`), res) + + // HTML path: a labelled block pagebreak's content flows transparently + res = convertDoc(t, ` + Test +

swallowed

after

+ `, &mediatype.HTML) + assert.JSONEq(t, guidedJSON(` + {"role": ["pagebreak"], "text": "4"}, + {"role": ["paragraph"], "text": "swallowed"}, + {"role": ["paragraph"], "text": "after"}`), res) +} + +// The text of a note in an inline element must survive its embedding. +func TestConvertInlineNoterefTarget(t *testing.T) { + res := convertBody(t, ` +

Text with note1.

+

Note is right here ok

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "Text with note.", + "ssml": "Text with note." + }, + "children": [ + { + "role": ["noteref"], + "id": "fn1", + "text": "1", + "children": [{"text": "right here"}] + } + ] + }, + { + "role": ["paragraph"], + "text": "Note is ok" + }`), res) +} + +// An image without src and description must not leave a dangling id in the SSML. +func TestConvertEmptyImageNoDanglingID(t *testing.T) { + res := convertBody(t, `

Before after

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": "Before after" + }`), res) +} + +// Words around a dropped noteref keep their separation, and punctuation binds left. +func TestConvertPlainTextJoins(t *testing.T) { + res := convertBody(t, ` +

word[1] next

+

resource [2] and more

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["paragraph"], + "text": { + "plain": "word next", + "ssml": "word next" + }, + "children": [ + {"role": ["noteref"], "id": "n1", "text": "[1]", "children": [{"textref": "other.xhtml#n1"}]} + ] + }, + { + "role": ["paragraph"], + "text": { + "plain": "resource and more", + "ssml": "resource and more" + }, + "children": [ + {"role": ["noteref"], "id": "n2", "text": "[2]", "children": [{"textref": "other.xhtml#n2"}]} + ] + }`), res) +} + +// A noteref inside hidden content is never emitted, so its target must stay in the flow. +func TestConvertHiddenNoterefKeepsTarget(t *testing.T) { + res := convertBody(t, ` + + `) + assert.JSONEq(t, guidedJSON(`{ + "role": ["aside", "footnote"], + "textref": "hello.xhtml#note1", + "text": "Visible note" + }`), res) +} + +// A noteref pointing at its own ancestor must not suppress the enclosing content. +func TestConvertNoterefToAncestor(t *testing.T) { + res := convertBody(t, ` +

Content [s] here

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["section"], + "textref": "hello.xhtml#sec1", + "children": [{ + "role": ["paragraph"], + "text": { + "plain": "Content here", + "ssml": "Content here" + }, + "children": [{ + "role": ["noteref"], + "id": "sec1", + "text": "[s]", + "children": [{"textref": "hello.xhtml#sec1"}] + }] + }] + }`), res) +} + +// An empty body still yields the top-level object. +func TestConvertEmptyBody(t *testing.T) { + res := convertBody(t, ``) + assert.JSONEq(t, guidedJSON(``), res) +} + +func TestDoErrorsWithoutBody(t *testing.T) { + f := fetcher.NewBytesResource(manifest.Link{ + Href: manifest.MustNewHREFFromString("broken.xhtml", false), + }, func() []byte { + return []byte(``) + }) + _, err := Do(context.Background(), f, manifest.Locator{ + Href: f.Link().Href.Resolve(nil, nil), + }) + require.Error(t, err) +} diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go new file mode 100644 index 00000000..872b6477 --- /dev/null +++ b/pkg/guidednavigation/converter/html.go @@ -0,0 +1,1113 @@ +package converter + +import ( + "encoding/xml" + "fmt" + nurl "net/url" + "slices" + "strings" + "unicode" + + "github.com/readium/go-toolkit/pkg/guidednavigation" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/util/url" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func onlySpace(s string) bool { + for _, runeValue := range s { + if !unicode.IsSpace(runeValue) { + return false + } + } + return true +} + +func getAttr(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func hasAttr(n *html.Node, key string) bool { + for _, attr := range n.Attr { + if attr.Key == key { + return true + } + } + return false +} + +func hasElementChild(n *html.Node) bool { + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode { + return true + } + } + return false +} + +func srcRelativeToHref(n *html.Node, base url.URL) url.URL { + if n == nil { + return nil + } + + if v := getAttr(n, "src"); v != "" { + if u, _ := url.URLFromString(v); u != nil { + if base == nil { + return u + } + return base.Resolve(u) + } + } + return nil +} + +// Get child elements of a certain type, with a maximum depth. +func childrenOfType(doc *html.Node, typ atom.Atom, depth uint) (children []*html.Node) { + var f func(*html.Node, uint) + f = func(n *html.Node, d uint) { + if n.Type == html.ElementNode && n.DataAtom == typ { + children = append(children, n) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if d > 0 { + f(c, d-1) + } + } + } + f(doc, depth) + return +} + +// Get the first or last element of a certain type +func childOfType(doc *html.Node, typ atom.Atom, first bool) *html.Node { + var b *html.Node + var f func(*html.Node) + f = func(n *html.Node) { + if b != nil && first { + return + } + if n.Type == html.ElementNode && n.DataAtom == typ { + b = n + if first { + return + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(doc) + return b +} + +// Everything from this list except "device": +// https://github.com/jhy/jsoup/blob/0b10d516ed8f907f8fb4acb9a0806137a8988d45/src/main/java/org/jsoup/parser/Tag.java#L243 +var inlineTags map[atom.Atom]struct{} = map[atom.Atom]struct{}{ + atom.Object: {}, + atom.Base: {}, + atom.Font: {}, + atom.Tt: {}, + atom.I: {}, + atom.B: {}, + atom.U: {}, + atom.Big: {}, + atom.Small: {}, + atom.Em: {}, + atom.Strong: {}, + atom.Dfn: {}, + atom.Code: {}, + atom.Samp: {}, + atom.Kbd: {}, + atom.Var: {}, + atom.Cite: {}, + atom.Abbr: {}, + atom.Time: {}, + atom.Acronym: {}, + atom.Mark: {}, + atom.Ruby: {}, + atom.Rt: {}, + atom.Rp: {}, + atom.Rtc: {}, + atom.A: {}, + atom.Img: {}, + atom.Br: {}, + atom.Wbr: {}, + atom.Map: {}, + atom.Q: {}, + atom.Sub: {}, + atom.Sup: {}, + atom.Bdo: {}, + atom.Iframe: {}, + atom.Embed: {}, + atom.Span: {}, + atom.Input: {}, + atom.Select: {}, + atom.Textarea: {}, + atom.Label: {}, + atom.Button: {}, + atom.Optgroup: {}, + atom.Option: {}, + atom.Legend: {}, + atom.Datalist: {}, + atom.Keygen: {}, + atom.Output: {}, + atom.Progress: {}, + atom.Meter: {}, + atom.Area: {}, + atom.Param: {}, + atom.Source: {}, + atom.Track: {}, + atom.Summary: {}, + atom.Command: {}, + atom.Basefont: {}, + atom.Bgsound: {}, + atom.Menuitem: {}, + atom.Data: {}, + atom.Bdi: {}, + atom.S: {}, + atom.Strike: {}, + atom.Nobr: {}, + atom.Rb: {}, +} + +// Not inline = is block +func isInlineTag(n *html.Node) bool { + if n == nil { + return false + } + _, ok := inlineTags[n.DataAtom] + return ok +} + +// The language in effect for a node, from the nearest lang / xml:lang attribute. +// xml:lang takes priority over lang. +func nodeLanguage(n *html.Node) string { + for ; n != nil; n = n.Parent { + var lang string + for _, attr := range n.Attr { + if attr.Key == "xml:lang" && attr.Val != "" { + return attr.Val + } else if attr.Key == "lang" { + lang = attr.Val + } + } + if lang != "" { + return lang + } + } + return "" +} + +// From JSoup: https://github.com/jhy/jsoup/blob/1762412a28fa7b08ccf71d93fc4c98dc73086e03/src/main/java/org/jsoup/internal/StringUtil.java#L233 +// Slight differing definition of what a whitespace characacter is +func appendNormalizedWhitespace(accum *strings.Builder, text string, stripLeading bool) { + var lastWasWhite, reachedNonWhite bool + for _, t := range text { + if unicode.IsSpace(t) { + if (stripLeading && !reachedNonWhite) || lastWasWhite { + continue + } + accum.WriteRune(' ') + lastWasWhite = true + } else if t != 8203 && t != 173 { // zero width sp, soft hyphen + accum.WriteRune(t) + lastWasWhite = false + reachedNonWhite = true + } + } +} + +var ssmlTextEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">") +var ssmlAttrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + +// The SSML context a run of text lives in: its language and the SSML tag +// (with attributes) derived from its closest mapped inline ancestor. +type ssmlContext struct { + lang string + tag string + attrs []xml.Attr +} + +func (a ssmlContext) equal(b ssmlContext) bool { + if a.lang != b.lang || a.tag != b.tag || len(a.attrs) != len(b.attrs) { + return false + } + for i := range a.attrs { + if a.attrs[i].Name.Local != b.attrs[i].Name.Local || a.attrs[i].Value != b.attrs[i].Value { + return false + } + } + return true +} + +type segmentKind int + +const ( + segmentText segmentKind = iota + segmentBreak + segmentPlaceholder +) + +type textSegment struct { + kind segmentKind + + text string // segmentText: the (whitespace-normalized) text + ctx ssmlContext // segmentText: the SSML context of the text + + tag string // segmentPlaceholder: SSML tag name without the readium: prefix + child *navigationObject // segmentPlaceholder: the object the placeholder references + candidateID string // segmentPlaceholder: preferred id for the referenced object +} + +type navigationObject struct { + node *html.Node + object guidednavigation.GuidedNavigationObject + children []*navigationObject + parent *navigationObject + noText bool +} + +func (n *navigationObject) convert() guidednavigation.GuidedNavigationObject { + result := n.object + + for _, child := range n.children { + res := child.convert() + if res.Empty() { + continue + } + if res.ChildrenOnly() { + // Splice transparent wrappers (e.g. plain
s) into the parent + result.Children = append(result.Children, res.Children...) + continue + } + result.Children = append(result.Children, res) + } + + // Hoist a lone anonymous text child into the object itself, together with the + // objects referenced from its SSML (they carry an id), e.g. a paragraph with an + // inline image becomes {role, text, children: [image]} like the spec examples. + if result.Text.Empty() && len(result.Children) == 1 { + child := result.Children[0] + if !child.Text.Empty() && child.ID == "" && len(child.Role) == 0 && + child.TextRef == nil && child.ImgRef == nil && child.AudioRef == nil && child.VideoRef == nil && + child.Description.Empty() { + result.Text = child.Text + result.Children = child.Children + } + } + + return result +} + +// Allocates document-unique identifiers for objects referenced from SSML placeholders. +type idAllocator struct { + reserved map[string]struct{} // Element ids of the document, avoided when generating ids + claimed map[string]struct{} // Ids already assigned to objects + counters map[string]int +} + +// Try to assign a preferred id (e.g. the element's own id) to an object. +func (a *idAllocator) claim(id string) bool { + if _, ok := a.claimed[id]; ok { + return false + } + a.claimed[id] = struct{}{} + return true +} + +func (a *idAllocator) allocate(prefix string) string { + for { + a.counters[prefix]++ + id := fmt.Sprintf("%s%d", prefix, a.counters[prefix]) + if _, ok := a.reserved[id]; ok { + continue + } + if _, ok := a.claimed[id]; ok { + continue + } + a.claimed[id] = struct{}{} + return id + } +} + +// Maximum depth of note embedding for noterefs referencing notes that themselves +// contain noterefs. Beyond it, notes are referenced by textref instead. +const maxNoterefDepth = 3 + +type HTMLConverter struct { + baseLocator manifest.Locator + xmlParsed bool // Whether the tree comes from an XML parser (self-closing tags handled correctly). + + segments []textSegment // Closed segments of the text flow accumulated for the current block. + textAcc strings.Builder // Text of the currently open segment, with coalesced whitespace. + currentCtx ssmlContext // SSML context of the currently open segment. + flowEndsWithSpace bool // Whether the accumulated text flow currently ends in whitespace. + pendingChildren []*navigationObject // Objects for placeholders of the current flow, attached at flush time. + + root *navigationObject + current *navigationObject + skipNode bool + + ids map[string]*html.Node // All element ids of the document. + suppressed map[*html.Node]struct{} // Elements excluded from the normal flow (e.g. footnotes embedded in a noteref). + transparent map[*html.Node]struct{} // Elements whose children flow into the parent without opening an object. + idAlloc *idAllocator + noterefDepth int + allowNode *html.Node // Node exempt from suppression/visibility checks (target of a noteref sub-conversion). +} + +func NewHTMLConverter(baseLocator manifest.Locator) *HTMLConverter { + return &HTMLConverter{ + baseLocator: baseLocator, + } +} + +// Whether an element opens (and closes) a navigation object during the traversal. +func (c *HTMLConverter) isBlockNode(n *html.Node) bool { + if n == c.allowNode { + // A noteref target is converted as a block even when inline, + // so that its text is flushed into its own object + return true + } + if _, ok := c.transparent[n]; ok { + return false + } + return !isInlineTag(n) +} + +// Builds a reference to a fragment of the converted resource, e.g. "chapter.xhtml#par1". +func (c *HTMLConverter) fragmentRef(id string) url.URL { + if id == "" || c.baseLocator.Href == nil { + return nil + } + frag, err := url.URLFromGo(&nurl.URL{Fragment: id}) + if err != nil { + return nil + } + return c.baseLocator.Href.Resolve(frag) +} + +func (c *HTMLConverter) resolveHref(href string) url.URL { + u, err := url.URLFromString(href) + if err != nil || u == nil { + return nil + } + if c.baseLocator.Href == nil { + return u + } + return c.baseLocator.Href.Resolve(u) +} + +// If href points to a fragment of the document being converted, returns the fragment. +func (c *HTMLConverter) sameDocumentFragment(href string) (string, bool) { + if href == "" { + return "", false + } + u, err := url.URLFromString(href) + if err != nil || u == nil { + return "", false + } + if strings.HasPrefix(href, "#") { + return u.Fragment(), true + } + if c.baseLocator.Href == nil { + return "", false + } + resolved := c.baseLocator.Href.Resolve(u) + if resolved.Fragment() == "" { + return "", false + } + if strings.TrimPrefix(resolved.Path(), "/") != strings.TrimPrefix(c.baseLocator.Href.Path(), "/") { + return "", false + } + return resolved.Fragment(), true +} + +// Collect all element ids and the targets of same-document noterefs, which are +// embedded in their referencing object and excluded from the normal flow. +func (c *HTMLConverter) prescan(root *html.Node) { + c.ids = make(map[string]*html.Node) + c.suppressed = make(map[*html.Node]struct{}) + c.idAlloc = &idAllocator{ + reserved: map[string]struct{}{}, + claimed: map[string]struct{}{}, + counters: map[string]int{}, + } + + type noterefTarget struct { + id string + ref *html.Node + } + var noterefTargets []noterefTarget + var f func(*html.Node, bool) + f = func(n *html.Node, hidden bool) { + if n.Type == html.ElementNode { + if id := getAttr(n, "id"); id != "" { + if _, ok := c.ids[id]; !ok { + c.ids[id] = n + } + c.idAlloc.reserved[id] = struct{}{} + } + hidden = hidden || nodeIsHidden(n) + // Hidden noterefs are never emitted, so they can't suppress their target + if !hidden && n.DataAtom == atom.A { + if roles := ExtractNodeRoles(n); slices.Contains(roles, guidednavigation.RoleNoteref) { + if target, ok := c.sameDocumentFragment(getAttr(n, "href")); ok { + noterefTargets = append(noterefTargets, noterefTarget{id: target, ref: n}) + } + } + } + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + f(child, hidden) + } + } + f(root, false) + + for _, target := range noterefTargets { + n, ok := c.ids[target.id] + if !ok { + continue + } + // A note containing its own noteref (e.g. a reference to an enclosing + // section) must stay in the normal flow, or its content would be lost + if isAncestorOf(n, target.ref) { + continue + } + c.suppressed[n] = struct{}{} + } +} + +// Whether anc is n itself or one of its ancestors. +func isAncestorOf(anc, n *html.Node) bool { + for p := n; p != nil; p = p.Parent { + if p == anc { + return true + } + } + return false +} + +func (c *HTMLConverter) descend(n *html.Node) { + newNode := &navigationObject{ + node: n, + parent: c.current, + noText: c.current.noText, + } + c.current.children = append(c.current.children, newNode) + c.current = newNode +} + +func (c *HTMLConverter) ascend() { + if c.current.parent != nil { + c.current = c.current.parent + } +} + +func (c *HTMLConverter) appendChild(child *navigationObject) { + child.parent = c.current + child.noText = c.current.noText + c.current.children = append(c.current.children, child) +} + +func (c *HTMLConverter) Convert(doc *html.Node) { + if c.ids == nil { + c.prescan(doc) + } + + c.root = &navigationObject{node: doc} + c.current = c.root + c.resetFlow() + + node := doc + depth := 0 + + for node != nil { + c.head(node) + if node.FirstChild != nil && !c.skipNode { // descend + node = node.FirstChild + depth++ + } else { + for node.NextSibling == nil && depth > 0 { + c.tail(node) + node = node.Parent + depth-- + } + c.tail(node) + if node == doc { + break + } + node = node.NextSibling + } + } +} + +func (c *HTMLConverter) Result() []guidednavigation.GuidedNavigationObject { + if c.root == nil { + return nil + } + res := c.root.convert() + if len(res.Children) == 0 { + if res.Empty() { + return nil + } + // The root wrapper merged its only child (e.g. the sub-conversion of an + // inline noteref target): the merged object is the result itself + return []guidednavigation.GuidedNavigationObject{res} + } + return res.Children +} + +func (c *HTMLConverter) head(n *html.Node) { + if n.Type != html.ElementNode { + return + } + + if _, ok := skippedElements[n.DataAtom]; ok { + c.skipNode = true + return + } + if _, ok := c.suppressed[n]; ok && n != c.allowNode { + c.skipNode = true + return + } + + aria, visible := ExtractNodeAria(n) + if !visible && n != c.allowNode { + c.skipNode = true + return + } + + roles := ExtractNodeRoles(n) + + // Decorative images (role="presentation"/"none", or an explicitly empty alt + // with no other accessible name) are skipped entirely. + if n.DataAtom == atom.Img || n.DataAtom == atom.Svg { + if slices.Contains(roles, guidednavigation.RolePresentation) || + (aria == nil && hasAttr(n, "alt") && strings.TrimSpace(getAttr(n, "alt")) == "") { + c.skipNode = true + return + } + } + + if n.DataAtom == atom.Br { + if !c.current.noText { + c.closeSegment() + c.segments = append(c.segments, textSegment{kind: segmentBreak}) + c.flowEndsWithSpace = true + } + return + } + + // Elements handled wholesale, without descending into them. They become child + // objects of the enclosing block, referenced from its text with a readium: + // SSML placeholder tag when the block has text. + switch { + case slices.Contains(roles, guidednavigation.RolePagebreak): + if c.pagebreak(n, aria, roles) { + // Descend, with the children flowing into the parent context + if c.transparent == nil { + c.transparent = make(map[*html.Node]struct{}) + } + c.transparent[n] = struct{}{} + } else { + c.skipNode = true + } + return + case n.DataAtom == atom.A && slices.Contains(roles, guidednavigation.RoleNoteref) && getAttr(n, "href") != "": + c.noteref(n, roles) + c.skipNode = true + return + case n.DataAtom == atom.Img: + obj := guidednavigation.GuidedNavigationObject{Role: roles} + if href := srcRelativeToHref(n, c.baseLocator.Href); href != nil { + obj.ImgRef = href + } + if aria != nil { + obj.Description = guidednavigation.GuidedNavigationDescription{Text: *aria} + } + c.placeholder(n, "image", obj) + c.skipNode = true + return + case n.DataAtom == atom.Audio || n.DataAtom == atom.Video: + obj := guidednavigation.GuidedNavigationObject{Role: roles} + href := srcRelativeToHref(n, c.baseLocator.Href) + if href == nil { + // The media may be provided through children instead + for _, source := range childrenOfType(n, atom.Source, 1) { + if src := srcRelativeToHref(source, c.baseLocator.Href); src != nil { + href = src + break + } + } + } + tag := "audio" + if n.DataAtom == atom.Audio { + obj.AudioRef = href + } else { + obj.VideoRef = href + tag = "video" + } + if aria != nil { + obj.Description = guidednavigation.GuidedNavigationDescription{Text: *aria} + } + c.placeholder(n, tag, obj) + c.skipNode = true + return + case slices.Contains(roles, guidednavigation.RoleImage): + // Elements acting as images without being one, e.g. or : + // their content is replaced by their accessible name. + obj := guidednavigation.GuidedNavigationObject{Role: roles} + if aria != nil { + obj.Description = guidednavigation.GuidedNavigationDescription{Text: *aria} + } + c.placeholder(n, "image", obj) + c.skipNode = true + return + case n.DataAtom == atom.Math && strings.TrimSpace(getAttr(n, "alttext")) != "": + // MathML with alternative text: the markup itself is meaningless when read aloud + obj := guidednavigation.GuidedNavigationObject{ + Role: roles, + Text: guidednavigation.GuidedNavigationText{Plain: strings.TrimSpace(getAttr(n, "alttext"))}, + } + c.placeholder(n, "math", obj) + c.skipNode = true + return + } + + if !c.isBlockNode(n) { + // Text flows through inline elements; their SSML context is computed per text node + return + } + + // A block element: open a new navigation object + c.flushText() + c.descend(n) + + cur := &c.current.object + cur.Role = roles + if aria != nil { + cur.Description = guidednavigation.GuidedNavigationDescription{Text: *aria} + if slices.Contains(roles, guidednavigation.RoleFigure) { + // The accessible name replaces the figure's text content + // (but media children are still collected) + c.current.noText = true + } + } + if n.DataAtom == atom.Body { + // Contextualize the top-level object per the specification + cur.TextRef = c.baseLocator.Href + } else if len(roles) > 0 && !c.current.noText { + if id := getAttr(n, "id"); id != "" { + cur.TextRef = c.fragmentRef(id) + } + } +} + +func (c *HTMLConverter) tail(n *html.Node) { + if n.Type == html.TextNode { + c.text(n) + return + } + if n.Type != html.ElementNode { + return + } + if c.skipNode { + c.skipNode = false + return + } + if c.isBlockNode(n) { + c.flushText() + c.ascend() + } +} + +func (c *HTMLConverter) text(n *html.Node) { + if c.current.noText { + return + } + + if onlySpace(n.Data) { + // Whitespace is context-neutral: it separates words without opening a new segment + if c.textAcc.Len() > 0 || len(c.segments) > 0 { + appendNormalizedWhitespace(&c.textAcc, n.Data, c.flowEndsWithSpace) + c.updateFlowSpace() + } + return + } + + ctx := c.textContext(n) + if !ctx.equal(c.currentCtx) { + c.closeSegment() + c.currentCtx = ctx + } + appendNormalizedWhitespace(&c.textAcc, n.Data, c.flowEndsWithSpace) + c.updateFlowSpace() +} + +// The SSML context of a text node: its effective language, and the SSML tag mapped +// from its closest inline ancestor within the current block. +func (c *HTMLConverter) textContext(n *html.Node) (ctx ssmlContext) { + ctx.lang = nodeLanguage(n.Parent) + for p := n.Parent; p != nil && p != c.current.node; p = p.Parent { + if p.Type != html.ElementNode { + continue + } + if tag, attrs := ConvertElementToSSMLTag(p.DataAtom); tag != "" && tag != "break" { + ctx.tag = tag + ctx.attrs = attrs + break + } + } + return +} + +func (c *HTMLConverter) updateFlowSpace() { + if c.textAcc.Len() > 0 { + s := c.textAcc.String() + c.flowEndsWithSpace = s[len(s)-1] == ' ' + } +} + +func (c *HTMLConverter) closeSegment() { + if c.textAcc.Len() == 0 { + return + } + c.segments = append(c.segments, textSegment{ + kind: segmentText, + text: c.textAcc.String(), + ctx: c.currentCtx, + }) + c.textAcc.Reset() +} + +func (c *HTMLConverter) resetFlow() { + c.segments = nil + c.textAcc.Reset() + c.currentCtx = ssmlContext{} + c.flowEndsWithSpace = true + c.pendingChildren = nil +} + +// Register an object for an element embedded in the text flow (image, audio, video, +// pagebreak, noteref...). If the enclosing block turns out to have text, the object +// is linked from that text with a SSML placeholder. +func (c *HTMLConverter) placeholder(n *html.Node, tag string, object guidednavigation.GuidedNavigationObject) { + c.placeholderWithID(n, tag, object, getAttr(n, "id")) +} + +func (c *HTMLConverter) placeholderWithID(n *html.Node, tag string, object guidednavigation.GuidedNavigationObject, candidateID string) { + if object.Empty() { + // Nothing worth referencing, e.g. an without src or description. + // Registering it anyway would leave a dangling id in the SSML. + return + } + child := &navigationObject{node: n, object: object} + if c.current.noText { + // The surrounding text is suppressed: keep the object, without a placeholder + c.appendChild(child) + return + } + c.closeSegment() + c.pendingChildren = append(c.pendingChildren, child) + c.segments = append(c.segments, textSegment{ + kind: segmentPlaceholder, + tag: tag, + child: child, + candidateID: candidateID, + }) + c.flowEndsWithSpace = false +} + +// Emits a pagebreak object. Returns whether the traversal should still descend into +// the node: a pagebreak marker is normally empty, but when the HTML parser mistakes +// a self-closing for an open tag, the content following the marker ends up +// inside of it — that content must flow transparently instead of being dropped. +func (c *HTMLConverter) pagebreak(n *html.Node, aria *guidednavigation.GuidedNavigationText, roles []guidednavigation.GuidedNavigationRole) (descend bool) { + obj := guidednavigation.GuidedNavigationObject{Role: roles} + // The page number lives in the title attribute, the accessible name, or the content + if title := strings.TrimSpace(getAttr(n, "title")); title != "" { + obj.Text = guidednavigation.GuidedNavigationText{Plain: title} + } else if aria != nil { + obj.Text = *aria + } + labelled := !obj.Text.Empty() + // Swallowed content only exists on the HTML parse path (XML handles self-closing + // tags correctly). There, a labelled pagebreak has no content of its own — + // anything inside is swallowed document content. An unlabelled one might carry + // the page number as content. + descend = !c.xmlParsed && (hasElementChild(n) || (labelled && n.FirstChild != nil)) + if !labelled && !descend { + if text := normalizedNodeText(n); text != "" { + obj.Text = guidednavigation.GuidedNavigationText{Plain: text} + } + } + if id := getAttr(n, "id"); id != "" { + obj.TextRef = c.fragmentRef(id) + } + c.placeholder(n, "pagebreak", obj) + return descend +} + +func (c *HTMLConverter) noteref(n *html.Node, roles []guidednavigation.GuidedNavigationRole) { + obj := guidednavigation.GuidedNavigationObject{Role: roles} + if text := normalizedNodeText(n); text != "" { + obj.Text = guidednavigation.GuidedNavigationText{Plain: text} + } + + href := getAttr(n, "href") + resolved := c.resolveHref(href) + candidateID := getAttr(n, "id") + if candidateID == "" && resolved != nil { + // Identify the noteref object by the note it references, like the + // examples of the specification + candidateID = resolved.Fragment() + } + if fragment, ok := c.sameDocumentFragment(href); ok { + // The note lives in this document: embed it in the noteref object. + // Notes containing their own reference (e.g. a link to the enclosing + // section) are only referenced, never embedded. + if target := c.ids[fragment]; target != nil && !isAncestorOf(target, n) && c.noterefDepth < maxNoterefDepth { + sub := &HTMLConverter{ + baseLocator: c.baseLocator, + xmlParsed: c.xmlParsed, + ids: c.ids, + suppressed: c.suppressed, + idAlloc: c.idAlloc, + noterefDepth: c.noterefDepth + 1, + allowNode: target, + } + sub.Convert(target) + obj.Children = sub.Result() + } + } + if len(obj.Children) == 0 && resolved != nil { + // The note lives in another resource (or couldn't be embedded): reference it + obj.Children = []guidednavigation.GuidedNavigationObject{{TextRef: resolved}} + } + + c.placeholderWithID(n, "noteref", obj, candidateID) +} + +func (c *HTMLConverter) flushText() { + c.closeSegment() + segments := c.segments + pending := c.pendingChildren + c.resetFlow() + + if len(segments) == 0 { + return + } + + // Trim the edges of the flow: leading/trailing whitespace and breaks are + // meaningless, only whitespace between segments matters. + for len(segments) > 0 { + seg := &segments[0] + if seg.kind == segmentBreak { + segments = segments[1:] + continue + } + if seg.kind == segmentText { + seg.text = strings.TrimLeftFunc(seg.text, unicode.IsSpace) + if seg.text == "" { + segments = segments[1:] + continue + } + } + break + } + for len(segments) > 0 { + seg := &segments[len(segments)-1] + if seg.kind == segmentBreak { + segments = segments[:len(segments)-1] + continue + } + if seg.kind == segmentText { + seg.text = strings.TrimRightFunc(seg.text, unicode.IsSpace) + if seg.text == "" { + segments = segments[:len(segments)-1] + continue + } + } + break + } + + hasText := false + for _, seg := range segments { + if seg.kind == segmentText && strings.TrimSpace(seg.text) != "" { + hasText = true + break + } + } + if !hasText { + // No meaningful text: any placeholder objects simply stand on their own + for _, child := range pending { + c.appendChild(child) + } + return + } + + // The text's base language: the shared language of all its segments, + // or the block element's own language. + var textLangs []string + for _, seg := range segments { + if seg.kind == segmentText && strings.TrimSpace(seg.text) != "" { + if !slices.Contains(textLangs, seg.ctx.lang) { + textLangs = append(textLangs, seg.ctx.lang) + } + } + } + baseLang := nodeLanguage(c.current.node) + if len(textLangs) == 1 && textLangs[0] != "" { + baseLang = textLangs[0] + } + + needSSML := false + for _, seg := range segments { + if seg.kind != segmentText || seg.ctx.tag != "" || seg.ctx.lang != baseLang { + needSSML = true + break + } + } + + // Now that we know the text object will exist, link the placeholder objects to it + if needSSML { + for _, seg := range segments { + if seg.kind != segmentPlaceholder { + continue + } + id := seg.candidateID + if id == "" || !c.idAlloc.claim(id) { + id = c.idAlloc.allocate(seg.tag) + } + seg.child.object.ID = id + } + } + + // The plain text drops the placeholders. Joints between written chunks get a + // space depending on what stood between them: a break always separates; a + // dropped placeholder keeps a surrounding space unless the following text binds + // to the left, like punctuation ("endnote ." reads "endnote.", while + // "resource and" reads "resource and"). + var plainB strings.Builder + prevTrail := false // The last written chunk ended with a space + sawBreak := false // A break stands between the last written chunk and here + sawPlaceholder := false // A placeholder stands between the last written chunk and here + midSpace := false // A whitespace-only segment stands between the last written chunk and here + for _, seg := range segments { + switch seg.kind { + case segmentText: + lead := strings.HasPrefix(seg.text, " ") + t := strings.Trim(seg.text, " ") + if t == "" { + midSpace = true + continue + } + join := false + if plainB.Len() > 0 { + spaced := prevTrail || midSpace || lead + if sawBreak { + join = true + } else if sawPlaceholder { + join = spaced && !startsWithBindingPunct(t) + } else { + join = spaced + } + } + if join { + plainB.WriteByte(' ') + } + plainB.WriteString(t) + prevTrail = strings.HasSuffix(seg.text, " ") + sawBreak = false + sawPlaceholder = false + midSpace = false + case segmentBreak: + sawBreak = true + case segmentPlaceholder: + sawPlaceholder = true + } + } + + text := guidednavigation.GuidedNavigationText{ + Plain: strings.TrimSpace(plainB.String()), + Language: baseLang, + } + + if needSSML { + var sb strings.Builder + for _, seg := range segments { + switch seg.kind { + case segmentText: + tag := seg.ctx.tag + attrs := seg.ctx.attrs + if tag == "" && seg.ctx.lang != baseLang && seg.ctx.lang != "" { + tag = "lang" + } + if tag != "" { + sb.WriteByte('<') + sb.WriteString(tag) + for _, attr := range attrs { + sb.WriteByte(' ') + sb.WriteString(attr.Name.Local) + sb.WriteString(`="`) + sb.WriteString(ssmlAttrEscaper.Replace(attr.Value)) + sb.WriteByte('"') + } + if seg.ctx.lang != baseLang && seg.ctx.lang != "" { + sb.WriteString(` xml:lang="`) + sb.WriteString(ssmlAttrEscaper.Replace(seg.ctx.lang)) + sb.WriteByte('"') + } + sb.WriteByte('>') + sb.WriteString(ssmlTextEscaper.Replace(seg.text)) + sb.WriteString("') + } else { + sb.WriteString(ssmlTextEscaper.Replace(seg.text)) + } + case segmentBreak: + sb.WriteString("") + case segmentPlaceholder: + sb.WriteString("`) + } + } + text.SSML = sb.String() + } + + // The objects referenced from the text's SSML become children of the text + // object, so they stay attached to it even when the enclosing block hosts + // several text flows. + textObj := &navigationObject{ + object: guidednavigation.GuidedNavigationObject{Text: text}, + } + for _, child := range pending { + child.parent = textObj + textObj.children = append(textObj.children, child) + } + c.appendChild(textObj) +} + +// Punctuation that binds to the preceding word, suppressing the space a dropped +// placeholder would otherwise leave behind. +func startsWithBindingPunct(s string) bool { + if s == "" { + return false + } + switch []rune(s)[0] { + case '.', ',', ';', ':', '!', '?', ')', ']', '}': + return true + } + return false +} diff --git a/pkg/guidednavigation/converter/roles.go b/pkg/guidednavigation/converter/roles.go new file mode 100644 index 00000000..bb958a55 --- /dev/null +++ b/pkg/guidednavigation/converter/roles.go @@ -0,0 +1,330 @@ +package converter + +import ( + "slices" + "strconv" + "strings" + + "github.com/readium/go-toolkit/pkg/guidednavigation" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// Crawl up the tree and extract all namespaces. +// This is technically an incorrect implementation because it doesn't distinguish between XHTML and HTML. +// These namespaces are ignored in HTML documents, but we still support them. +func ExtractNamespaces(el *html.Node) (namespaces map[string]string) { + namespaces = map[string]string{ + "xml": "http://www.w3.org/XML/1998/namespace", + } + + f := func(n *html.Node) { + for _, at := range n.Attr { + if at.Key == "xmlns" { + if _, ok := namespaces[""]; !ok { + // Only the first xmlns gets set + namespaces[""] = at.Val + } + } else if strings.HasPrefix(at.Key, "xmlns:") { + namespace := strings.TrimPrefix(at.Key, "xmlns:") + if _, ok := namespaces[namespace]; !ok { + // Only the first unique xmlns:prefix gets set + namespaces[namespace] = at.Val + } + } + } + } + + f(el) + if el.Type != html.ElementNode || el.DataAtom != atom.Html { + for el.Parent != nil { + el = el.Parent + f(el) + if el.Type == html.ElementNode && el.DataAtom == atom.Html { + break + } + } + } + + return +} + +var headingRoles = [6]guidednavigation.GuidedNavigationRole{ + guidednavigation.RoleHeading1, + guidednavigation.RoleHeading2, + guidednavigation.RoleHeading3, + guidednavigation.RoleHeading4, + guidednavigation.RoleHeading5, + guidednavigation.RoleHeading6, +} + +var ariaRoles = map[string]guidednavigation.GuidedNavigationRole{ + "doc-abstract": guidednavigation.RoleAbstract, + "doc-acknowledgments": guidednavigation.RoleAcknowledgments, + "doc-afterword": guidednavigation.RoleAfterword, + "doc-appendix": guidednavigation.RoleAppendix, + "article": guidednavigation.RoleArticle, + "doc-backlink": guidednavigation.RoleBacklink, + "doc-bibliography": guidednavigation.RoleBibliography, + "doc-biblioref": guidednavigation.RoleBiblioref, + "blockquote": guidednavigation.RoleBlockquote, + "caption": guidednavigation.RoleCaption, + "cell": guidednavigation.RoleCell, + "doc-chapter": guidednavigation.RoleChapter, + "doc-colophon": guidednavigation.RoleColophon, + "columnheader": guidednavigation.RoleColumnHeader, + "complementary": guidednavigation.RoleComplementary, + "doc-conclusion": guidednavigation.RoleConclusion, + "doc-cover": guidednavigation.RoleCover, + "doc-credit": guidednavigation.RoleCredit, + "doc-credits": guidednavigation.RoleCredits, + "doc-dedication": guidednavigation.RoleDedication, + "definition": guidednavigation.RoleDefinition, + "doc-endnote": guidednavigation.RoleFootnote, // Deprecated in DPUB-ARIA 1.1 + "doc-endnotes": guidednavigation.RoleEndnotes, + "doc-epigraph": guidednavigation.RoleEpigraph, + "doc-epilogue": guidednavigation.RoleEpilogue, + "doc-errata": guidednavigation.RoleErrata, + "doc-example": guidednavigation.RoleExample, + "figure": guidednavigation.RoleFigure, + "doc-footnote": guidednavigation.RoleFootnote, + "doc-foreword": guidednavigation.RoleForeword, + "doc-glossary": guidednavigation.RoleGlossary, + "doc-glossref": guidednavigation.RoleGlossref, + "img": guidednavigation.RoleImage, + "image": guidednavigation.RoleImage, // ARIA 1.3 synonym of img + "doc-index": guidednavigation.RoleIndex, + "doc-introduction": guidednavigation.RoleIntroduction, + "list": guidednavigation.RoleList, + "listitem": guidednavigation.RoleListItem, + "main": guidednavigation.RoleMain, + "math": guidednavigation.RoleMath, + "navigation": guidednavigation.RoleNavigation, + "doc-noteref": guidednavigation.RoleNoteref, + "doc-notice": guidednavigation.RoleNotice, + "doc-pagebreak": guidednavigation.RolePagebreak, + "doc-pagelist": guidednavigation.RolePagelist, + "paragraph": guidednavigation.RoleParagraph, + "doc-part": guidednavigation.RolePart, + "doc-preface": guidednavigation.RolePreface, + "doc-prologue": guidednavigation.RolePrologue, + "doc-pullquote": guidednavigation.RolePullquote, + "presentation": guidednavigation.RolePresentation, + "none": guidednavigation.RolePresentation, + "doc-qna": guidednavigation.RoleQna, + "qna": guidednavigation.RoleQna, + "region": guidednavigation.RoleRegion, + "row": guidednavigation.RoleRow, + "rowheader": guidednavigation.RoleRowHeader, + "separator": guidednavigation.RoleSeparator, + "doc-subtitle": guidednavigation.RoleSubtitle, + "table": guidednavigation.RoleTable, + "term": guidednavigation.RoleTerm, + "doc-tip": guidednavigation.RoleTip, + "doc-toc": guidednavigation.RoleToc, +} + +var epubTypeRoles = map[string]guidednavigation.GuidedNavigationRole{ + "abstract": guidednavigation.RoleAbstract, + "acknowledgments": guidednavigation.RoleAcknowledgments, + "afterword": guidednavigation.RoleAfterword, + "appendix": guidednavigation.RoleAppendix, + "aside": guidednavigation.RoleAside, + "backlink": guidednavigation.RoleBacklink, + "bibliography": guidednavigation.RoleBibliography, + "biblioref": guidednavigation.RoleBiblioref, + "table-cell": guidednavigation.RoleCell, + "chapter": guidednavigation.RoleChapter, + "colophon": guidednavigation.RoleColophon, + "conclusion": guidednavigation.RoleConclusion, + "cover": guidednavigation.RoleCover, + "credit": guidednavigation.RoleCredit, + "credits": guidednavigation.RoleCredits, + "dedication": guidednavigation.RoleDedication, + "glossdef": guidednavigation.RoleDefinition, + "endnote": guidednavigation.RoleFootnote, + "endnotes": guidednavigation.RoleEndnotes, + "rearnote": guidednavigation.RoleFootnote, // Deprecated alias of endnote + "rearnotes": guidednavigation.RoleEndnotes, // Deprecated alias of endnotes + "epigraph": guidednavigation.RoleEpigraph, + "epilogue": guidednavigation.RoleEpilogue, + "errata": guidednavigation.RoleErrata, + "example": guidednavigation.RoleExample, + "figure": guidednavigation.RoleFigure, + "footnote": guidednavigation.RoleFootnote, + "foreword": guidednavigation.RoleForeword, + "glossary": guidednavigation.RoleGlossary, + "glossref": guidednavigation.RoleGlossref, + "index": guidednavigation.RoleIndex, + "introduction": guidednavigation.RoleIntroduction, + "landmarks": guidednavigation.RoleLandmarks, + "list": guidednavigation.RoleList, + "list-item": guidednavigation.RoleListItem, + "loa": guidednavigation.RoleLoa, + "loi": guidednavigation.RoleLoi, + "lot": guidednavigation.RoleLot, + "lov": guidednavigation.RoleLov, + "noteref": guidednavigation.RoleNoteref, + "notice": guidednavigation.RoleNotice, + "pagebreak": guidednavigation.RolePagebreak, + "page-list": guidednavigation.RolePagelist, + "pagelist": guidednavigation.RolePagelist, + "part": guidednavigation.RolePart, + "preface": guidednavigation.RolePreface, + "prologue": guidednavigation.RolePrologue, + "pullquote": guidednavigation.RolePullquote, + "qna": guidednavigation.RoleQna, + "table-row": guidednavigation.RoleRow, + "subtitle": guidednavigation.RoleSubtitle, + "table": guidednavigation.RoleTable, + "glossterm": guidednavigation.RoleTerm, + "tip": guidednavigation.RoleTip, + "toc": guidednavigation.RoleToc, +} + +var simpleElementTypeRoles = map[atom.Atom]guidednavigation.GuidedNavigationRole{ + atom.Article: guidednavigation.RoleArticle, + atom.Aside: guidednavigation.RoleAside, + atom.Audio: guidednavigation.RoleAudio, + atom.Blockquote: guidednavigation.RoleBlockquote, + atom.Caption: guidednavigation.RoleCaption, + atom.Figcaption: guidednavigation.RoleCaption, + atom.Td: guidednavigation.RoleCell, + atom.Dd: guidednavigation.RoleDefinition, + atom.Details: guidednavigation.RoleDetails, + atom.Figure: guidednavigation.RoleFigure, + atom.Header: guidednavigation.RoleHeader, + atom.H1: guidednavigation.RoleHeading1, + atom.H2: guidednavigation.RoleHeading2, + atom.H3: guidednavigation.RoleHeading3, + atom.H4: guidednavigation.RoleHeading4, + atom.H5: guidednavigation.RoleHeading5, + atom.H6: guidednavigation.RoleHeading6, + atom.Img: guidednavigation.RoleImage, + atom.Ul: guidednavigation.RoleList, + atom.Ol: guidednavigation.RoleList, + atom.Li: guidednavigation.RoleListItem, + atom.Main: guidednavigation.RoleMain, + atom.Math: guidednavigation.RoleMath, + atom.Nav: guidednavigation.RoleNavigation, + atom.P: guidednavigation.RoleParagraph, + atom.Pre: guidednavigation.RolePreformatted, + atom.Tr: guidednavigation.RoleRow, + atom.Section: guidednavigation.RoleSection, + atom.Hr: guidednavigation.RoleSeparator, + atom.Summary: guidednavigation.RoleSummary, + atom.Table: guidednavigation.RoleTable, + atom.Dfn: guidednavigation.RoleTerm, + atom.Dt: guidednavigation.RoleTerm, + atom.Video: guidednavigation.RoleVideo, + atom.Svg: guidednavigation.RoleImage, +} + +// ExtractNodeRoles determines the Guided Navigation roles of an element, combining +// the roles derived from the element type itself with the ones from its ARIA `role` +// and `epub:type` attributes, e.g.
-> [section, chapter]. +// An ARIA role of "presentation"/"none" strips the element of its native semantics. +func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationRole) { + add := func(role guidednavigation.GuidedNavigationRole) { + if !slices.Contains(roles, role) { + roles = append(roles, role) + } + } + + // Based on attributes. Both `role` and `epub:type` can hold a list of values. + var namespaces map[string]string + var attrRoles []guidednavigation.GuidedNavigationRole + presentational := false + for _, at := range el.Attr { + // Remove namespace prefix if it exists + frags := strings.SplitN(at.Key, ":", 2) + key := frags[len(frags)-1] + + if len(frags) == 1 { + // ARIA role + if key == "role" { + for _, val := range strings.Fields(at.Val) { + if val == "presentation" || val == "none" { + presentational = true + } + if val == "heading" { + // The heading level comes from aria-level. It defaults to 2: + // https://www.w3.org/TR/wai-aria/#heading + level := 2 + if l, err := strconv.Atoi(getAttr(el, "aria-level")); err == nil && l >= 1 { + level = min(l, 6) + } + attrRoles = append(attrRoles, headingRoles[level-1]) + } else if role, ok := ariaRoles[val]; ok { + attrRoles = append(attrRoles, role) + } + } + } + } else { + // First we check for an attribute key we're interested in, because extracting namespaces is expensive + if key != "type" { + continue + } + + // Maybe the attribute has a namespace...? + if at.Namespace == "" { + if namespaces == nil { + // Save namespaces so they're only extracted once per element + namespaces = ExtractNamespaces(el) + } + if namespace, ok := namespaces[frags[0]]; ok { + // Set the namespace if we found it + at.Namespace = namespace + } + } + + if at.Namespace == "http://www.idpf.org/2007/ops" && key == "type" { + for _, val := range strings.Fields(at.Val) { + if role, ok := epubTypeRoles[val]; ok { + attrRoles = append(attrRoles, role) + } + } + } + } + } + + if presentational { + // The element only retains the presentation role + return []guidednavigation.GuidedNavigationRole{guidednavigation.RolePresentation} + } + + // Based on element type. The element's own role comes first, followed by the + // more specific attribute-based roles, matching the ordering of the examples + // in the Guided Navigation specification. + switch el.DataAtom { + case atom.Body: + add(guidednavigation.RoleBody) + case atom.Th: + switch getAttr(el, "scope") { + case "col": + add(guidednavigation.RoleColumnHeader) + case "row": + add(guidednavigation.RoleRowHeader) + default: + // Without an explicit scope the direction is unknown, but it's still a cell + add(guidednavigation.RoleCell) + } + default: + if role, ok := simpleElementTypeRoles[el.DataAtom]; ok { + add(role) + } + } + + for _, role := range attrRoles { + add(role) + } + + return +} + +func ConvertEPUBRole(role string) guidednavigation.GuidedNavigationRole { + if gRole, ok := epubTypeRoles[role]; ok { + return gRole + } + return "" +} diff --git a/pkg/guidednavigation/converter/xhtml.go b/pkg/guidednavigation/converter/xhtml.go new file mode 100644 index 00000000..0a5edb40 --- /dev/null +++ b/pkg/guidednavigation/converter/xhtml.go @@ -0,0 +1,152 @@ +package converter + +import ( + "encoding/xml" + "io" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +const ( + xmlNamespace = "http://www.w3.org/XML/1998/namespace" + xlinkNamespace = "http://www.w3.org/1999/xlink" + svgNamespace = "http://www.w3.org/2000/svg" + mathmlNamespace = "http://www.w3.org/1998/Math/MathML" +) + +// Namespace prefixes in scope at a point of the document, for reconstructing +// prefixed attribute names (encoding/xml only exposes the resolved namespace URI). +type nsScope struct { + parent *nsScope + uriToPrefix map[string]string +} + +func (s *nsScope) lookup(uri string) (string, bool) { + for scope := s; scope != nil; scope = scope.parent { + if prefix, ok := scope.uriToPrefix[uri]; ok { + return prefix, true + } + } + return "", false +} + +// ParseXHTML parses an XHTML document into the tree shape html.Parse produces, so +// consumers written against html.Parse output work unchanged. Unlike html.Parse, +// self-closing elements like are handled per XML rules +// instead of being treated as unclosed tags that swallow the content following them. +func ParseXHTML(r io.Reader) (*html.Node, error) { + dec := xml.NewDecoder(r) + dec.Strict = false // Invent end tags for mismatched elements, keep unknown entities as text + dec.AutoClose = xml.HTMLAutoClose // Tolerate HTML-style void tags like
+ dec.Entity = xml.HTMLEntity //   etc. (the HTML 4 set, matching the XHTML DTDs) + // The input has already been decoded to UTF-8, whatever encoding the XML + // declaration announces + dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { + return input, nil + } + + doc := &html.Node{Type: html.DocumentNode} + cur := doc + scope := &nsScope{} + + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + // Track xmlns declarations, which encoding/xml leaves untranslated + child := &nsScope{parent: scope} + for _, a := range t.Attr { + if a.Name.Space == "xmlns" { + if child.uriToPrefix == nil { + child.uriToPrefix = make(map[string]string) + } + child.uriToPrefix[a.Value] = a.Name.Local + } + } + scope = child + + local := strings.ToLower(t.Name.Local) + n := &html.Node{ + Type: html.ElementNode, + Data: local, + DataAtom: atom.Lookup([]byte(local)), + Namespace: elementNamespace(t.Name.Space), + } + for _, a := range t.Attr { + n.Attr = append(n.Attr, attrFromXML(a, scope)) + } + cur.AppendChild(n) + cur = n + case xml.EndElement: + // Self-closing elements arrive as Start+End pairs, keeping this balanced + if scope.parent != nil { + scope = scope.parent + } + if cur.Parent != nil { + cur = cur.Parent + } + case xml.CharData: + if cur == doc { + continue // Whitespace around the root element + } + if last := cur.LastChild; last != nil && last.Type == html.TextNode { + last.Data += string(t) + } else { + cur.AppendChild(&html.Node{Type: html.TextNode, Data: string(t)}) + } + case xml.Directive: + if d := string(t); len(d) > 8 && strings.EqualFold(d[:8], "DOCTYPE ") { + cur.AppendChild(&html.Node{Type: html.DoctypeNode, Data: strings.TrimSpace(d[8:])}) + } + } + // Comments and processing instructions are skipped + } + return doc, nil +} + +func elementNamespace(space string) string { + switch space { + case svgNamespace: + return "svg" + case mathmlNamespace: + return "math" + default: + // XHTML and unknown namespaces are treated as HTML + return "" + } +} + +// Converts an XML attribute to the representation html.Parse would have produced: +// the whole qualified name (prefix included) in Key, and an empty Namespace. +func attrFromXML(a xml.Attr, scope *nsScope) html.Attribute { + local := strings.ToLower(a.Name.Local) + switch a.Name.Space { + case "": + return html.Attribute{Key: local, Val: a.Value} + case "xmlns": + // Keep the prefix's case, so the declaration matches the reconstructed + // qualified names below + return html.Attribute{Key: "xmlns:" + a.Name.Local, Val: a.Value} + case xmlNamespace: + return html.Attribute{Key: "xml:" + local, Val: a.Value} + case xlinkNamespace: + return html.Attribute{Key: "xlink:" + local, Val: a.Value} + } + if prefix, ok := scope.lookup(a.Name.Space); ok && prefix != "" { + return html.Attribute{Key: prefix + ":" + local, Val: a.Value} + } + if !strings.ContainsAny(a.Name.Space, ":/") { + // encoding/xml passes undeclared prefixes through verbatim + return html.Attribute{Key: a.Name.Space + ":" + local, Val: a.Value} + } + // A namespace URI without an in-scope prefix: degrade to the local name + return html.Attribute{Key: local, Val: a.Value} +} diff --git a/pkg/guidednavigation/converter/xhtml_test.go b/pkg/guidednavigation/converter/xhtml_test.go new file mode 100644 index 00000000..84034b88 --- /dev/null +++ b/pkg/guidednavigation/converter/xhtml_test.go @@ -0,0 +1,139 @@ +package converter + +import ( + "strings" + "testing" + + "github.com/readium/go-toolkit/pkg/guidednavigation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +const xhtmlSample = ` + + +Test + +

Café after

+

Bonjour

+ +` + +func TestParseXHTMLSelfClosingSpan(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(xhtmlSample)) + require.NoError(t, err) + + body := childOfType(doc, atom.Body, true) + require.NotNil(t, body) + + span := childOfType(doc, atom.Span, true) + require.NotNil(t, span) + // The self-closing span must be empty, not swallow the text after it + assert.Nil(t, span.FirstChild, "self-closing span should have no children") + require.NotNil(t, span.NextSibling) + assert.Equal(t, "after", span.NextSibling.Data) +} + +// Attributes must use the same representation html.Parse produces: +// the whole qualified name in Key, and an empty Namespace. +func TestParseXHTMLAttributeCompatibility(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(xhtmlSample)) + require.NoError(t, err) + + span := childOfType(doc, atom.Span, true) + require.NotNil(t, span) + assert.Equal(t, "pagebreak", getAttr(span, "epub:type")) + assert.Equal(t, "4", getAttr(span, "title")) + + htmlNode := childOfType(doc, atom.Html, true) + require.NotNil(t, htmlNode) + assert.Equal(t, "en", getAttr(htmlNode, "xml:lang")) + assert.Equal(t, "http://www.idpf.org/2007/ops", getAttr(htmlNode, "xmlns:epub")) + + for _, attr := range span.Attr { + assert.Empty(t, attr.Namespace, "attribute namespaces must be empty for html.Parse compatibility") + } +} + +func TestParseXHTMLEntities(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(xhtmlSample)) + require.NoError(t, err) + + p := childOfType(doc, atom.P, true) + require.NotNil(t, p) + require.NotNil(t, p.FirstChild) + assert.Equal(t, "Café ", p.FirstChild.Data) +} + +func TestParseXHTMLForeignContent(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(` + + Icon + x + `)) + require.NoError(t, err) + + svg := childOfType(doc, atom.Svg, true) + require.NotNil(t, svg) + assert.Equal(t, "svg", svg.Namespace) + + math := childOfType(doc, atom.Math, true) + require.NotNil(t, math) + assert.Equal(t, "math", math.Namespace) + assert.Equal(t, "x", getAttr(math, "alttext")) +} + +func TestParseXHTMLCDATAAndComments(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(` + +

before

+ `)) + require.NoError(t, err) + + p := childOfType(doc, atom.P, true) + require.NotNil(t, p) + require.NotNil(t, p.FirstChild) + // Comments are dropped; CDATA merges with adjacent text + assert.Equal(t, "before & cdata", p.FirstChild.Data) + assert.Equal(t, html.TextNode, p.FirstChild.Type) + assert.Nil(t, p.FirstChild.NextSibling) +} + +// Namespace prefixes keep their case consistently between the xmlns declaration +// and reconstructed attribute names. +func TestParseXHTMLUppercasePrefix(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(` + + `)) + require.NoError(t, err) + + span := childOfType(doc, atom.Span, true) + require.NotNil(t, span) + assert.Equal(t, "pagebreak", getAttr(span, "EPUB:type")) + + htmlNode := childOfType(doc, atom.Html, true) + require.NotNil(t, htmlNode) + assert.Equal(t, "http://www.idpf.org/2007/ops", getAttr(htmlNode, "xmlns:EPUB")) + + // The role extraction resolves the prefix through the declaration + roles := ExtractNodeRoles(span) + assert.Contains(t, roles, guidednavigation.RolePagebreak) +} + +// The input is already UTF-8, whatever encoding the XML declaration announces. +func TestParseXHTMLForeignEncodingDeclaration(t *testing.T) { + doc, err := ParseXHTML(strings.NewReader(` +

Café

`)) + require.NoError(t, err) + p := childOfType(doc, atom.P, true) + require.NotNil(t, p) + assert.Equal(t, "Café", p.FirstChild.Data) +} + +func TestParseXHTMLMalformed(t *testing.T) { + // An unclosed element at EOF is a hard error, triggering the html.Parse fallback in Do + _, err := ParseXHTML(strings.NewReader(`

Unclosed`)) + require.Error(t, err) +} diff --git a/pkg/guidednavigation/fragments.go b/pkg/guidednavigation/fragments.go new file mode 100644 index 00000000..94ac54c3 --- /dev/null +++ b/pkg/guidednavigation/fragments.go @@ -0,0 +1,588 @@ +package guidednavigation + +import ( + "math" + nurl "net/url" + "strconv" + "strings" + "time" + + "github.com/readium/go-toolkit/pkg/util/url" +) + +// Media reference utilities, per the "Media References" section of the +// Guided Navigation specification: +// +// - Audio/video: temporal media fragments, e.g. "audio.mp3#t=10,20" +// https://www.w3.org/TR/media-frags/#naming-time +// - Images/video: rectangular spatial media fragments, e.g. "page.jpg#xywh=percent:10,10,20,20" +// https://www.w3.org/TR/media-frags/#naming-space +// - Images: polygonal regions, e.g. "page.jpg#xyn=percent:0,50,50,0,100,50" +// https://idpf.org/epub/renditions/region-nav/#sec-3.5.1 +// - Text: fragment ids ("chapter.html#par1") and text fragments +// https://wicg.github.io/scroll-to-text-fragment/ + +// The unit of a spatial fragment's coordinates. +type RegionUnit string + +const ( + RegionUnitPixel RegionUnit = "pixel" + RegionUnitPercent RegionUnit = "percent" +) + +// Clip is the temporal fragment of a media resource (the "t" dimension of a +// media fragment, in Normal Play Time format). +type Clip struct { + Begin *time.Duration // Offset from the start of the media, or nil for its beginning. + End *time.Duration // Offset from the start of the media, or nil for its end. +} + +// MediaFragment returns the "t" media fragment dimension denoting the clip, +// e.g. "t=10.5,20", or an empty string for an unbounded clip. It can be used +// as the fragment of a media resource URL. +func (c Clip) MediaFragment() string { + if c.Begin == nil && c.End == nil { + return "" + } + var sb strings.Builder + sb.WriteString("t=") + if c.Begin != nil { + sb.WriteString(FormatNPTTime(*c.Begin)) + } + if c.End != nil { + sb.WriteByte(',') + sb.WriteString(FormatNPTTime(*c.End)) + } + return sb.String() +} + +// FormatNPTTime formats a duration as a Normal Play Time value in seconds, +// rounded to the millisecond, e.g. "123.4". +// https://www.w3.org/TR/media-frags/#npttimedef +func FormatNPTTime(d time.Duration) string { + if d < 0 { + d = 0 + } + seconds := math.Round(d.Seconds()*1000) / 1000 + return strconv.FormatFloat(seconds, 'f', -1, 64) +} + +// Region is a rectangular spatial fragment of a resource +// (the "xywh" dimension of a media fragment). +type Region struct { + Unit RegionUnit + X float64 + Y float64 + Width float64 + Height float64 +} + +// A point of a polygonal region. +type Point struct { + X float64 + Y float64 +} + +// Polygon is a polygonal region of a resource, as defined by EPUB Region-Based +// Navigation (the "xyn" fragment parameter). +type Polygon struct { + Unit RegionUnit + Points []Point // At least three points. +} + +// TextFragment is a text fragment of a textual resource, as defined by the +// URL Fragment Text Directives specification (":~:text=" fragments). +type TextFragment struct { + Prefix string // Text immediately before the target, or empty. + TextStart string // Start of the target text. + TextEnd string // End of the target text when it's a range, or empty. + Suffix string // Text immediately after the target, or empty. +} + +// AudioFile returns the audio resource referenced by the object, without its media fragment. +func (o GuidedNavigationObject) AudioFile() url.URL { + return refFile(o.AudioRef) +} + +// AudioClip returns the temporal fragment of the object's audio reference, +// or nil when the whole resource is referenced. +func (o GuidedNavigationObject) AudioClip() *Clip { + return refClip(o.AudioRef) +} + +// VideoFile returns the video resource referenced by the object, without its media fragment. +func (o GuidedNavigationObject) VideoFile() url.URL { + return refFile(o.VideoRef) +} + +// VideoClip returns the temporal fragment of the object's video reference, +// or nil when no time range is referenced. +func (o GuidedNavigationObject) VideoClip() *Clip { + return refClip(o.VideoRef) +} + +// VideoRegion returns the rectangular fragment of the object's video reference, +// or nil when no region is referenced. +func (o GuidedNavigationObject) VideoRegion() *Region { + return refRegion(o.VideoRef) +} + +// ImageFile returns the image referenced by the object, without its media fragment. +func (o GuidedNavigationObject) ImageFile() url.URL { + return refFile(o.ImgRef) +} + +// ImageRegion returns the rectangular fragment of the object's image reference, +// or nil when no rectangular region is referenced. +func (o GuidedNavigationObject) ImageRegion() *Region { + return refRegion(o.ImgRef) +} + +// ImagePolygon returns the polygonal fragment of the object's image reference, +// or nil when no polygonal region is referenced. +func (o GuidedNavigationObject) ImagePolygon() *Polygon { + return refPolygon(o.ImgRef) +} + +// TextFile returns the textual resource referenced by the object, without its fragment. +func (o GuidedNavigationObject) TextFile() url.URL { + return refFile(o.TextRef) +} + +// TextFragmentID returns the fragment id of the object's text reference +// (e.g. "par1" for "chapter.html#par1"), or an empty string when there is none. +func (o GuidedNavigationObject) TextFragmentID() string { + return refFragmentID(o.TextRef) +} + +// TextFragments returns the text fragments of the object's text reference +// (e.g. "chapter.html#:~:text=some,text"), or nil when there are none. +func (o GuidedNavigationObject) TextFragments() []TextFragment { + return refTextFragments(o.TextRef) +} + +// AudioFile returns the audio resource referenced by the description, without its media fragment. +func (d GuidedNavigationDescription) AudioFile() url.URL { + return refFile(d.AudioRef) +} + +// AudioClip returns the temporal fragment of the description's audio reference, +// or nil when the whole resource is referenced. +func (d GuidedNavigationDescription) AudioClip() *Clip { + return refClip(d.AudioRef) +} + +// VideoFile returns the video resource referenced by the description, without its media fragment. +func (d GuidedNavigationDescription) VideoFile() url.URL { + return refFile(d.VideoRef) +} + +// VideoClip returns the temporal fragment of the description's video reference, +// or nil when no time range is referenced. +func (d GuidedNavigationDescription) VideoClip() *Clip { + return refClip(d.VideoRef) +} + +// VideoRegion returns the rectangular fragment of the description's video reference, +// or nil when no region is referenced. +func (d GuidedNavigationDescription) VideoRegion() *Region { + return refRegion(d.VideoRef) +} + +// ImageFile returns the image referenced by the description, without its media fragment. +func (d GuidedNavigationDescription) ImageFile() url.URL { + return refFile(d.ImgRef) +} + +// ImageRegion returns the rectangular fragment of the description's image reference, +// or nil when no rectangular region is referenced. +func (d GuidedNavigationDescription) ImageRegion() *Region { + return refRegion(d.ImgRef) +} + +// ImagePolygon returns the polygonal fragment of the description's image reference, +// or nil when no polygonal region is referenced. +func (d GuidedNavigationDescription) ImagePolygon() *Polygon { + return refPolygon(d.ImgRef) +} + +// TextFile returns the textual resource referenced by the description, without its fragment. +func (d GuidedNavigationDescription) TextFile() url.URL { + return refFile(d.TextRef) +} + +// TextFragmentID returns the fragment id of the description's text reference, +// or an empty string when there is none. +func (d GuidedNavigationDescription) TextFragmentID() string { + return refFragmentID(d.TextRef) +} + +// TextFragments returns the text fragments of the description's text reference, +// or nil when there are none. +func (d GuidedNavigationDescription) TextFragments() []TextFragment { + return refTextFragments(d.TextRef) +} + +// The reference without its fragment. +func refFile(ref url.URL) url.URL { + if ref == nil { + return nil + } + return ref.RemoveFragment() +} + +type fragmentDimension struct { + name string + value string +} + +// The name-value dimensions of a media fragment, e.g. "t=10,20&xywh=0,0,5,5", +// percent-decoded after splitting, in document order. +// https://www.w3.org/TR/media-frags/#processing-name-value-lists +func fragmentDimensions(ref url.URL) []fragmentDimension { + if ref == nil { + return nil + } + fragment := ref.Raw().EscapedFragment() + if fragment == "" { + return nil + } + var res []fragmentDimension + for pair := range strings.SplitSeq(fragment, "&") { + name, value, found := strings.Cut(pair, "=") + if !found || name == "" { + continue + } + var err error + if name, err = nurl.PathUnescape(name); err != nil { + continue + } + if value, err = nurl.PathUnescape(value); err != nil { + continue + } + res = append(res, fragmentDimension{name: name, value: value}) + } + return res +} + +// The parsed values of a fragment dimension: only the last valid occurrence +// of a dimension is interpreted, the others are ignored. +// https://www.w3.org/TR/media-frags/#error-uri-general +func lastValidDimension[T any](ref url.URL, name string, parse func(string) *T) *T { + dims := fragmentDimensions(ref) + for i := len(dims) - 1; i >= 0; i-- { + if dims[i].name != name { + continue + } + if res := parse(dims[i].value); res != nil { + return res + } + } + return nil +} + +func refClip(ref url.URL) *Clip { + return lastValidDimension(ref, "t", parseClipValue) +} + +func parseClipValue(value string) *Clip { + // Only the Normal Play Time format is supported, which is also the default: + // https://www.w3.org/TR/media-frags/#naming-time + if prefix, rest, found := strings.Cut(value, ":"); found { + if prefix == "npt" { + value = rest + } else if isAlpha(prefix) { + // A time format like "smpte" or "clock" (unsupported). Anything else + // is a colon belonging to an hh:mm:ss time value. + return nil + } + } + + begin, end, hasEnd := strings.Cut(value, ",") + clip := &Clip{} + if begin != "" { + d, ok := parseNPTTime(begin) + if !ok { + return nil + } + clip.Begin = &d + } + if hasEnd { + if end == "" { + return nil + } + d, ok := parseNPTTime(end) + if !ok { + return nil + } + clip.End = &d + } + if clip.Begin == nil && clip.End == nil { + return nil + } + // The begin time (0 when omitted) must precede the end time + if clip.End != nil { + var begin time.Duration + if clip.Begin != nil { + begin = *clip.Begin + } + if *clip.End <= begin { + return nil + } + } + return clip +} + +func isAlpha(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { + return false + } + } + return true +} + +// Parses a value of the form 1*DIGIT ["." *DIGIT], the only number format the +// media fragment grammars allow. strconv.ParseFloat alone would also accept +// signs, exponents, hexadecimal floats, "inf" and "nan". +func parseDecimal(s string) (float64, bool) { + dot := false + for i, r := range s { + if r == '.' { + if dot || i == 0 { + return 0, false + } + dot = true + continue + } + if r < '0' || r > '9' { + return 0, false + } + } + if s == "" || s == "." { + return 0, false + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return v, true +} + +// The largest amount of seconds representable as a time.Duration. +const maxClipSeconds = float64(int64(^uint64(0)>>1) / int64(time.Second)) + +// Parses a Normal Play Time value: seconds ("123.45"), "mm:ss" or "hh:mm:ss", +// with an optional fraction. https://www.w3.org/TR/media-frags/#npttimedef +func parseNPTTime(s string) (time.Duration, bool) { + parts := strings.Split(s, ":") + if len(parts) > 3 { + return 0, false + } + + // The last part holds the seconds, with an optional fraction + seconds, ok := parseDecimal(parts[len(parts)-1]) + if !ok { + return 0, false + } + if len(parts) > 1 && seconds >= 60 { + return 0, false + } + + for i, mul := range []float64{60, 3600} { + idx := len(parts) - 2 - i + if idx < 0 { + break + } + v, err := strconv.ParseUint(parts[idx], 10, 32) + if err != nil { + return 0, false + } + if i == 0 && len(parts) > 1 && v >= 60 { + // Minutes are bound to 0-59 + return 0, false + } + seconds += float64(v) * mul + } + + if seconds > maxClipSeconds { + return 0, false + } + return time.Duration(seconds * float64(time.Second)), true +} + +// Parses the unit prefix of a spatial fragment value, defaulting to pixels. +func parseRegionUnit(value string) (RegionUnit, string, bool) { + if prefix, rest, found := strings.Cut(value, ":"); found { + switch RegionUnit(prefix) { + case RegionUnitPixel: + return RegionUnitPixel, rest, true + case RegionUnitPercent: + return RegionUnitPercent, rest, true + default: + return "", "", false + } + } + return RegionUnitPixel, value, true +} + +// Parses a comma-separated list of non-negative decimal values. +func parseCoordinates(value string, count int) ([]float64, bool) { + parts := strings.Split(value, ",") + if count > 0 && len(parts) != count { + return nil, false + } + res := make([]float64, len(parts)) + for i, part := range parts { + v, ok := parseDecimal(part) + if !ok { + return nil, false + } + res[i] = v + } + return res, true +} + +func refRegion(ref url.URL) *Region { + return lastValidDimension(ref, "xywh", parseRegionValue) +} + +func parseRegionValue(value string) *Region { + unit, value, ok := parseRegionUnit(value) + if !ok { + return nil + } + coords, ok := parseCoordinates(value, 4) + if !ok { + return nil + } + return &Region{ + Unit: unit, + X: coords[0], + Y: coords[1], + Width: coords[2], + Height: coords[3], + } +} + +func refPolygon(ref url.URL) *Polygon { + return lastValidDimension(ref, "xyn", parsePolygonValue) +} + +func parsePolygonValue(value string) *Polygon { + unit, value, ok := parseRegionUnit(value) + if !ok { + return nil + } + coords, ok := parseCoordinates(value, -1) + if !ok || len(coords) < 6 || len(coords)%2 != 0 { + // A polygon needs at least three x,y pairs + return nil + } + res := &Polygon{ + Unit: unit, + Points: make([]Point, len(coords)/2), + } + for i := range res.Points { + res.Points[i] = Point{X: coords[i*2], Y: coords[i*2+1]} + } + return res +} + +// The delimiter of a URL fragment text directive. +const textDirectiveDelimiter = ":~:" + +func refFragmentID(ref url.URL) string { + if ref == nil { + return "" + } + fragment := ref.Raw().EscapedFragment() + // A fragment directive doesn't belong to the fragment id preceding it + fragment, _, _ = strings.Cut(fragment, textDirectiveDelimiter) + if decoded, err := nurl.PathUnescape(fragment); err == nil { + return decoded + } + return fragment +} + +func refTextFragments(ref url.URL) []TextFragment { + if ref == nil { + return nil + } + // The directive must be parsed in percent-encoded form: its delimiters + // (",", "-", "&") are only syntax when they appear unencoded + _, directive, found := strings.Cut(ref.Raw().EscapedFragment(), textDirectiveDelimiter) + if !found { + return nil + } + + var res []TextFragment + for part := range strings.SplitSeq(directive, "&") { + value, found := strings.CutPrefix(part, "text=") + if !found { + continue + } + if fragment, ok := parseTextFragment(value); ok { + res = append(res, fragment) + } + } + return res +} + +// Parses a text directive value: [prefix-,]textStart[,textEnd][,-suffix] +// https://wicg.github.io/scroll-to-text-fragment/#syntax +func parseTextFragment(value string) (fragment TextFragment, ok bool) { + parts := strings.Split(value, ",") + + // Prefix and suffix tokens are extracted unconditionally: an invalid + // remainder rejects the whole directive + if prefix, found := strings.CutSuffix(parts[0], "-"); found { + if !validTextDirectiveTerm(prefix) { + return TextFragment{}, false + } + fragment.Prefix = decodeTextDirectiveTerm(prefix) + parts = parts[1:] + } + if len(parts) > 0 { + if suffix, found := strings.CutPrefix(parts[len(parts)-1], "-"); found { + if !validTextDirectiveTerm(suffix) { + return TextFragment{}, false + } + fragment.Suffix = decodeTextDirectiveTerm(suffix) + parts = parts[:len(parts)-1] + } + } + + switch len(parts) { + case 1: + fragment.TextStart = decodeTextDirectiveTerm(parts[0]) + case 2: + fragment.TextStart = decodeTextDirectiveTerm(parts[0]) + fragment.TextEnd = decodeTextDirectiveTerm(parts[1]) + if !validTextDirectiveTerm(parts[1]) { + return TextFragment{}, false + } + default: + return TextFragment{}, false + } + if !validTextDirectiveTerm(parts[0]) { + return TextFragment{}, false + } + return fragment, true +} + +// A text directive term must be non-empty, with any dash it contains +// percent-encoded to keep it distinguishable from the prefix/suffix markers. +func validTextDirectiveTerm(s string) bool { + return s != "" && !strings.Contains(s, "-") +} + +func decodeTextDirectiveTerm(s string) string { + if decoded, err := nurl.PathUnescape(s); err == nil { + return decoded + } + return s +} diff --git a/pkg/guidednavigation/fragments_test.go b/pkg/guidednavigation/fragments_test.go new file mode 100644 index 00000000..4e281907 --- /dev/null +++ b/pkg/guidednavigation/fragments_test.go @@ -0,0 +1,281 @@ +package guidednavigation + +import ( + "testing" + "time" + + "github.com/readium/go-toolkit/pkg/util/url" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func objWithAudioRef(t *testing.T, ref string) GuidedNavigationObject { + t.Helper() + return GuidedNavigationObject{AudioRef: url.MustURLFromString(ref)} +} + +func duration(seconds float64) *time.Duration { + d := time.Duration(seconds * float64(time.Second)) + return &d +} + +func TestAudioClip(t *testing.T) { + tests := []struct { + name string + ref string + clip *Clip + }{ + {"begin and end", "chapter1.mp3#t=0,20", &Clip{Begin: duration(0), End: duration(20)}}, + {"begin only", "chapter1.mp3#t=10", &Clip{Begin: duration(10)}}, + {"end only", "chapter1.mp3#t=,20", &Clip{End: duration(20)}}, + {"npt prefix", "chapter1.mp3#t=npt:10,20", &Clip{Begin: duration(10), End: duration(20)}}, + {"fractional seconds", "chapter1.mp3#t=10.5,21.25", &Clip{Begin: duration(10.5), End: duration(21.25)}}, + {"mm:ss", "chapter1.mp3#t=1:30,2:00", &Clip{Begin: duration(90), End: duration(120)}}, + {"hh:mm:ss with fraction", "chapter1.mp3#t=0:02:00.5,0:02:01", &Clip{Begin: duration(120.5), End: duration(121)}}, + {"end only with colons", "chapter1.mp3#t=,0:02:00", &Clip{End: duration(120)}}, + {"other dimensions around", "chapter1.mp3#u=v&t=5,6&x=y", &Clip{Begin: duration(5), End: duration(6)}}, + {"last occurrence wins", "chapter1.mp3#t=1,2&t=5,6", &Clip{Begin: duration(5), End: duration(6)}}, + + {"no fragment", "chapter1.mp3", nil}, + {"no t dimension", "chapter1.mp3#xywh=0,0,1,1", nil}, + {"empty value", "chapter1.mp3#t=", nil}, + {"end before begin", "chapter1.mp3#t=20,10", nil}, + {"zero length", "chapter1.mp3#t=5,5", nil}, + {"smpte unsupported", "chapter1.mp3#t=smpte:00:01:00,00:02:00", nil}, + {"clock unsupported", "chapter1.mp3#t=clock:2026-07-01T00:00:00Z", nil}, + {"garbage", "chapter1.mp3#t=abc", nil}, + {"negative", "chapter1.mp3#t=-5,10", nil}, + {"seconds out of range in mm:ss", "chapter1.mp3#t=1:75", nil}, + {"minutes out of range in hh:mm:ss", "chapter1.mp3#t=1:75:00", nil}, + {"minutes out of range in mm:ss", "chapter1.mp3#t=75:00", nil}, + {"trailing comma", "chapter1.mp3#t=10,", nil}, + + // The NPT grammar allows nothing but digits and a fraction + {"nan", "chapter1.mp3#t=nan,10", nil}, + {"inf", "chapter1.mp3#t=inf", nil}, + {"exponent", "chapter1.mp3#t=1e3", nil}, + {"hex float", "chapter1.mp3#t=0x1p4", nil}, + {"explicit sign", "chapter1.mp3#t=+5", nil}, + {"bare fraction", "chapter1.mp3#t=.5", nil}, + {"duration overflow", "chapter1.mp3#t=10000000000", nil}, + {"zero-length end-only", "chapter1.mp3#t=,0", nil}, + + // Percent-encoded dimensions are decoded before parsing + {"encoded npt prefix", "chapter1.mp3#t=npt%3A10,20", &Clip{Begin: duration(10), End: duration(20)}}, + {"encoded comma", "chapter1.mp3#t=10%2C20", &Clip{Begin: duration(10), End: duration(20)}}, + // Only the last VALID occurrence of a dimension counts + {"invalid last occurrence ignored", "chapter1.mp3#t=10,20&t=abc", &Clip{Begin: duration(10), End: duration(20)}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.clip, objWithAudioRef(t, tt.ref).AudioClip()) + }) + } +} + +func TestClipMediaFragment(t *testing.T) { + assert.Equal(t, "", Clip{}.MediaFragment()) + assert.Equal(t, "t=10", Clip{Begin: duration(10)}.MediaFragment()) + assert.Equal(t, "t=,20", Clip{End: duration(20)}.MediaFragment()) + assert.Equal(t, "t=10.5,20", Clip{Begin: duration(10.5), End: duration(20)}.MediaFragment()) + // Rounded to the millisecond, without trailing zeros + assert.Equal(t, "t=62.011,1647.2", Clip{Begin: duration(62.0109), End: duration(1647.2)}.MediaFragment()) + + // A formatted clip parses back to the same value + clips := []Clip{ + {Begin: duration(10)}, + {End: duration(20)}, + {Begin: duration(0), End: duration(20)}, + {Begin: duration(90.25), End: duration(120.5)}, + } + for _, clip := range clips { + ref := url.MustURLFromString("chapter1.mp3#" + clip.MediaFragment()) + parsed := GuidedNavigationObject{AudioRef: ref}.AudioClip() + require.NotNil(t, parsed, clip.MediaFragment()) + assert.Equal(t, &clip, parsed, clip.MediaFragment()) + } +} + +func TestFormatNPTTime(t *testing.T) { + assert.Equal(t, "0", FormatNPTTime(0)) + assert.Equal(t, "0", FormatNPTTime(-5*time.Second)) + assert.Equal(t, "90.5", FormatNPTTime(90*time.Second+500*time.Millisecond)) + assert.Equal(t, "0.001", FormatNPTTime(1400*time.Microsecond)) +} + +func TestClipOfVideoRef(t *testing.T) { + obj := GuidedNavigationObject{VideoRef: url.MustURLFromString("movie.mp4#t=30,60")} + assert.Equal(t, &Clip{Begin: duration(30), End: duration(60)}, obj.VideoClip()) + assert.Nil(t, obj.AudioClip()) +} + +func TestAudioFile(t *testing.T) { + obj := objWithAudioRef(t, "audio/chapter1.mp3#t=0,20") + assert.Equal(t, "audio/chapter1.mp3", obj.AudioFile().String()) + // The original reference must not lose its fragment + assert.Equal(t, "audio/chapter1.mp3#t=0,20", obj.AudioRef.String()) + + // Without a fragment the reference is returned as-is + obj = objWithAudioRef(t, "audio/chapter1.mp3") + assert.Equal(t, "audio/chapter1.mp3", obj.AudioFile().String()) + + assert.Nil(t, GuidedNavigationObject{}.AudioFile()) +} + +func TestImageRegion(t *testing.T) { + tests := []struct { + name string + ref string + region *Region + }{ + // Example from the specification's README + {"percent", "page10.jpg#xywh=percent:10,10,20,20", + &Region{Unit: RegionUnitPercent, X: 10, Y: 10, Width: 20, Height: 20}}, + // The comics example uses decimal percentages + {"decimal percent", "page1.jpg#xywh=percent:4.1,50.3,91.8,21.5", + &Region{Unit: RegionUnitPercent, X: 4.1, Y: 50.3, Width: 91.8, Height: 21.5}}, + {"explicit pixel", "page.jpg#xywh=pixel:10,20,30,40", + &Region{Unit: RegionUnitPixel, X: 10, Y: 20, Width: 30, Height: 40}}, + {"default pixel", "page.jpg#xywh=10,20,30,40", + &Region{Unit: RegionUnitPixel, X: 10, Y: 20, Width: 30, Height: 40}}, + + {"encoded unit prefix", "page.jpg#xywh=percent%3A10,10,20,20", + &Region{Unit: RegionUnitPercent, X: 10, Y: 10, Width: 20, Height: 20}}, + + {"no fragment", "page.jpg", nil}, + {"wrong dimension", "page.jpg#t=1,2", nil}, + {"bad unit", "page.jpg#xywh=inch:1,2,3,4", nil}, + {"too few values", "page.jpg#xywh=1,2,3", nil}, + {"too many values", "page.jpg#xywh=1,2,3,4,5", nil}, + {"negative value", "page.jpg#xywh=-1,2,3,4", nil}, + {"garbage", "page.jpg#xywh=a,b,c,d", nil}, + {"nan", "page.jpg#xywh=nan,0,1,1", nil}, + {"inf", "page.jpg#xywh=inf,0,10,10", nil}, + {"exponent", "page.jpg#xywh=0,0,1e2,50", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := GuidedNavigationObject{ImgRef: url.MustURLFromString(tt.ref)} + assert.Equal(t, tt.region, obj.ImageRegion()) + }) + } +} + +func TestImagePolygon(t *testing.T) { + // Example from EPUB Region-Based Navigation + obj := GuidedNavigationObject{ImgRef: url.MustURLFromString("page05.xhtml#xyn=percent:0,50,50,0,100,50")} + require.NotNil(t, obj.ImagePolygon()) + assert.Equal(t, &Polygon{ + Unit: RegionUnitPercent, + Points: []Point{{X: 0, Y: 50}, {X: 50, Y: 0}, {X: 100, Y: 50}}, + }, obj.ImagePolygon()) + + // Pixels by default + obj = GuidedNavigationObject{ImgRef: url.MustURLFromString("page.jpg#xyn=0,0,10,0,10,10,0,10")} + polygon := obj.ImagePolygon() + require.NotNil(t, polygon) + assert.Equal(t, RegionUnitPixel, polygon.Unit) + assert.Len(t, polygon.Points, 4) + + for name, ref := range map[string]string{ + "odd number of values": "page.jpg#xyn=percent:0,50,50", + "fewer than 3 points": "page.jpg#xyn=percent:0,50,50,0", + "no fragment": "page.jpg", + "garbage": "page.jpg#xyn=percent:a,b,c,d,e,f", + } { + t.Run(name, func(t *testing.T) { + obj := GuidedNavigationObject{ImgRef: url.MustURLFromString(ref)} + assert.Nil(t, obj.ImagePolygon()) + }) + } +} + +func TestTextFragmentID(t *testing.T) { + tests := []struct { + ref string + id string + }{ + {"chapter1.html#start", "start"}, + {"chapter1.html#par%201", "par 1"}, + {"chapter1.html#id:~:text=highlighted", "id"}, + {"chapter1.html#:~:text=highlighted", ""}, + {"chapter1.html", ""}, + } + for _, tt := range tests { + obj := GuidedNavigationObject{TextRef: url.MustURLFromString(tt.ref)} + assert.Equal(t, tt.id, obj.TextFragmentID(), tt.ref) + } + assert.Empty(t, GuidedNavigationObject{}.TextFragmentID()) +} + +func TestTextFragments(t *testing.T) { + // Example from the specification's README + obj := GuidedNavigationObject{TextRef: url.MustURLFromString( + "chapter1.html#:~:text=It%20is%20a%20truth,want%20of%20a%20wife")} + assert.Equal(t, []TextFragment{ + {TextStart: "It is a truth", TextEnd: "want of a wife"}, + }, obj.TextFragments()) + + // All four parts + obj = GuidedNavigationObject{TextRef: url.MustURLFromString( + "chapter1.html#:~:text=before-,start%20text,end%20text,-after")} + assert.Equal(t, []TextFragment{ + {Prefix: "before", TextStart: "start text", TextEnd: "end text", Suffix: "after"}, + }, obj.TextFragments()) + + // Start only, with a percent-encoded comma and dash kept as content + obj = GuidedNavigationObject{TextRef: url.MustURLFromString( + "chapter1.html#:~:text=one%2C%20two%2Dthree")} + assert.Equal(t, []TextFragment{ + {TextStart: "one, two-three"}, + }, obj.TextFragments()) + + // Multiple directives + obj = GuidedNavigationObject{TextRef: url.MustURLFromString( + "chapter1.html#:~:text=first&text=second")} + assert.Equal(t, []TextFragment{ + {TextStart: "first"}, + {TextStart: "second"}, + }, obj.TextFragments()) + + // Not text fragments + for _, ref := range []string{ + "chapter1.html#start", + "chapter1.html", + "chapter1.html#:~:unknown=x", + } { + obj := GuidedNavigationObject{TextRef: url.MustURLFromString(ref)} + assert.Nil(t, obj.TextFragments(), ref) + } + + // Invalid directives, per the WICG parsing algorithm + for _, ref := range []string{ + "chapter1.html#:~:text=foo-", // Prefix without a start + "chapter1.html#:~:text=-bar", // Suffix without a start + "chapter1.html#:~:text=a-,-b", // Prefix and suffix without a start + "chapter1.html#:~:text=foo,", // Empty end + "chapter1.html#:~:text=two-three", // Unencoded dash in the start + "chapter1.html#:~:text=a,b,c,d,e", // Too many terms + "chapter1.html#:~:text=-,foo", // Empty prefix + } { + obj := GuidedNavigationObject{TextRef: url.MustURLFromString(ref)} + assert.Nil(t, obj.TextFragments(), ref) + } +} + +func TestDescriptionMediaReferences(t *testing.T) { + // From the comics example of the specification + d := GuidedNavigationDescription{ + AudioRef: url.MustURLFromString("audio/page1-panel1-description.mp3#t=0,5"), + ImgRef: url.MustURLFromString("page1.jpg#xywh=percent:4.1,4.1,91.8,44.5"), + } + assert.Equal(t, "audio/page1-panel1-description.mp3", d.AudioFile().String()) + assert.Equal(t, &Clip{Begin: duration(0), End: duration(5)}, d.AudioClip()) + assert.Equal(t, &Region{Unit: RegionUnitPercent, X: 4.1, Y: 4.1, Width: 91.8, Height: 44.5}, d.ImageRegion()) + assert.Equal(t, "page1.jpg", d.ImageFile().String()) + assert.Nil(t, d.TextFile()) + assert.Empty(t, d.TextFragmentID()) + + d = GuidedNavigationDescription{VideoRef: url.MustURLFromString("clip.mp4#xywh=0,0,100,100")} + assert.Equal(t, &Region{Unit: RegionUnitPixel, X: 0, Y: 0, Width: 100, Height: 100}, d.VideoRegion()) +} diff --git a/pkg/guidednavigation/guided_navigation.go b/pkg/guidednavigation/guided_navigation.go new file mode 100644 index 00000000..b42c0bf0 --- /dev/null +++ b/pkg/guidednavigation/guided_navigation.go @@ -0,0 +1,241 @@ +package guidednavigation + +import ( + "encoding/json" + + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/util/url" +) + +// Readium Guided Navigation Document +// https://readium.org/guided-navigation/schema/document.schema.json +type GuidedNavigationDocument struct { + Links manifest.LinkList `json:"links,omitempty"` // References to other resources that are related to the current Guided Navigation Document. + Guided []GuidedNavigationObject `json:"guided"` // A sequence of resources and/or media fragments into these resources, meant to be presented sequentially to the user. +} + +// Readium Guided Navigation Object +// https://readium.org/guided-navigation/schema/object.schema.json +type GuidedNavigationObject struct { + ID string `json:"id,omitempty"` // Identifier for the object, referenced from the SSML representation of a sibling text (e.g. ). + AudioRef url.URL `json:"audioref,omitempty"` // References an audio resource or a fragment of it. + ImgRef url.URL `json:"imgref,omitempty"` // References an image or a fragment of it. + TextRef url.URL `json:"textref,omitempty"` // References a textual resource or a fragment of it. + VideoRef url.URL `json:"videoref,omitempty"` // References a video resource or a fragment of it. + Text GuidedNavigationText `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object. + Role []GuidedNavigationRole `json:"role,omitempty"` // Convey the structural semantics of a publication + Children []GuidedNavigationObject `json:"children,omitempty"` // Items that are children of the containing Guided Navigation Object. + Description GuidedNavigationDescription `json:"description,omitempty"` // Text, audio or image description for the current Guided Navigation Object. +} + +func (o GuidedNavigationObject) Empty() bool { + return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.VideoRef == nil && + o.Text.Empty() && len(o.Children) == 0 && o.Description.Empty() +} + +// ChildrenOnly reports whether the object carries no information of its own besides its children. +// Such objects are transparent wrappers (e.g. from

or ) whose children can be +// spliced into the parent's children. +func (o GuidedNavigationObject) ChildrenOnly() bool { + return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.VideoRef == nil && + o.Text.Empty() && len(o.Children) > 0 && o.Description.Empty() && len(o.Role) == 0 && o.ID == "" +} + +func (o GuidedNavigationObject) TextOnly() bool { + return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.VideoRef == nil && + !o.Text.Empty() && len(o.Children) == 0 && o.Description.Empty() && len(o.Role) == 0 && o.ID == "" +} + +func (o GuidedNavigationObject) MarshalJSON() ([]byte, error) { + res := make(map[string]interface{}) + if o.Empty() { + return json.Marshal(res) + } + + if o.ID != "" { + res["id"] = o.ID + } + if o.TextRef != nil { + if s := o.TextRef.String(); s != "" { + res["textref"] = s + } + } + if o.ImgRef != nil { + if s := o.ImgRef.String(); s != "" { + res["imgref"] = s + } + } + if o.AudioRef != nil { + if s := o.AudioRef.String(); s != "" { + res["audioref"] = s + } + } + if o.VideoRef != nil { + if s := o.VideoRef.String(); s != "" { + res["videoref"] = s + } + } + + if !o.Text.Empty() { + res["text"] = o.Text + } + if len(o.Role) > 0 { + res["role"] = o.Role + } + if len(o.Children) > 0 { + res["children"] = o.Children + } + if !o.Description.Empty() { + res["description"] = o.Description + } + + return json.Marshal(res) +} + +type GuidedNavigationText struct { + Plain string `json:"plain,omitempty"` // Plain text + SSML string `json:"ssml,omitempty"` // SSML markup + Language string `json:"language,omitempty"` // BCP-47 language tag +} + +func (t GuidedNavigationText) Empty() bool { + return t.Plain == "" && t.SSML == "" && t.Language == "" +} + +func (t *GuidedNavigationText) UnmarshalJSON(data []byte) error { + // Just plain text + var plain string + if err := json.Unmarshal(data, &plain); err == nil { + t.Plain = plain + return nil + } + + type alias GuidedNavigationText + var obj alias + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + *t = GuidedNavigationText(obj) + return nil +} + +func (t GuidedNavigationText) MarshalJSON() ([]byte, error) { + res := make(map[string]interface{}) + + if t.SSML == "" && t.Language == "" { + return json.Marshal(t.Plain) + } + + if t.Plain != "" { + res["plain"] = t.Plain + } + if t.SSML != "" { + res["ssml"] = t.SSML + } + if t.Language != "" { + res["language"] = t.Language + } + return json.Marshal(res) +} + +// Readium Guided Navigation Description Object +// https://readium.org/guided-navigation/schema/description.schema.json +// Serialized as a plain string when it only carries plain text. +type GuidedNavigationDescription struct { + AudioRef url.URL `json:"audioref,omitempty"` // References an audio resource or a fragment of it. + ImgRef url.URL `json:"imgref,omitempty"` // References an image or a fragment of it. + TextRef url.URL `json:"textref,omitempty"` // References a textual resource or a fragment of it. + VideoRef url.URL `json:"videoref,omitempty"` // References a video resource or a fragment of it. + Text GuidedNavigationText `json:"text,omitempty"` // Textual description. +} + +// NewTextDescription creates a description carrying only plain text. +func NewTextDescription(text string) GuidedNavigationDescription { + return GuidedNavigationDescription{ + Text: GuidedNavigationText{Plain: text}, + } +} + +func (d GuidedNavigationDescription) Empty() bool { + return d.AudioRef == nil && d.ImgRef == nil && d.TextRef == nil && d.VideoRef == nil && d.Text.Empty() +} + +func (d *GuidedNavigationDescription) UnmarshalJSON(data []byte) error { + // Just plain text + var plain string + if err := json.Unmarshal(data, &plain); err == nil { + d.Text.Plain = plain + return nil + } + + var obj struct { + AudioRef string `json:"audioref"` + ImgRef string `json:"imgref"` + TextRef string `json:"textref"` + VideoRef string `json:"videoref"` + Text GuidedNavigationText `json:"text"` + } + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + var res GuidedNavigationDescription + var err error + if obj.AudioRef != "" { + if res.AudioRef, err = url.URLFromString(obj.AudioRef); err != nil { + return err + } + } + if obj.ImgRef != "" { + if res.ImgRef, err = url.URLFromString(obj.ImgRef); err != nil { + return err + } + } + if obj.TextRef != "" { + if res.TextRef, err = url.URLFromString(obj.TextRef); err != nil { + return err + } + } + if obj.VideoRef != "" { + if res.VideoRef, err = url.URLFromString(obj.VideoRef); err != nil { + return err + } + } + res.Text = obj.Text + *d = res + return nil +} + +func (d GuidedNavigationDescription) MarshalJSON() ([]byte, error) { + // Plain-text-only descriptions are serialized as a string, matching the + // examples of the specification. + if d.AudioRef == nil && d.ImgRef == nil && d.TextRef == nil && d.VideoRef == nil && + d.Text.SSML == "" && d.Text.Language == "" { + return json.Marshal(d.Text.Plain) + } + + res := make(map[string]interface{}) + if d.AudioRef != nil { + if s := d.AudioRef.String(); s != "" { + res["audioref"] = s + } + } + if d.ImgRef != nil { + if s := d.ImgRef.String(); s != "" { + res["imgref"] = s + } + } + if d.TextRef != nil { + if s := d.TextRef.String(); s != "" { + res["textref"] = s + } + } + if d.VideoRef != nil { + if s := d.VideoRef.String(); s != "" { + res["videoref"] = s + } + } + if !d.Text.Empty() { + res["text"] = d.Text + } + return json.Marshal(res) +} diff --git a/pkg/guidednavigation/guided_navigation_test.go b/pkg/guidednavigation/guided_navigation_test.go new file mode 100644 index 00000000..3c67c455 --- /dev/null +++ b/pkg/guidednavigation/guided_navigation_test.go @@ -0,0 +1,105 @@ +package guidednavigation + +import ( + "encoding/json" + "testing" + + "github.com/readium/go-toolkit/pkg/util/url" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTextMarshalPlainOnly(t *testing.T) { + b, err := json.Marshal(GuidedNavigationText{Plain: "Hello"}) + require.NoError(t, err) + assert.JSONEq(t, `"Hello"`, string(b)) +} + +func TestTextMarshalFull(t *testing.T) { + b, err := json.Marshal(GuidedNavigationText{ + Plain: "Hello", + SSML: "Hello", + Language: "en", + }) + require.NoError(t, err) + assert.JSONEq(t, `{"plain": "Hello", "ssml": "Hello", "language": "en"}`, string(b)) +} + +func TestTextUnmarshalString(t *testing.T) { + var text GuidedNavigationText + require.NoError(t, json.Unmarshal([]byte(`"Hello"`), &text)) + assert.Equal(t, GuidedNavigationText{Plain: "Hello"}, text) +} + +func TestTextUnmarshalObject(t *testing.T) { + var text GuidedNavigationText + require.NoError(t, json.Unmarshal([]byte(`{"plain": "Hi", "language": "en"}`), &text)) + assert.Equal(t, GuidedNavigationText{Plain: "Hi", Language: "en"}, text) +} + +func TestDescriptionMarshalPlainString(t *testing.T) { + b, err := json.Marshal(NewTextDescription("A cool image")) + require.NoError(t, err) + assert.JSONEq(t, `"A cool image"`, string(b)) +} + +func TestDescriptionMarshalObject(t *testing.T) { + b, err := json.Marshal(GuidedNavigationDescription{ + AudioRef: url.MustURLFromString("description.mp3"), + Text: GuidedNavigationText{Plain: "A described panel"}, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"audioref": "description.mp3", "text": "A described panel"}`, string(b)) +} + +func TestDescriptionUnmarshalString(t *testing.T) { + var d GuidedNavigationDescription + require.NoError(t, json.Unmarshal([]byte(`"A cool image"`), &d)) + assert.Equal(t, NewTextDescription("A cool image"), d) +} + +func TestDescriptionUnmarshalObject(t *testing.T) { + var d GuidedNavigationDescription + require.NoError(t, json.Unmarshal([]byte(`{"audioref": "audio/page1.mp3", "text": "Pepper walks away."}`), &d)) + assert.Equal(t, "audio/page1.mp3", d.AudioRef.String()) + assert.Equal(t, "Pepper walks away.", d.Text.Plain) +} + +func TestObjectMarshal(t *testing.T) { + obj := GuidedNavigationObject{ + ID: "image1", + ImgRef: url.MustURLFromString("image.jpg"), + VideoRef: url.MustURLFromString("movie.mp4"), + Role: []GuidedNavigationRole{RoleImage}, + Children: []GuidedNavigationObject{ + {Text: GuidedNavigationText{Plain: "Child"}}, + }, + Description: NewTextDescription("Description"), + } + b, err := json.Marshal(obj) + require.NoError(t, err) + assert.JSONEq(t, `{ + "id": "image1", + "imgref": "image.jpg", + "videoref": "movie.mp4", + "role": ["image"], + "children": [{"text": "Child"}], + "description": "Description" + }`, string(b)) +} + +func TestObjectPredicates(t *testing.T) { + assert.True(t, GuidedNavigationObject{}.Empty()) + assert.True(t, GuidedNavigationObject{Role: []GuidedNavigationRole{RoleSeparator}}.Empty(), + "an object with only roles carries no content") + assert.False(t, GuidedNavigationObject{VideoRef: url.MustURLFromString("v.mp4")}.Empty()) + assert.False(t, GuidedNavigationObject{Description: NewTextDescription("d")}.Empty()) + + wrapper := GuidedNavigationObject{Children: []GuidedNavigationObject{{Text: GuidedNavigationText{Plain: "t"}}}} + assert.True(t, wrapper.ChildrenOnly()) + wrapper.ID = "id1" + assert.False(t, wrapper.ChildrenOnly(), "an object with an id is referenced from SSML and can't be dissolved") + + assert.True(t, GuidedNavigationObject{Text: GuidedNavigationText{Plain: "t"}}.TextOnly()) + assert.False(t, GuidedNavigationObject{Text: GuidedNavigationText{Plain: "t"}, ID: "x"}.TextOnly()) +} diff --git a/pkg/guidednavigation/roles.go b/pkg/guidednavigation/roles.go new file mode 100644 index 00000000..9ea68e38 --- /dev/null +++ b/pkg/guidednavigation/roles.go @@ -0,0 +1,89 @@ +package guidednavigation + +// Readium Guided Navigation Roles +// https://github.com/readium/guided-navigation/blob/main/schema/roles.schema.json +type GuidedNavigationRole string + +const ( + RoleNone GuidedNavigationRole = "" // No role. Shouldn't be used + RoleAbstract GuidedNavigationRole = "abstract" // A short summary of the principle ideas, concepts and conclusions of the work, or of a section or excerpt within it. + RoleAcknowledgments GuidedNavigationRole = "acknowledgments" // A section or statement that acknowledges significant contributions by persons, organizations, governments and other entities to the realization of the work. + RoleAfterword GuidedNavigationRole = "afterword" // A closing statement from the author or a person of importance, typically providing insight into how the content came to be written, its significance, or related events that have transpired since its timeline. + RoleAppendix GuidedNavigationRole = "appendix" // A section of supplemental information located after the primary content that informs the content but is not central to it. + RoleArticle GuidedNavigationRole = "article" // Represents a self-contained composition in a document, page, application, or site, which is intended to be independently distributable or reusable + RoleAside GuidedNavigationRole = "aside" // Secondary or supplementary content. + RoleAudio GuidedNavigationRole = "audio" // Embedded sound content in a document. + RoleBacklink GuidedNavigationRole = "backlink" // A link that allows the user to return to a related location in the content (e.g., from a footnote to its reference or from a glossary definition to where a term is used). + RoleBibliography GuidedNavigationRole = "bibliography" // A list of external references cited in the work, which may be to print or digital sources. + RoleBiblioref GuidedNavigationRole = "biblioref" // A reference to a bibliography entry. + RoleBlockquote GuidedNavigationRole = "blockquote" // Represents a section that is quoted from another source. + RoleBody GuidedNavigationRole = "body" // Represents the content of an HTML document. + RoleCaption GuidedNavigationRole = "caption" // A caption for an image or a table. + RoleChapter GuidedNavigationRole = "chapter" // A major thematic section of content in a work. + RoleCell GuidedNavigationRole = "cell" // A single cell of tabular data or content. + RoleColumnHeader GuidedNavigationRole = "columnheader" // The header cell for a column, establishing a relationship between it and the other cells in the same column. + RoleColophon GuidedNavigationRole = "colophon" // A short section of production notes particular to the edition (e.g., describing the typeface used), often located at the end of a work. + RoleComplementary GuidedNavigationRole = "complementary" // A supporting section of the document, designed to be complementary to the main content at a similar level in the DOM hierarchy, but remains meaningful when separated from the main content. + RoleConclusion GuidedNavigationRole = "conclusion" // A concluding section or statement that summarizes the work or wraps up the narrative. + RoleCover GuidedNavigationRole = "cover" // An image that sets the mood or tone for the work and typically includes the title and author. + RoleCredit GuidedNavigationRole = "credit" // An acknowledgment of the source of integrated content from third-party sources, such as photos. Typically identifies the creator, copyright and any restrictions on reuse. + RoleCredits GuidedNavigationRole = "credits" // A collection of credits. + RoleDedication GuidedNavigationRole = "dedication" // An inscription at the front of the work, typically addressed in tribute to one or more persons close to the author. + RoleDefinition GuidedNavigationRole = "definition" // A definition of a term or concept. + RoleDetails GuidedNavigationRole = "details" // A disclosure widget that can be expanded. + RoleEndnotes GuidedNavigationRole = "endnotes" // A collection of notes at the end of a work or a section within it. + RoleEpigraph GuidedNavigationRole = "epigraph" // A quotation set at the start of the work or a section that establishes the theme or sets the mood. + RoleEpilogue GuidedNavigationRole = "epilogue" // A quotation set at the start of the work or a section that establishes the theme or sets the mood. + RoleErrata GuidedNavigationRole = "errata" // A set of corrections discovered after initial publication of the work, sometimes referred to as corrigenda. + RoleExample GuidedNavigationRole = "example" // An illustration of the usage of a defined term or phrase. + RoleFigure GuidedNavigationRole = "figure" // An illustration, diagram, photo, code listing or similar, referenced from the text of a work, and typically annotated with a title, caption and/or credits. + RoleFootnote GuidedNavigationRole = "footnote" // Ancillary information, such as a citation or commentary, that provides additional context to a referenced passage of text. + RoleForeword GuidedNavigationRole = "foreword" // An introductory section that precedes the work, typically written by a person other than the author. + RoleGlossary GuidedNavigationRole = "glossary" // A brief dictionary of new, uncommon, or specialized terms used in the content. + RoleGlossref GuidedNavigationRole = "glossref" // A reference to a glossary definition. + RoleHeader GuidedNavigationRole = "header" // Represents introductory content, typically a group of introductory or navigational aids. + RoleHeading1 GuidedNavigationRole = "heading1" // A level 1 heading for a section of the page. + RoleHeading2 GuidedNavigationRole = "heading2" // A level 2 heading for a section of the page. + RoleHeading3 GuidedNavigationRole = "heading3" // A level 3 heading for a section of the page. + RoleHeading4 GuidedNavigationRole = "heading4" // A level 4 heading for a section of the page. + RoleHeading5 GuidedNavigationRole = "heading5" // A level 5 heading for a section of the page. + RoleHeading6 GuidedNavigationRole = "heading6" // A level 6 heading for a section of the page. + RoleImage GuidedNavigationRole = "image" // Represents an image. + RoleIndex GuidedNavigationRole = "index" // A navigational aid that provides a detailed list of links to key subjects, names and other important topics covered in the work. + RoleIntroduction GuidedNavigationRole = "introduction" // A preliminary section that typically introduces the scope or nature of the work. + RoleLandmarks GuidedNavigationRole = "landmarks" // A short summary of the principle ideas, concepts and conclusions of the work, or of a section or excerpt within it. + RoleList GuidedNavigationRole = "list" // A structure that contains an enumeration of related content items. + RoleListItem GuidedNavigationRole = "listItem" // A single item in an enumeration. + RoleLoa GuidedNavigationRole = "loa" // A listing of audio clips included in the work. + RoleLoi GuidedNavigationRole = "loi" // A listing of illustrations included in the work. + RoleLot GuidedNavigationRole = "lot" // A listing of tables included in the work. + RoleLov GuidedNavigationRole = "lov" // A listing of video clips included in the work. + RoleMain GuidedNavigationRole = "main" // Content that is directly related to or expands upon the central topic of the document. + RoleMath GuidedNavigationRole = "math" // Content that represents a mathematical expression. + RoleNavigation GuidedNavigationRole = "navigation" // Represents a section of a page that links to other pages or to parts within the page: a section with navigation links. + RoleNoteref GuidedNavigationRole = "noteref" // A reference to a footnote or endnote, typically appearing as a superscripted number or symbol in the main body of text. + RoleNotice GuidedNavigationRole = "notice" // Notifies the user of consequences that might arise from an action or event. Examples include warnings, cautions and dangers. + RolePagebreak GuidedNavigationRole = "pagebreak" // A separator denoting the position before which a break occurs between two contiguous pages in a statically paginated version of the content. + RolePagelist GuidedNavigationRole = "pagelist" // A navigational aid that provides a list of links to the pagebreaks in the content. + RoleParagraph GuidedNavigationRole = "paragraph" // Represents a paragraph. + RolePart GuidedNavigationRole = "part" // A major structural division in a work that contains a set of related sections dealing with a particular subject, narrative arc or similar encapsulated theme. + RolePreface GuidedNavigationRole = "preface" // An introductory section that precedes the work, typically written by the author of the work. + RolePreformatted GuidedNavigationRole = "preformatted" // Represents preformatted text which is to be presented exactly as written. + RolePresentation GuidedNavigationRole = "presentation" // Represents an element being used only for presentation and therefore that does not have any accessibility semantics. + RolePrologue GuidedNavigationRole = "prologue" // An introductory section that sets the background to a work, typically part of the narrative. + RolePullquote GuidedNavigationRole = "pullquote" // A distinctively placed or highlighted quotation from the current content designed to draw attention to a topic or highlight a key point. + RoleQna GuidedNavigationRole = "qna" // A section of content structured as a series of questions and answers, such as an interview or list of frequently asked questions. + RoleRegion GuidedNavigationRole = "region" // Represents content that is relevant to a specific, author-specified purpose and sufficiently important that users will likely want to be able to navigate to the section easily and to have it listed in a summary of the page. + RoleRow GuidedNavigationRole = "row" // A row of data or content in a tabular structure. + RoleRowHeader GuidedNavigationRole = "rowheader" // The header cell for a row, establishing a relationship between it and the other cells in the same row. + RoleSection GuidedNavigationRole = "section" // Represents a generic standalone section of a document, which doesn't have a more specific semantic element to represent it. + RoleSeparator GuidedNavigationRole = "separator" // Indicates the element is a divider that separates and distinguishes sections of content or groups of menuitems. + RoleSequence GuidedNavigationRole = "sequence" // A sequence of related Guided Navigation Objects. + RoleSubtitle GuidedNavigationRole = "subtitle" // An explanatory or alternate title for the work, or a section or component within it. + RoleSummary GuidedNavigationRole = "summary" // A summary of an element contained in details. + RoleTable GuidedNavigationRole = "table" // A structure containing data or content laid out in tabular form. + RoleTerm GuidedNavigationRole = "term" // A word or phrase with a corresponding definition. + RoleTip GuidedNavigationRole = "tip" // Helpful information that clarifies some aspect of the content or assists in its comprehension. + RoleToc GuidedNavigationRole = "toc" // A navigational aid that provides an ordered list of links to the major sectional headings in the content. A table of contents may cover an entire work, or only a smaller section of it. + RoleVideo GuidedNavigationRole = "video" // Embedded videos, movies, or audio files with captions in a document. +) diff --git a/pkg/manifest/guided_navigation.go b/pkg/manifest/guided_navigation.go deleted file mode 100644 index b3abeb17..00000000 --- a/pkg/manifest/guided_navigation.go +++ /dev/null @@ -1,24 +0,0 @@ -package manifest - -// Readium Guided Navigation Document -// https://readium.org/guided-navigation/schema/document.schema.json -type GuidedNavigationDocument struct { - Links LinkList `json:"links,omitempty"` // References to other resources that are related to the current Guided Navigation Document. - Guided []GuidedNavigationObject `json:"guided"` // A sequence of resources and/or media fragments into these resources, meant to be presented sequentially to the user. -} - -// Readium Guided Navigation Object -// https://readium.org/guided-navigation/schema/object.schema.json -// TODO: Role should be typed -// TODO: all refs should be url.URL -type GuidedNavigationObject struct { - AudioRef string `json:"audioref,omitempty"` // References an audio resource or a fragment of it. - ImgRef string `json:"imgref,omitempty"` // References an image or a fragment of it. - TextRef string `json:"textref,omitempty"` // References a textual resource or a fragment of it. - Text string `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object. - Role []string `json:"role,omitempty"` // Convey the structural semantics of a publication - Children []GuidedNavigationObject `json:"children,omitempty"` // Items that are children of the containing Guided Navigation Object. -} - -// TODO: functions for objects to get e.g. audio time, audio file, text file, fragment id, audio "clip", image xywh, etc. -// This will come after the URL utility revamp to avoid implementation twice diff --git a/pkg/parser/audio/formats_test.go b/pkg/parser/audio/formats_test.go index 0e1fec91..ed259c36 100644 --- a/pkg/parser/audio/formats_test.go +++ b/pkg/parser/audio/formats_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/readium/go-toolkit/pkg/manifest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -119,10 +120,11 @@ func TestPlaylistEntryName(t *testing.T) { assert.Equal(t, "track.mp3", playlistEntryName("C:\\music\\track.mp3")) } -func TestFormatFragmentTime(t *testing.T) { - assert.Equal(t, "0", formatFragmentTime(0)) - assert.Equal(t, "1647.2", formatFragmentTime(1647.2)) - assert.Equal(t, "62.011", formatFragmentTime(62.0109)) +func TestChapterLinkFragmentTime(t *testing.T) { + base := manifest.Link{Href: manifest.MustNewHREFFromString("track.m4b", false)} + assert.Equal(t, "track.m4b", chapterLink(base, "t", 0).Href.String()) + assert.Equal(t, "track.m4b#t=1647.2", chapterLink(base, "t", 1647.2).Href.String()) + assert.Equal(t, "track.m4b#t=62.011", chapterLink(base, "t", 62.0109).Href.String()) } func TestParseTimecode(t *testing.T) { diff --git a/pkg/parser/audio/toc.go b/pkg/parser/audio/toc.go index a5b5c30a..826b8a8f 100644 --- a/pkg/parser/audio/toc.go +++ b/pkg/parser/audio/toc.go @@ -1,12 +1,13 @@ package audio import ( - "math" "regexp" "sort" "strconv" "strings" + "time" + "github.com/readium/go-toolkit/pkg/guidednavigation" "github.com/readium/go-toolkit/pkg/manifest" ) @@ -33,7 +34,8 @@ func chaptersToLinks(entries []chapterEntry, base manifest.Link) manifest.LinkLi func chapterLink(base manifest.Link, title string, start float64) manifest.Link { href := base.URL(nil, nil).String() if start > 0 { - href += "#t=" + formatFragmentTime(start) + begin := time.Duration(start * float64(time.Second)) + href += "#" + guidednavigation.Clip{Begin: &begin}.MediaFragment() } return manifest.Link{ Href: manifest.MustNewHREFFromString(href, false), @@ -42,13 +44,6 @@ func chapterLink(base manifest.Link, title string, start float64) manifest.Link } } -// formatFragmentTime formats a number of seconds for a media fragment, rounded -// to the millisecond and without trailing zeros. -func formatFragmentTime(seconds float64) string { - rounded := math.Round(seconds*1000) / 1000 - return strconv.FormatFloat(rounded, 'f', -1, 64) -} - var vorbisChapterKey = regexp.MustCompile(`^chapter(\d+)$`) // vorbisChapters extracts chapters declared via the Vorbis comment chapter diff --git a/pkg/parser/epub/media_overlay_service.go b/pkg/parser/epub/media_overlay_service.go index a10266d8..656ff735 100644 --- a/pkg/parser/epub/media_overlay_service.go +++ b/pkg/parser/epub/media_overlay_service.go @@ -4,7 +4,10 @@ import ( "context" "slices" + "github.com/pkg/errors" "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/guidednavigation" + "github.com/readium/go-toolkit/pkg/guidednavigation/converter" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/mediatype" "github.com/readium/go-toolkit/pkg/pub" @@ -14,8 +17,12 @@ func MediaOverlayFactory() pub.ServiceFactory { return func(context pub.Context, public bool) pub.Service { // Process reading order to find and replace SMIL alternates smilMap := make(map[string]manifest.Link) - var smilIndexes []string + htmlMap := make(map[string]manifest.Link) + var guideIndexes []string for i := range context.Manifest.ReadingOrder { + href := context.Manifest.ReadingOrder[i].Href.String() + hasGuide := false + alts := context.Manifest.ReadingOrder[i].Alternates for j := range alts { alt := context.Manifest.ReadingOrder[i].Alternates[j] @@ -23,7 +30,6 @@ func MediaOverlayFactory() pub.ServiceFactory { // SMIL alternate for reading order item found // Create a guided navigation link for the SMIL alt - href := context.Manifest.ReadingOrder[i].Href.String() gnLink := pub.GuidedNavigationLink gnLink.Href = manifest.NewHREF(gnLink.URL(nil, map[string]string{ @@ -33,14 +39,26 @@ func MediaOverlayFactory() pub.ServiceFactory { // Store the original SMIL alt in an internal map smilMap[href] = alt - smilIndexes = append(smilIndexes, href) + hasGuide = true // Swap the original SMIL alt with the new guided navigation link alts = append(append(alts[:j], gnLink), alts[j+1:]...) } } + + if !hasGuide { + if mt := context.Manifest.ReadingOrder[i].MediaType; mt != nil && mt.IsHTML() { + // No SMIL alternate, but a guided navigation document can still + // be generated from the (X)HTML resource's content + htmlMap[href] = context.Manifest.ReadingOrder[i] + hasGuide = true + } + } + if hasGuide { + guideIndexes = append(guideIndexes, href) + } } - if len(smilMap) == 0 { + if len(guideIndexes) == 0 { // No items anyway, don't set up service return nil } @@ -48,23 +66,29 @@ func MediaOverlayFactory() pub.ServiceFactory { return &MediaOverlayService{ fetcher: context.Fetcher, originalSmilAlternates: smilMap, - originalSmilIndexes: smilIndexes, + htmlResources: htmlMap, + guideIndexes: guideIndexes, public: public, } } } +// MediaOverlayService provides guided navigation documents for a publication's +// reading order: from a SMIL media overlay when the resource has one, otherwise +// by converting the (X)HTML resource's content. type MediaOverlayService struct { public bool fetcher fetcher.Fetcher originalSmilAlternates map[string]manifest.Link - originalSmilIndexes []string + htmlResources map[string]manifest.Link + guideIndexes []string // TODO: smil parsing cache } func (s *MediaOverlayService) Close() { clear(s.originalSmilAlternates) - clear(s.originalSmilIndexes) + clear(s.htmlResources) + clear(s.guideIndexes) } func (s *MediaOverlayService) Links() manifest.LinkList { @@ -75,13 +99,17 @@ func (s *MediaOverlayService) Links() manifest.LinkList { } func (s *MediaOverlayService) HasGuideForResource(href string) bool { - _, ok := s.originalSmilAlternates[href] + if _, ok := s.originalSmilAlternates[href]; ok { + return true + } + _, ok := s.htmlResources[href] return ok } -func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*manifest.GuidedNavigationDocument, error) { - // Check if the provided resource has a guided navigation document +func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) { + var doc *guidednavigation.GuidedNavigationDocument if link, ok := s.originalSmilAlternates[href]; ok { + // The resource has a SMIL media overlay res := s.fetcher.Get(ctx, link) defer res.Close() @@ -91,33 +119,49 @@ func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) } // Convert SMIL to guided navigation document - doc, err := ParseSMILDocument(n, link.URL(nil, nil)) + var err error + doc, err = ParseSMILDocument(n, link.URL(nil, nil)) if err != nil { return nil, err } + } else if link, ok := s.htmlResources[href]; ok { + // Fall back to converting the (X)HTML resource's content + res := s.fetcher.Get(ctx, link) + defer res.Close() - // Find the next and previous guided navigation docs in the readingOrder - // Then enhance the document with additional next/prev links - idx := slices.Index(s.originalSmilIndexes, href) - if idx > 0 { - l := pub.GuidedNavigationLink - l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{ - "ref": s.originalSmilIndexes[idx-1], - })) - l.Rels = append(l.Rels, "prev") - doc.Links = append(doc.Links, l) - } - if idx < len(s.originalSmilIndexes)-1 { - l := pub.GuidedNavigationLink - l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{ - "ref": s.originalSmilIndexes[idx+1], - })) - l.Rels = append(l.Rels, "next") - doc.Links = append(doc.Links, l) + var err error + doc, err = converter.Do(ctx, res, manifest.Locator{ + Href: link.URL(nil, nil), + MediaType: *link.MediaType, + Title: link.Title, + }) + if err != nil { + return nil, err } - return doc, nil + } else { + return nil, errors.New("resource cannot be converted to a guided navigation document") + } + + // Find the next and previous guided navigation docs in the readingOrder + // Then enhance the document with additional next/prev links + idx := slices.Index(s.guideIndexes, href) + if idx > 0 { + l := pub.GuidedNavigationLink + l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{ + "ref": s.guideIndexes[idx-1], + })) + l.Rels = append(l.Rels, "prev") + doc.Links = append(doc.Links, l) + } + if idx < len(s.guideIndexes)-1 { + l := pub.GuidedNavigationLink + l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{ + "ref": s.guideIndexes[idx+1], + })) + l.Rels = append(l.Rels, "next") + doc.Links = append(doc.Links, l) } - return nil, nil + return doc, nil } func (s *MediaOverlayService) Get(ctx context.Context, link manifest.Link) (fetcher.Resource, bool) { diff --git a/pkg/parser/epub/parser.go b/pkg/parser/epub/parser.go index e22046d1..c29a14b8 100644 --- a/pkg/parser/epub/parser.go +++ b/pkg/parser/epub/parser.go @@ -6,7 +6,6 @@ import ( "github.com/antchfx/xmlquery" "github.com/pkg/errors" "github.com/readium/go-toolkit/pkg/asset" - "github.com/readium/go-toolkit/pkg/content/iterator" "github.com/readium/go-toolkit/pkg/fetcher" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/mediatype" @@ -83,9 +82,10 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch builder := pub.NewServicesBuilder(map[pub.ServiceName]pub.ServiceFactory{ pub.PositionsService_Name: PositionsServiceFactory(p.reflowablePositionsStrategy), - pub.ContentService_Name: pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{ - iterator.HTMLFactory(), - }), + // The guided navigation service replaces the content service + // pub.ContentService_Name: pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{ + // iterator.HTMLFactory(), + // }), pub.GuidedNavigationService_Name: MediaOverlayFactory(), }) return pub.NewBuilder(manifest, ffetcher, builder), nil diff --git a/pkg/parser/epub/parser_smil.go b/pkg/parser/epub/parser_smil.go index f573af06..439cefe0 100644 --- a/pkg/parser/epub/parser_smil.go +++ b/pkg/parser/epub/parser_smil.go @@ -1,11 +1,12 @@ package epub import ( - "strconv" + "time" "github.com/antchfx/xmlquery" "github.com/pkg/errors" - "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/guidednavigation" + "github.com/readium/go-toolkit/pkg/guidednavigation/converter" "github.com/readium/go-toolkit/pkg/util/url" ) @@ -17,7 +18,7 @@ var ( xpSMILAudio = mustCompileNS("smil:audio | smil2:audio") ) -func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*manifest.GuidedNavigationDocument, error) { +func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*guidednavigation.GuidedNavigationDocument, error) { smil := xmlquery.QuerySelector(document, xpSMILRoot) if smil == nil { return nil, errors.New("SMIL root element not found") @@ -34,17 +35,17 @@ func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*manifest.Gui if err != nil { return nil, errors.Wrap(err, "failed parsing SMIL body") } - return &manifest.GuidedNavigationDocument{ + return &guidednavigation.GuidedNavigationDocument{ Guided: seqs, }, nil } -func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNavigationObject, error) { +func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]guidednavigation.GuidedNavigationObject, error) { childElements := xmlquery.QuerySelectorAll(seq, xpSMILParOrSeq) if len(childElements) == 0 && seq.Data == "body" { return nil, errors.New("SMIL body is empty") } - objects := make([]manifest.GuidedNavigationObject, 0, len(childElements)) + objects := make([]guidednavigation.GuidedNavigationObject, 0, len(childElements)) for _, el := range childElements { if el.Data == "par" { // @@ -55,27 +56,28 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNaviga objects = append(objects, *o) } else { // - o := &manifest.GuidedNavigationObject{ - TextRef: SelectNodeAttrNs(el, NamespaceOPS, "textref"), - } - if o.TextRef == "" { + textrefAttr := SelectNodeAttrNs(el, NamespaceOPS, "textref") + if textrefAttr == "" { return nil, errors.New("SMIL seq has no textref") } - u, err := url.URLFromString(o.TextRef) + u, err := url.URLFromString(textrefAttr) if err != nil { return nil, errors.Wrap(err, "failed parsing SMIL seq textref") } - o.TextRef = filePath.Resolve(u).String() + o := &guidednavigation.GuidedNavigationObject{ + TextRef: filePath.Resolve(u), + } // epub:type pp := parseProperties(SelectNodeAttrNs(el, NamespaceOPS, "type")) if len(pp) > 0 { - o.Role = make([]string, 0, len(pp)) + o.Role = make([]guidednavigation.GuidedNavigationRole, 0, len(pp)) for _, prop := range pp { - if prop == "" { + p := converter.ConvertEPUBRole(prop) + if p == "" { continue } - o.Role = append(o.Role, prop) + o.Role = append(o.Role, p) } } @@ -91,57 +93,62 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNaviga return objects, nil } -func ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*manifest.GuidedNavigationObject, error) { +func secondsToDuration(seconds *float64) *time.Duration { + if seconds == nil { + return nil + } + d := time.Duration(*seconds * float64(time.Second)) + return &d +} + +func ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*guidednavigation.GuidedNavigationObject, error) { text := xmlquery.QuerySelector(par, xpSMILTextChild) if text == nil { return nil, errors.New("SMIL par has no text element") } - o := &manifest.GuidedNavigationObject{ - TextRef: text.SelectAttr("src"), - } - if o.TextRef == "" { + srcAttr := text.SelectAttr("src") + if srcAttr == "" { return nil, errors.New("SMIL par text element has empty src attribute") } - u, err := url.URLFromString(o.TextRef) + u, err := url.URLFromString(srcAttr) if err != nil { return nil, errors.Wrap(err, "failed parsing SMIL par text element textref") } - o.TextRef = filePath.Resolve(u).String() + o := &guidednavigation.GuidedNavigationObject{ + TextRef: filePath.Resolve(u), + } // Audio is optional if audio := xmlquery.QuerySelector(par, xpSMILAudio); audio != nil { - o.AudioRef = audio.SelectAttr("src") - if o.AudioRef == "" { + audioAttr := audio.SelectAttr("src") + if audioAttr == "" { return nil, errors.New("SMIL par audio element has empty src attribute") } - begin := ParseClockValue(audio.SelectAttr("clipBegin")) - end := ParseClockValue(audio.SelectAttr("clipEnd")) - if begin != nil || end != nil { - o.AudioRef += "#t=" - } - if begin != nil { - o.AudioRef += strconv.FormatFloat(*begin, 'f', -1, 64) + clip := guidednavigation.Clip{ + Begin: secondsToDuration(ParseClockValue(audio.SelectAttr("clipBegin"))), + End: secondsToDuration(ParseClockValue(audio.SelectAttr("clipEnd"))), } - if end != nil { - o.AudioRef += "," + strconv.FormatFloat(*end, 'f', -1, 64) + if fragment := clip.MediaFragment(); fragment != "" { + audioAttr += "#" + fragment } - u, err := url.URLFromString(o.AudioRef) + u, err := url.URLFromString(audioAttr) if err != nil { return nil, errors.Wrap(err, "failed parsing SMIL par audio element textref") } - o.AudioRef = filePath.Resolve(u).String() + o.AudioRef = filePath.Resolve(u) } // epub:type pp := parseProperties(SelectNodeAttrNs(par, NamespaceOPS, "type")) if len(pp) > 0 { - o.Role = make([]string, 0, len(pp)) + o.Role = make([]guidednavigation.GuidedNavigationRole, 0, len(pp)) for _, prop := range pp { - if prop == "" { + p := converter.ConvertEPUBRole(prop) + if p == "" { continue } - o.Role = append(o.Role, prop) + o.Role = append(o.Role, p) } } diff --git a/pkg/parser/epub/parser_smil_test.go b/pkg/parser/epub/parser_smil_test.go index 9bb42786..32f856e9 100644 --- a/pkg/parser/epub/parser_smil_test.go +++ b/pkg/parser/epub/parser_smil_test.go @@ -5,13 +5,14 @@ import ( "testing" "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/guidednavigation" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/util/url" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func loadSmil(ctx context.Context, name string) (*manifest.GuidedNavigationDocument, error) { +func loadSmil(ctx context.Context, name string) (*guidednavigation.GuidedNavigationDocument, error) { n, rerr := fetcher.ReadResourceAsXML(ctx, fetcher.NewFileResource(manifest.Link{}, "./testdata/smil/"+name+".smil")) if rerr != nil { return nil, rerr.Cause @@ -25,8 +26,8 @@ func TestSMILDocTypicalAudio(t *testing.T) { require.NoError(t, err) assert.Empty(t, doc.Links) if assert.Len(t, doc.Guided, 6) { - assert.Equal(t, "OEBPS/page1.xhtml#word0", doc.Guided[0].TextRef) - assert.Equal(t, "OEBPS/audio/page1.m4a#t=0,0.84", doc.Guided[0].AudioRef) + assert.Equal(t, "OEBPS/page1.xhtml#word0", doc.Guided[0].TextRef.String()) + assert.Equal(t, "OEBPS/audio/page1.m4a#t=0,0.84", doc.Guided[0].AudioRef.String()) } } @@ -42,7 +43,7 @@ func TestSMILClipBoundaries(t *testing.T) { doc, err := loadSmil(t.Context(), "audio-clip") require.NoError(t, err) require.Len(t, doc.Guided, 3) - assert.Equal(t, "OEBPS/audio/page1.m4a#t=,0.84", doc.Guided[0].AudioRef) - assert.Equal(t, "OEBPS/audio/page1.m4a#t=0.84", doc.Guided[1].AudioRef) - assert.Equal(t, "OEBPS/audio/page1.m4a", doc.Guided[2].AudioRef) + assert.Equal(t, "OEBPS/audio/page1.m4a#t=,0.84", doc.Guided[0].AudioRef.String()) + assert.Equal(t, "OEBPS/audio/page1.m4a#t=0.84", doc.Guided[1].AudioRef.String()) + assert.Equal(t, "OEBPS/audio/page1.m4a", doc.Guided[2].AudioRef.String()) } diff --git a/pkg/parser/webpub/parser.go b/pkg/parser/webpub/parser.go index 11c80dbd..35dc1954 100644 --- a/pkg/parser/webpub/parser.go +++ b/pkg/parser/webpub/parser.go @@ -8,7 +8,6 @@ import ( "github.com/pkg/errors" "github.com/readium/go-toolkit/pkg/asset" - "github.com/readium/go-toolkit/pkg/content/iterator" "github.com/readium/go-toolkit/pkg/fetcher" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/mediatype" @@ -158,12 +157,15 @@ func (p WebPubParser) Parse(ctx context.Context, a asset.PublicationAsset, f fet serviceFactories[pub.PositionsService_Name] = epub.PositionsServiceFactory(nil) } - // Add content service for WebPubs with HTML contents. + // Add guided navigation service for WebPubs with HTML contents. It serves SMIL + // media overlays when reading order items have them, and converts the HTML + // resources themselves otherwise. It replaces the content service: + // serviceFactories[pub.ContentService_Name] = pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{ + // iterator.HTMLFactory(), + // }) for _, link := range m.ReadingOrder { if link.MediaType != nil && link.MediaType.IsHTML() { - serviceFactories[pub.ContentService_Name] = pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{ - iterator.HTMLFactory(), - }) + serviceFactories[pub.GuidedNavigationService_Name] = epub.MediaOverlayFactory() break } } diff --git a/pkg/parser/webpub/parser_test.go b/pkg/parser/webpub/parser_test.go index 829e83ec..98acf652 100644 --- a/pkg/parser/webpub/parser_test.go +++ b/pkg/parser/webpub/parser_test.go @@ -451,7 +451,7 @@ func TestParseExplodedPublicationSandboxed(t *testing.T) { func TestParseServiceFactories(t *testing.T) { // An EPUB-profile WebPub gets a positions service and, having HTML contents, - // a content service. + // a guided navigation service. dir := t.TempDir() manifestJSON := `{ "@context": "https://readium.org/webpub-manifest/context.jsonld", @@ -471,7 +471,7 @@ func TestParseServiceFactories(t *testing.T) { require.NotNil(t, builder) assert.NotNil(t, builder.ServicesBuilder.Get(pub.PositionsService_Name)) - assert.NotNil(t, builder.ServicesBuilder.Get(pub.ContentService_Name)) + assert.NotNil(t, builder.ServicesBuilder.Get(pub.GuidedNavigationService_Name)) } // The parser must not swallow assets which aren't WebPub flavored. diff --git a/pkg/pub/service_guided_navigation.go b/pkg/pub/service_guided_navigation.go index 980d38d6..c4f5a4ca 100644 --- a/pkg/pub/service_guided_navigation.go +++ b/pkg/pub/service_guided_navigation.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/guidednavigation" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/mediatype" "github.com/readium/go-toolkit/pkg/util/url" @@ -27,7 +28,7 @@ func init() { // Provides a way to access guided navigation documents for resources of a [Publication]. type GuidedNavigationService interface { Service - GuideForResource(ctx context.Context, href string) (*manifest.GuidedNavigationDocument, error) + GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) HasGuideForResource(href string) bool } diff --git a/pkg/util/url/url.go b/pkg/util/url/url.go index 280404a2..0a2c206e 100644 --- a/pkg/util/url/url.go +++ b/pkg/util/url/url.go @@ -88,8 +88,10 @@ func (u RelativeURL) Extension() string { // RemoveQuery implements URL func (u RelativeURL) RemoveQuery() URL { - u.url.RawQuery = "" - return RelativeURL{url: u.url, normalized: u.normalized} + res := *u.url + res.ForceQuery = false + res.RawQuery = "" + return RelativeURL{url: &res, normalized: u.normalized} } // Fragment implements URL @@ -99,8 +101,10 @@ func (u RelativeURL) Fragment() string { // RemoveFragment implements URL func (u RelativeURL) RemoveFragment() URL { - u.url.Fragment = "" - return RelativeURL{url: u.url, normalized: u.normalized} + res := *u.url + res.Fragment = "" + res.RawFragment = "" + return RelativeURL{url: &res, normalized: u.normalized} } // Resolve implements URL @@ -168,16 +172,17 @@ func (u RelativeURL) Normalize() URL { return u } + res := *u.url var hadSlash bool - if strings.HasSuffix(u.url.Path, "/") { + if strings.HasSuffix(res.Path, "/") { hadSlash = true } - u.url.Path = path.Clean(u.url.Path) + res.Path = path.Clean(res.Path) if hadSlash { - u.url.Path += "/" + res.Path += "/" } - return RelativeURL{url: u.url, normalized: true} + return RelativeURL{url: &res, normalized: true} } // String implements URL @@ -238,8 +243,10 @@ func (u AbsoluteURL) Extension() string { // RemoveQuery implements URL func (u AbsoluteURL) RemoveQuery() URL { - u.url.RawQuery = "" - return AbsoluteURL{url: u.url, scheme: u.scheme, normalized: u.normalized} + res := *u.url + res.ForceQuery = false + res.RawQuery = "" + return AbsoluteURL{url: &res, scheme: u.scheme, normalized: u.normalized} } // Fragment implements URL @@ -249,8 +256,10 @@ func (u AbsoluteURL) Fragment() string { // RemoveFragment implements URL func (u AbsoluteURL) RemoveFragment() URL { - u.url.Fragment = "" - return AbsoluteURL{url: u.url, scheme: u.scheme, normalized: u.normalized} + res := *u.url + res.Fragment = "" + res.RawFragment = "" + return AbsoluteURL{url: &res, scheme: u.scheme, normalized: u.normalized} } // Resolve implements URL @@ -307,22 +316,23 @@ func (u AbsoluteURL) Normalize() URL { return u } + res := *u.url var hadSlash bool - if strings.HasSuffix(u.url.Path, "/") { + if strings.HasSuffix(res.Path, "/") { hadSlash = true } - u.url.Path = path.Clean(u.url.Path) + res.Path = path.Clean(res.Path) if hadSlash { - u.url.Path += "/" + res.Path += "/" } - u.url.Scheme = SchemeFromString(u.url.Scheme).String() - asciiHost, err := idna.ToASCII(u.url.Host) + res.Scheme = SchemeFromString(res.Scheme).String() + asciiHost, err := idna.ToASCII(res.Host) if err == nil { - u.url.Host = asciiHost + res.Host = asciiHost } - return AbsoluteURL{url: u.url, scheme: Scheme(u.url.Scheme), normalized: true} + return AbsoluteURL{url: &res, scheme: Scheme(res.Scheme), normalized: true} } // String implements URL diff --git a/pkg/util/url/url_test.go b/pkg/util/url/url_test.go index 1c22fc24..454b9952 100644 --- a/pkg/util/url/url_test.go +++ b/pkg/util/url/url_test.go @@ -441,6 +441,53 @@ func TestNormalize(t *testing.T) { assert.Equal(t, "http://user:password@example.com:443/foo?b=b&a=a#fragment", u.Normalize().String()) } +// The methods documented as returning copies must not mutate the receiver's +// underlying net/url.URL, which is shared between the copies. +func TestRemoveFragmentReturnsCopy(t *testing.T) { + for _, urlTest := range []string{ + "chapter.xhtml?q=1#fragment", + "http://example.com/chapter.xhtml?q=1#fragment", + } { + u := MustURLFromString(urlTest) + removed := u.RemoveFragment() + assert.Empty(t, removed.Fragment(), "for URL '%s'", urlTest) + assert.Equal(t, "fragment", u.Fragment(), "original URL '%s' must keep its fragment", urlTest) + assert.Equal(t, urlTest, u.String(), "original URL '%s' must be unchanged", urlTest) + } +} + +func TestRemoveQueryReturnsCopy(t *testing.T) { + for _, urlTest := range []string{ + "chapter.xhtml?q=1#fragment", + "http://example.com/chapter.xhtml?q=1#fragment", + } { + u := MustURLFromString(urlTest) + removed := u.RemoveQuery() + assert.Empty(t, removed.Raw().RawQuery, "for URL '%s'", urlTest) + assert.Equal(t, "fragment", removed.Fragment(), "RemoveQuery must keep the fragment of '%s'", urlTest) + assert.Equal(t, urlTest, u.String(), "original URL '%s' must be unchanged", urlTest) + } +} + +func TestNormalizeReturnsCopy(t *testing.T) { + for _, urlTest := range []struct { + url string + normalized string + }{ + {"foo/../bar.xhtml", "bar.xhtml"}, + {"http://example.com/foo/../bar.xhtml", "http://example.com/bar.xhtml"}, + } { + u := MustURLFromString(urlTest.url) + assert.Equal(t, urlTest.normalized, u.Normalize().String()) + assert.Equal(t, urlTest.url, u.String(), "original URL '%s' must be unchanged", urlTest.url) + + // Equivalent uses Normalize internally and must not mutate either side + other := MustURLFromString(urlTest.normalized) + assert.True(t, u.Equivalent(other)) + assert.Equal(t, urlTest.url, u.String(), "Equivalent must not mutate the receiver") + } +} + // Windows drive paths round-trip through file URLs in the file:///C:/dir form, // so that resolving relative references does not corrupt the drive letter. func TestFilepathWindowsDrive(t *testing.T) {