From 8283a2afcb58d50b56b075c303ad6d940171523a Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 16 Sep 2025 05:10:42 -0700 Subject: [PATCH 1/5] WIP HTML -> guided navigation conversion --- pkg/content/element/attributes.go | 3 + pkg/content/element/element.go | 2 +- pkg/content/iterator/html_converter.go | 1 + pkg/guidednavigation/converter/a11y.go | 129 +++++ pkg/guidednavigation/converter/converter.go | 42 ++ .../converter/converter_test.go | 69 +++ pkg/guidednavigation/converter/html.go | 539 ++++++++++++++++++ pkg/guidednavigation/converter/roles.go | 283 +++++++++ pkg/guidednavigation/guided_navigation.go | 136 +++++ pkg/guidednavigation/roles.go | 81 +++ pkg/manifest/guided_navigation.go | 24 - pkg/parser/epub/parser_smil.go | 16 +- pkg/pub/service_guided_navigation.go | 3 +- 13 files changed, 1294 insertions(+), 34 deletions(-) create mode 100644 pkg/guidednavigation/converter/a11y.go create mode 100644 pkg/guidednavigation/converter/converter.go create mode 100644 pkg/guidednavigation/converter/converter_test.go create mode 100644 pkg/guidednavigation/converter/html.go create mode 100644 pkg/guidednavigation/converter/roles.go create mode 100644 pkg/guidednavigation/guided_navigation.go create mode 100644 pkg/guidednavigation/roles.go delete mode 100644 pkg/manifest/guided_navigation.go 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..201f17d3 --- /dev/null +++ b/pkg/guidednavigation/converter/a11y.go @@ -0,0 +1,129 @@ +package converter + +import ( + "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) + } + if n.FirstChild != nil { + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + } + f(n) +} + +// 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.Split(strings.TrimSpace(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 + } + } + text := strings.TrimSpace(sb.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 + if el.DataAtom == atom.Img { + if alt := strings.TrimSpace(getAttr(el, "alt")); alt != "" { + return &guidednavigation.GuidedNavigationText{ + Plain: alt, + }, true + } + } + + return nil, true +} diff --git a/pkg/guidednavigation/converter/converter.go b/pkg/guidednavigation/converter/converter.go new file mode 100644 index 00000000..71a4379a --- /dev/null +++ b/pkg/guidednavigation/converter/converter.go @@ -0,0 +1,42 @@ +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" + "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()) + } + + 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 ") + } + + contentConverter := NewHTMLConverter(locator) + + // Traverse the document's HTML + TraverseNode(contentConverter, 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..43770db2 --- /dev/null +++ b/pkg/guidednavigation/converter/converter_test.go @@ -0,0 +1,69 @@ +package converter + +import ( + "context" + "encoding/json" + "testing" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/stretchr/testify/require" +) + +func TestDo(t *testing.T) { + f := fetcher.NewBytesResource(manifest.Link{ + Href: manifest.MustNewHREFFromString("hello.xhtml", false), + }, func() []byte { + return []byte(` + + + +

Paragraphe avec image: A cool image

+

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

+

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

+ +
+ +

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

+
+ + +
+

Title of the chapter

+
+ + + + Alternative text using the alt attribute + + + + + + + +
+
+				 /\_/\
+				( o.o )
+				   ^ 
+				
+
+ ASCII Art of a cat face +
+
+ + `) + }) + + nav, err := Do(context.Background(), f, manifest.Locator{ + Href: f.Link().Href.Resolve(nil, nil), + }) + require.NoError(t, err) + bin, _ := json.Marshal(nav) + t.Log(string(bin)) +} diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go new file mode 100644 index 00000000..5078f514 --- /dev/null +++ b/pkg/guidednavigation/converter/html.go @@ -0,0 +1,539 @@ +package converter + +import ( + "slices" + "strings" + "unicode" + "unicode/utf8" + + "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 trimText(text string, before *string) manifest.Text { + var b string + if before != nil { + b = *before + } + // Get all the space from the beginning of the string and add it to the before + var bsb strings.Builder + for _, v := range text { + if unicode.IsSpace(v) { + bsb.WriteRune(v) + } else { + break + } + } + b += bsb.String() + + // Get all the space from the end of the string and add it to the after + var asb strings.Builder + for i := len(text) - 1; i >= 0; i-- { + if unicode.IsSpace(rune(text[i])) { + asb.WriteRune(rune(text[i])) + } else { + break + } + } + + return manifest.Text{ + Before: b + bsb.String(), + Highlight: text[bsb.Len() : len(text)-asb.Len()], + After: asb.String(), + } +} + +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 getFirstAttr(n *html.Node, keys []string) string { + for _, attr := range n.Attr { + if slices.Contains(keys, attr.Key) { + return attr.Val + } + } + return "" +}*/ + +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 { + 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 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 +} + +// This isn't cheap to run +func nodeLanguage(n *html.Node) *string { + // xml:lang takes priority over lang + + 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 + } + + if n.Parent != nil { + return nodeLanguage(n.Parent) + } + return nil +} + +// 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 + } + } +} + +type NodeVisitor interface { + Head(n *html.Node, depth int) // Callback for when a node is first visited. + Tail(n *html.Node, depth int) // Callback for when a node is last visited, after all of its descendants have been visited. +} + +// Start a depth-first traverse of the root and all of its descendants. +// This implementation does not use recursion, so a deep DOM does not risk blowing the stack. +// From JSoup: https://github.com/jhy/jsoup/blob/1762412a28fa7b08ccf71d93fc4c98dc73086e03/src/main/java/org/jsoup/select/NodeTraversor.java#L20 +// NOTE: Unlike the JSoup implementation, we expect any implementor of NodeVisitor to be read-only, because it simplifies implementation +func TraverseNode(visitor NodeVisitor, root *html.Node) { + node := root + depth := 0 + + for node != nil { + visitor.Head(node, depth) // visit current node + + // DON'T check if removed or replaced + + if node.FirstChild != nil { // descend + node = node.FirstChild + depth++ + } else { + for { + if !(node.NextSibling == nil && depth > 0) { + break + } + visitor.Tail(node, depth) // when no more siblings, ascend + node = node.Parent + depth-- + } + visitor.Tail(node, depth) + if node == root { + break + } + node = node.NextSibling + } + } +} + +type breadcrumbData struct { + node *html.Node + object *guidednavigation.GuidedNavigationObject + skip bool // If true, this block and its children should be skipped +} + +// Note that this whole thing is based off of JSoup's NodeVisitor and NodeTraverser classes +// https://jsoup.org/apidocs/org/jsoup/select/NodeVisitor.html +// https://jsoup.org/apidocs/org/jsoup/select/NodeTraversor.html +type HTMLConverter struct { + baseLocator manifest.Locator + + root *guidednavigation.GuidedNavigationObject + current *guidednavigation.GuidedNavigationObject + + skipCurrent bool + segmentsAcc []guidednavigation.GuidedNavigationObject // Segments accumulated for the current element. + textAcc strings.Builder // Text since the beginning of the current segment, after coalescing whitespaces. + currentLanguage *string // Language of the current segment. + + breadcrumbs []breadcrumbData // LIFO stack of the current element's block ancestors. +} + +func NewHTMLConverter(baseLocator manifest.Locator) *HTMLConverter { + doc := &guidednavigation.GuidedNavigationObject{} + return &HTMLConverter{ + baseLocator: baseLocator, + root: doc, + current: doc, + } +} + +func (c *HTMLConverter) Result() []guidednavigation.GuidedNavigationObject { + return c.root.Children +} + +// Implements NodeTraversor +func (c *HTMLConverter) Head(n *html.Node, depth int) { + if n.Type == html.ElementNode { + aria, visible := ExtractNodeAria(n) + + isBlock := !isInlineTag(n) + if isBlock { + // Flush text + c.flushText() + + // Go down in the GN tree + c.current.Children = append(c.current.Children, guidednavigation.GuidedNavigationObject{ + // Role: []guidednavigation.GuidedNavigationRole{guidednavigation.GuidedNavigationRole(n.Data)}, + }) + c.current = &c.current.Children[len(c.current.Children)-1] + + // Add blocks to breadcrumbs + c.breadcrumbs = append(c.breadcrumbs, breadcrumbData{ + node: n, + object: c.current, + skip: !visible, + }) + } + + roles, level := ExtractNodeRoles(n) + + if n.DataAtom == atom.Br { + c.flushText() + } else if n.DataAtom == atom.Audio || n.DataAtom == atom.Video || slices.Contains(roles, guidednavigation.RoleImage) { + c.flushText() + + if slices.Contains(roles, guidednavigation.RoleImage) { + obj := guidednavigation.GuidedNavigationObject{ + Role: roles, + } + if href := srcRelativeToHref(n, c.baseLocator.Href); href != nil { + obj.ImgRef = href + } + if aria != nil { + obj.Description = aria.Plain + c.skipCurrent = true + } + c.current.Children = append(c.current.Children, obj) + } else { // Audio or Video + href := srcRelativeToHref(n, c.baseLocator.Href) + if href == nil { + sourceNodes := childrenOfType(n, atom.Source, 1) + for _, source := range sourceNodes { + if src := srcRelativeToHref(source, c.baseLocator.Href); src != nil { + href = src + // TODO: we're losing the alts + break + } + } + } + + if href != nil { + if n.DataAtom == atom.Audio { + obj := guidednavigation.GuidedNavigationObject{ + AudioRef: href, + // elementLocator + } + if aria != nil { + obj.Description = aria.Plain + c.skipCurrent = true + } + c.current.Children = append(c.current.Children, obj) + } else if n.DataAtom == atom.Video { + // TODO: videoref? + panic("videoref not implemented!") + } + } + } + } else { + if isBlock { + if level > 0 { + c.current.Level = level + } + if len(roles) > 0 { + for _, role := range roles { + if !slices.Contains(c.current.Role, role) { + c.current.Role = append(c.current.Role, role) + } + } + } + } + + if aria != nil { + c.current.Description = aria.Plain + c.breadcrumbs[len(c.breadcrumbs)-1].skip = true + c.skipCurrent = true + } + } + + if isBlock { + c.flushText() + } + } +} + +// Implements NodeTraversor +func (c *HTMLConverter) Tail(n *html.Node, depth int) { + if n.Type == html.TextNode && !onlySpace(n.Data) && !c.skippable() { + language := nodeLanguage(n) + if c.currentLanguage != language { + c.flushSegment() + c.currentLanguage = language + } + + var stripLeading bool + if acc := c.textAcc.String(); len(acc) > 0 && acc[len(acc)-1] == ' ' { + stripLeading = true + } + appendNormalizedWhitespace(&c.textAcc, n.Data, stripLeading) + } else if n.Type == html.ElementNode { + if !isInlineTag(n) { // Is block + c.skipCurrent = false + c.flushText() + + cleanChildren := make([]guidednavigation.GuidedNavigationObject, 0, len(c.current.Children)) + for _, child := range c.current.Children { + if !child.Empty() { + cleanChildren = append(cleanChildren, child) + } + } + c.current.Children = cleanChildren + + if len(c.breadcrumbs) > 0 { + if c.breadcrumbs[len(c.breadcrumbs)-1].node != n { + panic("HTMLConverter: breadcrumbs mismatch") + } + + c.breadcrumbs = c.breadcrumbs[:len(c.breadcrumbs)-1] + if len(c.breadcrumbs) > 0 { + // Go up in the GN tree + c.current = c.breadcrumbs[len(c.breadcrumbs)-1].object + } else { + c.current = c.root + } + } else { + c.current = c.root + } + } + } +} + +func (c *HTMLConverter) skippable() bool { + if c.skipCurrent { + return true + } + if len(c.breadcrumbs) == 0 { + return false + } + for i := len(c.breadcrumbs) - 1; i >= 0; i-- { + if c.breadcrumbs[i].skip { + return true + } + } + return false +} + +func (c *HTMLConverter) flushText() { + c.flushSegment() + + if len(c.segmentsAcc) == 0 { + return + } + + // Trim the end of the last segment's text to get a cleaner output for the TextElement. + // Only whitespaces between the segments are meaningful. + lastSegment := c.segmentsAcc[len(c.segmentsAcc)-1] + lastSegment.Text.Plain = strings.TrimRightFunc(lastSegment.Text.Plain, unicode.IsSpace) + + c.current.Children = append(c.current.Children, c.segmentsAcc...) + + if len(c.breadcrumbs) > 0 { + el := c.breadcrumbs[len(c.breadcrumbs)-1].node + roles, level := ExtractNodeRoles(el) + if level > 0 { + c.current.Level = level + } + if len(roles) > 0 { + for _, role := range roles { + if !slices.Contains(c.current.Role, role) { + c.current.Role = append(c.current.Role, role) + } + } + } + } + + c.segmentsAcc = []guidednavigation.GuidedNavigationObject{} +} + +func (c *HTMLConverter) flushSegment() { + text := c.textAcc.String() + trimmedText := strings.TrimSpace(text) + + if len(text) > 0 { + if len(c.segmentsAcc) == 0 { + text = strings.TrimLeftFunc(text, unicode.IsSpace) + + var whitespaceSuffix string + r, _ := utf8.DecodeLastRuneInString(text) + if unicode.IsSpace(r) { + whitespaceSuffix = string(r) + } + + text = trimmedText + whitespaceSuffix + } + + obj := guidednavigation.GuidedNavigationObject{} + obj.Text.Plain = text + if c.currentLanguage != nil { + obj.Text.Language = *c.currentLanguage + } + c.segmentsAcc = append(c.segmentsAcc, obj) + } + + c.textAcc.Reset() +} diff --git a/pkg/guidednavigation/converter/roles.go b/pkg/guidednavigation/converter/roles.go new file mode 100644 index 00000000..cbf58528 --- /dev/null +++ b/pkg/guidednavigation/converter/roles.go @@ -0,0 +1,283 @@ +package converter + +import ( + "slices" + "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 el.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 ariaRoles = map[string]guidednavigation.GuidedNavigationRole{ + "doc-abstract": guidednavigation.RoleAbstract, + "doc-acknowledgments": guidednavigation.RoleAcknowledgments, + "doc-afterword": guidednavigation.RoleAfterword, + "doc-appendix": guidednavigation.RoleAppendix, + "doc-backlink": guidednavigation.RoleBacklink, + "doc-bibliography": guidednavigation.RoleBibliography, + "doc-biblioref": guidednavigation.RoleBiblioref, + "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-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-glossary": guidednavigation.RoleGlossary, + "doc-glossref": guidednavigation.RoleGlossref, + "heading": guidednavigation.RoleHeading, + "img": guidednavigation.RoleImage, + "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, + "doc-part": guidednavigation.RolePart, + "doc-preface": guidednavigation.RolePreface, + "doc-prologue": guidednavigation.RolePrologue, + "doc-pullquote": guidednavigation.RolePullquote, + "presentation": guidednavigation.RolePresentation, + "none": guidednavigation.RolePresentation, + "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, + "endnotes": guidednavigation.RoleEndnotes, + "epigraph": guidednavigation.RoleEpigraph, + "epilogue": guidednavigation.RoleEpilogue, + "errata": guidednavigation.RoleErrata, + "example": guidednavigation.RoleExample, + "figure": guidednavigation.RoleFigure, + "footnote": guidednavigation.RoleFootnote, + "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, + "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.RoleHeading, + atom.H2: guidednavigation.RoleHeading, + atom.H3: guidednavigation.RoleHeading, + atom.H4: guidednavigation.RoleHeading, + atom.H5: guidednavigation.RoleHeading, + atom.H6: guidednavigation.RoleHeading, + 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, +} + +func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationRole, level uint8) { + add := func(role guidednavigation.GuidedNavigationRole) { + if !slices.Contains(roles, role) { + roles = append(roles, role) + } + } + + // Based on attributes + var namespaces map[string]string + var alreadyHasRole bool + 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" { + if role, ok := ariaRoles[at.Val]; ok { + alreadyHasRole = true + add(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" { + if role, ok := epubTypeRoles[at.Val]; ok { + add(role) + } + } + } + } + if alreadyHasRole { + // Aria role overrides logic based on the element type + return + } + + // Based on element type + switch el.DataAtom { + case atom.Th: + scope := getAttr(el, "scope") + switch scope { + case "col": + add(guidednavigation.RoleColumnHeader) + case "row": + add(guidednavigation.RoleRowHeader) + } + default: + if role, ok := simpleElementTypeRoles[el.DataAtom]; ok { + add(role) + } + } + + /*case atom.Blockquote, atom.Q: + quote := element.Quote{} + for _, at := range el.Attr { + if at.Key == "cite" { + quote.ReferenceURL, _ = nurl.Parse(at.Val) + } + if at.Key == "title" { + quote.ReferenceTitle = at.Val + } + } + bestRole = quote*/ // TODO + + return +} diff --git a/pkg/guidednavigation/guided_navigation.go b/pkg/guidednavigation/guided_navigation.go new file mode 100644 index 00000000..26cd3c2c --- /dev/null +++ b/pkg/guidednavigation/guided_navigation.go @@ -0,0 +1,136 @@ +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 { + 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. + Text GuidedNavigationText `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object. + Level uint8 `json:"level,omitempty"` // Level 1-6, for e.g. headings + 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 string `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.Text.Empty() && len(o.Children) == 0 && o.Description == "" +} + +func (o GuidedNavigationObject) ChildrenOnly() bool { + return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.Text.Empty() && len(o.Children) > 0 && o.Description == "" +} + +func (o GuidedNavigationObject) MarshalJSON() ([]byte, error) { + res := make(map[string]interface{}) + if o.Empty() { + return json.Marshal(res) + } + + if o.TextRef != nil { + if s := o.TextRef.String(); s != "" { + res["textref"] = o.TextRef.String() + } + } + if o.ImgRef != nil { + if s := o.ImgRef.String(); s != "" { + res["imgref"] = o.ImgRef.String() + } + } + if o.AudioRef != nil { + if s := o.AudioRef.String(); s != "" { + res["audioref"] = o.AudioRef.String() + } + } + + if (o.Text != GuidedNavigationText{}) { + res["text"] = o.Text + } + if o.Level != 0 { + res["level"] = o.Level + } + if len(o.Role) > 0 { + res["role"] = o.Role + } + if len(o.Children) > 0 { + res["children"] = o.Children + } + if o.Description != "" { + 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) +} + +// Same as GuidedNavigationObject but without Children +/*type GuidedNavigationDescriptionObject 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. + Text string `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object. + Level uint8 `json:"level,omitempty"` // TODO + Role []string `json:"role,omitempty"` // Convey the structural semantics of a publication +}*/ + +// 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/guidednavigation/roles.go b/pkg/guidednavigation/roles.go new file mode 100644 index 00000000..6ed731ea --- /dev/null +++ b/pkg/guidednavigation/roles.go @@ -0,0 +1,81 @@ +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. + 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. + 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. + RoleHeading GuidedNavigationRole = "heading" // A 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. + 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/epub/parser_smil.go b/pkg/parser/epub/parser_smil.go index 1a38aca3..16dbbf16 100644 --- a/pkg/parser/epub/parser_smil.go +++ b/pkg/parser/epub/parser_smil.go @@ -4,12 +4,12 @@ import ( "strconv" "github.com/pkg/errors" - "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/guidednavigation" "github.com/readium/go-toolkit/pkg/util/url" "github.com/readium/xmlquery" ) -func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*manifest.GuidedNavigationDocument, error) { +func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*guidednavigation.GuidedNavigationDocument, error) { smil := document.SelectElement("/" + DualNSSelect(NamespaceSMIL, NamespaceSMIL2, "smil")) if smil == nil { return nil, errors.New("SMIL root element not found") @@ -26,17 +26,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 := seq.SelectElements(ManyNSSelectMany([]string{NamespaceSMIL, NamespaceSMIL2}, []string{"par", "seq"})) 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" { // @@ -47,7 +47,7 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNaviga objects = append(objects, *o) } else { // - o := &manifest.GuidedNavigationObject{ + o := &guidednavigation.GuidedNavigationObject{ TextRef: SelectNodeAttrNs(el, NamespaceOPS, "textref"), } if o.TextRef == "" { @@ -83,12 +83,12 @@ 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 ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*guidednavigation.GuidedNavigationObject, error) { text := par.SelectElement(DualNSSelect(NamespaceSMIL, NamespaceSMIL2, "text")) if text == nil { return nil, errors.New("SMIL par has no text element") } - o := &manifest.GuidedNavigationObject{ + o := &guidednavigation.GuidedNavigationObject{ TextRef: text.SelectAttr("src"), } if o.TextRef == "" { 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 } From 2128e58db1fbd448f302bf9da2764d220cc94459 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 20 Oct 2025 05:07:17 -0700 Subject: [PATCH 2/5] more work on guided navigation doc generation --- pkg/guidednavigation/converter/a11y.go | 24 + pkg/guidednavigation/converter/converter.go | 2 +- .../converter/converter_test.go | 9 +- pkg/guidednavigation/converter/html.go | 450 ++++++++++-------- pkg/guidednavigation/converter/roles.go | 25 + pkg/guidednavigation/guided_navigation.go | 6 +- pkg/parser/epub/media_overlay_service.go | 3 +- pkg/parser/epub/parser_smil.go | 53 ++- pkg/parser/epub/parser_smil_test.go | 13 +- 9 files changed, 354 insertions(+), 231 deletions(-) diff --git a/pkg/guidednavigation/converter/a11y.go b/pkg/guidednavigation/converter/a11y.go index 201f17d3..bc78a4fa 100644 --- a/pkg/guidednavigation/converter/a11y.go +++ b/pkg/guidednavigation/converter/a11y.go @@ -1,6 +1,7 @@ package converter import ( + "encoding/xml" "slices" "strings" @@ -127,3 +128,26 @@ func ExtractNodeAria(el *html.Node) (*guidednavigation.GuidedNavigationText, boo return nil, true } + +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 + } +} diff --git a/pkg/guidednavigation/converter/converter.go b/pkg/guidednavigation/converter/converter.go index 71a4379a..03c46f2b 100644 --- a/pkg/guidednavigation/converter/converter.go +++ b/pkg/guidednavigation/converter/converter.go @@ -34,7 +34,7 @@ func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator contentConverter := NewHTMLConverter(locator) // Traverse the document's HTML - TraverseNode(contentConverter, body) + contentConverter.Convert(body) return &guidednavigation.GuidedNavigationDocument{ Guided: contentConverter.Result(), diff --git a/pkg/guidednavigation/converter/converter_test.go b/pkg/guidednavigation/converter/converter_test.go index 43770db2..c36d8e7b 100644 --- a/pkg/guidednavigation/converter/converter_test.go +++ b/pkg/guidednavigation/converter/converter_test.go @@ -19,8 +19,13 @@ func TestDo(t *testing.T) {

Paragraphe avec image: A cool image

+

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

+

The coolest image et The boring image

+

A paragraph with: A cool imageest cool!

+

Simple paragraph

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

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

+

Just
testing
some
breaks! And useless elements...

@@ -37,6 +42,8 @@ func TestDo(t *testing.T) {
  • Third item
  • + + Alternative text using the alt attribute @@ -64,6 +71,6 @@ func TestDo(t *testing.T) { Href: f.Link().Href.Resolve(nil, nil), }) require.NoError(t, err) - bin, _ := json.Marshal(nav) + bin, _ := json.MarshalIndent(nav, "", " ") t.Log(string(bin)) } diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go index 5078f514..4b0cc9c9 100644 --- a/pkg/guidednavigation/converter/html.go +++ b/pkg/guidednavigation/converter/html.go @@ -1,6 +1,7 @@ package converter import ( + "encoding/xml" "slices" "strings" "unicode" @@ -242,187 +243,207 @@ func appendNormalizedWhitespace(accum *strings.Builder, text string, stripLeadin } } -type NodeVisitor interface { - Head(n *html.Node, depth int) // Callback for when a node is first visited. - Tail(n *html.Node, depth int) // Callback for when a node is last visited, after all of its descendants have been visited. -} +type textSegment struct { + text string -// Start a depth-first traverse of the root and all of its descendants. -// This implementation does not use recursion, so a deep DOM does not risk blowing the stack. -// From JSoup: https://github.com/jhy/jsoup/blob/1762412a28fa7b08ccf71d93fc4c98dc73086e03/src/main/java/org/jsoup/select/NodeTraversor.java#L20 -// NOTE: Unlike the JSoup implementation, we expect any implementor of NodeVisitor to be read-only, because it simplifies implementation -func TraverseNode(visitor NodeVisitor, root *html.Node) { - node := root - depth := 0 + tag *string + attributes []xml.Attr +} - for node != nil { - visitor.Head(node, depth) // visit current node +type navigationObject struct { + node *html.Node + object guidednavigation.GuidedNavigationObject + children []*navigationObject + parent *navigationObject + noText bool +} - // DON'T check if removed or replaced +func (n *navigationObject) convert(prettify bool) guidednavigation.GuidedNavigationObject { + result := n.object - if node.FirstChild != nil { // descend - node = node.FirstChild - depth++ - } else { - for { - if !(node.NextSibling == nil && depth > 0) { - break - } - visitor.Tail(node, depth) // when no more siblings, ascend - node = node.Parent - depth-- - } - visitor.Tail(node, depth) - if node == root { - break - } - node = node.NextSibling + for _, child := range n.children { + res := child.convert(prettify) + if !res.Empty() { + result.Children = append(result.Children, res) } } -} + // Prettify + if len(result.Children) == 1 && result.Children[0].TextOnly() && prettify { + result.Text = result.Children[0].Text + result.Children = nil + } -type breadcrumbData struct { - node *html.Node - object *guidednavigation.GuidedNavigationObject - skip bool // If true, this block and its children should be skipped + return result } -// Note that this whole thing is based off of JSoup's NodeVisitor and NodeTraverser classes -// https://jsoup.org/apidocs/org/jsoup/select/NodeVisitor.html -// https://jsoup.org/apidocs/org/jsoup/select/NodeTraversor.html type HTMLConverter struct { baseLocator manifest.Locator - root *guidednavigation.GuidedNavigationObject - current *guidednavigation.GuidedNavigationObject - - skipCurrent bool - segmentsAcc []guidednavigation.GuidedNavigationObject // Segments accumulated for the current element. - textAcc strings.Builder // Text since the beginning of the current segment, after coalescing whitespaces. - currentLanguage *string // Language of the current segment. + segmentsAcc []textSegment // Segments accumulated for the current element. + textAcc strings.Builder // Text since the beginning of the current segment, after coalescing whitespaces. + currentLanguage *string // Language of the current segment. + lastTextNode *html.Node - breadcrumbs []breadcrumbData // LIFO stack of the current element's block ancestors. + root *navigationObject + current *navigationObject + skipNode bool } func NewHTMLConverter(baseLocator manifest.Locator) *HTMLConverter { - doc := &guidednavigation.GuidedNavigationObject{} return &HTMLConverter{ baseLocator: baseLocator, - root: doc, - current: doc, } } -func (c *HTMLConverter) Result() []guidednavigation.GuidedNavigationObject { - return c.root.Children +func (c *HTMLConverter) descend(n *html.Node) { + newNode := &navigationObject{ + node: n, + parent: c.current, + noText: c.current.noText, + } + if c.current == nil { + c.root = newNode + } else { + c.current.children = append(c.current.children, newNode) + } + c.current = newNode } -// Implements NodeTraversor -func (c *HTMLConverter) Head(n *html.Node, depth int) { - if n.Type == html.ElementNode { - aria, visible := ExtractNodeAria(n) +func (c *HTMLConverter) ascend() { + if c.current != nil { + c.current = c.current.parent + } +} - isBlock := !isInlineTag(n) - if isBlock { - // Flush text - c.flushText() +func (c *HTMLConverter) Convert(doc *html.Node) { + node := doc + c.root = &navigationObject{ + node: doc, + } + c.current = c.root - // Go down in the GN tree - c.current.Children = append(c.current.Children, guidednavigation.GuidedNavigationObject{ - // Role: []guidednavigation.GuidedNavigationRole{guidednavigation.GuidedNavigationRole(n.Data)}, - }) - c.current = &c.current.Children[len(c.current.Children)-1] + depth := 0 - // Add blocks to breadcrumbs - c.breadcrumbs = append(c.breadcrumbs, breadcrumbData{ - node: n, - object: c.current, - skip: !visible, - }) + for node != nil { + c.head(node) + if node.FirstChild != nil && !c.skipNode { // descend + node = node.FirstChild + depth++ + } else { + for { + if !(node.NextSibling == nil && depth > 0) { + break + } + c.tail(node) + node = node.Parent + depth-- + } + c.tail(node) + if node == doc { + break + } + node = node.NextSibling } + } +} - roles, level := ExtractNodeRoles(n) +func (c *HTMLConverter) Result() []guidednavigation.GuidedNavigationObject { + if c.root == nil { + return nil + } + return c.root.convert(true).Children +} - if n.DataAtom == atom.Br { - c.flushText() - } else if n.DataAtom == atom.Audio || n.DataAtom == atom.Video || slices.Contains(roles, guidednavigation.RoleImage) { - c.flushText() +func (c *HTMLConverter) head(n *html.Node) { + if n.Type != html.ElementNode { + return + } - if slices.Contains(roles, guidednavigation.RoleImage) { - obj := guidednavigation.GuidedNavigationObject{ - Role: roles, - } - if href := srcRelativeToHref(n, c.baseLocator.Href); href != nil { - obj.ImgRef = href - } - if aria != nil { - obj.Description = aria.Plain - c.skipCurrent = true - } - c.current.Children = append(c.current.Children, obj) - } else { // Audio or Video - href := srcRelativeToHref(n, c.baseLocator.Href) - if href == nil { - sourceNodes := childrenOfType(n, atom.Source, 1) - for _, source := range sourceNodes { - if src := srcRelativeToHref(source, c.baseLocator.Href); src != nil { - href = src - // TODO: we're losing the alts - break - } - } - } + aria, visible := ExtractNodeAria(n) + if !visible { + c.skipNode = true + return + } - if href != nil { - if n.DataAtom == atom.Audio { - obj := guidednavigation.GuidedNavigationObject{ - AudioRef: href, - // elementLocator - } - if aria != nil { - obj.Description = aria.Plain - c.skipCurrent = true - } - c.current.Children = append(c.current.Children, obj) - } else if n.DataAtom == atom.Video { - // TODO: videoref? - panic("videoref not implemented!") - } - } + isBlock := !isInlineTag(n) + if isBlock { + // Flush text + c.flushText() + } + c.descend(n) + + cur := &c.current.object + + roles, level := ExtractNodeRoles(n) + + if n.DataAtom == atom.Br { + c.flushSegment("", nil) + breakStr := "break" + c.segmentsAcc = append(c.segmentsAcc, textSegment{ + text: "", + tag: &breakStr, + }) + } else if n.DataAtom == atom.Audio || n.DataAtom == atom.Video || slices.Contains(roles, guidednavigation.RoleImage) || slices.Contains(roles, guidednavigation.RoleFigure) { + // These three ops are essential to ensuring the correct order of the inline elements in the guided nav tree + c.flushText() + c.ascend() + c.descend(n) + c.current.object.Role = roles + if slices.Contains(roles, guidednavigation.RoleImage) { + if href := srcRelativeToHref(n, c.baseLocator.Href); href != nil { + c.current.object.ImgRef = href } - } else { - if isBlock { - if level > 0 { - c.current.Level = level - } - if len(roles) > 0 { - for _, role := range roles { - if !slices.Contains(c.current.Role, role) { - c.current.Role = append(c.current.Role, role) - } + if aria != nil { + c.current.object.Description = aria.Plain + c.current.noText = true + } + } else if slices.Contains(roles, guidednavigation.RoleFigure) { + if aria != nil { + c.current.object.Description = aria.Plain + c.current.noText = true + } + } else { // Audio or Video + href := srcRelativeToHref(n, c.baseLocator.Href) + if href == nil { + sourceNodes := childrenOfType(n, atom.Source, 1) + for _, source := range sourceNodes { + if src := srcRelativeToHref(source, c.baseLocator.Href); src != nil { + href = src + // TODO: we're losing the alts + break } } } - if aria != nil { - c.current.Description = aria.Plain - c.breadcrumbs[len(c.breadcrumbs)-1].skip = true - c.skipCurrent = true + if href != nil { + switch n.DataAtom { + case atom.Audio: + c.current.object.AudioRef = href + if aria != nil { + c.current.object.Description = aria.Plain + c.current.noText = true + } + case atom.Video: + // TODO: videoref? + c.current.noText = true + } } } - - if isBlock { - c.flushText() + } else { + cur.Level = level + cur.Role = roles + if aria != nil { + cur.Description = aria.Plain } } } -// Implements NodeTraversor -func (c *HTMLConverter) Tail(n *html.Node, depth int) { - if n.Type == html.TextNode && !onlySpace(n.Data) && !c.skippable() { +func (c *HTMLConverter) tail(n *html.Node) { + if n.Type == html.TextNode && !onlySpace(n.Data) && !c.current.noText { language := nodeLanguage(n) - if c.currentLanguage != language { - c.flushSegment() + ssmlTag, attrs := ConvertElementToSSMLTag(n.Parent.DataAtom) + if c.currentLanguage != language || ssmlTag != "" { + c.flushSegment(ssmlTag, attrs) c.currentLanguage = language } @@ -431,55 +452,26 @@ func (c *HTMLConverter) Tail(n *html.Node, depth int) { stripLeading = true } appendNormalizedWhitespace(&c.textAcc, n.Data, stripLeading) + c.lastTextNode = n } else if n.Type == html.ElementNode { if !isInlineTag(n) { // Is block - c.skipCurrent = false c.flushText() - - cleanChildren := make([]guidednavigation.GuidedNavigationObject, 0, len(c.current.Children)) - for _, child := range c.current.Children { - if !child.Empty() { - cleanChildren = append(cleanChildren, child) - } - } - c.current.Children = cleanChildren - - if len(c.breadcrumbs) > 0 { - if c.breadcrumbs[len(c.breadcrumbs)-1].node != n { - panic("HTMLConverter: breadcrumbs mismatch") - } - - c.breadcrumbs = c.breadcrumbs[:len(c.breadcrumbs)-1] - if len(c.breadcrumbs) > 0 { - // Go up in the GN tree - c.current = c.breadcrumbs[len(c.breadcrumbs)-1].object - } else { - c.current = c.root - } - } else { - c.current = c.root - } } - } -} - -func (c *HTMLConverter) skippable() bool { - if c.skipCurrent { - return true - } - if len(c.breadcrumbs) == 0 { - return false - } - for i := len(c.breadcrumbs) - 1; i >= 0; i-- { - if c.breadcrumbs[i].skip { - return true + if !c.skipNode { + c.ascend() + } else { + c.skipNode = false } } - return false } func (c *HTMLConverter) flushText() { - c.flushSegment() + if c.lastTextNode != nil { + ssmlTag, attrs := ConvertElementToSSMLTag(c.lastTextNode.Parent.DataAtom) + c.flushSegment(ssmlTag, attrs) + } else { + c.flushSegment("", nil) + } if len(c.segmentsAcc) == 0 { return @@ -487,30 +479,83 @@ func (c *HTMLConverter) flushText() { // Trim the end of the last segment's text to get a cleaner output for the TextElement. // Only whitespaces between the segments are meaningful. - lastSegment := c.segmentsAcc[len(c.segmentsAcc)-1] - lastSegment.Text.Plain = strings.TrimRightFunc(lastSegment.Text.Plain, unicode.IsSpace) - - c.current.Children = append(c.current.Children, c.segmentsAcc...) + c.segmentsAcc[len(c.segmentsAcc)-1].text = strings.TrimRightFunc(c.segmentsAcc[len(c.segmentsAcc)-1].text, unicode.IsSpace) + + cobj := guidednavigation.GuidedNavigationObject{} + + var ssml bool + allLang := true + var lastLang string + var sb strings.Builder + for _, v := range c.segmentsAcc { + if v.tag != nil { + ssml = true + if *v.tag == "lang" { + // Cheating here because we're in control of the attributes + if lastLang != "" && lastLang != v.attributes[0].Value { + allLang = false + break + } + lastLang = v.attributes[0].Value + } else { + allLang = false + break + } + } else { + allLang = false + } + } + if allLang { + ssml = false + cobj.Text.Language = lastLang + } - if len(c.breadcrumbs) > 0 { - el := c.breadcrumbs[len(c.breadcrumbs)-1].node - roles, level := ExtractNodeRoles(el) - if level > 0 { - c.current.Level = level + for i, v := range c.segmentsAcc { + if i > 0 && len(c.segmentsAcc[i-1].text) > 0 && len(v.text) > 0 && v.tag == nil { + sb.WriteRune(' ') } - if len(roles) > 0 { - for _, role := range roles { - if !slices.Contains(c.current.Role, role) { - c.current.Role = append(c.current.Role, role) + if ssml { + if v.tag != nil { + sb.WriteRune('<') + sb.WriteString(*v.tag) + for _, attr := range v.attributes { + sb.WriteRune(' ') + sb.WriteString(attr.Name.Local) + sb.WriteString(`="`) + xml.EscapeText(&sb, []byte(attr.Value)) + sb.WriteRune('"') + } + if len(v.text) > 0 { + sb.WriteRune('>') + } else { + sb.WriteString("/>") + } + } + if len(v.text) > 0 { + xml.EscapeText(&sb, []byte(v.text)) + if v.tag != nil { + sb.WriteString("') } } + } else { + sb.WriteString(v.text) } } + if ssml { + cobj.Text.SSML = sb.String() + } else { + cobj.Text.Plain = sb.String() + } + c.current.children = append(c.current.children, &navigationObject{ + object: cobj, + }) - c.segmentsAcc = []guidednavigation.GuidedNavigationObject{} + c.segmentsAcc = []textSegment{} } -func (c *HTMLConverter) flushSegment() { +func (c *HTMLConverter) flushSegment(asTag string, extraAttrs []xml.Attr) { text := c.textAcc.String() trimmedText := strings.TrimSpace(text) @@ -527,10 +572,23 @@ func (c *HTMLConverter) flushSegment() { text = trimmedText + whitespaceSuffix } - obj := guidednavigation.GuidedNavigationObject{} - obj.Text.Plain = text + obj := textSegment{ + text: text, + } + + if asTag != "" { + obj.tag = &asTag + } if c.currentLanguage != nil { - obj.Text.Language = *c.currentLanguage + if obj.tag == nil { + langStr := "lang" + obj.tag = &langStr + } + obj.attributes = append(obj.attributes, extraAttrs...) + obj.attributes = append(obj.attributes, xml.Attr{ + Name: xml.Name{Local: "xml:lang"}, + Value: *c.currentLanguage, + }) } c.segmentsAcc = append(c.segmentsAcc, obj) } diff --git a/pkg/guidednavigation/converter/roles.go b/pkg/guidednavigation/converter/roles.go index cbf58528..7aae5ac7 100644 --- a/pkg/guidednavigation/converter/roles.go +++ b/pkg/guidednavigation/converter/roles.go @@ -264,6 +264,24 @@ func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationR default: if role, ok := simpleElementTypeRoles[el.DataAtom]; ok { add(role) + + switch role { + case guidednavigation.RoleHeading: + switch el.DataAtom { + case atom.H1: + level = 1 + case atom.H2: + level = 2 + case atom.H3: + level = 3 + case atom.H4: + level = 4 + case atom.H5: + level = 5 + case atom.H6: + level = 6 + } + } } } @@ -281,3 +299,10 @@ func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationR return } + +func ConvertEPUBRole(role string) guidednavigation.GuidedNavigationRole { + if gRole, ok := epubTypeRoles[role]; ok { + return gRole + } + return "" +} diff --git a/pkg/guidednavigation/guided_navigation.go b/pkg/guidednavigation/guided_navigation.go index 26cd3c2c..4df6d342 100644 --- a/pkg/guidednavigation/guided_navigation.go +++ b/pkg/guidednavigation/guided_navigation.go @@ -32,7 +32,11 @@ func (o GuidedNavigationObject) Empty() bool { } func (o GuidedNavigationObject) ChildrenOnly() bool { - return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.Text.Empty() && len(o.Children) > 0 && o.Description == "" + return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.Text.Empty() && len(o.Children) > 0 && o.Description == "" && len(o.Role) == 0 && o.Level == 0 +} + +func (o GuidedNavigationObject) TextOnly() bool { + return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && !o.Text.Empty() && len(o.Children) == 0 && o.Description == "" && len(o.Role) == 0 && o.Level == 0 } func (o GuidedNavigationObject) MarshalJSON() ([]byte, error) { diff --git a/pkg/parser/epub/media_overlay_service.go b/pkg/parser/epub/media_overlay_service.go index f5083ee2..b4513e67 100644 --- a/pkg/parser/epub/media_overlay_service.go +++ b/pkg/parser/epub/media_overlay_service.go @@ -5,6 +5,7 @@ import ( "slices" "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/pub" @@ -74,7 +75,7 @@ func (s *MediaOverlayService) HasGuideForResource(href string) bool { return ok } -func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*manifest.GuidedNavigationDocument, error) { +func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) { // Check if the provided resource has a guided navigation document if link, ok := s.originalSmilAlternates[href]; ok { res := s.fetcher.Get(ctx, link) diff --git a/pkg/parser/epub/parser_smil.go b/pkg/parser/epub/parser_smil.go index 16dbbf16..943ecb58 100644 --- a/pkg/parser/epub/parser_smil.go +++ b/pkg/parser/epub/parser_smil.go @@ -5,6 +5,7 @@ import ( "github.com/pkg/errors" "github.com/readium/go-toolkit/pkg/guidednavigation" + "github.com/readium/go-toolkit/pkg/guidednavigation/converter" "github.com/readium/go-toolkit/pkg/util/url" "github.com/readium/xmlquery" ) @@ -47,27 +48,28 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]guidednavigation.Guid objects = append(objects, *o) } else { // - o := &guidednavigation.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) } } @@ -88,52 +90,53 @@ func ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*guidednavigation.Guide if text == nil { return nil, errors.New("SMIL par has no text element") } - o := &guidednavigation.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 := par.SelectElement(DualNSSelect(NamespaceSMIL, NamespaceSMIL2, "audio")); 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=" + audioAttr += "#t=" } if begin != nil { - o.AudioRef += strconv.FormatFloat(*begin, 'f', -1, 64) + audioAttr += strconv.FormatFloat(*begin, 'f', -1, 64) } if end != nil { - o.AudioRef += "," + strconv.FormatFloat(*end, 'f', -1, 64) + audioAttr += "," + strconv.FormatFloat(*end, 'f', -1, 64) } - 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 dcd1ca69..a7441b7e 100644 --- a/pkg/parser/epub/parser_smil_test.go +++ b/pkg/parser/epub/parser_smil_test.go @@ -5,12 +5,13 @@ 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" ) -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"), map[string]string{ NamespaceOPS: "epub", NamespaceSMIL: "smil", @@ -30,8 +31,8 @@ func TestSMILDocTypicalAudio(t *testing.T) { } 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()) } } @@ -51,7 +52,7 @@ func TestSMILClipBoundaries(t *testing.T) { if !assert.Len(t, doc.Guided, 3) { return } - 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()) } From 356c498a51885b84d1d423b616389526b60d3baf Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 20 Oct 2025 05:26:14 -0700 Subject: [PATCH 3/5] bugfix --- pkg/guidednavigation/converter/html.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go index 4b0cc9c9..6d1f9c02 100644 --- a/pkg/guidednavigation/converter/html.go +++ b/pkg/guidednavigation/converter/html.go @@ -579,12 +579,12 @@ func (c *HTMLConverter) flushSegment(asTag string, extraAttrs []xml.Attr) { if asTag != "" { obj.tag = &asTag } + obj.attributes = append(obj.attributes, extraAttrs...) if c.currentLanguage != nil { if obj.tag == nil { langStr := "lang" obj.tag = &langStr } - obj.attributes = append(obj.attributes, extraAttrs...) obj.attributes = append(obj.attributes, xml.Attr{ Name: xml.Name{Local: "xml:lang"}, Value: *c.currentLanguage, From 21597639789554b5b690a572ded8d534a07443cd Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 1 Jul 2026 21:17:18 -0700 Subject: [PATCH 4/5] finish MVP implementation of (X)HTML to Guided Nav conversion + various fixes (mostly coded by Claude Fable) --- pkg/guidednavigation/converter/a11y.go | 57 +- pkg/guidednavigation/converter/converter.go | 27 +- .../converter/converter_test.go | 764 +++++++++++- pkg/guidednavigation/converter/html.go | 1050 ++++++++++++----- pkg/guidednavigation/converter/roles.go | 124 +- pkg/guidednavigation/converter/xhtml.go | 152 +++ pkg/guidednavigation/converter/xhtml_test.go | 139 +++ pkg/guidednavigation/fragments.go | 588 +++++++++ pkg/guidednavigation/fragments_test.go | 281 +++++ pkg/guidednavigation/guided_navigation.go | 163 ++- .../guided_navigation_test.go | 105 ++ pkg/guidednavigation/roles.go | 10 +- pkg/parser/audio/formats_test.go | 10 +- pkg/parser/audio/toc.go | 13 +- pkg/parser/epub/parser_smil.go | 24 +- pkg/util/url/url.go | 48 +- pkg/util/url/url_test.go | 47 + 17 files changed, 3146 insertions(+), 456 deletions(-) create mode 100644 pkg/guidednavigation/converter/xhtml.go create mode 100644 pkg/guidednavigation/converter/xhtml_test.go create mode 100644 pkg/guidednavigation/fragments.go create mode 100644 pkg/guidednavigation/fragments_test.go create mode 100644 pkg/guidednavigation/guided_navigation_test.go diff --git a/pkg/guidednavigation/converter/a11y.go b/pkg/guidednavigation/converter/a11y.go index bc78a4fa..e405641d 100644 --- a/pkg/guidednavigation/converter/a11y.go +++ b/pkg/guidednavigation/converter/a11y.go @@ -44,15 +44,22 @@ func nodeText(sb *strings.Builder, n *html.Node) { if n.Type == html.TextNode { sb.WriteString(n.Data) } - if n.FirstChild != nil { - for c := n.FirstChild; c != nil; c = c.NextSibling { - f(c) - } + 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) { @@ -63,7 +70,7 @@ func ExtractNodeAria(el *html.Node) (*guidednavigation.GuidedNavigationText, boo // 2.B if labelledBy := strings.TrimSpace(getAttr(el, "aria-labelledby")); labelledBy != "" { - rawIds := strings.Split(strings.TrimSpace(labelledBy), " ") + rawIds := strings.Fields(labelledBy) ids := make([]string, 0, len(rawIds)) for _, v := range rawIds { if v != "" && !slices.Contains(ids, v) { @@ -100,7 +107,9 @@ func ExtractNodeAria(el *html.Node) (*guidednavigation.GuidedNavigationText, boo sb.WriteRune(' ') // Add a space at the end } } - text := strings.TrimSpace(sb.String()) + var normalized strings.Builder + appendNormalizedWhitespace(&normalized, sb.String(), true) + text := strings.TrimSpace(normalized.String()) if text != "" { return &guidednavigation.GuidedNavigationText{ Plain: text, @@ -118,17 +127,35 @@ func ExtractNodeAria(el *html.Node) (*guidednavigation.GuidedNavigationText, boo // 2.D // TODO: more support for els - if el.DataAtom == atom.Img { + 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: @@ -151,3 +178,19 @@ func ConvertElementToSSMLTag(a atom.Atom) (string, []xml.Attr) { 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 index 03c46f2b..5dde48a7 100644 --- a/pkg/guidednavigation/converter/converter.go +++ b/pkg/guidednavigation/converter/converter.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -18,12 +19,25 @@ func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator return nil, errors.Wrap(rerr, "failed reading HTML string of "+resource.Link().Href.String()) } - 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()) + 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) @@ -32,6 +46,7 @@ func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator } contentConverter := NewHTMLConverter(locator) + contentConverter.xmlParsed = xmlParsed // Traverse the document's HTML contentConverter.Convert(body) diff --git a/pkg/guidednavigation/converter/converter_test.go b/pkg/guidednavigation/converter/converter_test.go index c36d8e7b..c974f614 100644 --- a/pkg/guidednavigation/converter/converter_test.go +++ b/pkg/guidednavigation/converter/converter_test.go @@ -7,70 +7,732 @@ import ( "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 TestDo(t *testing.T) { +func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType) string { + t.Helper() f := fetcher.NewBytesResource(manifest.Link{ - Href: manifest.MustNewHREFFromString("hello.xhtml", false), + Href: manifest.MustNewHREFFromString("hello.xhtml", false), + MediaType: mt, }, func() []byte { - return []byte(` - <!doctype html> - <html xmlns:epub="http://www.idpf.org/2007/ops"><!-- lang="en" xml:lang="en" --> + 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 -

    Paragraphe avec image: A cool image

    -

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

    -

    The coolest image et The boring image

    -

    A paragraph with: A cool imageest cool!

    -

    Simple paragraph

    +

    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.

    -

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

    -

    Just
    testing
    some
    breaks! And useless elements...

    + + `, &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.

    + +

    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) +} -
    -

    Title of the chapter

    -
    -
      -
    • First item
    • -
    • Second item
    • -
    • Third item
    • -
    - - - - - Alternative text using the alt attribute - - - - - - - -
    -
    -				 /\_/\
    -				( o.o )
    -				   ^ 
    -				
    -
    - ASCII Art of a cat face -
    -
    - - `) - }) +// 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) +} - nav, err := Do(context.Background(), f, manifest.Locator{ +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.NoError(t, err) - bin, _ := json.MarshalIndent(nav, "", " ") - t.Log(string(bin)) + require.Error(t, err) } diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go index 6d1f9c02..872b6477 100644 --- a/pkg/guidednavigation/converter/html.go +++ b/pkg/guidednavigation/converter/html.go @@ -2,10 +2,11 @@ package converter import ( "encoding/xml" + "fmt" + nurl "net/url" "slices" "strings" "unicode" - "unicode/utf8" "github.com/readium/go-toolkit/pkg/guidednavigation" "github.com/readium/go-toolkit/pkg/manifest" @@ -14,39 +15,6 @@ import ( "golang.org/x/net/html/atom" ) -func trimText(text string, before *string) manifest.Text { - var b string - if before != nil { - b = *before - } - // Get all the space from the beginning of the string and add it to the before - var bsb strings.Builder - for _, v := range text { - if unicode.IsSpace(v) { - bsb.WriteRune(v) - } else { - break - } - } - b += bsb.String() - - // Get all the space from the end of the string and add it to the after - var asb strings.Builder - for i := len(text) - 1; i >= 0; i-- { - if unicode.IsSpace(rune(text[i])) { - asb.WriteRune(rune(text[i])) - } else { - break - } - } - - return manifest.Text{ - Before: b + bsb.String(), - Highlight: text[bsb.Len() : len(text)-asb.Len()], - After: asb.String(), - } -} - func onlySpace(s string) bool { for _, runeValue := range s { if !unicode.IsSpace(runeValue) { @@ -65,14 +33,23 @@ func getAttr(n *html.Node, key string) string { return "" } -/*func getFirstAttr(n *html.Node, keys []string) string { +func hasAttr(n *html.Node, key string) bool { for _, attr := range n.Attr { - if slices.Contains(keys, attr.Key) { - return attr.Val + if attr.Key == key { + return true } } - return "" -}*/ + 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 { @@ -81,6 +58,9 @@ func srcRelativeToHref(n *html.Node, base url.URL) url.URL { if v := getAttr(n, "src"); v != "" { if u, _ := url.URLFromString(v); u != nil { + if base == nil { + return u + } return base.Resolve(u) } } @@ -109,6 +89,9 @@ 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 { @@ -202,26 +185,23 @@ func isInlineTag(n *html.Node) bool { return ok } -// This isn't cheap to run -func nodeLanguage(n *html.Node) *string { - // xml:lang takes priority over lang - - 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 +// 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 } } - if lang != "" { - return &lang - } - - if n.Parent != nil { - return nodeLanguage(n.Parent) - } - return nil + return "" } // From JSoup: https://github.com/jhy/jsoup/blob/1762412a28fa7b08ccf71d93fc4c98dc73086e03/src/main/java/org/jsoup/internal/StringUtil.java#L233 @@ -243,11 +223,46 @@ func appendNormalizedWhitespace(accum *strings.Builder, text string, stripLeadin } } +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 { - text string + kind segmentKind + + text string // segmentText: the (whitespace-normalized) text + ctx ssmlContext // segmentText: the SSML context of the text - tag *string - attributes []xml.Attr + 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 { @@ -258,35 +273,93 @@ type navigationObject struct { noText bool } -func (n *navigationObject) convert(prettify bool) guidednavigation.GuidedNavigationObject { +func (n *navigationObject) convert() guidednavigation.GuidedNavigationObject { result := n.object for _, child := range n.children { - res := child.convert(prettify) - if !res.Empty() { - result.Children = append(result.Children, res) + 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) } - // Prettify - if len(result.Children) == 1 && result.Children[0].TextOnly() && prettify { - result.Text = result.Children[0].Text - result.Children = nil + + // 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). - segmentsAcc []textSegment // Segments accumulated for the current element. - textAcc strings.Builder // Text since the beginning of the current segment, after coalescing whitespaces. - currentLanguage *string // Language of the current segment. - lastTextNode *html.Node + 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 { @@ -295,33 +368,164 @@ func NewHTMLConverter(baseLocator manifest.Locator) *HTMLConverter { } } +// 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, } - if c.current == nil { - c.root = newNode - } else { - c.current.children = append(c.current.children, newNode) - } + c.current.children = append(c.current.children, newNode) c.current = newNode } func (c *HTMLConverter) ascend() { - if c.current != nil { + 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) { - node := doc - c.root = &navigationObject{ - node: doc, + 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 { @@ -330,10 +534,7 @@ func (c *HTMLConverter) Convert(doc *html.Node) { node = node.FirstChild depth++ } else { - for { - if !(node.NextSibling == nil && depth > 0) { - break - } + for node.NextSibling == nil && depth > 0 { c.tail(node) node = node.Parent depth-- @@ -351,7 +552,16 @@ func (c *HTMLConverter) Result() []guidednavigation.GuidedNavigationObject { if c.root == nil { return nil } - return c.root.convert(true).Children + 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) { @@ -359,239 +569,545 @@ func (c *HTMLConverter) head(n *html.Node) { return } - aria, visible := ExtractNodeAria(n) - if !visible { + if _, ok := skippedElements[n.DataAtom]; ok { + c.skipNode = true + return + } + if _, ok := c.suppressed[n]; ok && n != c.allowNode { c.skipNode = true return } - isBlock := !isInlineTag(n) - if isBlock { - // Flush text - c.flushText() + aria, visible := ExtractNodeAria(n) + if !visible && n != c.allowNode { + c.skipNode = true + return } - c.descend(n) - cur := &c.current.object + roles := ExtractNodeRoles(n) - roles, level := 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 { - c.flushSegment("", nil) - breakStr := "break" - c.segmentsAcc = append(c.segmentsAcc, textSegment{ - text: "", - tag: &breakStr, - }) - } else if n.DataAtom == atom.Audio || n.DataAtom == atom.Video || slices.Contains(roles, guidednavigation.RoleImage) || slices.Contains(roles, guidednavigation.RoleFigure) { - // These three ops are essential to ensuring the correct order of the inline elements in the guided nav tree - c.flushText() - c.ascend() - c.descend(n) - c.current.object.Role = roles - if slices.Contains(roles, guidednavigation.RoleImage) { - if href := srcRelativeToHref(n, c.baseLocator.Href); href != nil { - c.current.object.ImgRef = href - } - if aria != nil { - c.current.object.Description = aria.Plain - c.current.noText = true - } - } else if slices.Contains(roles, guidednavigation.RoleFigure) { - if aria != nil { - c.current.object.Description = aria.Plain - c.current.noText = true - } - } else { // Audio or Video - href := srcRelativeToHref(n, c.baseLocator.Href) - if href == nil { - sourceNodes := childrenOfType(n, atom.Source, 1) - for _, source := range sourceNodes { - if src := srcRelativeToHref(source, c.baseLocator.Href); src != nil { - href = src - // TODO: we're losing the alts - break - } - } - } + if !c.current.noText { + c.closeSegment() + c.segments = append(c.segments, textSegment{kind: segmentBreak}) + c.flowEndsWithSpace = true + } + return + } - if href != nil { - switch n.DataAtom { - case atom.Audio: - c.current.object.AudioRef = href - if aria != nil { - c.current.object.Description = aria.Plain - c.current.noText = true - } - case atom.Video: - // TODO: videoref? - c.current.noText = true + // 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 } } } - } else { - cur.Level = level - cur.Role = roles + tag := "audio" + if n.DataAtom == atom.Audio { + obj.AudioRef = href + } else { + obj.VideoRef = href + tag = "video" + } if aria != nil { - cur.Description = aria.Plain + 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 && !onlySpace(n.Data) && !c.current.noText { - language := nodeLanguage(n) - ssmlTag, attrs := ConvertElementToSSMLTag(n.Parent.DataAtom) - if c.currentLanguage != language || ssmlTag != "" { - c.flushSegment(ssmlTag, attrs) - c.currentLanguage = language - } - - var stripLeading bool - if acc := c.textAcc.String(); len(acc) > 0 && acc[len(acc)-1] == ' ' { - stripLeading = true - } - appendNormalizedWhitespace(&c.textAcc, n.Data, stripLeading) - c.lastTextNode = n - } else if n.Type == html.ElementNode { - if !isInlineTag(n) { // Is block - c.flushText() - } - if !c.skipNode { - c.ascend() - } else { - c.skipNode = false + 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() } -func (c *HTMLConverter) flushText() { - if c.lastTextNode != nil { - ssmlTag, attrs := ConvertElementToSSMLTag(c.lastTextNode.Parent.DataAtom) - c.flushSegment(ssmlTag, attrs) - } else { - c.flushSegment("", nil) +// 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] == ' ' + } +} - if len(c.segmentsAcc) == 0 { +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() +} - // Trim the end of the last segment's text to get a cleaner output for the TextElement. - // Only whitespaces between the segments are meaningful. - c.segmentsAcc[len(c.segmentsAcc)-1].text = strings.TrimRightFunc(c.segmentsAcc[len(c.segmentsAcc)-1].text, unicode.IsSpace) +func (c *HTMLConverter) resetFlow() { + c.segments = nil + c.textAcc.Reset() + c.currentCtx = ssmlContext{} + c.flowEndsWithSpace = true + c.pendingChildren = nil +} - cobj := guidednavigation.GuidedNavigationObject{} +// 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")) +} - var ssml bool - allLang := true - var lastLang string - var sb strings.Builder - for _, v := range c.segmentsAcc { - if v.tag != nil { - ssml = true - if *v.tag == "lang" { - // Cheating here because we're in control of the attributes - if lastLang != "" && lastLang != v.attributes[0].Value { - allLang = false - break - } - lastLang = v.attributes[0].Value - } else { - allLang = false - break - } - } else { - allLang = false - } +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 } - if allLang { - ssml = false - cobj.Text.Language = lastLang + 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 +} - for i, v := range c.segmentsAcc { - if i > 0 && len(c.segmentsAcc[i-1].text) > 0 && len(v.text) > 0 && v.tag == nil { - sb.WriteRune(' ') +// 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 ssml { - if v.tag != nil { - sb.WriteRune('<') - sb.WriteString(*v.tag) - for _, attr := range v.attributes { - sb.WriteRune(' ') - sb.WriteString(attr.Name.Local) - sb.WriteString(`="`) - xml.EscapeText(&sb, []byte(attr.Value)) - sb.WriteRune('"') - } - if len(v.text) > 0 { - sb.WriteRune('>') - } else { - sb.WriteString("/>") - } - } - if len(v.text) > 0 { - xml.EscapeText(&sb, []byte(v.text)) - if v.tag != nil { - sb.WriteString("') - } + } + 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, } - } else { - sb.WriteString(v.text) + sub.Convert(target) + obj.Children = sub.Result() } } - if ssml { - cobj.Text.SSML = sb.String() - } else { - cobj.Text.Plain = sb.String() + 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.current.children = append(c.current.children, &navigationObject{ - object: cobj, - }) - c.segmentsAcc = []textSegment{} + c.placeholderWithID(n, "noteref", obj, candidateID) } -func (c *HTMLConverter) flushSegment(asTag string, extraAttrs []xml.Attr) { - text := c.textAcc.String() - trimmedText := strings.TrimSpace(text) +func (c *HTMLConverter) flushText() { + c.closeSegment() + segments := c.segments + pending := c.pendingChildren + c.resetFlow() - if len(text) > 0 { - if len(c.segmentsAcc) == 0 { - text = strings.TrimLeftFunc(text, unicode.IsSpace) + if len(segments) == 0 { + return + } - var whitespaceSuffix string - r, _ := utf8.DecodeLastRuneInString(text) - if unicode.IsSpace(r) { - whitespaceSuffix = string(r) + // 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 + } - text = trimmedText + whitespaceSuffix + 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 + } - obj := textSegment{ - text: text, + // 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] + } - if asTag != "" { - obj.tag = &asTag + needSSML := false + for _, seg := range segments { + if seg.kind != segmentText || seg.ctx.tag != "" || seg.ctx.lang != baseLang { + needSSML = true + break } - obj.attributes = append(obj.attributes, extraAttrs...) - if c.currentLanguage != nil { - if obj.tag == nil { - langStr := "lang" - obj.tag = &langStr + } + + // 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) } - obj.attributes = append(obj.attributes, xml.Attr{ - Name: xml.Name{Local: "xml:lang"}, - Value: *c.currentLanguage, - }) + seg.child.object.ID = id } - c.segmentsAcc = append(c.segmentsAcc, obj) } - c.textAcc.Reset() + // 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 index 7aae5ac7..bb958a55 100644 --- a/pkg/guidednavigation/converter/roles.go +++ b/pkg/guidednavigation/converter/roles.go @@ -2,6 +2,7 @@ package converter import ( "slices" + "strconv" "strings" "github.com/readium/go-toolkit/pkg/guidednavigation" @@ -18,7 +19,7 @@ func ExtractNamespaces(el *html.Node) (namespaces map[string]string) { } f := func(n *html.Node) { - for _, at := range el.Attr { + for _, at := range n.Attr { if at.Key == "xmlns" { if _, ok := namespaces[""]; !ok { // Only the first xmlns gets set @@ -48,14 +49,26 @@ func ExtractNamespaces(el *html.Node) (namespaces map[string]string) { 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, @@ -67,6 +80,7 @@ var ariaRoles = map[string]guidednavigation.GuidedNavigationRole{ "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, @@ -74,10 +88,11 @@ var ariaRoles = map[string]guidednavigation.GuidedNavigationRole{ "doc-example": guidednavigation.RoleExample, "figure": guidednavigation.RoleFigure, "doc-footnote": guidednavigation.RoleFootnote, + "doc-foreword": guidednavigation.RoleForeword, "doc-glossary": guidednavigation.RoleGlossary, "doc-glossref": guidednavigation.RoleGlossref, - "heading": guidednavigation.RoleHeading, "img": guidednavigation.RoleImage, + "image": guidednavigation.RoleImage, // ARIA 1.3 synonym of img "doc-index": guidednavigation.RoleIndex, "doc-introduction": guidednavigation.RoleIntroduction, "list": guidednavigation.RoleList, @@ -89,12 +104,14 @@ var ariaRoles = map[string]guidednavigation.GuidedNavigationRole{ "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, @@ -125,13 +142,17 @@ var epubTypeRoles = map[string]guidednavigation.GuidedNavigationRole{ "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, @@ -146,6 +167,7 @@ var epubTypeRoles = map[string]guidednavigation.GuidedNavigationRole{ "noteref": guidednavigation.RoleNoteref, "notice": guidednavigation.RoleNotice, "pagebreak": guidednavigation.RolePagebreak, + "page-list": guidednavigation.RolePagelist, "pagelist": guidednavigation.RolePagelist, "part": guidednavigation.RolePart, "preface": guidednavigation.RolePreface, @@ -172,12 +194,12 @@ var simpleElementTypeRoles = map[atom.Atom]guidednavigation.GuidedNavigationRole atom.Details: guidednavigation.RoleDetails, atom.Figure: guidednavigation.RoleFigure, atom.Header: guidednavigation.RoleHeader, - atom.H1: guidednavigation.RoleHeading, - atom.H2: guidednavigation.RoleHeading, - atom.H3: guidednavigation.RoleHeading, - atom.H4: guidednavigation.RoleHeading, - atom.H5: guidednavigation.RoleHeading, - atom.H6: guidednavigation.RoleHeading, + 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, @@ -195,20 +217,25 @@ var simpleElementTypeRoles = map[atom.Atom]guidednavigation.GuidedNavigationRole atom.Dfn: guidednavigation.RoleTerm, atom.Dt: guidednavigation.RoleTerm, atom.Video: guidednavigation.RoleVideo, + atom.Svg: guidednavigation.RoleImage, } -func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationRole, level uint8) { +// 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 + // Based on attributes. Both `role` and `epub:type` can hold a list of values. var namespaces map[string]string - var alreadyHasRole bool + 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] @@ -216,9 +243,21 @@ func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationR if len(frags) == 1 { // ARIA role if key == "role" { - if role, ok := ariaRoles[at.Val]; ok { - alreadyHasRole = true - add(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 { @@ -240,62 +279,45 @@ func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationR } if at.Namespace == "http://www.idpf.org/2007/ops" && key == "type" { - if role, ok := epubTypeRoles[at.Val]; ok { - add(role) + for _, val := range strings.Fields(at.Val) { + if role, ok := epubTypeRoles[val]; ok { + attrRoles = append(attrRoles, role) + } } } } } - if alreadyHasRole { - // Aria role overrides logic based on the element type - return + + if presentational { + // The element only retains the presentation role + return []guidednavigation.GuidedNavigationRole{guidednavigation.RolePresentation} } - // Based on element type + // 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: - scope := getAttr(el, "scope") - switch scope { + 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) - - switch role { - case guidednavigation.RoleHeading: - switch el.DataAtom { - case atom.H1: - level = 1 - case atom.H2: - level = 2 - case atom.H3: - level = 3 - case atom.H4: - level = 4 - case atom.H5: - level = 5 - case atom.H6: - level = 6 - } - } } } - /*case atom.Blockquote, atom.Q: - quote := element.Quote{} - for _, at := range el.Attr { - if at.Key == "cite" { - quote.ReferenceURL, _ = nurl.Parse(at.Val) - } - if at.Key == "title" { - quote.ReferenceTitle = at.Val - } + for _, role := range attrRoles { + add(role) } - bestRole = quote*/ // TODO 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 index 4df6d342..b42c0bf0 100644 --- a/pkg/guidednavigation/guided_navigation.go +++ b/pkg/guidednavigation/guided_navigation.go @@ -17,26 +17,33 @@ type GuidedNavigationDocument struct { // Readium Guided Navigation Object // https://readium.org/guided-navigation/schema/object.schema.json type GuidedNavigationObject 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. - Text GuidedNavigationText `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object. - Level uint8 `json:"level,omitempty"` // Level 1-6, for e.g. headings - 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 string `json:"description,omitempty"` // Text, audio or image description for the current Guided Navigation Object. + 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.Text.Empty() && len(o.Children) == 0 && o.Description == "" + 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.Text.Empty() && len(o.Children) > 0 && o.Description == "" && len(o.Role) == 0 && o.Level == 0 + 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.Text.Empty() && len(o.Children) == 0 && o.Description == "" && len(o.Role) == 0 && o.Level == 0 + 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) { @@ -45,35 +52,40 @@ func (o GuidedNavigationObject) MarshalJSON() ([]byte, error) { return json.Marshal(res) } + if o.ID != "" { + res["id"] = o.ID + } if o.TextRef != nil { if s := o.TextRef.String(); s != "" { - res["textref"] = o.TextRef.String() + res["textref"] = s } } if o.ImgRef != nil { if s := o.ImgRef.String(); s != "" { - res["imgref"] = o.ImgRef.String() + res["imgref"] = s } } if o.AudioRef != nil { if s := o.AudioRef.String(); s != "" { - res["audioref"] = o.AudioRef.String() + res["audioref"] = s + } + } + if o.VideoRef != nil { + if s := o.VideoRef.String(); s != "" { + res["videoref"] = s } } - if (o.Text != GuidedNavigationText{}) { + if !o.Text.Empty() { res["text"] = o.Text } - if o.Level != 0 { - res["level"] = o.Level - } if len(o.Role) > 0 { res["role"] = o.Role } if len(o.Children) > 0 { res["children"] = o.Children } - if o.Description != "" { + if !o.Description.Empty() { res["description"] = o.Description } @@ -126,15 +138,104 @@ func (t GuidedNavigationText) MarshalJSON() ([]byte, error) { return json.Marshal(res) } -// Same as GuidedNavigationObject but without Children -/*type GuidedNavigationDescriptionObject 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. - Text string `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object. - Level uint8 `json:"level,omitempty"` // TODO - Role []string `json:"role,omitempty"` // Convey the structural semantics of a publication -}*/ - -// 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 +// 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 index 6ed731ea..9ea68e38 100644 --- a/pkg/guidednavigation/roles.go +++ b/pkg/guidednavigation/roles.go @@ -17,6 +17,7 @@ const ( 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. @@ -37,10 +38,16 @@ const ( 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. - RoleHeading GuidedNavigationRole = "heading" // A heading for a section of the page. + 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. @@ -71,6 +78,7 @@ const ( 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. 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/parser_smil.go b/pkg/parser/epub/parser_smil.go index 19c3a62d..439cefe0 100644 --- a/pkg/parser/epub/parser_smil.go +++ b/pkg/parser/epub/parser_smil.go @@ -1,7 +1,7 @@ package epub import ( - "strconv" + "time" "github.com/antchfx/xmlquery" "github.com/pkg/errors" @@ -93,6 +93,14 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]guidednavigation.Guid return objects, nil } +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 { @@ -116,16 +124,12 @@ func ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*guidednavigation.Guide 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 { - audioAttr += "#t=" - } - if begin != nil { - audioAttr += strconv.FormatFloat(*begin, 'f', -1, 64) + clip := guidednavigation.Clip{ + Begin: secondsToDuration(ParseClockValue(audio.SelectAttr("clipBegin"))), + End: secondsToDuration(ParseClockValue(audio.SelectAttr("clipEnd"))), } - if end != nil { - audioAttr += "," + strconv.FormatFloat(*end, 'f', -1, 64) + if fragment := clip.MediaFragment(); fragment != "" { + audioAttr += "#" + fragment } u, err := url.URLFromString(audioAttr) diff --git a/pkg/util/url/url.go b/pkg/util/url/url.go index 208aedbc..28a4db9b 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 0e0c2b49..b5557cc9 100644 --- a/pkg/util/url/url_test.go +++ b/pkg/util/url/url_test.go @@ -425,3 +425,50 @@ func TestNormalize(t *testing.T) { u, _ = URLFromString("http://user:password@example.com:443/foo?b=b&a=a#fragment") 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") + } +} From f66c95f96ac3e97c04255496a49c41a73d7b3e3c Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 8 Jul 2026 14:24:27 -0700 Subject: [PATCH 5/5] replace content API with guided navigation documents in EPUB/WebPub --- pkg/parser/epub/media_overlay_service.go | 103 ++++++++++++++++------- pkg/parser/epub/parser.go | 8 +- pkg/parser/webpub/parser.go | 12 +-- pkg/parser/webpub/parser_test.go | 4 +- 4 files changed, 86 insertions(+), 41 deletions(-) diff --git a/pkg/parser/epub/media_overlay_service.go b/pkg/parser/epub/media_overlay_service.go index c137c1b9..656ff735 100644 --- a/pkg/parser/epub/media_overlay_service.go +++ b/pkg/parser/epub/media_overlay_service.go @@ -4,8 +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" @@ -15,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] @@ -24,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{ @@ -34,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 } @@ -49,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 { @@ -76,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) (*guidednavigation.GuidedNavigationDocument, error) { - // Check if the provided resource has a guided navigation document + 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() @@ -92,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/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.