From d20447528a6e0baf92c5f694ce5f5f80f8884dfd Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 17 Aug 2026 01:52:36 -0700 Subject: [PATCH 1/4] take guided nav service and split into media overlay and guided nav services --- pkg/guidednavigation/converter/converter.go | 4 +- .../converter/converter_test.go | 65 +++++- pkg/parser/epub/media_overlay_service.go | 208 ++++++++---------- pkg/parser/epub/media_overlay_service_test.go | 117 ++++++++++ pkg/parser/epub/parser.go | 3 +- pkg/parser/webpub/parser.go | 15 +- pkg/parser/webpub/parser_test.go | 32 +++ pkg/pub/service.go | 1 + pkg/pub/service_guided_navigation.go | 34 ++- pkg/pub/service_guided_navigation_html.go | 127 +++++++++++ .../service_guided_navigation_html_test.go | 138 ++++++++++++ 11 files changed, 609 insertions(+), 135 deletions(-) create mode 100644 pkg/parser/epub/media_overlay_service_test.go create mode 100644 pkg/pub/service_guided_navigation_html.go create mode 100644 pkg/pub/service_guided_navigation_html_test.go diff --git a/pkg/guidednavigation/converter/converter.go b/pkg/guidednavigation/converter/converter.go index 5dde48a7..2047a739 100644 --- a/pkg/guidednavigation/converter/converter.go +++ b/pkg/guidednavigation/converter/converter.go @@ -13,7 +13,7 @@ import ( "golang.org/x/net/html/atom" ) -func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator) (*guidednavigation.GuidedNavigationDocument, error) { +func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator, opts ...Option) (*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()) @@ -45,7 +45,7 @@ func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator return nil, errors.New("HTML of " + resource.Link().Href.String() + " doesn't have a ") } - contentConverter := NewHTMLConverter(locator) + contentConverter := NewHTMLConverter(locator, opts...) contentConverter.xmlParsed = xmlParsed // Traverse the document's HTML diff --git a/pkg/guidednavigation/converter/converter_test.go b/pkg/guidednavigation/converter/converter_test.go index c974f614..ecf7234b 100644 --- a/pkg/guidednavigation/converter/converter_test.go +++ b/pkg/guidednavigation/converter/converter_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType) string { +func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType, opts ...Option) string { t.Helper() f := fetcher.NewBytesResource(manifest.Link{ Href: manifest.MustNewHREFFromString("hello.xhtml", false), @@ -23,7 +23,7 @@ func convertDoc(t *testing.T, doc string, mt *mediatype.MediaType) string { nav, err := Do(context.Background(), f, manifest.Locator{ Href: f.Link().Href.Resolve(nil, nil), - }) + }, opts...) require.NoError(t, err) bin, err := json.Marshal(nav) require.NoError(t, err) @@ -45,6 +45,12 @@ func convertBody(t *testing.T, body string) string { return convertDoc(t, wrapXHTML("", body), &mediatype.XHTML) } +// Like convertBody, with textref locators enabled. +func convertBodyWithLocators(t *testing.T, body string) string { + t.Helper() + return convertDoc(t, wrapXHTML("", body), &mediatype.XHTML, WithTextRefLocators()) +} + // The expected guided navigation document JSON for the given children of . func guidedJSON(children string) string { res := `{"guided":[{"role":["body"],"textref":"hello.xhtml"` @@ -736,3 +742,58 @@ func TestDoErrorsWithoutBody(t *testing.T) { }) require.Error(t, err) } + +// With WithTextRefLocators, every object carrying roles points back at its source +// element: by fragment id when the element has one, by css() selector otherwise. +// Selectors are anchored to the closest ancestor with an id. +func TestConvertTextRefLocators(t *testing.T) { + res := convertBodyWithLocators(t, ` +

Hello An image world

+

Two

`) + assert.JSONEq(t, guidedJSON(`{ + "role": ["section"], + "textref": "hello.xhtml#css(body%20%3E%20section:nth-child(1))", + "children": [{ + "role": ["paragraph"], + "textref": "hello.xhtml#css(body%20%3E%20section:nth-child(1)%20%3E%20p)", + "text": { + "plain": "Hello world", + "ssml": "Hello world" + }, + "children": [{ + "id": "image1", + "role": ["image"], + "imgref": "img.png", + "textref": "hello.xhtml#css(body%20%3E%20section:nth-child(1)%20%3E%20p%20%3E%20img)", + "description": "An image" + }] + }] + }, + { + "role": ["section"], + "textref": "hello.xhtml#s2", + "children": [{ + "role": ["paragraph"], + "textref": "hello.xhtml#css(%23s2%20%3E%20p)", + "text": "Two" + }] + }`), res) +} + +// Objects that carry nothing but a role are still emitted when locators are +// enabled, since the locator alone makes them useful. Pagebreaks without an id +// fall back to a css() selector too. +func TestConvertTextRefLocatorsStandalone(t *testing.T) { + res := convertBodyWithLocators(t, ` +
+ `) + assert.JSONEq(t, guidedJSON(`{ + "role": ["separator"], + "textref": "hello.xhtml#css(body%20%3E%20hr)" + }, + { + "role": ["pagebreak"], + "text": "5", + "textref": "hello.xhtml#css(body%20%3E%20span)" + }`), res) +} diff --git a/pkg/parser/epub/media_overlay_service.go b/pkg/parser/epub/media_overlay_service.go index 656ff735..f3f29051 100644 --- a/pkg/parser/epub/media_overlay_service.go +++ b/pkg/parser/epub/media_overlay_service.go @@ -7,166 +7,142 @@ 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/guidednavigation/converter" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/mediatype" "github.com/readium/go-toolkit/pkg/pub" ) +// MediaOverlayFactory creates a service converting the SMIL media overlays of an +// EPUB/WebPub publication into guided navigation documents. Each SMIL alternate in +// the manifest is replaced with an expansion of [pub.MediaOverlayLink] referencing +// its resource — those swapped alternates are the only way the service surfaces in +// the manifest, it is never advertised in the manifest's links. It does not fall +// back to converting (X)HTML content (see [pub.HTMLGuidedNavigationServiceFactory] +// for that). When the publication has no SMIL alternates, no service is created. 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) - 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] - if alt.MediaType.Equal(&mediatype.SMIL) { - // SMIL alternate for reading order item found - - // Create a guided navigation link for the SMIL alt - gnLink := pub.GuidedNavigationLink - gnLink.Href = manifest.NewHREF(gnLink.URL(nil, - map[string]string{ - "ref": href, - }, - )) - - // Store the original SMIL alt in an internal map - smilMap[href] = alt - 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 + // Find and replace SMIL alternates with media overlay links + process := func(link *manifest.Link) (hasOverlay bool) { + href := link.Href.String() + for j := range link.Alternates { + alt := link.Alternates[j] + if alt.MediaType == nil || !alt.MediaType.Equal(&mediatype.SMIL) { + continue } + // SMIL alternate for the item found + + // Create a media overlay link for the SMIL alt + moLink := pub.MediaOverlayLink + moLink.Href = manifest.NewHREF(moLink.URL(nil, + map[string]string{ + "ref": href, + }, + )) + + // Store the original SMIL alt in an internal map + smilMap[href] = alt + hasOverlay = true + + // Swap the original SMIL alt with the new media overlay link + link.Alternates[j] = moLink } - if hasGuide { - guideIndexes = append(guideIndexes, href) + return hasOverlay + } + + // Only reading order items are chained through next/prev links + var guideIndexes []string + for i := range context.Manifest.ReadingOrder { + if process(&context.Manifest.ReadingOrder[i]) { + guideIndexes = append(guideIndexes, context.Manifest.ReadingOrder[i].Href.String()) } } - if len(guideIndexes) == 0 { - // No items anyway, don't set up service + for i := range context.Manifest.Resources { + process(&context.Manifest.Resources[i]) + } + if len(smilMap) == 0 { + // No media overlays anyway, don't set up service return nil } return &MediaOverlayService{ - fetcher: context.Fetcher, - originalSmilAlternates: smilMap, - htmlResources: htmlMap, - guideIndexes: guideIndexes, - public: public, + fetcher: context.Fetcher, + smilAlternates: smilMap, + guideIndexes: guideIndexes, } } } -// 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. +// MediaOverlayService converts the SMIL media overlays of a publication's reading +// order and resources into guided navigation documents. type MediaOverlayService struct { - public bool - fetcher fetcher.Fetcher - originalSmilAlternates map[string]manifest.Link - htmlResources map[string]manifest.Link - guideIndexes []string + fetcher fetcher.Fetcher + smilAlternates map[string]manifest.Link + guideIndexes []string // TODO: smil parsing cache } func (s *MediaOverlayService) Close() { - clear(s.originalSmilAlternates) - clear(s.htmlResources) + clear(s.smilAlternates) clear(s.guideIndexes) } func (s *MediaOverlayService) Links() manifest.LinkList { - if !s.public { - return nil - } - return manifest.LinkList{pub.GuidedNavigationLink} + // The service is only surfaced through the swapped alternate links + return nil } func (s *MediaOverlayService) HasGuideForResource(href string) bool { - if _, ok := s.originalSmilAlternates[href]; ok { - return true - } - _, ok := s.htmlResources[href] + _, ok := s.smilAlternates[href] return ok } 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() - - n, rerr := fetcher.ReadResourceAsXML(ctx, res) - if rerr != nil { - return nil, rerr.Cause - } + link, ok := s.smilAlternates[href] + if !ok { + return nil, errors.New("resource has no media overlay") + } + res := s.fetcher.Get(ctx, link) + defer res.Close() - // Convert SMIL to guided navigation document - 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() - - 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 - } - } else { - return nil, errors.New("resource cannot be converted to a guided navigation document") + n, rerr := fetcher.ReadResourceAsXML(ctx, res) + if rerr != nil { + return nil, rerr.Cause } - // 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) + // Convert SMIL to guided navigation document + doc, err := ParseSMILDocument(n, link.URL(nil, nil)) + if err != nil { + return nil, err } - 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) + + // Find the next and previous media overlays in the readingOrder + // Then enhance the document with additional next/prev links. + // Resources outside the reading order aren't part of the chain + if idx := slices.Index(s.guideIndexes, href); idx >= 0 { + if idx > 0 { + l := pub.MediaOverlayLink + 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.MediaOverlayLink + 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 doc, nil } func (s *MediaOverlayService) Get(ctx context.Context, link manifest.Link) (fetcher.Resource, bool) { - if !s.public { - return nil, false - } - return pub.GetForGuidedNavigationService(ctx, s, link) + // Not gated on the service being public: the swapped alternate links are in + // the manifest unconditionally, so they must always be servable + return pub.GetForMediaOverlayService(ctx, s, link) } diff --git a/pkg/parser/epub/media_overlay_service_test.go b/pkg/parser/epub/media_overlay_service_test.go new file mode 100644 index 00000000..2aab902f --- /dev/null +++ b/pkg/parser/epub/media_overlay_service_test.go @@ -0,0 +1,117 @@ +package epub + +import ( + "context" + "testing" + + "github.com/readium/go-toolkit/pkg/fetcher" + "github.com/readium/go-toolkit/pkg/manifest" + "github.com/readium/go-toolkit/pkg/mediatype" + "github.com/readium/go-toolkit/pkg/pub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An in-memory fetcher serving string documents by href. +type stringFetcher map[string]string + +func (f stringFetcher) Links(ctx context.Context) (manifest.LinkList, error) { + return nil, nil +} + +func (f stringFetcher) Get(ctx context.Context, link manifest.Link) fetcher.Resource { + doc, ok := f[link.Href.String()] + if !ok { + return fetcher.NewFailureResource(link, fetcher.NotFound(nil)) + } + return fetcher.NewBytesResource(link, func() []byte { + return []byte(doc) + }) +} + +func (f stringFetcher) Close() {} + +func htmlLink(href string, alternates ...manifest.Link) manifest.Link { + return manifest.Link{ + Href: manifest.MustNewHREFFromString(href, false), + MediaType: &mediatype.XHTML, + Alternates: alternates, + } +} + +func smilAlt(href string) manifest.Link { + return manifest.Link{ + Href: manifest.MustNewHREFFromString(href, false), + MediaType: &mediatype.SMIL, + } +} + +const moTestSmil = ` + + +` + +// The media overlay service only covers resources with SMIL alternates: it swaps +// them for media overlay links, stays out of the manifest's links, and does not +// fall back to converting (X)HTML content. +func TestMediaOverlayServiceSMILOnly(t *testing.T) { + m := manifest.Manifest{ + ReadingOrder: manifest.LinkList{htmlLink("a.xhtml", smilAlt("a.smil")), htmlLink("b.xhtml")}, + } + f := stringFetcher{"a.smil": moTestSmil} + service, ok := MediaOverlayFactory()(pub.NewContext(m, f), false).(*MediaOverlayService) + require.True(t, ok) + + assert.True(t, service.HasGuideForResource("a.xhtml")) + // No fallback to HTML conversion + assert.False(t, service.HasGuideForResource("b.xhtml")) + // Never advertised in the manifest's links + assert.Empty(t, service.Links()) + + // The SMIL alternate was swapped for a media overlay link + require.Len(t, m.ReadingOrder[0].Alternates, 1) + assert.Equal(t, "~readium/media-overlay.json?ref=a.xhtml", m.ReadingOrder[0].Alternates[0].Href.String()) + + doc, err := service.GuideForResource(context.Background(), "a.xhtml") + require.NoError(t, err) + require.NotEmpty(t, doc.Guided) + assert.Equal(t, "a.xhtml#p1", doc.Guided[0].TextRef.String()) + // The only overlay in the reading order: no next/prev chain + assert.Empty(t, doc.Links) + + // The swapped alternate links must be servable even when the service isn't public + res, ok := service.Get(context.Background(), manifest.Link{ + Href: manifest.MustNewHREFFromString("~readium/media-overlay.json?ref=a.xhtml", false), + }) + require.True(t, ok) + data, rerr := res.Read(context.Background(), 0, 0) + require.Nil(t, rerr) + assert.NotEmpty(t, data) +} + +// Overlays chain through next/prev media overlay links along the reading order. +func TestMediaOverlayServiceChain(t *testing.T) { + m := manifest.Manifest{ + ReadingOrder: manifest.LinkList{ + htmlLink("a.xhtml", smilAlt("a.smil")), + htmlLink("b.xhtml", smilAlt("b.smil")), + }, + } + f := stringFetcher{"a.smil": moTestSmil, "b.smil": moTestSmil} + service, ok := MediaOverlayFactory()(pub.NewContext(m, f), false).(*MediaOverlayService) + require.True(t, ok) + + doc, err := service.GuideForResource(context.Background(), "a.xhtml") + require.NoError(t, err) + require.Len(t, doc.Links, 1) + assert.Contains(t, doc.Links[0].Rels, "next") + assert.Equal(t, "~readium/media-overlay.json?ref=b.xhtml", doc.Links[0].Href.String()) +} + +// Without SMIL alternates there is no media overlay service at all. +func TestMediaOverlayServiceAbsentWithoutSMIL(t *testing.T) { + m := manifest.Manifest{ + ReadingOrder: manifest.LinkList{htmlLink("a.xhtml"), htmlLink("b.xhtml")}, + } + assert.Nil(t, MediaOverlayFactory()(pub.NewContext(m, stringFetcher{}), false)) +} diff --git a/pkg/parser/epub/parser.go b/pkg/parser/epub/parser.go index c29a14b8..3049a550 100644 --- a/pkg/parser/epub/parser.go +++ b/pkg/parser/epub/parser.go @@ -86,7 +86,8 @@ func (p Parser) Parse(ctx context.Context, asset asset.PublicationAsset, f fetch // pub.ContentService_Name: pub.DefaultContentServiceFactory([]iterator.ResourceContentIteratorFactory{ // iterator.HTMLFactory(), // }), - pub.GuidedNavigationService_Name: MediaOverlayFactory(), + pub.GuidedNavigationService_Name: pub.HTMLGuidedNavigationServiceFactory(), + pub.MediaOverlayService_Name: MediaOverlayFactory(), }) return pub.NewBuilder(manifest, ffetcher, builder), nil } diff --git a/pkg/parser/webpub/parser.go b/pkg/parser/webpub/parser.go index 35dc1954..e5e98bab 100644 --- a/pkg/parser/webpub/parser.go +++ b/pkg/parser/webpub/parser.go @@ -157,18 +157,15 @@ func (p WebPubParser) Parse(ctx context.Context, a asset.PublicationAsset, f fet serviceFactories[pub.PositionsService_Name] = epub.PositionsServiceFactory(nil) } - // 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: + // Guided navigation for (X)HTML contents in the reading order or the resources, + // and media overlays for items with SMIL alternates. Both factories create no + // service when the publication has nothing for them. The guided navigation + // service 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.GuidedNavigationService_Name] = epub.MediaOverlayFactory() - break - } - } + serviceFactories[pub.GuidedNavigationService_Name] = pub.HTMLGuidedNavigationServiceFactory() + serviceFactories[pub.MediaOverlayService_Name] = epub.MediaOverlayFactory() var servicesBuilder *pub.ServicesBuilder if len(serviceFactories) > 0 { diff --git a/pkg/parser/webpub/parser_test.go b/pkg/parser/webpub/parser_test.go index 98acf652..64d329e2 100644 --- a/pkg/parser/webpub/parser_test.go +++ b/pkg/parser/webpub/parser_test.go @@ -474,6 +474,38 @@ func TestParseServiceFactories(t *testing.T) { assert.NotNil(t, builder.ServicesBuilder.Get(pub.GuidedNavigationService_Name)) } +// HTML documents among the resources are enough to get a guided navigation service, +// even when the reading order has none. Without SMIL alternates there is no media +// overlay service. +func TestParseGuidedNavigationForHTMLResources(t *testing.T) { + dir := t.TempDir() + manifestJSON := `{ + "@context": "https://readium.org/webpub-manifest/context.jsonld", + "metadata": { + "conformsTo": "https://readium.org/webpub-manifest/profiles/audiobook", + "title": "Audiobook with sync text", + "duration": 60 + }, + "links": [], + "readingOrder": [ + {"href": "track1.mp3", "type": "audio/mpeg", "duration": 60} + ], + "resources": [ + {"href": "sync.xhtml", "type": "application/xhtml+xml"} + ] + }` + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestJSON), 0o644)) + + builder, err := parseFileAsset(t, nil, filepath.ToSlash(filepath.Join(dir, "manifest.json")), &mediatype.ReadiumWebpubManifest) + require.NoError(t, err) + require.NotNil(t, builder) + + p := builder.Build() + defer p.Close() + assert.NotNil(t, p.FindService(pub.GuidedNavigationService_Name)) + assert.Nil(t, p.FindService(pub.MediaOverlayService_Name)) +} + // The parser must not swallow assets which aren't WebPub flavored. func TestParseSkipsOtherMediaTypes(t *testing.T) { builder, err := parseFileAsset(t, nil, "testdata/audio/manifest.json", &mediatype.JSON) diff --git a/pkg/pub/service.go b/pkg/pub/service.go index b71f3097..250de69a 100644 --- a/pkg/pub/service.go +++ b/pkg/pub/service.go @@ -17,6 +17,7 @@ const ( SearchService_Name ServiceName = "SearchService" ContentService_Name ServiceName = "ContentService" GuidedNavigationService_Name ServiceName = "GuidedNavigationService" + MediaOverlayService_Name ServiceName = "MediaOverlayService" ) // Base interface to be implemented by all publication services. diff --git a/pkg/pub/service_guided_navigation.go b/pkg/pub/service_guided_navigation.go index c4f5a4ca..36d8292d 100644 --- a/pkg/pub/service_guided_navigation.go +++ b/pkg/pub/service_guided_navigation.go @@ -12,16 +12,30 @@ import ( "github.com/readium/go-toolkit/pkg/util/url" ) +// GuidedNavigationLink is the templated link of the guided navigation service, +// which generates guided navigation documents from the publication's (X)HTML +// documents. It is advertised in the manifest's links when the service is public. var GuidedNavigationLink = manifest.Link{ Href: manifest.MustNewHREFFromString("~readium/guided-navigation.json{?ref}", true), MediaType: &mediatype.ReadiumGuidedNavigationDocument, } -// Pre-cached value of the guided navigation link's path +// MediaOverlayLink is the templated link of the media overlay service, which +// converts a resource's SMIL media overlay into a guided navigation document. +// It is never advertised in the manifest's links: the service replaces each SMIL +// alternate with an expansion of this link instead. +var MediaOverlayLink = manifest.Link{ + Href: manifest.MustNewHREFFromString("~readium/media-overlay.json{?ref}", true), + MediaType: &mediatype.ReadiumGuidedNavigationDocument, +} + +// Pre-cached values of the service links' paths var resolvedGuidedNavigation url.URL +var resolvedMediaOverlay url.URL func init() { resolvedGuidedNavigation = GuidedNavigationLink.URL(nil, nil) + resolvedMediaOverlay = MediaOverlayLink.URL(nil, nil) } // GuidedNavigationService implements Service @@ -32,11 +46,21 @@ type GuidedNavigationService interface { HasGuideForResource(href string) bool } +// GetForGuidedNavigationService serves a [GuidedNavigationLink] expansion from the given service. func GetForGuidedNavigationService(ctx context.Context, service GuidedNavigationService, link manifest.Link) (fetcher.Resource, bool) { + return getForGuideService(ctx, service, link, GuidedNavigationLink, resolvedGuidedNavigation) +} + +// GetForMediaOverlayService serves a [MediaOverlayLink] expansion from the given service. +func GetForMediaOverlayService(ctx context.Context, service GuidedNavigationService, link manifest.Link) (fetcher.Resource, bool) { + return getForGuideService(ctx, service, link, MediaOverlayLink, resolvedMediaOverlay) +} + +func getForGuideService(ctx context.Context, service GuidedNavigationService, link manifest.Link, template manifest.Link, resolved url.URL) (fetcher.Resource, bool) { u := link.URL(nil, nil) - if u.Path() != resolvedGuidedNavigation.Path() { - // Not the guided navigation link + if u.Path() != resolved.Path() { + // Not the service's link return nil, false } @@ -48,8 +72,8 @@ func GetForGuidedNavigationService(ctx context.Context, service GuidedNavigation return nil, false } - // Overrride the link's href with the expanded guided navigation link - expandedHref := GuidedNavigationLink.URL(nil, map[string]string{ + // Overrride the link's href with the expanded service link + expandedHref := template.URL(nil, map[string]string{ "ref": ref, }) link.Href = manifest.NewHREF(expandedHref) diff --git a/pkg/pub/service_guided_navigation_html.go b/pkg/pub/service_guided_navigation_html.go new file mode 100644 index 00000000..dc9deb88 --- /dev/null +++ b/pkg/pub/service_guided_navigation_html.go @@ -0,0 +1,127 @@ +package pub + +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" +) + +// HTMLGuidedNavigationServiceFactory creates a service generating guided navigation +// documents from the (X)HTML documents of a publication's reading order and +// resources. It never touches SMIL media overlays (see the EPUB parser's media +// overlay service for those) and adds nothing to the manifest besides its own +// templated link when public. The options are passed to the HTML converter, e.g. +// [converter.WithTextRefLocators]. When the publication has no (X)HTML documents, +// no service is created. +func HTMLGuidedNavigationServiceFactory(opts ...converter.Option) ServiceFactory { + return func(context Context, public bool) Service { + htmlMap := make(map[string]manifest.Link) + + // Only reading order items are chained through next/prev links + var guideIndexes []string + for _, link := range context.Manifest.ReadingOrder { + if mt := link.MediaType; mt != nil && mt.IsHTML() { + href := link.Href.String() + htmlMap[href] = link + guideIndexes = append(guideIndexes, href) + } + } + for _, link := range context.Manifest.Resources { + if mt := link.MediaType; mt != nil && mt.IsHTML() { + htmlMap[link.Href.String()] = link + } + } + if len(htmlMap) == 0 { + // No convertible documents, don't set up service + return nil + } + + return &HTMLGuidedNavigationService{ + fetcher: context.Fetcher, + htmlResources: htmlMap, + guideIndexes: guideIndexes, + converterOptions: opts, + public: public, + } + } +} + +// HTMLGuidedNavigationService provides guided navigation documents generated from +// the (X)HTML documents of a publication's reading order and resources. +type HTMLGuidedNavigationService struct { + public bool + fetcher fetcher.Fetcher + htmlResources map[string]manifest.Link + guideIndexes []string + converterOptions []converter.Option +} + +func (s *HTMLGuidedNavigationService) Close() { + clear(s.htmlResources) + clear(s.guideIndexes) +} + +func (s *HTMLGuidedNavigationService) Links() manifest.LinkList { + if !s.public { + return nil + } + return manifest.LinkList{GuidedNavigationLink} +} + +func (s *HTMLGuidedNavigationService) HasGuideForResource(href string) bool { + _, ok := s.htmlResources[href] + return ok +} + +func (s *HTMLGuidedNavigationService) GuideForResource(ctx context.Context, href string) (*guidednavigation.GuidedNavigationDocument, error) { + link, ok := s.htmlResources[href] + if !ok { + return nil, errors.New("resource cannot be converted to a guided navigation document") + } + res := s.fetcher.Get(ctx, link) + defer res.Close() + + doc, err := converter.Do(ctx, res, manifest.Locator{ + Href: link.URL(nil, nil), + MediaType: *link.MediaType, + Title: link.Title, + }, s.converterOptions...) + if err != nil { + return nil, err + } + + // Find the next and previous guided navigation docs in the readingOrder + // Then enhance the document with additional next/prev links. + // Resources outside the reading order aren't part of the chain + if idx := slices.Index(s.guideIndexes, href); idx >= 0 { + if idx > 0 { + l := 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 := 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 doc, nil +} + +func (s *HTMLGuidedNavigationService) Get(ctx context.Context, link manifest.Link) (fetcher.Resource, bool) { + if !s.public { + return nil, false + } + return GetForGuidedNavigationService(ctx, s, link) +} diff --git a/pkg/pub/service_guided_navigation_html_test.go b/pkg/pub/service_guided_navigation_html_test.go new file mode 100644 index 00000000..b48cce4e --- /dev/null +++ b/pkg/pub/service_guided_navigation_html_test.go @@ -0,0 +1,138 @@ +package pub + +import ( + "context" + "testing" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An in-memory fetcher serving string documents by href. +type stringFetcher map[string]string + +func (f stringFetcher) Links(ctx context.Context) (manifest.LinkList, error) { + return nil, nil +} + +func (f stringFetcher) Get(ctx context.Context, link manifest.Link) fetcher.Resource { + doc, ok := f[link.Href.String()] + if !ok { + return fetcher.NewFailureResource(link, fetcher.NotFound(nil)) + } + return fetcher.NewBytesResource(link, func() []byte { + return []byte(doc) + }) +} + +func (f stringFetcher) Close() {} + +func gnHTMLLink(href string, alternates ...manifest.Link) manifest.Link { + return manifest.Link{ + Href: manifest.MustNewHREFFromString(href, false), + MediaType: &mediatype.XHTML, + Alternates: alternates, + } +} + +const gnTestDoc = ` + +Test +

Hello An image world

+` + +// The guided navigation service converts (X)HTML documents of the reading order +// and resources, leaves SMIL alternates alone, and does not touch the manifest. +func TestHTMLGuidedNavigationService(t *testing.T) { + smilAlt := manifest.Link{ + Href: manifest.MustNewHREFFromString("a.smil", false), + MediaType: &mediatype.SMIL, + } + m := manifest.Manifest{ + ReadingOrder: manifest.LinkList{ + gnHTMLLink("a.xhtml", smilAlt), + {Href: manifest.MustNewHREFFromString("audio/a.mp3", false), MediaType: &mediatype.MPEGAudio}, + gnHTMLLink("b.xhtml"), + }, + Resources: manifest.LinkList{gnHTMLLink("cover.xhtml")}, + } + f := stringFetcher{"a.xhtml": gnTestDoc, "b.xhtml": gnTestDoc, "cover.xhtml": gnTestDoc} + service, ok := HTMLGuidedNavigationServiceFactory()(NewContext(m, f), false).(*HTMLGuidedNavigationService) + require.True(t, ok) + + assert.True(t, service.HasGuideForResource("a.xhtml")) + assert.True(t, service.HasGuideForResource("cover.xhtml")) + assert.False(t, service.HasGuideForResource("audio/a.mp3")) + + // The manifest is untouched: the SMIL alternate is still there + require.Len(t, m.ReadingOrder[0].Alternates, 1) + assert.Equal(t, "a.smil", m.ReadingOrder[0].Alternates[0].Href.String()) + + // A document with a SMIL overlay is still converted from its (X)HTML content + doc, err := service.GuideForResource(context.Background(), "a.xhtml") + require.NoError(t, err) + require.NotEmpty(t, doc.Guided) + assert.Contains(t, doc.Guided[0].Role, guidednavigation.RoleBody) + + // Reading order documents chain along the reading order's HTML items + require.Len(t, doc.Links, 1) + assert.Contains(t, doc.Links[0].Rels, "next") + assert.Equal(t, "~readium/guided-navigation.json?ref=b.xhtml", doc.Links[0].Href.String()) + + // Resources aren't part of the chain + doc, err = service.GuideForResource(context.Background(), "cover.xhtml") + require.NoError(t, err) + assert.Empty(t, doc.Links) +} + +// The service link is advertised when public, and Get is gated on it. +func TestHTMLGuidedNavigationServiceLinks(t *testing.T) { + m := manifest.Manifest{ReadingOrder: manifest.LinkList{gnHTMLLink("a.xhtml")}} + f := stringFetcher{"a.xhtml": gnTestDoc} + gnLink := manifest.Link{ + Href: manifest.MustNewHREFFromString("~readium/guided-navigation.json?ref=a.xhtml", false), + } + + private := HTMLGuidedNavigationServiceFactory()(NewContext(m, f), false) + assert.Empty(t, private.Links()) + _, ok := private.Get(context.Background(), gnLink) + assert.False(t, ok) + + public := HTMLGuidedNavigationServiceFactory()(NewContext(m, f), true) + assert.Equal(t, manifest.LinkList{GuidedNavigationLink}, public.Links()) + res, ok := public.Get(context.Background(), gnLink) + require.True(t, ok) + data, rerr := res.Read(context.Background(), 0, 0) + require.Nil(t, rerr) + assert.NotEmpty(t, data) +} + +// Converter options are threaded through, e.g. textref locators. +func TestHTMLGuidedNavigationServiceConverterOptions(t *testing.T) { + m := manifest.Manifest{ReadingOrder: manifest.LinkList{gnHTMLLink("a.xhtml")}} + f := stringFetcher{"a.xhtml": gnTestDoc} + service, ok := HTMLGuidedNavigationServiceFactory(converter.WithTextRefLocators())(NewContext(m, f), false).(*HTMLGuidedNavigationService) + require.True(t, ok) + + doc, err := service.GuideForResource(context.Background(), "a.xhtml") + require.NoError(t, err) + require.NotEmpty(t, doc.Guided) + require.NotEmpty(t, doc.Guided[0].Children) + assert.Equal(t, "body > p", doc.Guided[0].Children[0].TextCSSSelector()) +} + +// Without (X)HTML documents there is no guided navigation service at all. +func TestHTMLGuidedNavigationServiceAbsentWithoutHTML(t *testing.T) { + m := manifest.Manifest{ + ReadingOrder: manifest.LinkList{{ + Href: manifest.MustNewHREFFromString("audio/a.mp3", false), + MediaType: &mediatype.MPEGAudio, + }}, + } + assert.Nil(t, HTMLGuidedNavigationServiceFactory()(NewContext(m, stringFetcher{}), false)) +} From 14e06df60216a83118caf7b84c48505552c20000 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 17 Aug 2026 01:53:47 -0700 Subject: [PATCH 2/4] add locators in guided navigation doc --- pkg/guidednavigation/converter/html.go | 71 ++++++++++++++++++++++---- pkg/guidednavigation/fragments.go | 47 ++++++++++++++++- pkg/guidednavigation/fragments_test.go | 24 +++++++++ pkg/internal/util/css.go | 13 +++-- pkg/internal/util/css_test.go | 47 +++++++++++++++++ 5 files changed, 185 insertions(+), 17 deletions(-) diff --git a/pkg/guidednavigation/converter/html.go b/pkg/guidednavigation/converter/html.go index 872b6477..69e70db0 100644 --- a/pkg/guidednavigation/converter/html.go +++ b/pkg/guidednavigation/converter/html.go @@ -9,6 +9,7 @@ import ( "unicode" "github.com/readium/go-toolkit/pkg/guidednavigation" + iutil "github.com/readium/go-toolkit/pkg/internal/util" "github.com/readium/go-toolkit/pkg/manifest" "github.com/readium/go-toolkit/pkg/util/url" "golang.org/x/net/html" @@ -340,9 +341,24 @@ func (a *idAllocator) allocate(prefix string) string { // contain noterefs. Beyond it, notes are referenced by textref instead. const maxNoterefDepth = 3 +// An Option configures an [HTMLConverter]. +type Option func(*HTMLConverter) + +// WithTextRefLocators makes every emitted object whose element carries roles +// reference its location in the source document through textref: the element's +// fragment id when it has one (e.g. "chapter.xhtml#par1"), a css() fragment with +// a unique CSS selector otherwise (e.g. "chapter.xhtml#css(body%20%3E%20p:nth-child(3))"). +// The selectors are anchored to the closest ancestor with an id when there is one. +func WithTextRefLocators() Option { + return func(c *HTMLConverter) { + c.textRefLocators = true + } +} + type HTMLConverter struct { - baseLocator manifest.Locator - xmlParsed bool // Whether the tree comes from an XML parser (self-closing tags handled correctly). + baseLocator manifest.Locator + xmlParsed bool // Whether the tree comes from an XML parser (self-closing tags handled correctly). + textRefLocators bool // Whether every object with roles gets a textref locating its source element. 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. @@ -362,10 +378,14 @@ type HTMLConverter struct { allowNode *html.Node // Node exempt from suppression/visibility checks (target of a noteref sub-conversion). } -func NewHTMLConverter(baseLocator manifest.Locator) *HTMLConverter { - return &HTMLConverter{ +func NewHTMLConverter(baseLocator manifest.Locator, opts ...Option) *HTMLConverter { + c := &HTMLConverter{ baseLocator: baseLocator, } + for _, opt := range opts { + opt(c) + } + return c } // Whether an element opens (and closes) a navigation object during the traversal. @@ -393,6 +413,24 @@ func (c *HTMLConverter) fragmentRef(id string) url.URL { return c.baseLocator.Href.Resolve(frag) } +// Builds a reference locating an element of the converted resource: the element's +// fragment id when it has one, a css() fragment with a unique CSS selector otherwise, +// e.g. "chapter.xhtml#css(body%20%3E%20p:nth-child(3))". +func (c *HTMLConverter) nodeRef(n *html.Node) url.URL { + if id := getAttr(n, "id"); id != "" { + return c.fragmentRef(id) + } + sel := iutil.CSSSelector(n) + if sel == "" || c.baseLocator.Href == nil { + return nil + } + frag, err := url.URLFromGo(&nurl.URL{Fragment: "css(" + sel + ")"}) + 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 { @@ -703,6 +741,11 @@ func (c *HTMLConverter) head(n *html.Node) { if n.DataAtom == atom.Body { // Contextualize the top-level object per the specification cur.TextRef = c.baseLocator.Href + } else if c.textRefLocators { + // Locate every object carrying roles in the source document + if len(roles) > 0 { + cur.TextRef = c.nodeRef(n) + } } else if len(roles) > 0 && !c.current.noText { if id := getAttr(n, "id"); id != "" { cur.TextRef = c.fragmentRef(id) @@ -808,6 +851,11 @@ func (c *HTMLConverter) placeholderWithID(n *html.Node, tag string, object guide // Registering it anyway would leave a dangling id in the SSML. return } + if c.textRefLocators && object.TextRef == nil { + // Locate the object's source element (checked after Empty so that + // skipped elements don't come back as bare locators) + object.TextRef = c.nodeRef(n) + } child := &navigationObject{node: n, object: object} if c.current.noText { // The surrounding text is suppressed: keep the object, without a placeholder @@ -875,13 +923,14 @@ func (c *HTMLConverter) noteref(n *html.Node, roles []guidednavigation.GuidedNav // 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, + baseLocator: c.baseLocator, + xmlParsed: c.xmlParsed, + textRefLocators: c.textRefLocators, + ids: c.ids, + suppressed: c.suppressed, + idAlloc: c.idAlloc, + noterefDepth: c.noterefDepth + 1, + allowNode: target, } sub.Convert(target) obj.Children = sub.Result() diff --git a/pkg/guidednavigation/fragments.go b/pkg/guidednavigation/fragments.go index 94ac54c3..32731db3 100644 --- a/pkg/guidednavigation/fragments.go +++ b/pkg/guidednavigation/fragments.go @@ -21,6 +21,9 @@ import ( // 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/ +// - Text: css() selector fragments locating an element of an (X)HTML resource, +// e.g. "chapter.html#css(body%20%3E%20p:nth-child(3))", as emitted by the +// converter package's WithTextRefLocators option // The unit of a spatial fragment's coordinates. type RegionUnit string @@ -161,6 +164,13 @@ func (o GuidedNavigationObject) TextFragments() []TextFragment { return refTextFragments(o.TextRef) } +// TextCSSSelector returns the CSS selector of the object's text reference +// (e.g. "body > p:nth-child(3)" for "chapter.html#css(body%20%3E%20p:nth-child(3))"), +// or an empty string when the reference has no css() fragment. +func (o GuidedNavigationObject) TextCSSSelector() string { + return refCSSSelector(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) @@ -223,6 +233,12 @@ func (d GuidedNavigationDescription) TextFragments() []TextFragment { return refTextFragments(d.TextRef) } +// TextCSSSelector returns the CSS selector of the description's text reference, +// or an empty string when the reference has no css() fragment. +func (d GuidedNavigationDescription) TextCSSSelector() string { + return refCSSSelector(d.TextRef) +} + // The reference without its fragment. func refFile(ref url.URL) url.URL { if ref == nil { @@ -503,11 +519,40 @@ func refFragmentID(ref url.URL) string { // 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 + fragment = decoded + } + if _, ok := cssSelectorFragment(fragment); ok { + // A css() fragment is a selector, not an id + return "" } return fragment } +// The selector of a css() fragment, e.g. "css(body > p)" -> "body > p". +// The fragment is expected in decoded form. +func cssSelectorFragment(fragment string) (string, bool) { + inner, ok := strings.CutPrefix(fragment, "css(") + if !ok { + return "", false + } + inner, ok = strings.CutSuffix(inner, ")") + if !ok { + return "", false + } + return inner, true +} + +func refCSSSelector(ref url.URL) string { + if ref == nil { + return "" + } + fragment := ref.Fragment() + // A fragment directive doesn't belong to the fragment preceding it + fragment, _, _ = strings.Cut(fragment, textDirectiveDelimiter) + sel, _ := cssSelectorFragment(fragment) + return sel +} + func refTextFragments(ref url.URL) []TextFragment { if ref == nil { return nil diff --git a/pkg/guidednavigation/fragments_test.go b/pkg/guidednavigation/fragments_test.go index 4e281907..0fa8fe95 100644 --- a/pkg/guidednavigation/fragments_test.go +++ b/pkg/guidednavigation/fragments_test.go @@ -200,6 +200,8 @@ func TestTextFragmentID(t *testing.T) { {"chapter1.html#id:~:text=highlighted", "id"}, {"chapter1.html#:~:text=highlighted", ""}, {"chapter1.html", ""}, + // A css() fragment is a selector, not an id + {"chapter1.html#css(body%20%3E%20p:nth-child(3))", ""}, } for _, tt := range tests { obj := GuidedNavigationObject{TextRef: url.MustURLFromString(tt.ref)} @@ -208,6 +210,28 @@ func TestTextFragmentID(t *testing.T) { assert.Empty(t, GuidedNavigationObject{}.TextFragmentID()) } +func TestTextCSSSelector(t *testing.T) { + tests := []struct { + ref string + selector string + }{ + {"chapter1.html#css(body%20%3E%20p:nth-child(3))", "body > p:nth-child(3)"}, + {"chapter1.html#css(%23s2%20%3E%20p)", "#s2 > p"}, + {"chapter1.html#css(body%20%3E%20img)", "body > img"}, + {"chapter1.html#start", ""}, + {"chapter1.html#css(unterminated", ""}, + {"chapter1.html", ""}, + } + for _, tt := range tests { + obj := GuidedNavigationObject{TextRef: url.MustURLFromString(tt.ref)} + assert.Equal(t, tt.selector, obj.TextCSSSelector(), tt.ref) + desc := GuidedNavigationDescription{TextRef: url.MustURLFromString(tt.ref)} + assert.Equal(t, tt.selector, desc.TextCSSSelector(), tt.ref) + } + assert.Empty(t, GuidedNavigationObject{}.TextCSSSelector()) + assert.Empty(t, GuidedNavigationDescription{}.TextCSSSelector()) +} + func TestTextFragments(t *testing.T) { // Example from the specification's README obj := GuidedNavigationObject{TextRef: url.MustURLFromString( diff --git a/pkg/internal/util/css.go b/pkg/internal/util/css.go index e665f313..8044b4ed 100644 --- a/pkg/internal/util/css.go +++ b/pkg/internal/util/css.go @@ -7,7 +7,6 @@ import ( "github.com/agext/regexp" "github.com/andybalholm/cascadia" - "github.com/pkg/errors" "golang.org/x/net/html" "golang.org/x/net/html/atom" ) @@ -101,11 +100,15 @@ func CSSSelector(n *html.Node) string { return selector.String() } - s, err := cascadia.Parse(selector.String()) - if err != nil { - panic(errors.Wrap(err, "failed parsing generated CSS selector")) + // Check whether the selector needs disambiguation among the parent's contents. + // When the rudimentary escaping above produces a selector cascadia fails to + // parse, matches cannot be counted: conservatively pin the element's position, + // a redundant nth-child is still correct. + ambiguous := true + if s, err := cascadia.Parse(selector.String()); err == nil { + ambiguous = len(cascadia.QueryAll(n.Parent, s)) > 1 } - if nodes := cascadia.QueryAll(n.Parent, s); len(nodes) > 1 { + if ambiguous { // Figure out the index of this node among its siblings idx := 1 for ps := n.PrevSibling; ps != nil; ps = ps.PrevSibling { diff --git a/pkg/internal/util/css_test.go b/pkg/internal/util/css_test.go index edbd7aa8..09f2331a 100644 --- a/pkg/internal/util/css_test.go +++ b/pkg/internal/util/css_test.go @@ -48,3 +48,50 @@ func TestCSSSelector(t *testing.T) { assert.Equal(t, qf("#pgepubid00498 > p:nth-child(5)"), "#pgepubid00498 > p:nth-child(5)") assert.Equal(t, qf("#pgepubid00498 > p:nth-child(4) > span"), "#pgepubid00498 > p:nth-child(4) > span") } + +// Hostile identifiers must never panic the selector generator, and the escaped +// selectors should round-trip: parsing them back finds exactly the source element. +func TestCSSSelectorHostileIdentifiers(t *testing.T) { + docs := []string{ + `

x

y

`, + `

x

`, + `

x

`, + `

x

`, + `

x

`, + `

x

`, + `

x

`, + `

x

`, + `

x

`, + `

x

`, + `

x

y

`, + `
x
`, + } + for _, doc := range docs { + root, err := html.Parse(strings.NewReader(doc)) + require.NoError(t, err) + + // The target is the first

(or unknown prefixed element) in the doc + var target *html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && (n.Data == "p" || strings.Contains(n.Data, ":")) { + target = n + return + } + for c := n.FirstChild; c != nil && target == nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + require.NotNil(t, target, doc) + + sel := CSSSelector(target) + assert.NotEmpty(t, sel, doc) + + s, err := cascadia.Parse(sel) + if !assert.NoError(t, err, "generated selector %q should parse", sel) { + continue + } + assert.Equal(t, target, cascadia.Query(root, s), "%q should round-trip", sel) + } +} From 00cf5227d5f81f0e83ef3a1d2963696de64a321a Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 17 Aug 2026 01:54:12 -0700 Subject: [PATCH 3/4] add parseOptInt64 --- pkg/manifest/encryption.go | 4 ++-- pkg/manifest/utils.go | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/manifest/encryption.go b/pkg/manifest/encryption.go index ce60c148..2d8b9977 100644 --- a/pkg/manifest/encryption.go +++ b/pkg/manifest/encryption.go @@ -27,9 +27,9 @@ func EncryptionFromJSON(rawJson map[string]interface{}) (*Encryption, error) { e := new(Encryption) e.Algorithm = algorithm e.Compression = parseOptString(rawJson["compression"]) - e.OriginalLength = int64(parseOptFloat64(rawJson["originalLength"])) + e.OriginalLength = parseOptInt64(rawJson["originalLength"]) if e.OriginalLength == 0 { - e.OriginalLength = int64(parseOptFloat64(rawJson["original-length"])) + e.OriginalLength = parseOptInt64(rawJson["original-length"]) } e.Profile = parseOptString(rawJson["profile"]) e.Scheme = parseOptString(rawJson["scheme"]) diff --git a/pkg/manifest/utils.go b/pkg/manifest/utils.go index 71ee35cc..68f72c6d 100644 --- a/pkg/manifest/utils.go +++ b/pkg/manifest/utils.go @@ -95,6 +95,22 @@ func parseOptFloat64(raw interface{}) float64 { return rb } +// parseOptInt64 reads an integer that may come from JSON (float64) or from +// in-memory maps built with native integer types. +func parseOptInt64(raw interface{}) int64 { + switch v := raw.(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case uint64: + return int64(v) + } + return 0 +} + func float64ToUint(f float64) uint { if f < 0 { return 0 From fed4b8a3bc4bcf866c1f9d71d6759761c3d96b49 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 17 Aug 2026 01:54:29 -0700 Subject: [PATCH 4/4] font obfuscation fix --- pkg/parser/epub/deobfuscator.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/parser/epub/deobfuscator.go b/pkg/parser/epub/deobfuscator.go index 792db485..19f7ab13 100644 --- a/pkg/parser/epub/deobfuscator.go +++ b/pkg/parser/epub/deobfuscator.go @@ -63,6 +63,12 @@ func (d DeobfuscatingResource) Read(ctx context.Context, start, end int64) ([]by shasum := sha1.Sum([]byte(d.identifier)) obfuscationKey = shasum[:] } + + // If getHashKeyAdobe() is blank, meaning the hex decoding of the UUID failed + if len(obfuscationKey) == 0 { + return nil, fetcher.Other(errors.New("error deriving font deobfuscation key")) + } + deobfuscateFont(data, start, obfuscationKey, v) return data, nil } @@ -231,7 +237,7 @@ func (d DeobfuscatingResource) getHashKeyAdobe() []byte { } func deobfuscateFont(data []byte, start int64, obfuscationKey []byte, obfuscationLength int64) { - if start >= obfuscationLength { + if start >= obfuscationLength || len(obfuscationKey) == 0 { return } max := obfuscationLength - start