-> [section, chapter].
+// An ARIA role of "presentation"/"none" strips the element of its native semantics.
+func ExtractNodeRoles(el *html.Node) (roles []guidednavigation.GuidedNavigationRole) {
+ add := func(role guidednavigation.GuidedNavigationRole) {
+ if !slices.Contains(roles, role) {
+ roles = append(roles, role)
+ }
+ }
+
+ // Based on attributes. Both `role` and `epub:type` can hold a list of values.
+ var namespaces map[string]string
+ var attrRoles []guidednavigation.GuidedNavigationRole
+ presentational := false
+ for _, at := range el.Attr {
+ // Remove namespace prefix if it exists
+ frags := strings.SplitN(at.Key, ":", 2)
+ key := frags[len(frags)-1]
+
+ if len(frags) == 1 {
+ // ARIA role
+ if key == "role" {
+ for _, val := range strings.Fields(at.Val) {
+ if val == "presentation" || val == "none" {
+ presentational = true
+ }
+ if val == "heading" {
+ // The heading level comes from aria-level. It defaults to 2:
+ // https://www.w3.org/TR/wai-aria/#heading
+ level := 2
+ if l, err := strconv.Atoi(getAttr(el, "aria-level")); err == nil && l >= 1 {
+ level = min(l, 6)
+ }
+ attrRoles = append(attrRoles, headingRoles[level-1])
+ } else if role, ok := ariaRoles[val]; ok {
+ attrRoles = append(attrRoles, role)
+ }
+ }
+ }
+ } else {
+ // First we check for an attribute key we're interested in, because extracting namespaces is expensive
+ if key != "type" {
+ continue
+ }
+
+ // Maybe the attribute has a namespace...?
+ if at.Namespace == "" {
+ if namespaces == nil {
+ // Save namespaces so they're only extracted once per element
+ namespaces = ExtractNamespaces(el)
+ }
+ if namespace, ok := namespaces[frags[0]]; ok {
+ // Set the namespace if we found it
+ at.Namespace = namespace
+ }
+ }
+
+ if at.Namespace == "http://www.idpf.org/2007/ops" && key == "type" {
+ for _, val := range strings.Fields(at.Val) {
+ if role, ok := epubTypeRoles[val]; ok {
+ attrRoles = append(attrRoles, role)
+ }
+ }
+ }
+ }
+ }
+
+ if presentational {
+ // The element only retains the presentation role
+ return []guidednavigation.GuidedNavigationRole{guidednavigation.RolePresentation}
+ }
+
+ // Based on element type. The element's own role comes first, followed by the
+ // more specific attribute-based roles, matching the ordering of the examples
+ // in the Guided Navigation specification.
+ switch el.DataAtom {
+ case atom.Body:
+ add(guidednavigation.RoleBody)
+ case atom.Th:
+ switch getAttr(el, "scope") {
+ case "col":
+ add(guidednavigation.RoleColumnHeader)
+ case "row":
+ add(guidednavigation.RoleRowHeader)
+ default:
+ // Without an explicit scope the direction is unknown, but it's still a cell
+ add(guidednavigation.RoleCell)
+ }
+ default:
+ if role, ok := simpleElementTypeRoles[el.DataAtom]; ok {
+ add(role)
+ }
+ }
+
+ for _, role := range attrRoles {
+ add(role)
+ }
+
+ return
+}
+
+func ConvertEPUBRole(role string) guidednavigation.GuidedNavigationRole {
+ if gRole, ok := epubTypeRoles[role]; ok {
+ return gRole
+ }
+ return ""
+}
diff --git a/pkg/guidednavigation/converter/xhtml.go b/pkg/guidednavigation/converter/xhtml.go
new file mode 100644
index 00000000..0a5edb40
--- /dev/null
+++ b/pkg/guidednavigation/converter/xhtml.go
@@ -0,0 +1,152 @@
+package converter
+
+import (
+ "encoding/xml"
+ "io"
+ "strings"
+
+ "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+)
+
+const (
+ xmlNamespace = "http://www.w3.org/XML/1998/namespace"
+ xlinkNamespace = "http://www.w3.org/1999/xlink"
+ svgNamespace = "http://www.w3.org/2000/svg"
+ mathmlNamespace = "http://www.w3.org/1998/Math/MathML"
+)
+
+// Namespace prefixes in scope at a point of the document, for reconstructing
+// prefixed attribute names (encoding/xml only exposes the resolved namespace URI).
+type nsScope struct {
+ parent *nsScope
+ uriToPrefix map[string]string
+}
+
+func (s *nsScope) lookup(uri string) (string, bool) {
+ for scope := s; scope != nil; scope = scope.parent {
+ if prefix, ok := scope.uriToPrefix[uri]; ok {
+ return prefix, true
+ }
+ }
+ return "", false
+}
+
+// ParseXHTML parses an XHTML document into the tree shape html.Parse produces, so
+// consumers written against html.Parse output work unchanged. Unlike html.Parse,
+// self-closing elements like are handled per XML rules
+// instead of being treated as unclosed tags that swallow the content following them.
+func ParseXHTML(r io.Reader) (*html.Node, error) {
+ dec := xml.NewDecoder(r)
+ dec.Strict = false // Invent end tags for mismatched elements, keep unknown entities as text
+ dec.AutoClose = xml.HTMLAutoClose // Tolerate HTML-style void tags like
+ dec.Entity = xml.HTMLEntity // etc. (the HTML 4 set, matching the XHTML DTDs)
+ // The input has already been decoded to UTF-8, whatever encoding the XML
+ // declaration announces
+ dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
+ return input, nil
+ }
+
+ doc := &html.Node{Type: html.DocumentNode}
+ cur := doc
+ scope := &nsScope{}
+
+ for {
+ tok, err := dec.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ switch t := tok.(type) {
+ case xml.StartElement:
+ // Track xmlns declarations, which encoding/xml leaves untranslated
+ child := &nsScope{parent: scope}
+ for _, a := range t.Attr {
+ if a.Name.Space == "xmlns" {
+ if child.uriToPrefix == nil {
+ child.uriToPrefix = make(map[string]string)
+ }
+ child.uriToPrefix[a.Value] = a.Name.Local
+ }
+ }
+ scope = child
+
+ local := strings.ToLower(t.Name.Local)
+ n := &html.Node{
+ Type: html.ElementNode,
+ Data: local,
+ DataAtom: atom.Lookup([]byte(local)),
+ Namespace: elementNamespace(t.Name.Space),
+ }
+ for _, a := range t.Attr {
+ n.Attr = append(n.Attr, attrFromXML(a, scope))
+ }
+ cur.AppendChild(n)
+ cur = n
+ case xml.EndElement:
+ // Self-closing elements arrive as Start+End pairs, keeping this balanced
+ if scope.parent != nil {
+ scope = scope.parent
+ }
+ if cur.Parent != nil {
+ cur = cur.Parent
+ }
+ case xml.CharData:
+ if cur == doc {
+ continue // Whitespace around the root element
+ }
+ if last := cur.LastChild; last != nil && last.Type == html.TextNode {
+ last.Data += string(t)
+ } else {
+ cur.AppendChild(&html.Node{Type: html.TextNode, Data: string(t)})
+ }
+ case xml.Directive:
+ if d := string(t); len(d) > 8 && strings.EqualFold(d[:8], "DOCTYPE ") {
+ cur.AppendChild(&html.Node{Type: html.DoctypeNode, Data: strings.TrimSpace(d[8:])})
+ }
+ }
+ // Comments and processing instructions are skipped
+ }
+ return doc, nil
+}
+
+func elementNamespace(space string) string {
+ switch space {
+ case svgNamespace:
+ return "svg"
+ case mathmlNamespace:
+ return "math"
+ default:
+ // XHTML and unknown namespaces are treated as HTML
+ return ""
+ }
+}
+
+// Converts an XML attribute to the representation html.Parse would have produced:
+// the whole qualified name (prefix included) in Key, and an empty Namespace.
+func attrFromXML(a xml.Attr, scope *nsScope) html.Attribute {
+ local := strings.ToLower(a.Name.Local)
+ switch a.Name.Space {
+ case "":
+ return html.Attribute{Key: local, Val: a.Value}
+ case "xmlns":
+ // Keep the prefix's case, so the declaration matches the reconstructed
+ // qualified names below
+ return html.Attribute{Key: "xmlns:" + a.Name.Local, Val: a.Value}
+ case xmlNamespace:
+ return html.Attribute{Key: "xml:" + local, Val: a.Value}
+ case xlinkNamespace:
+ return html.Attribute{Key: "xlink:" + local, Val: a.Value}
+ }
+ if prefix, ok := scope.lookup(a.Name.Space); ok && prefix != "" {
+ return html.Attribute{Key: prefix + ":" + local, Val: a.Value}
+ }
+ if !strings.ContainsAny(a.Name.Space, ":/") {
+ // encoding/xml passes undeclared prefixes through verbatim
+ return html.Attribute{Key: a.Name.Space + ":" + local, Val: a.Value}
+ }
+ // A namespace URI without an in-scope prefix: degrade to the local name
+ return html.Attribute{Key: local, Val: a.Value}
+}
diff --git a/pkg/guidednavigation/converter/xhtml_test.go b/pkg/guidednavigation/converter/xhtml_test.go
new file mode 100644
index 00000000..84034b88
--- /dev/null
+++ b/pkg/guidednavigation/converter/xhtml_test.go
@@ -0,0 +1,139 @@
+package converter
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+)
+
+const xhtmlSample = `
+
+
+Test
+
+Café after
+Bonjour
+
+`
+
+func TestParseXHTMLSelfClosingSpan(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(xhtmlSample))
+ require.NoError(t, err)
+
+ body := childOfType(doc, atom.Body, true)
+ require.NotNil(t, body)
+
+ span := childOfType(doc, atom.Span, true)
+ require.NotNil(t, span)
+ // The self-closing span must be empty, not swallow the text after it
+ assert.Nil(t, span.FirstChild, "self-closing span should have no children")
+ require.NotNil(t, span.NextSibling)
+ assert.Equal(t, "after", span.NextSibling.Data)
+}
+
+// Attributes must use the same representation html.Parse produces:
+// the whole qualified name in Key, and an empty Namespace.
+func TestParseXHTMLAttributeCompatibility(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(xhtmlSample))
+ require.NoError(t, err)
+
+ span := childOfType(doc, atom.Span, true)
+ require.NotNil(t, span)
+ assert.Equal(t, "pagebreak", getAttr(span, "epub:type"))
+ assert.Equal(t, "4", getAttr(span, "title"))
+
+ htmlNode := childOfType(doc, atom.Html, true)
+ require.NotNil(t, htmlNode)
+ assert.Equal(t, "en", getAttr(htmlNode, "xml:lang"))
+ assert.Equal(t, "http://www.idpf.org/2007/ops", getAttr(htmlNode, "xmlns:epub"))
+
+ for _, attr := range span.Attr {
+ assert.Empty(t, attr.Namespace, "attribute namespaces must be empty for html.Parse compatibility")
+ }
+}
+
+func TestParseXHTMLEntities(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(xhtmlSample))
+ require.NoError(t, err)
+
+ p := childOfType(doc, atom.P, true)
+ require.NotNil(t, p)
+ require.NotNil(t, p.FirstChild)
+ assert.Equal(t, "Café ", p.FirstChild.Data)
+}
+
+func TestParseXHTMLForeignContent(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(`
+
+ Icon
+ x
+ `))
+ require.NoError(t, err)
+
+ svg := childOfType(doc, atom.Svg, true)
+ require.NotNil(t, svg)
+ assert.Equal(t, "svg", svg.Namespace)
+
+ math := childOfType(doc, atom.Math, true)
+ require.NotNil(t, math)
+ assert.Equal(t, "math", math.Namespace)
+ assert.Equal(t, "x", getAttr(math, "alttext"))
+}
+
+func TestParseXHTMLCDATAAndComments(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(`
+
+ before
+ `))
+ require.NoError(t, err)
+
+ p := childOfType(doc, atom.P, true)
+ require.NotNil(t, p)
+ require.NotNil(t, p.FirstChild)
+ // Comments are dropped; CDATA merges with adjacent text
+ assert.Equal(t, "before & cdata", p.FirstChild.Data)
+ assert.Equal(t, html.TextNode, p.FirstChild.Type)
+ assert.Nil(t, p.FirstChild.NextSibling)
+}
+
+// Namespace prefixes keep their case consistently between the xmlns declaration
+// and reconstructed attribute names.
+func TestParseXHTMLUppercasePrefix(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(`
+
+ `))
+ require.NoError(t, err)
+
+ span := childOfType(doc, atom.Span, true)
+ require.NotNil(t, span)
+ assert.Equal(t, "pagebreak", getAttr(span, "EPUB:type"))
+
+ htmlNode := childOfType(doc, atom.Html, true)
+ require.NotNil(t, htmlNode)
+ assert.Equal(t, "http://www.idpf.org/2007/ops", getAttr(htmlNode, "xmlns:EPUB"))
+
+ // The role extraction resolves the prefix through the declaration
+ roles := ExtractNodeRoles(span)
+ assert.Contains(t, roles, guidednavigation.RolePagebreak)
+}
+
+// The input is already UTF-8, whatever encoding the XML declaration announces.
+func TestParseXHTMLForeignEncodingDeclaration(t *testing.T) {
+ doc, err := ParseXHTML(strings.NewReader(`
+ Café
`))
+ require.NoError(t, err)
+ p := childOfType(doc, atom.P, true)
+ require.NotNil(t, p)
+ assert.Equal(t, "Café", p.FirstChild.Data)
+}
+
+func TestParseXHTMLMalformed(t *testing.T) {
+ // An unclosed element at EOF is a hard error, triggering the html.Parse fallback in Do
+ _, err := ParseXHTML(strings.NewReader(`Unclosed`))
+ require.Error(t, err)
+}
diff --git a/pkg/guidednavigation/fragments.go b/pkg/guidednavigation/fragments.go
new file mode 100644
index 00000000..94ac54c3
--- /dev/null
+++ b/pkg/guidednavigation/fragments.go
@@ -0,0 +1,588 @@
+package guidednavigation
+
+import (
+ "math"
+ nurl "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/readium/go-toolkit/pkg/util/url"
+)
+
+// Media reference utilities, per the "Media References" section of the
+// Guided Navigation specification:
+//
+// - Audio/video: temporal media fragments, e.g. "audio.mp3#t=10,20"
+// https://www.w3.org/TR/media-frags/#naming-time
+// - Images/video: rectangular spatial media fragments, e.g. "page.jpg#xywh=percent:10,10,20,20"
+// https://www.w3.org/TR/media-frags/#naming-space
+// - Images: polygonal regions, e.g. "page.jpg#xyn=percent:0,50,50,0,100,50"
+// https://idpf.org/epub/renditions/region-nav/#sec-3.5.1
+// - Text: fragment ids ("chapter.html#par1") and text fragments
+// https://wicg.github.io/scroll-to-text-fragment/
+
+// The unit of a spatial fragment's coordinates.
+type RegionUnit string
+
+const (
+ RegionUnitPixel RegionUnit = "pixel"
+ RegionUnitPercent RegionUnit = "percent"
+)
+
+// Clip is the temporal fragment of a media resource (the "t" dimension of a
+// media fragment, in Normal Play Time format).
+type Clip struct {
+ Begin *time.Duration // Offset from the start of the media, or nil for its beginning.
+ End *time.Duration // Offset from the start of the media, or nil for its end.
+}
+
+// MediaFragment returns the "t" media fragment dimension denoting the clip,
+// e.g. "t=10.5,20", or an empty string for an unbounded clip. It can be used
+// as the fragment of a media resource URL.
+func (c Clip) MediaFragment() string {
+ if c.Begin == nil && c.End == nil {
+ return ""
+ }
+ var sb strings.Builder
+ sb.WriteString("t=")
+ if c.Begin != nil {
+ sb.WriteString(FormatNPTTime(*c.Begin))
+ }
+ if c.End != nil {
+ sb.WriteByte(',')
+ sb.WriteString(FormatNPTTime(*c.End))
+ }
+ return sb.String()
+}
+
+// FormatNPTTime formats a duration as a Normal Play Time value in seconds,
+// rounded to the millisecond, e.g. "123.4".
+// https://www.w3.org/TR/media-frags/#npttimedef
+func FormatNPTTime(d time.Duration) string {
+ if d < 0 {
+ d = 0
+ }
+ seconds := math.Round(d.Seconds()*1000) / 1000
+ return strconv.FormatFloat(seconds, 'f', -1, 64)
+}
+
+// Region is a rectangular spatial fragment of a resource
+// (the "xywh" dimension of a media fragment).
+type Region struct {
+ Unit RegionUnit
+ X float64
+ Y float64
+ Width float64
+ Height float64
+}
+
+// A point of a polygonal region.
+type Point struct {
+ X float64
+ Y float64
+}
+
+// Polygon is a polygonal region of a resource, as defined by EPUB Region-Based
+// Navigation (the "xyn" fragment parameter).
+type Polygon struct {
+ Unit RegionUnit
+ Points []Point // At least three points.
+}
+
+// TextFragment is a text fragment of a textual resource, as defined by the
+// URL Fragment Text Directives specification (":~:text=" fragments).
+type TextFragment struct {
+ Prefix string // Text immediately before the target, or empty.
+ TextStart string // Start of the target text.
+ TextEnd string // End of the target text when it's a range, or empty.
+ Suffix string // Text immediately after the target, or empty.
+}
+
+// AudioFile returns the audio resource referenced by the object, without its media fragment.
+func (o GuidedNavigationObject) AudioFile() url.URL {
+ return refFile(o.AudioRef)
+}
+
+// AudioClip returns the temporal fragment of the object's audio reference,
+// or nil when the whole resource is referenced.
+func (o GuidedNavigationObject) AudioClip() *Clip {
+ return refClip(o.AudioRef)
+}
+
+// VideoFile returns the video resource referenced by the object, without its media fragment.
+func (o GuidedNavigationObject) VideoFile() url.URL {
+ return refFile(o.VideoRef)
+}
+
+// VideoClip returns the temporal fragment of the object's video reference,
+// or nil when no time range is referenced.
+func (o GuidedNavigationObject) VideoClip() *Clip {
+ return refClip(o.VideoRef)
+}
+
+// VideoRegion returns the rectangular fragment of the object's video reference,
+// or nil when no region is referenced.
+func (o GuidedNavigationObject) VideoRegion() *Region {
+ return refRegion(o.VideoRef)
+}
+
+// ImageFile returns the image referenced by the object, without its media fragment.
+func (o GuidedNavigationObject) ImageFile() url.URL {
+ return refFile(o.ImgRef)
+}
+
+// ImageRegion returns the rectangular fragment of the object's image reference,
+// or nil when no rectangular region is referenced.
+func (o GuidedNavigationObject) ImageRegion() *Region {
+ return refRegion(o.ImgRef)
+}
+
+// ImagePolygon returns the polygonal fragment of the object's image reference,
+// or nil when no polygonal region is referenced.
+func (o GuidedNavigationObject) ImagePolygon() *Polygon {
+ return refPolygon(o.ImgRef)
+}
+
+// TextFile returns the textual resource referenced by the object, without its fragment.
+func (o GuidedNavigationObject) TextFile() url.URL {
+ return refFile(o.TextRef)
+}
+
+// TextFragmentID returns the fragment id of the object's text reference
+// (e.g. "par1" for "chapter.html#par1"), or an empty string when there is none.
+func (o GuidedNavigationObject) TextFragmentID() string {
+ return refFragmentID(o.TextRef)
+}
+
+// TextFragments returns the text fragments of the object's text reference
+// (e.g. "chapter.html#:~:text=some,text"), or nil when there are none.
+func (o GuidedNavigationObject) TextFragments() []TextFragment {
+ return refTextFragments(o.TextRef)
+}
+
+// AudioFile returns the audio resource referenced by the description, without its media fragment.
+func (d GuidedNavigationDescription) AudioFile() url.URL {
+ return refFile(d.AudioRef)
+}
+
+// AudioClip returns the temporal fragment of the description's audio reference,
+// or nil when the whole resource is referenced.
+func (d GuidedNavigationDescription) AudioClip() *Clip {
+ return refClip(d.AudioRef)
+}
+
+// VideoFile returns the video resource referenced by the description, without its media fragment.
+func (d GuidedNavigationDescription) VideoFile() url.URL {
+ return refFile(d.VideoRef)
+}
+
+// VideoClip returns the temporal fragment of the description's video reference,
+// or nil when no time range is referenced.
+func (d GuidedNavigationDescription) VideoClip() *Clip {
+ return refClip(d.VideoRef)
+}
+
+// VideoRegion returns the rectangular fragment of the description's video reference,
+// or nil when no region is referenced.
+func (d GuidedNavigationDescription) VideoRegion() *Region {
+ return refRegion(d.VideoRef)
+}
+
+// ImageFile returns the image referenced by the description, without its media fragment.
+func (d GuidedNavigationDescription) ImageFile() url.URL {
+ return refFile(d.ImgRef)
+}
+
+// ImageRegion returns the rectangular fragment of the description's image reference,
+// or nil when no rectangular region is referenced.
+func (d GuidedNavigationDescription) ImageRegion() *Region {
+ return refRegion(d.ImgRef)
+}
+
+// ImagePolygon returns the polygonal fragment of the description's image reference,
+// or nil when no polygonal region is referenced.
+func (d GuidedNavigationDescription) ImagePolygon() *Polygon {
+ return refPolygon(d.ImgRef)
+}
+
+// TextFile returns the textual resource referenced by the description, without its fragment.
+func (d GuidedNavigationDescription) TextFile() url.URL {
+ return refFile(d.TextRef)
+}
+
+// TextFragmentID returns the fragment id of the description's text reference,
+// or an empty string when there is none.
+func (d GuidedNavigationDescription) TextFragmentID() string {
+ return refFragmentID(d.TextRef)
+}
+
+// TextFragments returns the text fragments of the description's text reference,
+// or nil when there are none.
+func (d GuidedNavigationDescription) TextFragments() []TextFragment {
+ return refTextFragments(d.TextRef)
+}
+
+// The reference without its fragment.
+func refFile(ref url.URL) url.URL {
+ if ref == nil {
+ return nil
+ }
+ return ref.RemoveFragment()
+}
+
+type fragmentDimension struct {
+ name string
+ value string
+}
+
+// The name-value dimensions of a media fragment, e.g. "t=10,20&xywh=0,0,5,5",
+// percent-decoded after splitting, in document order.
+// https://www.w3.org/TR/media-frags/#processing-name-value-lists
+func fragmentDimensions(ref url.URL) []fragmentDimension {
+ if ref == nil {
+ return nil
+ }
+ fragment := ref.Raw().EscapedFragment()
+ if fragment == "" {
+ return nil
+ }
+ var res []fragmentDimension
+ for pair := range strings.SplitSeq(fragment, "&") {
+ name, value, found := strings.Cut(pair, "=")
+ if !found || name == "" {
+ continue
+ }
+ var err error
+ if name, err = nurl.PathUnescape(name); err != nil {
+ continue
+ }
+ if value, err = nurl.PathUnescape(value); err != nil {
+ continue
+ }
+ res = append(res, fragmentDimension{name: name, value: value})
+ }
+ return res
+}
+
+// The parsed values of a fragment dimension: only the last valid occurrence
+// of a dimension is interpreted, the others are ignored.
+// https://www.w3.org/TR/media-frags/#error-uri-general
+func lastValidDimension[T any](ref url.URL, name string, parse func(string) *T) *T {
+ dims := fragmentDimensions(ref)
+ for i := len(dims) - 1; i >= 0; i-- {
+ if dims[i].name != name {
+ continue
+ }
+ if res := parse(dims[i].value); res != nil {
+ return res
+ }
+ }
+ return nil
+}
+
+func refClip(ref url.URL) *Clip {
+ return lastValidDimension(ref, "t", parseClipValue)
+}
+
+func parseClipValue(value string) *Clip {
+ // Only the Normal Play Time format is supported, which is also the default:
+ // https://www.w3.org/TR/media-frags/#naming-time
+ if prefix, rest, found := strings.Cut(value, ":"); found {
+ if prefix == "npt" {
+ value = rest
+ } else if isAlpha(prefix) {
+ // A time format like "smpte" or "clock" (unsupported). Anything else
+ // is a colon belonging to an hh:mm:ss time value.
+ return nil
+ }
+ }
+
+ begin, end, hasEnd := strings.Cut(value, ",")
+ clip := &Clip{}
+ if begin != "" {
+ d, ok := parseNPTTime(begin)
+ if !ok {
+ return nil
+ }
+ clip.Begin = &d
+ }
+ if hasEnd {
+ if end == "" {
+ return nil
+ }
+ d, ok := parseNPTTime(end)
+ if !ok {
+ return nil
+ }
+ clip.End = &d
+ }
+ if clip.Begin == nil && clip.End == nil {
+ return nil
+ }
+ // The begin time (0 when omitted) must precede the end time
+ if clip.End != nil {
+ var begin time.Duration
+ if clip.Begin != nil {
+ begin = *clip.Begin
+ }
+ if *clip.End <= begin {
+ return nil
+ }
+ }
+ return clip
+}
+
+func isAlpha(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
+ return false
+ }
+ }
+ return true
+}
+
+// Parses a value of the form 1*DIGIT ["." *DIGIT], the only number format the
+// media fragment grammars allow. strconv.ParseFloat alone would also accept
+// signs, exponents, hexadecimal floats, "inf" and "nan".
+func parseDecimal(s string) (float64, bool) {
+ dot := false
+ for i, r := range s {
+ if r == '.' {
+ if dot || i == 0 {
+ return 0, false
+ }
+ dot = true
+ continue
+ }
+ if r < '0' || r > '9' {
+ return 0, false
+ }
+ }
+ if s == "" || s == "." {
+ return 0, false
+ }
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return 0, false
+ }
+ return v, true
+}
+
+// The largest amount of seconds representable as a time.Duration.
+const maxClipSeconds = float64(int64(^uint64(0)>>1) / int64(time.Second))
+
+// Parses a Normal Play Time value: seconds ("123.45"), "mm:ss" or "hh:mm:ss",
+// with an optional fraction. https://www.w3.org/TR/media-frags/#npttimedef
+func parseNPTTime(s string) (time.Duration, bool) {
+ parts := strings.Split(s, ":")
+ if len(parts) > 3 {
+ return 0, false
+ }
+
+ // The last part holds the seconds, with an optional fraction
+ seconds, ok := parseDecimal(parts[len(parts)-1])
+ if !ok {
+ return 0, false
+ }
+ if len(parts) > 1 && seconds >= 60 {
+ return 0, false
+ }
+
+ for i, mul := range []float64{60, 3600} {
+ idx := len(parts) - 2 - i
+ if idx < 0 {
+ break
+ }
+ v, err := strconv.ParseUint(parts[idx], 10, 32)
+ if err != nil {
+ return 0, false
+ }
+ if i == 0 && len(parts) > 1 && v >= 60 {
+ // Minutes are bound to 0-59
+ return 0, false
+ }
+ seconds += float64(v) * mul
+ }
+
+ if seconds > maxClipSeconds {
+ return 0, false
+ }
+ return time.Duration(seconds * float64(time.Second)), true
+}
+
+// Parses the unit prefix of a spatial fragment value, defaulting to pixels.
+func parseRegionUnit(value string) (RegionUnit, string, bool) {
+ if prefix, rest, found := strings.Cut(value, ":"); found {
+ switch RegionUnit(prefix) {
+ case RegionUnitPixel:
+ return RegionUnitPixel, rest, true
+ case RegionUnitPercent:
+ return RegionUnitPercent, rest, true
+ default:
+ return "", "", false
+ }
+ }
+ return RegionUnitPixel, value, true
+}
+
+// Parses a comma-separated list of non-negative decimal values.
+func parseCoordinates(value string, count int) ([]float64, bool) {
+ parts := strings.Split(value, ",")
+ if count > 0 && len(parts) != count {
+ return nil, false
+ }
+ res := make([]float64, len(parts))
+ for i, part := range parts {
+ v, ok := parseDecimal(part)
+ if !ok {
+ return nil, false
+ }
+ res[i] = v
+ }
+ return res, true
+}
+
+func refRegion(ref url.URL) *Region {
+ return lastValidDimension(ref, "xywh", parseRegionValue)
+}
+
+func parseRegionValue(value string) *Region {
+ unit, value, ok := parseRegionUnit(value)
+ if !ok {
+ return nil
+ }
+ coords, ok := parseCoordinates(value, 4)
+ if !ok {
+ return nil
+ }
+ return &Region{
+ Unit: unit,
+ X: coords[0],
+ Y: coords[1],
+ Width: coords[2],
+ Height: coords[3],
+ }
+}
+
+func refPolygon(ref url.URL) *Polygon {
+ return lastValidDimension(ref, "xyn", parsePolygonValue)
+}
+
+func parsePolygonValue(value string) *Polygon {
+ unit, value, ok := parseRegionUnit(value)
+ if !ok {
+ return nil
+ }
+ coords, ok := parseCoordinates(value, -1)
+ if !ok || len(coords) < 6 || len(coords)%2 != 0 {
+ // A polygon needs at least three x,y pairs
+ return nil
+ }
+ res := &Polygon{
+ Unit: unit,
+ Points: make([]Point, len(coords)/2),
+ }
+ for i := range res.Points {
+ res.Points[i] = Point{X: coords[i*2], Y: coords[i*2+1]}
+ }
+ return res
+}
+
+// The delimiter of a URL fragment text directive.
+const textDirectiveDelimiter = ":~:"
+
+func refFragmentID(ref url.URL) string {
+ if ref == nil {
+ return ""
+ }
+ fragment := ref.Raw().EscapedFragment()
+ // A fragment directive doesn't belong to the fragment id preceding it
+ fragment, _, _ = strings.Cut(fragment, textDirectiveDelimiter)
+ if decoded, err := nurl.PathUnescape(fragment); err == nil {
+ return decoded
+ }
+ return fragment
+}
+
+func refTextFragments(ref url.URL) []TextFragment {
+ if ref == nil {
+ return nil
+ }
+ // The directive must be parsed in percent-encoded form: its delimiters
+ // (",", "-", "&") are only syntax when they appear unencoded
+ _, directive, found := strings.Cut(ref.Raw().EscapedFragment(), textDirectiveDelimiter)
+ if !found {
+ return nil
+ }
+
+ var res []TextFragment
+ for part := range strings.SplitSeq(directive, "&") {
+ value, found := strings.CutPrefix(part, "text=")
+ if !found {
+ continue
+ }
+ if fragment, ok := parseTextFragment(value); ok {
+ res = append(res, fragment)
+ }
+ }
+ return res
+}
+
+// Parses a text directive value: [prefix-,]textStart[,textEnd][,-suffix]
+// https://wicg.github.io/scroll-to-text-fragment/#syntax
+func parseTextFragment(value string) (fragment TextFragment, ok bool) {
+ parts := strings.Split(value, ",")
+
+ // Prefix and suffix tokens are extracted unconditionally: an invalid
+ // remainder rejects the whole directive
+ if prefix, found := strings.CutSuffix(parts[0], "-"); found {
+ if !validTextDirectiveTerm(prefix) {
+ return TextFragment{}, false
+ }
+ fragment.Prefix = decodeTextDirectiveTerm(prefix)
+ parts = parts[1:]
+ }
+ if len(parts) > 0 {
+ if suffix, found := strings.CutPrefix(parts[len(parts)-1], "-"); found {
+ if !validTextDirectiveTerm(suffix) {
+ return TextFragment{}, false
+ }
+ fragment.Suffix = decodeTextDirectiveTerm(suffix)
+ parts = parts[:len(parts)-1]
+ }
+ }
+
+ switch len(parts) {
+ case 1:
+ fragment.TextStart = decodeTextDirectiveTerm(parts[0])
+ case 2:
+ fragment.TextStart = decodeTextDirectiveTerm(parts[0])
+ fragment.TextEnd = decodeTextDirectiveTerm(parts[1])
+ if !validTextDirectiveTerm(parts[1]) {
+ return TextFragment{}, false
+ }
+ default:
+ return TextFragment{}, false
+ }
+ if !validTextDirectiveTerm(parts[0]) {
+ return TextFragment{}, false
+ }
+ return fragment, true
+}
+
+// A text directive term must be non-empty, with any dash it contains
+// percent-encoded to keep it distinguishable from the prefix/suffix markers.
+func validTextDirectiveTerm(s string) bool {
+ return s != "" && !strings.Contains(s, "-")
+}
+
+func decodeTextDirectiveTerm(s string) string {
+ if decoded, err := nurl.PathUnescape(s); err == nil {
+ return decoded
+ }
+ return s
+}
diff --git a/pkg/guidednavigation/fragments_test.go b/pkg/guidednavigation/fragments_test.go
new file mode 100644
index 00000000..4e281907
--- /dev/null
+++ b/pkg/guidednavigation/fragments_test.go
@@ -0,0 +1,281 @@
+package guidednavigation
+
+import (
+ "testing"
+ "time"
+
+ "github.com/readium/go-toolkit/pkg/util/url"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func objWithAudioRef(t *testing.T, ref string) GuidedNavigationObject {
+ t.Helper()
+ return GuidedNavigationObject{AudioRef: url.MustURLFromString(ref)}
+}
+
+func duration(seconds float64) *time.Duration {
+ d := time.Duration(seconds * float64(time.Second))
+ return &d
+}
+
+func TestAudioClip(t *testing.T) {
+ tests := []struct {
+ name string
+ ref string
+ clip *Clip
+ }{
+ {"begin and end", "chapter1.mp3#t=0,20", &Clip{Begin: duration(0), End: duration(20)}},
+ {"begin only", "chapter1.mp3#t=10", &Clip{Begin: duration(10)}},
+ {"end only", "chapter1.mp3#t=,20", &Clip{End: duration(20)}},
+ {"npt prefix", "chapter1.mp3#t=npt:10,20", &Clip{Begin: duration(10), End: duration(20)}},
+ {"fractional seconds", "chapter1.mp3#t=10.5,21.25", &Clip{Begin: duration(10.5), End: duration(21.25)}},
+ {"mm:ss", "chapter1.mp3#t=1:30,2:00", &Clip{Begin: duration(90), End: duration(120)}},
+ {"hh:mm:ss with fraction", "chapter1.mp3#t=0:02:00.5,0:02:01", &Clip{Begin: duration(120.5), End: duration(121)}},
+ {"end only with colons", "chapter1.mp3#t=,0:02:00", &Clip{End: duration(120)}},
+ {"other dimensions around", "chapter1.mp3#u=v&t=5,6&x=y", &Clip{Begin: duration(5), End: duration(6)}},
+ {"last occurrence wins", "chapter1.mp3#t=1,2&t=5,6", &Clip{Begin: duration(5), End: duration(6)}},
+
+ {"no fragment", "chapter1.mp3", nil},
+ {"no t dimension", "chapter1.mp3#xywh=0,0,1,1", nil},
+ {"empty value", "chapter1.mp3#t=", nil},
+ {"end before begin", "chapter1.mp3#t=20,10", nil},
+ {"zero length", "chapter1.mp3#t=5,5", nil},
+ {"smpte unsupported", "chapter1.mp3#t=smpte:00:01:00,00:02:00", nil},
+ {"clock unsupported", "chapter1.mp3#t=clock:2026-07-01T00:00:00Z", nil},
+ {"garbage", "chapter1.mp3#t=abc", nil},
+ {"negative", "chapter1.mp3#t=-5,10", nil},
+ {"seconds out of range in mm:ss", "chapter1.mp3#t=1:75", nil},
+ {"minutes out of range in hh:mm:ss", "chapter1.mp3#t=1:75:00", nil},
+ {"minutes out of range in mm:ss", "chapter1.mp3#t=75:00", nil},
+ {"trailing comma", "chapter1.mp3#t=10,", nil},
+
+ // The NPT grammar allows nothing but digits and a fraction
+ {"nan", "chapter1.mp3#t=nan,10", nil},
+ {"inf", "chapter1.mp3#t=inf", nil},
+ {"exponent", "chapter1.mp3#t=1e3", nil},
+ {"hex float", "chapter1.mp3#t=0x1p4", nil},
+ {"explicit sign", "chapter1.mp3#t=+5", nil},
+ {"bare fraction", "chapter1.mp3#t=.5", nil},
+ {"duration overflow", "chapter1.mp3#t=10000000000", nil},
+ {"zero-length end-only", "chapter1.mp3#t=,0", nil},
+
+ // Percent-encoded dimensions are decoded before parsing
+ {"encoded npt prefix", "chapter1.mp3#t=npt%3A10,20", &Clip{Begin: duration(10), End: duration(20)}},
+ {"encoded comma", "chapter1.mp3#t=10%2C20", &Clip{Begin: duration(10), End: duration(20)}},
+ // Only the last VALID occurrence of a dimension counts
+ {"invalid last occurrence ignored", "chapter1.mp3#t=10,20&t=abc", &Clip{Begin: duration(10), End: duration(20)}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.clip, objWithAudioRef(t, tt.ref).AudioClip())
+ })
+ }
+}
+
+func TestClipMediaFragment(t *testing.T) {
+ assert.Equal(t, "", Clip{}.MediaFragment())
+ assert.Equal(t, "t=10", Clip{Begin: duration(10)}.MediaFragment())
+ assert.Equal(t, "t=,20", Clip{End: duration(20)}.MediaFragment())
+ assert.Equal(t, "t=10.5,20", Clip{Begin: duration(10.5), End: duration(20)}.MediaFragment())
+ // Rounded to the millisecond, without trailing zeros
+ assert.Equal(t, "t=62.011,1647.2", Clip{Begin: duration(62.0109), End: duration(1647.2)}.MediaFragment())
+
+ // A formatted clip parses back to the same value
+ clips := []Clip{
+ {Begin: duration(10)},
+ {End: duration(20)},
+ {Begin: duration(0), End: duration(20)},
+ {Begin: duration(90.25), End: duration(120.5)},
+ }
+ for _, clip := range clips {
+ ref := url.MustURLFromString("chapter1.mp3#" + clip.MediaFragment())
+ parsed := GuidedNavigationObject{AudioRef: ref}.AudioClip()
+ require.NotNil(t, parsed, clip.MediaFragment())
+ assert.Equal(t, &clip, parsed, clip.MediaFragment())
+ }
+}
+
+func TestFormatNPTTime(t *testing.T) {
+ assert.Equal(t, "0", FormatNPTTime(0))
+ assert.Equal(t, "0", FormatNPTTime(-5*time.Second))
+ assert.Equal(t, "90.5", FormatNPTTime(90*time.Second+500*time.Millisecond))
+ assert.Equal(t, "0.001", FormatNPTTime(1400*time.Microsecond))
+}
+
+func TestClipOfVideoRef(t *testing.T) {
+ obj := GuidedNavigationObject{VideoRef: url.MustURLFromString("movie.mp4#t=30,60")}
+ assert.Equal(t, &Clip{Begin: duration(30), End: duration(60)}, obj.VideoClip())
+ assert.Nil(t, obj.AudioClip())
+}
+
+func TestAudioFile(t *testing.T) {
+ obj := objWithAudioRef(t, "audio/chapter1.mp3#t=0,20")
+ assert.Equal(t, "audio/chapter1.mp3", obj.AudioFile().String())
+ // The original reference must not lose its fragment
+ assert.Equal(t, "audio/chapter1.mp3#t=0,20", obj.AudioRef.String())
+
+ // Without a fragment the reference is returned as-is
+ obj = objWithAudioRef(t, "audio/chapter1.mp3")
+ assert.Equal(t, "audio/chapter1.mp3", obj.AudioFile().String())
+
+ assert.Nil(t, GuidedNavigationObject{}.AudioFile())
+}
+
+func TestImageRegion(t *testing.T) {
+ tests := []struct {
+ name string
+ ref string
+ region *Region
+ }{
+ // Example from the specification's README
+ {"percent", "page10.jpg#xywh=percent:10,10,20,20",
+ &Region{Unit: RegionUnitPercent, X: 10, Y: 10, Width: 20, Height: 20}},
+ // The comics example uses decimal percentages
+ {"decimal percent", "page1.jpg#xywh=percent:4.1,50.3,91.8,21.5",
+ &Region{Unit: RegionUnitPercent, X: 4.1, Y: 50.3, Width: 91.8, Height: 21.5}},
+ {"explicit pixel", "page.jpg#xywh=pixel:10,20,30,40",
+ &Region{Unit: RegionUnitPixel, X: 10, Y: 20, Width: 30, Height: 40}},
+ {"default pixel", "page.jpg#xywh=10,20,30,40",
+ &Region{Unit: RegionUnitPixel, X: 10, Y: 20, Width: 30, Height: 40}},
+
+ {"encoded unit prefix", "page.jpg#xywh=percent%3A10,10,20,20",
+ &Region{Unit: RegionUnitPercent, X: 10, Y: 10, Width: 20, Height: 20}},
+
+ {"no fragment", "page.jpg", nil},
+ {"wrong dimension", "page.jpg#t=1,2", nil},
+ {"bad unit", "page.jpg#xywh=inch:1,2,3,4", nil},
+ {"too few values", "page.jpg#xywh=1,2,3", nil},
+ {"too many values", "page.jpg#xywh=1,2,3,4,5", nil},
+ {"negative value", "page.jpg#xywh=-1,2,3,4", nil},
+ {"garbage", "page.jpg#xywh=a,b,c,d", nil},
+ {"nan", "page.jpg#xywh=nan,0,1,1", nil},
+ {"inf", "page.jpg#xywh=inf,0,10,10", nil},
+ {"exponent", "page.jpg#xywh=0,0,1e2,50", nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ obj := GuidedNavigationObject{ImgRef: url.MustURLFromString(tt.ref)}
+ assert.Equal(t, tt.region, obj.ImageRegion())
+ })
+ }
+}
+
+func TestImagePolygon(t *testing.T) {
+ // Example from EPUB Region-Based Navigation
+ obj := GuidedNavigationObject{ImgRef: url.MustURLFromString("page05.xhtml#xyn=percent:0,50,50,0,100,50")}
+ require.NotNil(t, obj.ImagePolygon())
+ assert.Equal(t, &Polygon{
+ Unit: RegionUnitPercent,
+ Points: []Point{{X: 0, Y: 50}, {X: 50, Y: 0}, {X: 100, Y: 50}},
+ }, obj.ImagePolygon())
+
+ // Pixels by default
+ obj = GuidedNavigationObject{ImgRef: url.MustURLFromString("page.jpg#xyn=0,0,10,0,10,10,0,10")}
+ polygon := obj.ImagePolygon()
+ require.NotNil(t, polygon)
+ assert.Equal(t, RegionUnitPixel, polygon.Unit)
+ assert.Len(t, polygon.Points, 4)
+
+ for name, ref := range map[string]string{
+ "odd number of values": "page.jpg#xyn=percent:0,50,50",
+ "fewer than 3 points": "page.jpg#xyn=percent:0,50,50,0",
+ "no fragment": "page.jpg",
+ "garbage": "page.jpg#xyn=percent:a,b,c,d,e,f",
+ } {
+ t.Run(name, func(t *testing.T) {
+ obj := GuidedNavigationObject{ImgRef: url.MustURLFromString(ref)}
+ assert.Nil(t, obj.ImagePolygon())
+ })
+ }
+}
+
+func TestTextFragmentID(t *testing.T) {
+ tests := []struct {
+ ref string
+ id string
+ }{
+ {"chapter1.html#start", "start"},
+ {"chapter1.html#par%201", "par 1"},
+ {"chapter1.html#id:~:text=highlighted", "id"},
+ {"chapter1.html#:~:text=highlighted", ""},
+ {"chapter1.html", ""},
+ }
+ for _, tt := range tests {
+ obj := GuidedNavigationObject{TextRef: url.MustURLFromString(tt.ref)}
+ assert.Equal(t, tt.id, obj.TextFragmentID(), tt.ref)
+ }
+ assert.Empty(t, GuidedNavigationObject{}.TextFragmentID())
+}
+
+func TestTextFragments(t *testing.T) {
+ // Example from the specification's README
+ obj := GuidedNavigationObject{TextRef: url.MustURLFromString(
+ "chapter1.html#:~:text=It%20is%20a%20truth,want%20of%20a%20wife")}
+ assert.Equal(t, []TextFragment{
+ {TextStart: "It is a truth", TextEnd: "want of a wife"},
+ }, obj.TextFragments())
+
+ // All four parts
+ obj = GuidedNavigationObject{TextRef: url.MustURLFromString(
+ "chapter1.html#:~:text=before-,start%20text,end%20text,-after")}
+ assert.Equal(t, []TextFragment{
+ {Prefix: "before", TextStart: "start text", TextEnd: "end text", Suffix: "after"},
+ }, obj.TextFragments())
+
+ // Start only, with a percent-encoded comma and dash kept as content
+ obj = GuidedNavigationObject{TextRef: url.MustURLFromString(
+ "chapter1.html#:~:text=one%2C%20two%2Dthree")}
+ assert.Equal(t, []TextFragment{
+ {TextStart: "one, two-three"},
+ }, obj.TextFragments())
+
+ // Multiple directives
+ obj = GuidedNavigationObject{TextRef: url.MustURLFromString(
+ "chapter1.html#:~:text=first&text=second")}
+ assert.Equal(t, []TextFragment{
+ {TextStart: "first"},
+ {TextStart: "second"},
+ }, obj.TextFragments())
+
+ // Not text fragments
+ for _, ref := range []string{
+ "chapter1.html#start",
+ "chapter1.html",
+ "chapter1.html#:~:unknown=x",
+ } {
+ obj := GuidedNavigationObject{TextRef: url.MustURLFromString(ref)}
+ assert.Nil(t, obj.TextFragments(), ref)
+ }
+
+ // Invalid directives, per the WICG parsing algorithm
+ for _, ref := range []string{
+ "chapter1.html#:~:text=foo-", // Prefix without a start
+ "chapter1.html#:~:text=-bar", // Suffix without a start
+ "chapter1.html#:~:text=a-,-b", // Prefix and suffix without a start
+ "chapter1.html#:~:text=foo,", // Empty end
+ "chapter1.html#:~:text=two-three", // Unencoded dash in the start
+ "chapter1.html#:~:text=a,b,c,d,e", // Too many terms
+ "chapter1.html#:~:text=-,foo", // Empty prefix
+ } {
+ obj := GuidedNavigationObject{TextRef: url.MustURLFromString(ref)}
+ assert.Nil(t, obj.TextFragments(), ref)
+ }
+}
+
+func TestDescriptionMediaReferences(t *testing.T) {
+ // From the comics example of the specification
+ d := GuidedNavigationDescription{
+ AudioRef: url.MustURLFromString("audio/page1-panel1-description.mp3#t=0,5"),
+ ImgRef: url.MustURLFromString("page1.jpg#xywh=percent:4.1,4.1,91.8,44.5"),
+ }
+ assert.Equal(t, "audio/page1-panel1-description.mp3", d.AudioFile().String())
+ assert.Equal(t, &Clip{Begin: duration(0), End: duration(5)}, d.AudioClip())
+ assert.Equal(t, &Region{Unit: RegionUnitPercent, X: 4.1, Y: 4.1, Width: 91.8, Height: 44.5}, d.ImageRegion())
+ assert.Equal(t, "page1.jpg", d.ImageFile().String())
+ assert.Nil(t, d.TextFile())
+ assert.Empty(t, d.TextFragmentID())
+
+ d = GuidedNavigationDescription{VideoRef: url.MustURLFromString("clip.mp4#xywh=0,0,100,100")}
+ assert.Equal(t, &Region{Unit: RegionUnitPixel, X: 0, Y: 0, Width: 100, Height: 100}, d.VideoRegion())
+}
diff --git a/pkg/guidednavigation/guided_navigation.go b/pkg/guidednavigation/guided_navigation.go
new file mode 100644
index 00000000..b42c0bf0
--- /dev/null
+++ b/pkg/guidednavigation/guided_navigation.go
@@ -0,0 +1,241 @@
+package guidednavigation
+
+import (
+ "encoding/json"
+
+ "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/util/url"
+)
+
+// Readium Guided Navigation Document
+// https://readium.org/guided-navigation/schema/document.schema.json
+type GuidedNavigationDocument struct {
+ Links manifest.LinkList `json:"links,omitempty"` // References to other resources that are related to the current Guided Navigation Document.
+ Guided []GuidedNavigationObject `json:"guided"` // A sequence of resources and/or media fragments into these resources, meant to be presented sequentially to the user.
+}
+
+// Readium Guided Navigation Object
+// https://readium.org/guided-navigation/schema/object.schema.json
+type GuidedNavigationObject struct {
+ ID string `json:"id,omitempty"` // Identifier for the object, referenced from the SSML representation of a sibling text (e.g. ).
+ AudioRef url.URL `json:"audioref,omitempty"` // References an audio resource or a fragment of it.
+ ImgRef url.URL `json:"imgref,omitempty"` // References an image or a fragment of it.
+ TextRef url.URL `json:"textref,omitempty"` // References a textual resource or a fragment of it.
+ VideoRef url.URL `json:"videoref,omitempty"` // References a video resource or a fragment of it.
+ Text GuidedNavigationText `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object.
+ Role []GuidedNavigationRole `json:"role,omitempty"` // Convey the structural semantics of a publication
+ Children []GuidedNavigationObject `json:"children,omitempty"` // Items that are children of the containing Guided Navigation Object.
+ Description GuidedNavigationDescription `json:"description,omitempty"` // Text, audio or image description for the current Guided Navigation Object.
+}
+
+func (o GuidedNavigationObject) Empty() bool {
+ return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.VideoRef == nil &&
+ o.Text.Empty() && len(o.Children) == 0 && o.Description.Empty()
+}
+
+// ChildrenOnly reports whether the object carries no information of its own besides its children.
+// Such objects are transparent wrappers (e.g. from
or
) whose children can be
+// spliced into the parent's children.
+func (o GuidedNavigationObject) ChildrenOnly() bool {
+ return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.VideoRef == nil &&
+ o.Text.Empty() && len(o.Children) > 0 && o.Description.Empty() && len(o.Role) == 0 && o.ID == ""
+}
+
+func (o GuidedNavigationObject) TextOnly() bool {
+ return o.TextRef == nil && o.ImgRef == nil && o.AudioRef == nil && o.VideoRef == nil &&
+ !o.Text.Empty() && len(o.Children) == 0 && o.Description.Empty() && len(o.Role) == 0 && o.ID == ""
+}
+
+func (o GuidedNavigationObject) MarshalJSON() ([]byte, error) {
+ res := make(map[string]interface{})
+ if o.Empty() {
+ return json.Marshal(res)
+ }
+
+ if o.ID != "" {
+ res["id"] = o.ID
+ }
+ if o.TextRef != nil {
+ if s := o.TextRef.String(); s != "" {
+ res["textref"] = s
+ }
+ }
+ if o.ImgRef != nil {
+ if s := o.ImgRef.String(); s != "" {
+ res["imgref"] = s
+ }
+ }
+ if o.AudioRef != nil {
+ if s := o.AudioRef.String(); s != "" {
+ res["audioref"] = s
+ }
+ }
+ if o.VideoRef != nil {
+ if s := o.VideoRef.String(); s != "" {
+ res["videoref"] = s
+ }
+ }
+
+ if !o.Text.Empty() {
+ res["text"] = o.Text
+ }
+ if len(o.Role) > 0 {
+ res["role"] = o.Role
+ }
+ if len(o.Children) > 0 {
+ res["children"] = o.Children
+ }
+ if !o.Description.Empty() {
+ res["description"] = o.Description
+ }
+
+ return json.Marshal(res)
+}
+
+type GuidedNavigationText struct {
+ Plain string `json:"plain,omitempty"` // Plain text
+ SSML string `json:"ssml,omitempty"` // SSML markup
+ Language string `json:"language,omitempty"` // BCP-47 language tag
+}
+
+func (t GuidedNavigationText) Empty() bool {
+ return t.Plain == "" && t.SSML == "" && t.Language == ""
+}
+
+func (t *GuidedNavigationText) UnmarshalJSON(data []byte) error {
+ // Just plain text
+ var plain string
+ if err := json.Unmarshal(data, &plain); err == nil {
+ t.Plain = plain
+ return nil
+ }
+
+ type alias GuidedNavigationText
+ var obj alias
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return err
+ }
+ *t = GuidedNavigationText(obj)
+ return nil
+}
+
+func (t GuidedNavigationText) MarshalJSON() ([]byte, error) {
+ res := make(map[string]interface{})
+
+ if t.SSML == "" && t.Language == "" {
+ return json.Marshal(t.Plain)
+ }
+
+ if t.Plain != "" {
+ res["plain"] = t.Plain
+ }
+ if t.SSML != "" {
+ res["ssml"] = t.SSML
+ }
+ if t.Language != "" {
+ res["language"] = t.Language
+ }
+ return json.Marshal(res)
+}
+
+// Readium Guided Navigation Description Object
+// https://readium.org/guided-navigation/schema/description.schema.json
+// Serialized as a plain string when it only carries plain text.
+type GuidedNavigationDescription struct {
+ AudioRef url.URL `json:"audioref,omitempty"` // References an audio resource or a fragment of it.
+ ImgRef url.URL `json:"imgref,omitempty"` // References an image or a fragment of it.
+ TextRef url.URL `json:"textref,omitempty"` // References a textual resource or a fragment of it.
+ VideoRef url.URL `json:"videoref,omitempty"` // References a video resource or a fragment of it.
+ Text GuidedNavigationText `json:"text,omitempty"` // Textual description.
+}
+
+// NewTextDescription creates a description carrying only plain text.
+func NewTextDescription(text string) GuidedNavigationDescription {
+ return GuidedNavigationDescription{
+ Text: GuidedNavigationText{Plain: text},
+ }
+}
+
+func (d GuidedNavigationDescription) Empty() bool {
+ return d.AudioRef == nil && d.ImgRef == nil && d.TextRef == nil && d.VideoRef == nil && d.Text.Empty()
+}
+
+func (d *GuidedNavigationDescription) UnmarshalJSON(data []byte) error {
+ // Just plain text
+ var plain string
+ if err := json.Unmarshal(data, &plain); err == nil {
+ d.Text.Plain = plain
+ return nil
+ }
+
+ var obj struct {
+ AudioRef string `json:"audioref"`
+ ImgRef string `json:"imgref"`
+ TextRef string `json:"textref"`
+ VideoRef string `json:"videoref"`
+ Text GuidedNavigationText `json:"text"`
+ }
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return err
+ }
+ var res GuidedNavigationDescription
+ var err error
+ if obj.AudioRef != "" {
+ if res.AudioRef, err = url.URLFromString(obj.AudioRef); err != nil {
+ return err
+ }
+ }
+ if obj.ImgRef != "" {
+ if res.ImgRef, err = url.URLFromString(obj.ImgRef); err != nil {
+ return err
+ }
+ }
+ if obj.TextRef != "" {
+ if res.TextRef, err = url.URLFromString(obj.TextRef); err != nil {
+ return err
+ }
+ }
+ if obj.VideoRef != "" {
+ if res.VideoRef, err = url.URLFromString(obj.VideoRef); err != nil {
+ return err
+ }
+ }
+ res.Text = obj.Text
+ *d = res
+ return nil
+}
+
+func (d GuidedNavigationDescription) MarshalJSON() ([]byte, error) {
+ // Plain-text-only descriptions are serialized as a string, matching the
+ // examples of the specification.
+ if d.AudioRef == nil && d.ImgRef == nil && d.TextRef == nil && d.VideoRef == nil &&
+ d.Text.SSML == "" && d.Text.Language == "" {
+ return json.Marshal(d.Text.Plain)
+ }
+
+ res := make(map[string]interface{})
+ if d.AudioRef != nil {
+ if s := d.AudioRef.String(); s != "" {
+ res["audioref"] = s
+ }
+ }
+ if d.ImgRef != nil {
+ if s := d.ImgRef.String(); s != "" {
+ res["imgref"] = s
+ }
+ }
+ if d.TextRef != nil {
+ if s := d.TextRef.String(); s != "" {
+ res["textref"] = s
+ }
+ }
+ if d.VideoRef != nil {
+ if s := d.VideoRef.String(); s != "" {
+ res["videoref"] = s
+ }
+ }
+ if !d.Text.Empty() {
+ res["text"] = d.Text
+ }
+ return json.Marshal(res)
+}
diff --git a/pkg/guidednavigation/guided_navigation_test.go b/pkg/guidednavigation/guided_navigation_test.go
new file mode 100644
index 00000000..3c67c455
--- /dev/null
+++ b/pkg/guidednavigation/guided_navigation_test.go
@@ -0,0 +1,105 @@
+package guidednavigation
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/readium/go-toolkit/pkg/util/url"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTextMarshalPlainOnly(t *testing.T) {
+ b, err := json.Marshal(GuidedNavigationText{Plain: "Hello"})
+ require.NoError(t, err)
+ assert.JSONEq(t, `"Hello"`, string(b))
+}
+
+func TestTextMarshalFull(t *testing.T) {
+ b, err := json.Marshal(GuidedNavigationText{
+ Plain: "Hello",
+ SSML: "Hello ",
+ Language: "en",
+ })
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"plain": "Hello", "ssml": "Hello ", "language": "en"}`, string(b))
+}
+
+func TestTextUnmarshalString(t *testing.T) {
+ var text GuidedNavigationText
+ require.NoError(t, json.Unmarshal([]byte(`"Hello"`), &text))
+ assert.Equal(t, GuidedNavigationText{Plain: "Hello"}, text)
+}
+
+func TestTextUnmarshalObject(t *testing.T) {
+ var text GuidedNavigationText
+ require.NoError(t, json.Unmarshal([]byte(`{"plain": "Hi", "language": "en"}`), &text))
+ assert.Equal(t, GuidedNavigationText{Plain: "Hi", Language: "en"}, text)
+}
+
+func TestDescriptionMarshalPlainString(t *testing.T) {
+ b, err := json.Marshal(NewTextDescription("A cool image"))
+ require.NoError(t, err)
+ assert.JSONEq(t, `"A cool image"`, string(b))
+}
+
+func TestDescriptionMarshalObject(t *testing.T) {
+ b, err := json.Marshal(GuidedNavigationDescription{
+ AudioRef: url.MustURLFromString("description.mp3"),
+ Text: GuidedNavigationText{Plain: "A described panel"},
+ })
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"audioref": "description.mp3", "text": "A described panel"}`, string(b))
+}
+
+func TestDescriptionUnmarshalString(t *testing.T) {
+ var d GuidedNavigationDescription
+ require.NoError(t, json.Unmarshal([]byte(`"A cool image"`), &d))
+ assert.Equal(t, NewTextDescription("A cool image"), d)
+}
+
+func TestDescriptionUnmarshalObject(t *testing.T) {
+ var d GuidedNavigationDescription
+ require.NoError(t, json.Unmarshal([]byte(`{"audioref": "audio/page1.mp3", "text": "Pepper walks away."}`), &d))
+ assert.Equal(t, "audio/page1.mp3", d.AudioRef.String())
+ assert.Equal(t, "Pepper walks away.", d.Text.Plain)
+}
+
+func TestObjectMarshal(t *testing.T) {
+ obj := GuidedNavigationObject{
+ ID: "image1",
+ ImgRef: url.MustURLFromString("image.jpg"),
+ VideoRef: url.MustURLFromString("movie.mp4"),
+ Role: []GuidedNavigationRole{RoleImage},
+ Children: []GuidedNavigationObject{
+ {Text: GuidedNavigationText{Plain: "Child"}},
+ },
+ Description: NewTextDescription("Description"),
+ }
+ b, err := json.Marshal(obj)
+ require.NoError(t, err)
+ assert.JSONEq(t, `{
+ "id": "image1",
+ "imgref": "image.jpg",
+ "videoref": "movie.mp4",
+ "role": ["image"],
+ "children": [{"text": "Child"}],
+ "description": "Description"
+ }`, string(b))
+}
+
+func TestObjectPredicates(t *testing.T) {
+ assert.True(t, GuidedNavigationObject{}.Empty())
+ assert.True(t, GuidedNavigationObject{Role: []GuidedNavigationRole{RoleSeparator}}.Empty(),
+ "an object with only roles carries no content")
+ assert.False(t, GuidedNavigationObject{VideoRef: url.MustURLFromString("v.mp4")}.Empty())
+ assert.False(t, GuidedNavigationObject{Description: NewTextDescription("d")}.Empty())
+
+ wrapper := GuidedNavigationObject{Children: []GuidedNavigationObject{{Text: GuidedNavigationText{Plain: "t"}}}}
+ assert.True(t, wrapper.ChildrenOnly())
+ wrapper.ID = "id1"
+ assert.False(t, wrapper.ChildrenOnly(), "an object with an id is referenced from SSML and can't be dissolved")
+
+ assert.True(t, GuidedNavigationObject{Text: GuidedNavigationText{Plain: "t"}}.TextOnly())
+ assert.False(t, GuidedNavigationObject{Text: GuidedNavigationText{Plain: "t"}, ID: "x"}.TextOnly())
+}
diff --git a/pkg/guidednavigation/roles.go b/pkg/guidednavigation/roles.go
new file mode 100644
index 00000000..9ea68e38
--- /dev/null
+++ b/pkg/guidednavigation/roles.go
@@ -0,0 +1,89 @@
+package guidednavigation
+
+// Readium Guided Navigation Roles
+// https://github.com/readium/guided-navigation/blob/main/schema/roles.schema.json
+type GuidedNavigationRole string
+
+const (
+ RoleNone GuidedNavigationRole = "" // No role. Shouldn't be used
+ RoleAbstract GuidedNavigationRole = "abstract" // A short summary of the principle ideas, concepts and conclusions of the work, or of a section or excerpt within it.
+ RoleAcknowledgments GuidedNavigationRole = "acknowledgments" // A section or statement that acknowledges significant contributions by persons, organizations, governments and other entities to the realization of the work.
+ RoleAfterword GuidedNavigationRole = "afterword" // A closing statement from the author or a person of importance, typically providing insight into how the content came to be written, its significance, or related events that have transpired since its timeline.
+ RoleAppendix GuidedNavigationRole = "appendix" // A section of supplemental information located after the primary content that informs the content but is not central to it.
+ RoleArticle GuidedNavigationRole = "article" // Represents a self-contained composition in a document, page, application, or site, which is intended to be independently distributable or reusable
+ RoleAside GuidedNavigationRole = "aside" // Secondary or supplementary content.
+ RoleAudio GuidedNavigationRole = "audio" // Embedded sound content in a document.
+ RoleBacklink GuidedNavigationRole = "backlink" // A link that allows the user to return to a related location in the content (e.g., from a footnote to its reference or from a glossary definition to where a term is used).
+ RoleBibliography GuidedNavigationRole = "bibliography" // A list of external references cited in the work, which may be to print or digital sources.
+ RoleBiblioref GuidedNavigationRole = "biblioref" // A reference to a bibliography entry.
+ RoleBlockquote GuidedNavigationRole = "blockquote" // Represents a section that is quoted from another source.
+ RoleBody GuidedNavigationRole = "body" // Represents the content of an HTML document.
+ RoleCaption GuidedNavigationRole = "caption" // A caption for an image or a table.
+ RoleChapter GuidedNavigationRole = "chapter" // A major thematic section of content in a work.
+ RoleCell GuidedNavigationRole = "cell" // A single cell of tabular data or content.
+ RoleColumnHeader GuidedNavigationRole = "columnheader" // The header cell for a column, establishing a relationship between it and the other cells in the same column.
+ RoleColophon GuidedNavigationRole = "colophon" // A short section of production notes particular to the edition (e.g., describing the typeface used), often located at the end of a work.
+ RoleComplementary GuidedNavigationRole = "complementary" // A supporting section of the document, designed to be complementary to the main content at a similar level in the DOM hierarchy, but remains meaningful when separated from the main content.
+ RoleConclusion GuidedNavigationRole = "conclusion" // A concluding section or statement that summarizes the work or wraps up the narrative.
+ RoleCover GuidedNavigationRole = "cover" // An image that sets the mood or tone for the work and typically includes the title and author.
+ RoleCredit GuidedNavigationRole = "credit" // An acknowledgment of the source of integrated content from third-party sources, such as photos. Typically identifies the creator, copyright and any restrictions on reuse.
+ RoleCredits GuidedNavigationRole = "credits" // A collection of credits.
+ RoleDedication GuidedNavigationRole = "dedication" // An inscription at the front of the work, typically addressed in tribute to one or more persons close to the author.
+ RoleDefinition GuidedNavigationRole = "definition" // A definition of a term or concept.
+ RoleDetails GuidedNavigationRole = "details" // A disclosure widget that can be expanded.
+ RoleEndnotes GuidedNavigationRole = "endnotes" // A collection of notes at the end of a work or a section within it.
+ RoleEpigraph GuidedNavigationRole = "epigraph" // A quotation set at the start of the work or a section that establishes the theme or sets the mood.
+ RoleEpilogue GuidedNavigationRole = "epilogue" // A quotation set at the start of the work or a section that establishes the theme or sets the mood.
+ RoleErrata GuidedNavigationRole = "errata" // A set of corrections discovered after initial publication of the work, sometimes referred to as corrigenda.
+ RoleExample GuidedNavigationRole = "example" // An illustration of the usage of a defined term or phrase.
+ RoleFigure GuidedNavigationRole = "figure" // An illustration, diagram, photo, code listing or similar, referenced from the text of a work, and typically annotated with a title, caption and/or credits.
+ RoleFootnote GuidedNavigationRole = "footnote" // Ancillary information, such as a citation or commentary, that provides additional context to a referenced passage of text.
+ RoleForeword GuidedNavigationRole = "foreword" // An introductory section that precedes the work, typically written by a person other than the author.
+ RoleGlossary GuidedNavigationRole = "glossary" // A brief dictionary of new, uncommon, or specialized terms used in the content.
+ RoleGlossref GuidedNavigationRole = "glossref" // A reference to a glossary definition.
+ RoleHeader GuidedNavigationRole = "header" // Represents introductory content, typically a group of introductory or navigational aids.
+ RoleHeading1 GuidedNavigationRole = "heading1" // A level 1 heading for a section of the page.
+ RoleHeading2 GuidedNavigationRole = "heading2" // A level 2 heading for a section of the page.
+ RoleHeading3 GuidedNavigationRole = "heading3" // A level 3 heading for a section of the page.
+ RoleHeading4 GuidedNavigationRole = "heading4" // A level 4 heading for a section of the page.
+ RoleHeading5 GuidedNavigationRole = "heading5" // A level 5 heading for a section of the page.
+ RoleHeading6 GuidedNavigationRole = "heading6" // A level 6 heading for a section of the page.
+ RoleImage GuidedNavigationRole = "image" // Represents an image.
+ RoleIndex GuidedNavigationRole = "index" // A navigational aid that provides a detailed list of links to key subjects, names and other important topics covered in the work.
+ RoleIntroduction GuidedNavigationRole = "introduction" // A preliminary section that typically introduces the scope or nature of the work.
+ RoleLandmarks GuidedNavigationRole = "landmarks" // A short summary of the principle ideas, concepts and conclusions of the work, or of a section or excerpt within it.
+ RoleList GuidedNavigationRole = "list" // A structure that contains an enumeration of related content items.
+ RoleListItem GuidedNavigationRole = "listItem" // A single item in an enumeration.
+ RoleLoa GuidedNavigationRole = "loa" // A listing of audio clips included in the work.
+ RoleLoi GuidedNavigationRole = "loi" // A listing of illustrations included in the work.
+ RoleLot GuidedNavigationRole = "lot" // A listing of tables included in the work.
+ RoleLov GuidedNavigationRole = "lov" // A listing of video clips included in the work.
+ RoleMain GuidedNavigationRole = "main" // Content that is directly related to or expands upon the central topic of the document.
+ RoleMath GuidedNavigationRole = "math" // Content that represents a mathematical expression.
+ RoleNavigation GuidedNavigationRole = "navigation" // Represents a section of a page that links to other pages or to parts within the page: a section with navigation links.
+ RoleNoteref GuidedNavigationRole = "noteref" // A reference to a footnote or endnote, typically appearing as a superscripted number or symbol in the main body of text.
+ RoleNotice GuidedNavigationRole = "notice" // Notifies the user of consequences that might arise from an action or event. Examples include warnings, cautions and dangers.
+ RolePagebreak GuidedNavigationRole = "pagebreak" // A separator denoting the position before which a break occurs between two contiguous pages in a statically paginated version of the content.
+ RolePagelist GuidedNavigationRole = "pagelist" // A navigational aid that provides a list of links to the pagebreaks in the content.
+ RoleParagraph GuidedNavigationRole = "paragraph" // Represents a paragraph.
+ RolePart GuidedNavigationRole = "part" // A major structural division in a work that contains a set of related sections dealing with a particular subject, narrative arc or similar encapsulated theme.
+ RolePreface GuidedNavigationRole = "preface" // An introductory section that precedes the work, typically written by the author of the work.
+ RolePreformatted GuidedNavigationRole = "preformatted" // Represents preformatted text which is to be presented exactly as written.
+ RolePresentation GuidedNavigationRole = "presentation" // Represents an element being used only for presentation and therefore that does not have any accessibility semantics.
+ RolePrologue GuidedNavigationRole = "prologue" // An introductory section that sets the background to a work, typically part of the narrative.
+ RolePullquote GuidedNavigationRole = "pullquote" // A distinctively placed or highlighted quotation from the current content designed to draw attention to a topic or highlight a key point.
+ RoleQna GuidedNavigationRole = "qna" // A section of content structured as a series of questions and answers, such as an interview or list of frequently asked questions.
+ RoleRegion GuidedNavigationRole = "region" // Represents content that is relevant to a specific, author-specified purpose and sufficiently important that users will likely want to be able to navigate to the section easily and to have it listed in a summary of the page.
+ RoleRow GuidedNavigationRole = "row" // A row of data or content in a tabular structure.
+ RoleRowHeader GuidedNavigationRole = "rowheader" // The header cell for a row, establishing a relationship between it and the other cells in the same row.
+ RoleSection GuidedNavigationRole = "section" // Represents a generic standalone section of a document, which doesn't have a more specific semantic element to represent it.
+ RoleSeparator GuidedNavigationRole = "separator" // Indicates the element is a divider that separates and distinguishes sections of content or groups of menuitems.
+ RoleSequence GuidedNavigationRole = "sequence" // A sequence of related Guided Navigation Objects.
+ RoleSubtitle GuidedNavigationRole = "subtitle" // An explanatory or alternate title for the work, or a section or component within it.
+ RoleSummary GuidedNavigationRole = "summary" // A summary of an element contained in details.
+ RoleTable GuidedNavigationRole = "table" // A structure containing data or content laid out in tabular form.
+ RoleTerm GuidedNavigationRole = "term" // A word or phrase with a corresponding definition.
+ RoleTip GuidedNavigationRole = "tip" // Helpful information that clarifies some aspect of the content or assists in its comprehension.
+ RoleToc GuidedNavigationRole = "toc" // A navigational aid that provides an ordered list of links to the major sectional headings in the content. A table of contents may cover an entire work, or only a smaller section of it.
+ RoleVideo GuidedNavigationRole = "video" // Embedded videos, movies, or audio files with captions in a document.
+)
diff --git a/pkg/manifest/guided_navigation.go b/pkg/manifest/guided_navigation.go
deleted file mode 100644
index b3abeb17..00000000
--- a/pkg/manifest/guided_navigation.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package manifest
-
-// Readium Guided Navigation Document
-// https://readium.org/guided-navigation/schema/document.schema.json
-type GuidedNavigationDocument struct {
- Links LinkList `json:"links,omitempty"` // References to other resources that are related to the current Guided Navigation Document.
- Guided []GuidedNavigationObject `json:"guided"` // A sequence of resources and/or media fragments into these resources, meant to be presented sequentially to the user.
-}
-
-// Readium Guided Navigation Object
-// https://readium.org/guided-navigation/schema/object.schema.json
-// TODO: Role should be typed
-// TODO: all refs should be url.URL
-type GuidedNavigationObject struct {
- AudioRef string `json:"audioref,omitempty"` // References an audio resource or a fragment of it.
- ImgRef string `json:"imgref,omitempty"` // References an image or a fragment of it.
- TextRef string `json:"textref,omitempty"` // References a textual resource or a fragment of it.
- Text string `json:"text,omitempty"` // Textual equivalent of the resources or fragment of the resources referenced by the current Guided Navigation Object.
- Role []string `json:"role,omitempty"` // Convey the structural semantics of a publication
- Children []GuidedNavigationObject `json:"children,omitempty"` // Items that are children of the containing Guided Navigation Object.
-}
-
-// TODO: functions for objects to get e.g. audio time, audio file, text file, fragment id, audio "clip", image xywh, etc.
-// This will come after the URL utility revamp to avoid implementation twice
diff --git a/pkg/parser/audio/formats_test.go b/pkg/parser/audio/formats_test.go
index 0e1fec91..ed259c36 100644
--- a/pkg/parser/audio/formats_test.go
+++ b/pkg/parser/audio/formats_test.go
@@ -4,6 +4,7 @@ import (
"os"
"testing"
+ "github.com/readium/go-toolkit/pkg/manifest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -119,10 +120,11 @@ func TestPlaylistEntryName(t *testing.T) {
assert.Equal(t, "track.mp3", playlistEntryName("C:\\music\\track.mp3"))
}
-func TestFormatFragmentTime(t *testing.T) {
- assert.Equal(t, "0", formatFragmentTime(0))
- assert.Equal(t, "1647.2", formatFragmentTime(1647.2))
- assert.Equal(t, "62.011", formatFragmentTime(62.0109))
+func TestChapterLinkFragmentTime(t *testing.T) {
+ base := manifest.Link{Href: manifest.MustNewHREFFromString("track.m4b", false)}
+ assert.Equal(t, "track.m4b", chapterLink(base, "t", 0).Href.String())
+ assert.Equal(t, "track.m4b#t=1647.2", chapterLink(base, "t", 1647.2).Href.String())
+ assert.Equal(t, "track.m4b#t=62.011", chapterLink(base, "t", 62.0109).Href.String())
}
func TestParseTimecode(t *testing.T) {
diff --git a/pkg/parser/audio/toc.go b/pkg/parser/audio/toc.go
index a5b5c30a..826b8a8f 100644
--- a/pkg/parser/audio/toc.go
+++ b/pkg/parser/audio/toc.go
@@ -1,12 +1,13 @@
package audio
import (
- "math"
"regexp"
"sort"
"strconv"
"strings"
+ "time"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
"github.com/readium/go-toolkit/pkg/manifest"
)
@@ -33,7 +34,8 @@ func chaptersToLinks(entries []chapterEntry, base manifest.Link) manifest.LinkLi
func chapterLink(base manifest.Link, title string, start float64) manifest.Link {
href := base.URL(nil, nil).String()
if start > 0 {
- href += "#t=" + formatFragmentTime(start)
+ begin := time.Duration(start * float64(time.Second))
+ href += "#" + guidednavigation.Clip{Begin: &begin}.MediaFragment()
}
return manifest.Link{
Href: manifest.MustNewHREFFromString(href, false),
@@ -42,13 +44,6 @@ func chapterLink(base manifest.Link, title string, start float64) manifest.Link
}
}
-// formatFragmentTime formats a number of seconds for a media fragment, rounded
-// to the millisecond and without trailing zeros.
-func formatFragmentTime(seconds float64) string {
- rounded := math.Round(seconds*1000) / 1000
- return strconv.FormatFloat(rounded, 'f', -1, 64)
-}
-
var vorbisChapterKey = regexp.MustCompile(`^chapter(\d+)$`)
// vorbisChapters extracts chapters declared via the Vorbis comment chapter
diff --git a/pkg/parser/epub/media_overlay_service.go b/pkg/parser/epub/media_overlay_service.go
index a10266d8..656ff735 100644
--- a/pkg/parser/epub/media_overlay_service.go
+++ b/pkg/parser/epub/media_overlay_service.go
@@ -4,7 +4,10 @@ import (
"context"
"slices"
+ "github.com/pkg/errors"
"github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
+ "github.com/readium/go-toolkit/pkg/guidednavigation/converter"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
"github.com/readium/go-toolkit/pkg/pub"
@@ -14,8 +17,12 @@ func MediaOverlayFactory() pub.ServiceFactory {
return func(context pub.Context, public bool) pub.Service {
// Process reading order to find and replace SMIL alternates
smilMap := make(map[string]manifest.Link)
- var smilIndexes []string
+ htmlMap := make(map[string]manifest.Link)
+ var guideIndexes []string
for i := range context.Manifest.ReadingOrder {
+ href := context.Manifest.ReadingOrder[i].Href.String()
+ hasGuide := false
+
alts := context.Manifest.ReadingOrder[i].Alternates
for j := range alts {
alt := context.Manifest.ReadingOrder[i].Alternates[j]
@@ -23,7 +30,6 @@ func MediaOverlayFactory() pub.ServiceFactory {
// SMIL alternate for reading order item found
// Create a guided navigation link for the SMIL alt
- href := context.Manifest.ReadingOrder[i].Href.String()
gnLink := pub.GuidedNavigationLink
gnLink.Href = manifest.NewHREF(gnLink.URL(nil,
map[string]string{
@@ -33,14 +39,26 @@ func MediaOverlayFactory() pub.ServiceFactory {
// Store the original SMIL alt in an internal map
smilMap[href] = alt
- smilIndexes = append(smilIndexes, href)
+ hasGuide = true
// Swap the original SMIL alt with the new guided navigation link
alts = append(append(alts[:j], gnLink), alts[j+1:]...)
}
}
+
+ if !hasGuide {
+ if mt := context.Manifest.ReadingOrder[i].MediaType; mt != nil && mt.IsHTML() {
+ // No SMIL alternate, but a guided navigation document can still
+ // be generated from the (X)HTML resource's content
+ htmlMap[href] = context.Manifest.ReadingOrder[i]
+ hasGuide = true
+ }
+ }
+ if hasGuide {
+ guideIndexes = append(guideIndexes, href)
+ }
}
- if len(smilMap) == 0 {
+ if len(guideIndexes) == 0 {
// No items anyway, don't set up service
return nil
}
@@ -48,23 +66,29 @@ func MediaOverlayFactory() pub.ServiceFactory {
return &MediaOverlayService{
fetcher: context.Fetcher,
originalSmilAlternates: smilMap,
- originalSmilIndexes: smilIndexes,
+ htmlResources: htmlMap,
+ guideIndexes: guideIndexes,
public: public,
}
}
}
+// MediaOverlayService provides guided navigation documents for a publication's
+// reading order: from a SMIL media overlay when the resource has one, otherwise
+// by converting the (X)HTML resource's content.
type MediaOverlayService struct {
public bool
fetcher fetcher.Fetcher
originalSmilAlternates map[string]manifest.Link
- originalSmilIndexes []string
+ htmlResources map[string]manifest.Link
+ guideIndexes []string
// TODO: smil parsing cache
}
func (s *MediaOverlayService) Close() {
clear(s.originalSmilAlternates)
- clear(s.originalSmilIndexes)
+ clear(s.htmlResources)
+ clear(s.guideIndexes)
}
func (s *MediaOverlayService) Links() manifest.LinkList {
@@ -75,13 +99,17 @@ func (s *MediaOverlayService) Links() manifest.LinkList {
}
func (s *MediaOverlayService) HasGuideForResource(href string) bool {
- _, ok := s.originalSmilAlternates[href]
+ if _, ok := s.originalSmilAlternates[href]; ok {
+ return true
+ }
+ _, ok := s.htmlResources[href]
return ok
}
-func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*manifest.GuidedNavigationDocument, error) {
- // Check if the provided resource has a guided navigation document
+func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) {
+ var doc *guidednavigation.GuidedNavigationDocument
if link, ok := s.originalSmilAlternates[href]; ok {
+ // The resource has a SMIL media overlay
res := s.fetcher.Get(ctx, link)
defer res.Close()
@@ -91,33 +119,49 @@ func (s *MediaOverlayService) GuideForResource(ctx context.Context, href string)
}
// Convert SMIL to guided navigation document
- doc, err := ParseSMILDocument(n, link.URL(nil, nil))
+ var err error
+ doc, err = ParseSMILDocument(n, link.URL(nil, nil))
if err != nil {
return nil, err
}
+ } else if link, ok := s.htmlResources[href]; ok {
+ // Fall back to converting the (X)HTML resource's content
+ res := s.fetcher.Get(ctx, link)
+ defer res.Close()
- // Find the next and previous guided navigation docs in the readingOrder
- // Then enhance the document with additional next/prev links
- idx := slices.Index(s.originalSmilIndexes, href)
- if idx > 0 {
- l := pub.GuidedNavigationLink
- l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
- "ref": s.originalSmilIndexes[idx-1],
- }))
- l.Rels = append(l.Rels, "prev")
- doc.Links = append(doc.Links, l)
- }
- if idx < len(s.originalSmilIndexes)-1 {
- l := pub.GuidedNavigationLink
- l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
- "ref": s.originalSmilIndexes[idx+1],
- }))
- l.Rels = append(l.Rels, "next")
- doc.Links = append(doc.Links, l)
+ var err error
+ doc, err = converter.Do(ctx, res, manifest.Locator{
+ Href: link.URL(nil, nil),
+ MediaType: *link.MediaType,
+ Title: link.Title,
+ })
+ if err != nil {
+ return nil, err
}
- return doc, nil
+ } else {
+ return nil, errors.New("resource cannot be converted to a guided navigation document")
+ }
+
+ // Find the next and previous guided navigation docs in the readingOrder
+ // Then enhance the document with additional next/prev links
+ idx := slices.Index(s.guideIndexes, href)
+ if idx > 0 {
+ l := pub.GuidedNavigationLink
+ l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
+ "ref": s.guideIndexes[idx-1],
+ }))
+ l.Rels = append(l.Rels, "prev")
+ doc.Links = append(doc.Links, l)
+ }
+ if idx < len(s.guideIndexes)-1 {
+ l := pub.GuidedNavigationLink
+ l.Href = manifest.NewHREF(l.Href.Resolve(nil, map[string]string{
+ "ref": s.guideIndexes[idx+1],
+ }))
+ l.Rels = append(l.Rels, "next")
+ doc.Links = append(doc.Links, l)
}
- return nil, nil
+ return doc, nil
}
func (s *MediaOverlayService) Get(ctx context.Context, link manifest.Link) (fetcher.Resource, bool) {
diff --git a/pkg/parser/epub/parser.go b/pkg/parser/epub/parser.go
index e22046d1..c29a14b8 100644
--- a/pkg/parser/epub/parser.go
+++ b/pkg/parser/epub/parser.go
@@ -6,7 +6,6 @@ import (
"github.com/antchfx/xmlquery"
"github.com/pkg/errors"
"github.com/readium/go-toolkit/pkg/asset"
- "github.com/readium/go-toolkit/pkg/content/iterator"
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
@@ -83,9 +82,10 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch
builder := pub.NewServicesBuilder(map[pub.ServiceName]pub.ServiceFactory{
pub.PositionsService_Name: PositionsServiceFactory(p.reflowablePositionsStrategy),
- pub.ContentService_Name: pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{
- iterator.HTMLFactory(),
- }),
+ // The guided navigation service replaces the content service
+ // pub.ContentService_Name: pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{
+ // iterator.HTMLFactory(),
+ // }),
pub.GuidedNavigationService_Name: MediaOverlayFactory(),
})
return pub.NewBuilder(manifest, ffetcher, builder), nil
diff --git a/pkg/parser/epub/parser_smil.go b/pkg/parser/epub/parser_smil.go
index f573af06..439cefe0 100644
--- a/pkg/parser/epub/parser_smil.go
+++ b/pkg/parser/epub/parser_smil.go
@@ -1,11 +1,12 @@
package epub
import (
- "strconv"
+ "time"
"github.com/antchfx/xmlquery"
"github.com/pkg/errors"
- "github.com/readium/go-toolkit/pkg/manifest"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
+ "github.com/readium/go-toolkit/pkg/guidednavigation/converter"
"github.com/readium/go-toolkit/pkg/util/url"
)
@@ -17,7 +18,7 @@ var (
xpSMILAudio = mustCompileNS("smil:audio | smil2:audio")
)
-func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*manifest.GuidedNavigationDocument, error) {
+func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*guidednavigation.GuidedNavigationDocument, error) {
smil := xmlquery.QuerySelector(document, xpSMILRoot)
if smil == nil {
return nil, errors.New("SMIL root element not found")
@@ -34,17 +35,17 @@ func ParseSMILDocument(document *xmlquery.Node, filePath url.URL) (*manifest.Gui
if err != nil {
return nil, errors.Wrap(err, "failed parsing SMIL body")
}
- return &manifest.GuidedNavigationDocument{
+ return &guidednavigation.GuidedNavigationDocument{
Guided: seqs,
}, nil
}
-func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNavigationObject, error) {
+func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]guidednavigation.GuidedNavigationObject, error) {
childElements := xmlquery.QuerySelectorAll(seq, xpSMILParOrSeq)
if len(childElements) == 0 && seq.Data == "body" {
return nil, errors.New("SMIL body is empty")
}
- objects := make([]manifest.GuidedNavigationObject, 0, len(childElements))
+ objects := make([]guidednavigation.GuidedNavigationObject, 0, len(childElements))
for _, el := range childElements {
if el.Data == "par" {
//
@@ -55,27 +56,28 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNaviga
objects = append(objects, *o)
} else {
//
- o := &manifest.GuidedNavigationObject{
- TextRef: SelectNodeAttrNs(el, NamespaceOPS, "textref"),
- }
- if o.TextRef == "" {
+ textrefAttr := SelectNodeAttrNs(el, NamespaceOPS, "textref")
+ if textrefAttr == "" {
return nil, errors.New("SMIL seq has no textref")
}
- u, err := url.URLFromString(o.TextRef)
+ u, err := url.URLFromString(textrefAttr)
if err != nil {
return nil, errors.Wrap(err, "failed parsing SMIL seq textref")
}
- o.TextRef = filePath.Resolve(u).String()
+ o := &guidednavigation.GuidedNavigationObject{
+ TextRef: filePath.Resolve(u),
+ }
// epub:type
pp := parseProperties(SelectNodeAttrNs(el, NamespaceOPS, "type"))
if len(pp) > 0 {
- o.Role = make([]string, 0, len(pp))
+ o.Role = make([]guidednavigation.GuidedNavigationRole, 0, len(pp))
for _, prop := range pp {
- if prop == "" {
+ p := converter.ConvertEPUBRole(prop)
+ if p == "" {
continue
}
- o.Role = append(o.Role, prop)
+ o.Role = append(o.Role, p)
}
}
@@ -91,57 +93,62 @@ func ParseSMILSeq(seq *xmlquery.Node, filePath url.URL) ([]manifest.GuidedNaviga
return objects, nil
}
-func ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*manifest.GuidedNavigationObject, error) {
+func secondsToDuration(seconds *float64) *time.Duration {
+ if seconds == nil {
+ return nil
+ }
+ d := time.Duration(*seconds * float64(time.Second))
+ return &d
+}
+
+func ParseSMILPar(par *xmlquery.Node, filePath url.URL) (*guidednavigation.GuidedNavigationObject, error) {
text := xmlquery.QuerySelector(par, xpSMILTextChild)
if text == nil {
return nil, errors.New("SMIL par has no text element")
}
- o := &manifest.GuidedNavigationObject{
- TextRef: text.SelectAttr("src"),
- }
- if o.TextRef == "" {
+ srcAttr := text.SelectAttr("src")
+ if srcAttr == "" {
return nil, errors.New("SMIL par text element has empty src attribute")
}
- u, err := url.URLFromString(o.TextRef)
+ u, err := url.URLFromString(srcAttr)
if err != nil {
return nil, errors.Wrap(err, "failed parsing SMIL par text element textref")
}
- o.TextRef = filePath.Resolve(u).String()
+ o := &guidednavigation.GuidedNavigationObject{
+ TextRef: filePath.Resolve(u),
+ }
// Audio is optional
if audio := xmlquery.QuerySelector(par, xpSMILAudio); audio != nil {
- o.AudioRef = audio.SelectAttr("src")
- if o.AudioRef == "" {
+ audioAttr := audio.SelectAttr("src")
+ if audioAttr == "" {
return nil, errors.New("SMIL par audio element has empty src attribute")
}
- begin := ParseClockValue(audio.SelectAttr("clipBegin"))
- end := ParseClockValue(audio.SelectAttr("clipEnd"))
- if begin != nil || end != nil {
- o.AudioRef += "#t="
- }
- if begin != nil {
- o.AudioRef += strconv.FormatFloat(*begin, 'f', -1, 64)
+ clip := guidednavigation.Clip{
+ Begin: secondsToDuration(ParseClockValue(audio.SelectAttr("clipBegin"))),
+ End: secondsToDuration(ParseClockValue(audio.SelectAttr("clipEnd"))),
}
- if end != nil {
- o.AudioRef += "," + strconv.FormatFloat(*end, 'f', -1, 64)
+ if fragment := clip.MediaFragment(); fragment != "" {
+ audioAttr += "#" + fragment
}
- u, err := url.URLFromString(o.AudioRef)
+ u, err := url.URLFromString(audioAttr)
if err != nil {
return nil, errors.Wrap(err, "failed parsing SMIL par audio element textref")
}
- o.AudioRef = filePath.Resolve(u).String()
+ o.AudioRef = filePath.Resolve(u)
}
// epub:type
pp := parseProperties(SelectNodeAttrNs(par, NamespaceOPS, "type"))
if len(pp) > 0 {
- o.Role = make([]string, 0, len(pp))
+ o.Role = make([]guidednavigation.GuidedNavigationRole, 0, len(pp))
for _, prop := range pp {
- if prop == "" {
+ p := converter.ConvertEPUBRole(prop)
+ if p == "" {
continue
}
- o.Role = append(o.Role, prop)
+ o.Role = append(o.Role, p)
}
}
diff --git a/pkg/parser/epub/parser_smil_test.go b/pkg/parser/epub/parser_smil_test.go
index 9bb42786..32f856e9 100644
--- a/pkg/parser/epub/parser_smil_test.go
+++ b/pkg/parser/epub/parser_smil_test.go
@@ -5,13 +5,14 @@ import (
"testing"
"github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/util/url"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func loadSmil(ctx context.Context, name string) (*manifest.GuidedNavigationDocument, error) {
+func loadSmil(ctx context.Context, name string) (*guidednavigation.GuidedNavigationDocument, error) {
n, rerr := fetcher.ReadResourceAsXML(ctx, fetcher.NewFileResource(manifest.Link{}, "./testdata/smil/"+name+".smil"))
if rerr != nil {
return nil, rerr.Cause
@@ -25,8 +26,8 @@ func TestSMILDocTypicalAudio(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, doc.Links)
if assert.Len(t, doc.Guided, 6) {
- assert.Equal(t, "OEBPS/page1.xhtml#word0", doc.Guided[0].TextRef)
- assert.Equal(t, "OEBPS/audio/page1.m4a#t=0,0.84", doc.Guided[0].AudioRef)
+ assert.Equal(t, "OEBPS/page1.xhtml#word0", doc.Guided[0].TextRef.String())
+ assert.Equal(t, "OEBPS/audio/page1.m4a#t=0,0.84", doc.Guided[0].AudioRef.String())
}
}
@@ -42,7 +43,7 @@ func TestSMILClipBoundaries(t *testing.T) {
doc, err := loadSmil(t.Context(), "audio-clip")
require.NoError(t, err)
require.Len(t, doc.Guided, 3)
- assert.Equal(t, "OEBPS/audio/page1.m4a#t=,0.84", doc.Guided[0].AudioRef)
- assert.Equal(t, "OEBPS/audio/page1.m4a#t=0.84", doc.Guided[1].AudioRef)
- assert.Equal(t, "OEBPS/audio/page1.m4a", doc.Guided[2].AudioRef)
+ assert.Equal(t, "OEBPS/audio/page1.m4a#t=,0.84", doc.Guided[0].AudioRef.String())
+ assert.Equal(t, "OEBPS/audio/page1.m4a#t=0.84", doc.Guided[1].AudioRef.String())
+ assert.Equal(t, "OEBPS/audio/page1.m4a", doc.Guided[2].AudioRef.String())
}
diff --git a/pkg/parser/webpub/parser.go b/pkg/parser/webpub/parser.go
index 11c80dbd..35dc1954 100644
--- a/pkg/parser/webpub/parser.go
+++ b/pkg/parser/webpub/parser.go
@@ -8,7 +8,6 @@ import (
"github.com/pkg/errors"
"github.com/readium/go-toolkit/pkg/asset"
- "github.com/readium/go-toolkit/pkg/content/iterator"
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
@@ -158,12 +157,15 @@ func (p WebPubParser) Parse(ctx context.Context, a asset.PublicationAsset, f fet
serviceFactories[pub.PositionsService_Name] = epub.PositionsServiceFactory(nil)
}
- // Add content service for WebPubs with HTML contents.
+ // Add guided navigation service for WebPubs with HTML contents. It serves SMIL
+ // media overlays when reading order items have them, and converts the HTML
+ // resources themselves otherwise. It replaces the content service:
+ // serviceFactories[pub.ContentService_Name] = pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{
+ // iterator.HTMLFactory(),
+ // })
for _, link := range m.ReadingOrder {
if link.MediaType != nil && link.MediaType.IsHTML() {
- serviceFactories[pub.ContentService_Name] = pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{
- iterator.HTMLFactory(),
- })
+ serviceFactories[pub.GuidedNavigationService_Name] = epub.MediaOverlayFactory()
break
}
}
diff --git a/pkg/parser/webpub/parser_test.go b/pkg/parser/webpub/parser_test.go
index 829e83ec..98acf652 100644
--- a/pkg/parser/webpub/parser_test.go
+++ b/pkg/parser/webpub/parser_test.go
@@ -451,7 +451,7 @@ func TestParseExplodedPublicationSandboxed(t *testing.T) {
func TestParseServiceFactories(t *testing.T) {
// An EPUB-profile WebPub gets a positions service and, having HTML contents,
- // a content service.
+ // a guided navigation service.
dir := t.TempDir()
manifestJSON := `{
"@context": "https://readium.org/webpub-manifest/context.jsonld",
@@ -471,7 +471,7 @@ func TestParseServiceFactories(t *testing.T) {
require.NotNil(t, builder)
assert.NotNil(t, builder.ServicesBuilder.Get(pub.PositionsService_Name))
- assert.NotNil(t, builder.ServicesBuilder.Get(pub.ContentService_Name))
+ assert.NotNil(t, builder.ServicesBuilder.Get(pub.GuidedNavigationService_Name))
}
// The parser must not swallow assets which aren't WebPub flavored.
diff --git a/pkg/pub/service_guided_navigation.go b/pkg/pub/service_guided_navigation.go
index 980d38d6..c4f5a4ca 100644
--- a/pkg/pub/service_guided_navigation.go
+++ b/pkg/pub/service_guided_navigation.go
@@ -6,6 +6,7 @@ import (
"github.com/pkg/errors"
"github.com/readium/go-toolkit/pkg/fetcher"
+ "github.com/readium/go-toolkit/pkg/guidednavigation"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
"github.com/readium/go-toolkit/pkg/util/url"
@@ -27,7 +28,7 @@ func init() {
// Provides a way to access guided navigation documents for resources of a [Publication].
type GuidedNavigationService interface {
Service
- GuideForResource(ctx context.Context, href string) (*manifest.GuidedNavigationDocument, error)
+ GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error)
HasGuideForResource(href string) bool
}
diff --git a/pkg/util/url/url.go b/pkg/util/url/url.go
index 280404a2..0a2c206e 100644
--- a/pkg/util/url/url.go
+++ b/pkg/util/url/url.go
@@ -88,8 +88,10 @@ func (u RelativeURL) Extension() string {
// RemoveQuery implements URL
func (u RelativeURL) RemoveQuery() URL {
- u.url.RawQuery = ""
- return RelativeURL{url: u.url, normalized: u.normalized}
+ res := *u.url
+ res.ForceQuery = false
+ res.RawQuery = ""
+ return RelativeURL{url: &res, normalized: u.normalized}
}
// Fragment implements URL
@@ -99,8 +101,10 @@ func (u RelativeURL) Fragment() string {
// RemoveFragment implements URL
func (u RelativeURL) RemoveFragment() URL {
- u.url.Fragment = ""
- return RelativeURL{url: u.url, normalized: u.normalized}
+ res := *u.url
+ res.Fragment = ""
+ res.RawFragment = ""
+ return RelativeURL{url: &res, normalized: u.normalized}
}
// Resolve implements URL
@@ -168,16 +172,17 @@ func (u RelativeURL) Normalize() URL {
return u
}
+ res := *u.url
var hadSlash bool
- if strings.HasSuffix(u.url.Path, "/") {
+ if strings.HasSuffix(res.Path, "/") {
hadSlash = true
}
- u.url.Path = path.Clean(u.url.Path)
+ res.Path = path.Clean(res.Path)
if hadSlash {
- u.url.Path += "/"
+ res.Path += "/"
}
- return RelativeURL{url: u.url, normalized: true}
+ return RelativeURL{url: &res, normalized: true}
}
// String implements URL
@@ -238,8 +243,10 @@ func (u AbsoluteURL) Extension() string {
// RemoveQuery implements URL
func (u AbsoluteURL) RemoveQuery() URL {
- u.url.RawQuery = ""
- return AbsoluteURL{url: u.url, scheme: u.scheme, normalized: u.normalized}
+ res := *u.url
+ res.ForceQuery = false
+ res.RawQuery = ""
+ return AbsoluteURL{url: &res, scheme: u.scheme, normalized: u.normalized}
}
// Fragment implements URL
@@ -249,8 +256,10 @@ func (u AbsoluteURL) Fragment() string {
// RemoveFragment implements URL
func (u AbsoluteURL) RemoveFragment() URL {
- u.url.Fragment = ""
- return AbsoluteURL{url: u.url, scheme: u.scheme, normalized: u.normalized}
+ res := *u.url
+ res.Fragment = ""
+ res.RawFragment = ""
+ return AbsoluteURL{url: &res, scheme: u.scheme, normalized: u.normalized}
}
// Resolve implements URL
@@ -307,22 +316,23 @@ func (u AbsoluteURL) Normalize() URL {
return u
}
+ res := *u.url
var hadSlash bool
- if strings.HasSuffix(u.url.Path, "/") {
+ if strings.HasSuffix(res.Path, "/") {
hadSlash = true
}
- u.url.Path = path.Clean(u.url.Path)
+ res.Path = path.Clean(res.Path)
if hadSlash {
- u.url.Path += "/"
+ res.Path += "/"
}
- u.url.Scheme = SchemeFromString(u.url.Scheme).String()
- asciiHost, err := idna.ToASCII(u.url.Host)
+ res.Scheme = SchemeFromString(res.Scheme).String()
+ asciiHost, err := idna.ToASCII(res.Host)
if err == nil {
- u.url.Host = asciiHost
+ res.Host = asciiHost
}
- return AbsoluteURL{url: u.url, scheme: Scheme(u.url.Scheme), normalized: true}
+ return AbsoluteURL{url: &res, scheme: Scheme(res.Scheme), normalized: true}
}
// String implements URL
diff --git a/pkg/util/url/url_test.go b/pkg/util/url/url_test.go
index 1c22fc24..454b9952 100644
--- a/pkg/util/url/url_test.go
+++ b/pkg/util/url/url_test.go
@@ -441,6 +441,53 @@ func TestNormalize(t *testing.T) {
assert.Equal(t, "http://user:password@example.com:443/foo?b=b&a=a#fragment", u.Normalize().String())
}
+// The methods documented as returning copies must not mutate the receiver's
+// underlying net/url.URL, which is shared between the copies.
+func TestRemoveFragmentReturnsCopy(t *testing.T) {
+ for _, urlTest := range []string{
+ "chapter.xhtml?q=1#fragment",
+ "http://example.com/chapter.xhtml?q=1#fragment",
+ } {
+ u := MustURLFromString(urlTest)
+ removed := u.RemoveFragment()
+ assert.Empty(t, removed.Fragment(), "for URL '%s'", urlTest)
+ assert.Equal(t, "fragment", u.Fragment(), "original URL '%s' must keep its fragment", urlTest)
+ assert.Equal(t, urlTest, u.String(), "original URL '%s' must be unchanged", urlTest)
+ }
+}
+
+func TestRemoveQueryReturnsCopy(t *testing.T) {
+ for _, urlTest := range []string{
+ "chapter.xhtml?q=1#fragment",
+ "http://example.com/chapter.xhtml?q=1#fragment",
+ } {
+ u := MustURLFromString(urlTest)
+ removed := u.RemoveQuery()
+ assert.Empty(t, removed.Raw().RawQuery, "for URL '%s'", urlTest)
+ assert.Equal(t, "fragment", removed.Fragment(), "RemoveQuery must keep the fragment of '%s'", urlTest)
+ assert.Equal(t, urlTest, u.String(), "original URL '%s' must be unchanged", urlTest)
+ }
+}
+
+func TestNormalizeReturnsCopy(t *testing.T) {
+ for _, urlTest := range []struct {
+ url string
+ normalized string
+ }{
+ {"foo/../bar.xhtml", "bar.xhtml"},
+ {"http://example.com/foo/../bar.xhtml", "http://example.com/bar.xhtml"},
+ } {
+ u := MustURLFromString(urlTest.url)
+ assert.Equal(t, urlTest.normalized, u.Normalize().String())
+ assert.Equal(t, urlTest.url, u.String(), "original URL '%s' must be unchanged", urlTest.url)
+
+ // Equivalent uses Normalize internally and must not mutate either side
+ other := MustURLFromString(urlTest.normalized)
+ assert.True(t, u.Equivalent(other))
+ assert.Equal(t, urlTest.url, u.String(), "Equivalent must not mutate the receiver")
+ }
+}
+
// Windows drive paths round-trip through file URLs in the file:///C:/dir form,
// so that resolving relative references does not corrupt the drive letter.
func TestFilepathWindowsDrive(t *testing.T) {