diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index ab7de9c5..779480b8 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -7,6 +7,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- **URI-templated manifest links** are now expanded for guided-navigation and + sync-narration sidecar resources on web, iOS, and Android. - **Reader could report `ready` and then never emit a text locator (iOS, Android).** Locator enrichment (a JavaScript page-info call plus a ToC lookup) was unbounded, so a stalled platform webview silently froze `onTextLocatorChanged` for good. Enrichment now diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/LinkTemplateResolver.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/LinkTemplateResolver.kt new file mode 100644 index 00000000..81801574 --- /dev/null +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/LinkTemplateResolver.kt @@ -0,0 +1,119 @@ +package dk.nota.flutterreadium + +import org.readium.r2.shared.publication.Href +import org.readium.r2.shared.publication.Link +import java.util.concurrent.ConcurrentHashMap + +internal sealed interface LinkTemplateResolution { + data class Resolved( + val link: Link, + ) : LinkTemplateResolution + + data class Unresolved( + val reason: Reason, + val missingVariables: List = emptyList(), + ) : LinkTemplateResolution { + enum class Reason { + MISSING_VARIABLE, + INVALID_TEMPLATE, + INVALID_HREF, + } + } +} + +internal object LinkTemplateResolver { + private val reportedFailures = ConcurrentHashMap.newKeySet() + + fun resolve( + link: Link, + parameters: Map = emptyMap(), + ): LinkTemplateResolution { + if (!link.href.isTemplated) { + return LinkTemplateResolution.Resolved(link) + } + + if (!isValidTemplate(link.href.toString())) { + return LinkTemplateResolution.Unresolved( + LinkTemplateResolution.Unresolved.Reason.INVALID_TEMPLATE, + ) + } + + val missing = + link.href.parameters + .orEmpty() + .filter { it !in parameters } + .distinct() + .sorted() + if (missing.isNotEmpty()) { + return LinkTemplateResolution.Unresolved( + LinkTemplateResolution.Unresolved.Reason.MISSING_VARIABLE, + missing, + ) + } + + val expanded = link.url(parameters = parameters) + val resolvedHref = Href(expanded) + return if (resolvedHref == null || resolvedHref.isTemplated) { + LinkTemplateResolution.Unresolved( + LinkTemplateResolution.Unresolved.Reason.INVALID_HREF, + ) + } else { + LinkTemplateResolution.Resolved(link.copy(href = resolvedHref)) + } + } + + fun shouldReport( + link: Link, + resolution: LinkTemplateResolution.Unresolved, + ): Boolean = reportedFailures.add("${link.href}|${resolution.reason}|${resolution.missingVariables}") + + fun parameters( + resourceLink: Link?, + sidecarLink: Link? = null, + ): Map { + if (resourceLink == null) return emptyMap() + + val href = resourceLink.href.toString() + val parameters = + mutableMapOf( + "ref" to href, + "resource" to href, + ) + val fragment = href.substringAfter('#', "") + if (fragment.isNotEmpty()) { + parameters["id"] = fragment + } + if (sidecarLink != null) { + parameters["mediaOverlay"] = sidecarLink.href.toString() + parameters["media-overlay"] = sidecarLink.href.toString() + } + return parameters + } + + private fun isValidTemplate(href: String): Boolean { + val expression = Regex("""\{([^{}]*)\}""") + var cursor = 0 + while (cursor < href.length) { + val open = href.indexOf('{', cursor) + val close = href.indexOf('}', cursor) + if (open == -1) return close == -1 + if (close == -1 || close < open) return false + + val body = href.substring(open + 1, close) + val variables = if (body.startsWith('?')) body.substring(1) else body + if (variables.isEmpty() || (!body.startsWith("?") && body.startsWith("#"))) { + return false + } + if ( + variables.split(',').any { + !it.matches(Regex("""[A-Za-z][A-Za-z0-9._-]*""")) + } + ) { + return false + } + + cursor = close + 1 + } + return expression.findAll(href).count() > 0 || href.none { it == '}' } + } +} diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt index 0df979b5..120545f7 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt @@ -48,6 +48,32 @@ import org.readium.r2.navigator.preferences.Color as ReadiumColor private const val TAG = "ReadiumExtensions" +private fun resolvedTemplateLink( + link: Link, + parameters: Map, +): Link? = + when (val resolution = LinkTemplateResolver.resolve(link, parameters)) { + is LinkTemplateResolution.Resolved -> { + resolution.link + } + + is LinkTemplateResolution.Unresolved -> { + if (LinkTemplateResolver.shouldReport(link, resolution)) { + PluginLog.w( + TAG, + "::resolvedTemplateLink. URI template could not be resolved for " + + "${link.href}: ${resolution.reason}" + + resolution.missingVariables + .takeIf { it.isNotEmpty() } + ?.let { + " (${it.joinToString()})" + }.orEmpty(), + ) + } + null + } + } + /** * The [HttpError] found by unwrapping this error's cause chain, if any. Readium/ExoPlayer * errors often wrap the real [HttpError] at some depth (e.g. `ReadError.Access(HttpError)`), @@ -283,10 +309,11 @@ suspend fun Publication.getMediaOverlays(): List? { if (!hasMediaOverlays()) return null val overlayLinks = - this.readingOrder.mapNotNull { r -> - r.alternates - .find { a -> a.mediaType == syncNarrationsMediaType } - ?.copy(title = r.title) + this.readingOrder.withIndex().mapNotNull { (position, resourceLink) -> + resourceLink.alternates + .find { alternate -> alternate.mediaType == syncNarrationsMediaType } + ?.copy(title = resourceLink.title) + ?.let { overlayLink -> Triple(position, resourceLink, overlayLink) } } // Fetch+parse every overlay JSON in parallel on IO. Cap is configurable so we don't open @@ -295,10 +322,14 @@ suspend fun Publication.getMediaOverlays(): List? { coroutineScope { val gate = Semaphore(permits = mediaOverlayFetchConcurrency) overlayLinks - .mapIndexed { index, link -> + .map { (position, resourceLink, link) -> async(Dispatchers.IO) { gate.withPermit { - val resource = get(link) + val parameters = LinkTemplateResolver.parameters(resourceLink, link) + val resolvedLink = + resolvedTemplateLink(link, parameters) + ?: return@withPermit null + val resource = get(resolvedLink) if (resource == null) { PluginLog.w(TAG, "::getMediaOverlays() - no resource for ${link.href}") return@withPermit null @@ -318,7 +349,7 @@ suspend fun Publication.getMediaOverlays(): List? { return@withPermit FlutterMediaOverlay.fromJson( JSONObject(jsonString), - index + 1, + position + 1, null, link.title ?: "", duration, @@ -345,8 +376,9 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List roLink.alternates.find { it.mediaType == guidedNavigationMediaType } } - .distinctBy { it.href } + .mapNotNull { roLink -> + val guidedLink = + roLink.alternates.find { it.mediaType == guidedNavigationMediaType } + ?: return@mapNotNull null + resolvedTemplateLink( + guidedLink, + LinkTemplateResolver.parameters(roLink, guidedLink), + ) + }.distinctBy { it.href.toString() } if (guidedLinks.isEmpty()) return null val parsed: List?> = diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/LinkTemplateResolver.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/LinkTemplateResolver.swift new file mode 100644 index 00000000..1f7ddcbb --- /dev/null +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/LinkTemplateResolver.swift @@ -0,0 +1,130 @@ +import Foundation +import ReadiumShared + +enum LinkTemplateResolutionError: Error, Equatable, CustomStringConvertible { + case missingVariables([String]) + case invalidTemplate + case invalidHref + + var description: String { + switch self { + case .missingVariables(let variables): + return "missing variables: \(variables.joined(separator: ", "))" + case .invalidTemplate: + return "invalid URI template syntax" + case .invalidHref: + return "expanded href is not fetchable" + } + } +} + +enum LinkTemplateResolver { + private static let failureReporter = FailureReporter() + + static func resolve( + _ link: Link, + parameters: [String: String] = [:] + ) -> Result { + guard link.templated else { + return .success(link) + } + + guard isValidTemplate(link.href) else { + return .failure(.invalidTemplate) + } + + let missing = link.templateParameters.filter { parameters[$0] == nil }.sorted() + guard missing.isEmpty else { + return .failure(.missingVariables(missing)) + } + + var resolved = link + resolved.expandTemplate(with: parameters) + guard !resolved.href.contains("{"), !resolved.href.contains("}") else { + return .failure(.invalidTemplate) + } + guard !resolved.href.isEmpty else { + return .failure(.invalidHref) + } + return .success(resolved) + } + + static func shouldReport( + _ link: Link, + error: LinkTemplateResolutionError + ) -> Bool { + failureReporter.shouldReport("\(link.href)|\(error)") + } + + static func parameters( + for resourceLink: Link?, + sidecarLink: Link? = nil + ) -> [String: String] { + guard let resourceLink else { + return [:] + } + + var parameters = [ + "ref": resourceLink.href, + "resource": resourceLink.href, + ] + if let fragmentStart = resourceLink.href.firstIndex(of: "#") { + let idStart = resourceLink.href.index(after: fragmentStart) + if idStart < resourceLink.href.endIndex { + parameters["id"] = String(resourceLink.href[idStart...]) + } + } + if let sidecarLink { + parameters["mediaOverlay"] = sidecarLink.href + parameters["media-overlay"] = sidecarLink.href + } + return parameters + } + + private static func isValidTemplate(_ href: String) -> Bool { + var cursor = href.startIndex + + while let open = href[cursor...].firstIndex(of: "{") { + guard let close = href[open...].firstIndex(of: "}") else { + return false + } + if href[open...].dropFirst().firstIndex(of: "{") != nil, + href[open...].dropFirst().firstIndex(of: "{")! < close { + return false + } + + let bodyStart = href.index(after: open) + let body = String(href[bodyStart..() + + func shouldReport(_ key: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return reportedKeys.insert(key).inserted + } +} diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift index 5dcf1e36..687a80c3 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/utils/ReadiumExtensions.swift @@ -4,6 +4,21 @@ import ReadiumNavigator import ReadiumShared import ReadiumInternal +private func resolvedTemplateLink( + _ link: Link, + parameters: [String: String] +) -> Link? { + switch LinkTemplateResolver.resolve(link, parameters: parameters) { + case .success(let resolved): + return resolved + case .failure(let error): + if LinkTemplateResolver.shouldReport(link, error: error) { + Log.readium.warn("URI template could not be resolved for \(link.href): \(error)") + } + return nil + } +} + extension Locator { var timeOffset: TimeInterval? { MediaTimeFragment.seconds(from: locations.fragments) @@ -133,16 +148,42 @@ extension Publication { func getMediaOverlays() async -> [FlutterMediaOverlay]? { guard containsMediaOverlays else { return nil } - let narrationLinks = self.narrationLinks + let narrationLinks = readingOrder.enumerated().compactMap { + position, resourceLink -> (position: Int, resource: Link, overlay: Link)? in + guard var overlayLink = resourceLink.alternates + .filterByMediaType(MediaType("application/vnd.syncnarr+json")!) + .first else { + return nil + } + overlayLink.title = resourceLink.title + return (position: position, resource: resourceLink, overlay: overlayLink) + } - let narrationJson = await narrationLinks.asyncCompactMap { try? await self.get($0)?.read().asJSONObject().get() } - let rawOverlays = narrationJson.enumerated().compactMap({ idx, json in - let roDuration = readingOrder.getOrNil(idx)?.duration - return FlutterMediaOverlay.fromJson(json, atPosition: idx, atTocHref: nil, readingOrderDuration: roDuration) - }) + var narrationJson: [(position: Int, resource: Link, json: [String: Any])] = [] + for entry in narrationLinks { + let parameters = LinkTemplateResolver.parameters( + for: entry.resource, + sidecarLink: entry.overlay + ) + guard let resolved = resolvedTemplateLink(entry.overlay, parameters: parameters) else { + continue + } + guard let json = try? await self.get(resolved)?.read().asJSONObject().get() else { + continue + } + narrationJson.append((position: entry.position, resource: entry.resource, json: json)) + } + let rawOverlays = narrationJson.compactMap { entry -> FlutterMediaOverlay? in + return FlutterMediaOverlay.fromJson( + entry.json, + atPosition: entry.position, + atTocHref: nil, + readingOrderDuration: entry.resource.duration + ) + } - // Assert that we did not lose any MediaOverlays during JSON deserialization. - assert(rawOverlays.count == narrationLinks.count) + // Templated optional sidecars can be unresolved; only parsed overlays are returned. + assert(rawOverlays.count <= narrationLinks.count) return enrichOverlaysWithToc(rawOverlays) } @@ -154,8 +195,11 @@ extension Publication { // Strategy 1: single guided navigation document in publication links (preferred). if let singleDocLink = links.filterByMediaType(guidedNavMediaType).first { + guard let resolved = resolvedTemplateLink(singleDocLink, parameters: [:]) else { + return nil + } guard - let json = try? await get(singleDocLink)?.read().asJSONObject().get(), + let json = try? await get(resolved)?.read().asJSONObject().get(), let document = GuidedNavigationDocument.fromJson(json) else { return nil } @@ -184,10 +228,14 @@ extension Publication { for (_, roLink) in readingOrder.enumerated() { guard let gnLink = roLink.alternates.filterByMediaType(guidedNavMediaType).first else { continue } hasAny = true - guard !seenHrefs.contains(gnLink.href) else { continue } - seenHrefs.insert(gnLink.href) + let parameters = LinkTemplateResolver.parameters(for: roLink, sidecarLink: gnLink) + guard let resolved = resolvedTemplateLink(gnLink, parameters: parameters) else { + continue + } + guard !seenHrefs.contains(resolved.href) else { continue } + seenHrefs.insert(resolved.href) guard - let json = try? await get(gnLink)?.read().asJSONObject().get(), + let json = try? await get(resolved)?.read().asJSONObject().get(), let document = GuidedNavigationDocument.fromJson(json) else { continue } let rawOverlays = document.toMediaOverlays() diff --git a/flutter_readium/web/src/__tests__/linkTemplate.test.ts b/flutter_readium/web/src/__tests__/linkTemplate.test.ts new file mode 100644 index 00000000..4db51eab --- /dev/null +++ b/flutter_readium/web/src/__tests__/linkTemplate.test.ts @@ -0,0 +1,99 @@ +import { Link } from "@readium/shared"; +import { + linkTemplateContext, + resolveLink, +} from "../utils/linkTemplate"; + +describe("resolveLink", () => { + it("expands form-style query templates and resolves the result against the base URL", () => { + const link = new Link({ + href: "~readium/guided-navigation.json{?ref}", + templated: true, + type: "application/guided-navigation+json", + }); + + const result = resolveLink( + link, + { ref: "chapters/one.xhtml" }, + "https://example.test/book/" + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.link.href).toBe( + "~readium/guided-navigation.json?ref=chapters%2Fone.xhtml" + ); + expect(result.link.templated).toBe(false); + expect(result.url).toBe( + "https://example.test/book/~readium/guided-navigation.json?ref=chapters%2Fone.xhtml" + ); + } + }); + + it("uses the standard resource context for sidecar links", () => { + const resource = new Link({ href: "chapter.xhtml#section-1" }); + const sidecar = new Link({ + href: "overlay.json{?ref,id}", + templated: true, + }); + + const result = resolveLink( + sidecar, + linkTemplateContext(resource, sidecar), + "https://example.test/book/" + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.link.href).toBe( + "overlay.json?ref=chapter.xhtml%23section-1&id=section-1" + ); + } + }); + + it("returns a typed failure when a variable is missing", () => { + const link = new Link({ href: "search{?query}", templated: true }); + + expect(resolveLink(link, {}, "https://example.test/book/")).toEqual({ + ok: false, + link, + reason: "missing-variable", + missingVariables: ["query"], + }); + }); + + it("returns a typed failure for malformed template syntax", () => { + const link = new Link({ href: "search{?query", templated: true }); + + expect(resolveLink(link, { query: "readium" })).toEqual({ + ok: false, + link, + reason: "invalid-template", + }); + }); + + it("returns a typed failure when expansion produces an invalid href", () => { + const link = new Link({ + href: "https://[invalid]{?ref}", + templated: true, + }); + + expect(resolveLink(link, { ref: "chapter.xhtml" })).toEqual({ + ok: false, + link, + reason: "invalid-href", + }); + }); + + it("leaves non-templated links unchanged", () => { + const link = new Link({ href: "chapter.xhtml" }); + + const result = resolveLink(link, {}, "https://example.test/book/"); + + expect(result).toEqual({ + ok: true, + link, + url: "https://example.test/book/chapter.xhtml", + }); + }); +}); diff --git a/flutter_readium/web/src/__tests__/syncNarration.test.ts b/flutter_readium/web/src/__tests__/syncNarration.test.ts index 3fcc0129..cb3028a7 100644 --- a/flutter_readium/web/src/__tests__/syncNarration.test.ts +++ b/flutter_readium/web/src/__tests__/syncNarration.test.ts @@ -8,14 +8,22 @@ * - findItemByAudioTime */ -import { Link, Locator, LocatorLocations, LocatorText } from "@readium/shared"; +import { + Link, + Links, + Locator, + LocatorLocations, + LocatorText, +} from "@readium/shared"; import { SyncNarrationItem, combinedLocatorForItem, findItemByAudioTime, + parseSyncNarration, textLocatorForItem, textLocatorToAudioLocator, } from "../mediaoverlay/syncNarration"; +import { ReadiumPublication } from "../utils/ReadiumExtensions"; // --------------------------------------------------------------------------- // Helpers @@ -55,6 +63,25 @@ function textLocator(href: string, id?: string, progression?: number): Locator { }); } +function makeNarrationPublication( + readingOrder: Link[], + responses: Record +): { + publication: ReadiumPublication; + get: jest.Mock; +} { + const get = jest.fn((link: Link) => ({ + readAsJSON: async () => responses[link.href], + })); + const publication = { + baseURL: "https://example.test/book/", + readingOrder: { items: readingOrder }, + manifest: { toc: undefined }, + get, + } as unknown as ReadiumPublication; + return { publication, get }; +} + // --------------------------------------------------------------------------- // textLocatorForItem // --------------------------------------------------------------------------- @@ -67,6 +94,72 @@ describe("textLocatorForItem", () => { expect(loc.type).toBe("text/html"); }); + describe("parseSyncNarration URI templates", () => { + const narration = { + narration: [{ audio: "chapter.mp3#t=0,3", text: "chapter.xhtml#p1" }], + }; + + it("expands an alternate template using the owning reading-order resource", async () => { + const sidecar = new Link({ + href: "overlay.json{?ref}", + templated: true, + type: "application/vnd.readium.narration+json", + }); + const chapter = new Link({ + href: "chapter.xhtml", + alternates: new Links([sidecar]), + }); + const { publication, get } = makeNarrationPublication( + [chapter], + { "overlay.json?ref=chapter.xhtml": narration } + ); + + const items = await parseSyncNarration(publication); + + expect(items).toHaveLength(1); + expect(items[0].audioHref).toBe("chapter.mp3"); + expect(get).toHaveBeenCalledWith( + expect.objectContaining({ + href: "overlay.json?ref=chapter.xhtml", + templated: false, + }) + ); + }); + + it("skips unresolved optional templates while preserving plain sidecars", async () => { + const unresolvedSidecar = new Link({ + href: "overlay.json{?missing}", + templated: true, + type: "application/vnd.readium.narration+json", + }); + const plainSidecar = new Link({ + href: "plain-overlay.json", + type: "application/vnd.readium.narration+json", + }); + const unresolvedChapter = new Link({ + href: "missing.xhtml", + alternates: new Links([unresolvedSidecar]), + }); + const plainChapter = new Link({ + href: "plain.xhtml", + alternates: new Links([plainSidecar]), + }); + const { publication, get } = makeNarrationPublication( + [unresolvedChapter, plainChapter], + { "plain-overlay.json": narration } + ); + + const items = await parseSyncNarration(publication); + + expect(items).toHaveLength(1); + expect(items[0].position).toBe(1); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith( + expect.objectContaining({ href: "plain-overlay.json" }) + ); + }); + }); + it("includes the textId as a fragment when present", () => { const item = makeItem({ textHref: "chap1.html", textId: "par001", audioHref: "chap1.mp3", audioStart: 0, audioEnd: 3 }); const loc = textLocatorForItem(item); diff --git a/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts b/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts index 9917aa91..1c07864c 100644 --- a/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts +++ b/flutter_readium/web/src/mediaoverlay/guidedNavigation.ts @@ -16,6 +16,10 @@ import { Link, Resource } from "@readium/shared"; import { ReadiumPublication } from "../utils/ReadiumExtensions"; +import { + linkTemplateContext, + resolveLink, +} from "../utils/linkTemplate"; import { createLogger } from "../utils/ReadiumPluginLogger"; import { SyncNarrationItem, @@ -105,9 +109,12 @@ async function _parsePublicationLevelDocument( publication: ReadiumPublication, link: Link ): Promise { + const resolved = resolveLink(link, {}, publication.baseURL); + if (!resolved.ok) return []; + let document: GuidedNavigationDocument | null = null; try { - const resource: Resource = publication.get(link); + const resource: Resource = publication.get(resolved.link); const json = await resource.readAsJSON(); document = _parseDocument(json); } catch (err) { @@ -139,12 +146,18 @@ async function _parseReadingOrderAlternates( if (!alternates) continue; const gnLink = alternates.findWithMediaType(GUIDED_NAVIGATION_MEDIA_TYPE); if (!gnLink) continue; - if (seenHrefs.has(gnLink.href)) continue; - seenHrefs.add(gnLink.href); + const resolved = resolveLink( + gnLink, + linkTemplateContext(roLink, gnLink), + publication.baseURL + ); + if (!resolved.ok) continue; + if (seenHrefs.has(resolved.link.href)) continue; + seenHrefs.add(resolved.link.href); let document: GuidedNavigationDocument | null = null; try { - const resource: Resource = publication.get(gnLink); + const resource: Resource = publication.get(resolved.link); const json = await resource.readAsJSON(); document = _parseDocument(json); } catch (err) { diff --git a/flutter_readium/web/src/mediaoverlay/syncNarration.ts b/flutter_readium/web/src/mediaoverlay/syncNarration.ts index eb5a5dc2..1ecf9404 100644 --- a/flutter_readium/web/src/mediaoverlay/syncNarration.ts +++ b/flutter_readium/web/src/mediaoverlay/syncNarration.ts @@ -18,6 +18,7 @@ import { Link, Locator, LocatorLocations, LocatorText, Resource } from "@readium/shared"; import { ReadiumPublication } from "../utils/ReadiumExtensions"; import { createLogger } from "../utils/ReadiumPluginLogger"; +import { linkTemplateContext, resolveLink } from "../utils/linkTemplate"; const log = createLogger("SyncNarration"); @@ -108,9 +109,15 @@ export async function parseSyncNarration( const link = publication.readingOrder.items[i]; const narrationLink = _narrationAlternate(link); if (!narrationLink) continue; + const resolved = resolveLink( + narrationLink, + linkTemplateContext(link, narrationLink), + publication.baseURL + ); + if (!resolved.ok) continue; try { - const resource: Resource = publication.get(narrationLink); + const resource: Resource = publication.get(resolved.link); const json = await resource.readAsJSON(); const items = _parseNarrationJson(json, i, link.duration); result.push(...items); diff --git a/flutter_readium/web/src/utils/linkTemplate.ts b/flutter_readium/web/src/utils/linkTemplate.ts new file mode 100644 index 00000000..d0326a50 --- /dev/null +++ b/flutter_readium/web/src/utils/linkTemplate.ts @@ -0,0 +1,174 @@ +import { Link } from "@readium/shared"; +import { createLogger } from "./ReadiumPluginLogger"; + +const log = createLogger("LinkTemplate"); +const reportedFailures = new Set(); + +export type LinkTemplateContext = Readonly>; + +export type LinkResolutionFailureReason = + | "missing-variable" + | "invalid-template" + | "invalid-href"; + +export type LinkResolution = + | { + ok: true; + link: Link; + url: string | undefined; + } + | { + ok: false; + link: Link; + reason: LinkResolutionFailureReason; + missingVariables?: string[]; + }; + +/** + * Resolves a Link Object URI template without changing non-templated links. + * + * URI templates are expanded only when the manifest explicitly marks the link + * as templated. Missing values and malformed expressions are returned as + * typed failures so optional sidecar callers can continue without issuing a + * request for the literal template. + */ +export function resolveLink( + link: Link, + context: LinkTemplateContext = {}, + baseURL?: string +): LinkResolution { + if (!link.templated) { + const url = safeToURL(link, baseURL); + return url === undefined + ? failure(link, "invalid-href") + : { ok: true, link, url }; + } + + const expressions = parseExpressions(link.href); + if (expressions === undefined) { + return failure(link, "invalid-template"); + } + + const parameters = expressions.flatMap((expression) => expression.parameters); + const missingVariables = parameters.filter((parameter) => !(parameter in context)); + if (missingVariables.length > 0) { + return failure(link, "missing-variable", [...new Set(missingVariables)]); + } + + const expandedHref = + expressions.length === 0 ? link.href : link.expandTemplate(context).href; + if (/[{}]/.test(expandedHref)) { + return failure(link, "invalid-template"); + } + + const resolvedLink = copyLink(link, expandedHref); + const url = safeToURL(resolvedLink, baseURL); + if (url === undefined) { + return failure(link, "invalid-href"); + } + + return { ok: true, link: resolvedLink, url }; +} + +/** + * Builds the standard context used when a sidecar Link is attached to a + * publication resource. + */ +export function linkTemplateContext( + resourceLink: Link, + sidecarLink?: Link +): LinkTemplateContext { + const context: Record = { + ref: resourceLink.href, + resource: resourceLink.href, + }; + + const fragmentIndex = resourceLink.href.indexOf("#"); + if (fragmentIndex >= 0 && fragmentIndex < resourceLink.href.length - 1) { + context.id = resourceLink.href.slice(fragmentIndex + 1); + } + + if (sidecarLink) { + context.mediaOverlay = sidecarLink.href; + context["media-overlay"] = sidecarLink.href; + } + + return context; +} + +interface TemplateExpression { + parameters: string[]; +} + +function parseExpressions(href: string): TemplateExpression[] | undefined { + const expressions: TemplateExpression[] = []; + let cursor = 0; + + while (cursor < href.length) { + const open = href.indexOf("{", cursor); + const close = href.indexOf("}", cursor); + + if (open === -1) { + return close === -1 ? expressions : undefined; + } + const nestedOpen = href.indexOf("{", open + 1); + if (close === -1 || close < open || (nestedOpen >= 0 && nestedOpen < close)) { + return undefined; + } + + const body = href.slice(open + 1, close); + const operator = body.startsWith("?") ? "?" : ""; + const variableList = operator ? body.slice(1) : body; + const parameters = variableList.split(","); + if ( + variableList.length === 0 || + parameters.some((parameter) => !/^[A-Za-z][A-Za-z0-9._-]*$/.test(parameter)) + ) { + return undefined; + } + + expressions.push({ parameters }); + cursor = close + 1; + } + + return expressions; +} + +function copyLink(link: Link, href: string): Link { + const serialized = link.serialize(); + serialized.href = href; + serialized.templated = false; + return Link.deserialize(serialized) ?? new Link({ href }); +} + +function safeToURL(link: Link, baseURL?: string): string | undefined { + try { + return link.toURL(baseURL); + } catch { + return undefined; + } +} + +function failure( + link: Link, + reason: LinkResolutionFailureReason, + missingVariables?: string[] +): LinkResolution { + const key = `${reason}:${link.href}:${missingVariables?.join(",") ?? ""}`; + if (!reportedFailures.has(key)) { + reportedFailures.add(key); + if (reason === "missing-variable") { + log.warn("Unable to resolve templated link; missing variables", { + href: link.href, + missingVariables, + }); + } else { + log.warn("Unable to resolve templated link", { + href: link.href, + reason, + }); + } + } + + return { ok: false, link, reason, missingVariables }; +} diff --git a/flutter_readium_platform_interface/test/models_test.dart b/flutter_readium_platform_interface/test/models_test.dart index ac44010a..6a8998d3 100644 --- a/flutter_readium_platform_interface/test/models_test.dart +++ b/flutter_readium_platform_interface/test/models_test.dart @@ -58,21 +58,81 @@ void main() { }); }); + group('Publication URI templates', () { + Map templatedLink(String href) => { + 'href': href, + 'templated': true, + 'type': 'application/json', + }; + + test( + 'accepts templated links in standard and nested Link Object fields', + () { + final publication = Publication.fromJson({ + 'metadata': {'title': 'Templated publication'}, + 'links': [ + templatedLink('service{?ref}'), + { + ...templatedLink('parent{?resource}'), + 'alternate': [templatedLink('alternate{?id}')], + 'children': [templatedLink('child{?ref}')], + }, + ], + 'readingOrder': [templatedLink('chapter{?resource}')], + 'resources': [templatedLink('resource{?id}')], + 'toc': [templatedLink('toc{?ref}')], + 'page-list': { + 'links': [templatedLink('page{?ref}')], + }, + }); + + expect(publication, isNotNull); + final parsed = publication!; + expect(parsed.links.every((link) => link.templated), isTrue); + expect(parsed.readingOrder.single.templated, isTrue); + expect(parsed.resources.single.templated, isTrue); + expect(parsed.tableOfContents.single.templated, isTrue); + expect(parsed.links[1].alternates.single.templated, isTrue); + expect(parsed.links[1].children.single.templated, isTrue); + expect(parsed.collectionLinks('page-list').single.templated, isTrue); + }, + ); + + test('preserves templated flags through manifest serialization', () { + final publication = Publication.fromJson({ + 'metadata': {'title': 'Templated publication'}, + 'links': [templatedLink('service{?ref}')], + 'readingOrder': [templatedLink('chapter{?resource}')], + })!; + + final json = publication.toJson(); + + expect(json['links'].single['templated'], isTrue); + expect(json['readingOrder'].single['templated'], isTrue); + }); + }); + // --------------------------------------------------------------------------- // EPUBPreferences serialisation // --------------------------------------------------------------------------- group('EPUBPreferences', () { - test('round-trips preventMOColumnBreaks: true through toJson / fromJson', () { - const prefs = EPUBPreferences(preventMOColumnBreaks: true); - final restored = EPUBPreferences.fromJson(prefs.toJson()); - expect(restored.preventMOColumnBreaks, isTrue); - }); + test( + 'round-trips preventMOColumnBreaks: true through toJson / fromJson', + () { + const prefs = EPUBPreferences(preventMOColumnBreaks: true); + final restored = EPUBPreferences.fromJson(prefs.toJson()); + expect(restored.preventMOColumnBreaks, isTrue); + }, + ); - test('round-trips preventMOColumnBreaks: false through toJson / fromJson', () { - const prefs = EPUBPreferences(preventMOColumnBreaks: false); - final restored = EPUBPreferences.fromJson(prefs.toJson()); - expect(restored.preventMOColumnBreaks, isFalse); - }); + test( + 'round-trips preventMOColumnBreaks: false through toJson / fromJson', + () { + const prefs = EPUBPreferences(preventMOColumnBreaks: false); + final restored = EPUBPreferences.fromJson(prefs.toJson()); + expect(restored.preventMOColumnBreaks, isFalse); + }, + ); test('toJson emits preventMOColumnBreaks under the correct key', () { const prefs = EPUBPreferences(preventMOColumnBreaks: false); @@ -81,10 +141,13 @@ void main() { expect(json['preventMOColumnBreaks'], isFalse); }); - test('fromJson defaults preventMOColumnBreaks to true when key is absent', () { - final restored = EPUBPreferences.fromJson({}); - expect(restored.preventMOColumnBreaks, isTrue); - }); + test( + 'fromJson defaults preventMOColumnBreaks to true when key is absent', + () { + final restored = EPUBPreferences.fromJson({}); + expect(restored.preventMOColumnBreaks, isTrue); + }, + ); test('copyWith preserves preventMOColumnBreaks when not overridden', () { const prefs = EPUBPreferences(preventMOColumnBreaks: false); @@ -152,7 +215,11 @@ void main() { PlatformException( code: 'notFound', message: 'Publication not found', - details: {'href': '/pub.epub', 'httpStatus': 404, 'message': 'native detail'}, + details: { + 'href': '/pub.epub', + 'httpStatus': 404, + 'message': 'native detail', + }, ), ); @@ -187,7 +254,12 @@ void main() { final error = ReadiumError( 'oops', code: '42', - details: {'href': '/ch1.mp3', 'attempt': 1, 'maxAttempts': 3, 'httpStatus': 503}, + details: { + 'href': '/ch1.mp3', + 'attempt': 1, + 'maxAttempts': 3, + 'httpStatus': 503, + }, ); final restored = ReadiumError.fromJson(error.toJson()); expect(restored.message, 'oops'); @@ -207,15 +279,18 @@ void main() { expect(error.httpStatus, isNull); }); - test('tolerates a legacy freeform-string data payload by wrapping it as message', () { - final error = ReadiumError.fromJson({ - 'message': 'oops', - 'code': '42', - 'data': 'attempt=1/3 href=/ch1.mp3', - }); - expect(error.details, {'message': 'attempt=1/3 href=/ch1.mp3'}); - expect(error.href, isNull); - }); + test( + 'tolerates a legacy freeform-string data payload by wrapping it as message', + () { + final error = ReadiumError.fromJson({ + 'message': 'oops', + 'code': '42', + 'data': 'attempt=1/3 href=/ch1.mp3', + }); + expect(error.details, {'message': 'attempt=1/3 href=/ch1.mp3'}); + expect(error.href, isNull); + }, + ); test('ignores legacy stackTrace payload from stale producers', () { final error = ReadiumError.fromJson({ @@ -335,9 +410,7 @@ void main() { // --------------------------------------------------------------------------- group('Preferences controlPanelTimebase fallback', () { test('AudioPreferences.fromJson keeps null when missing', () { - final prefs = AudioPreferences.fromJson({ - 'speed': 1.0, - }); + final prefs = AudioPreferences.fromJson({'speed': 1.0}); expect(prefs.controlPanelTimebase, isNull); }); @@ -351,9 +424,7 @@ void main() { }); test('TTSPreferences.fromJson defaults missing value to chapter', () { - final prefs = TTSPreferences.fromJson({ - 'speed': 1.0, - }); + final prefs = TTSPreferences.fromJson({'speed': 1.0}); expect(prefs.controlPanelTimebase, ControlPanelTimebase.chapter); }); @@ -440,24 +511,27 @@ void main() { // ImageTapEvent serialisation // --------------------------------------------------------------------------- group('ImageTapEvent', () { - test('round-trips a fully-populated iOS-style event through toJson / fromJson', () { - final event = ImageTapEvent( - href: 'images/wendy.jpg', - caption: 'Wendy and the boys', - rect: const Rect.fromLTWH(10.0, 20.0, 300.0, 200.0), - pixelWidth: 600, - pixelHeight: 400, - ); - - final restored = ImageTapEvent.fromJson(event.toJson()); - expect(restored.href, event.href); - expect(restored.caption, event.caption); - expect(restored.alt, isNull); - expect(restored.rect!.left, closeTo(10.0, 1e-9)); - expect(restored.rect!.height, closeTo(200.0, 1e-9)); - expect(restored.pixelWidth, 600); - expect(restored.pixelHeight, 400); - }); + test( + 'round-trips a fully-populated iOS-style event through toJson / fromJson', + () { + final event = ImageTapEvent( + href: 'images/wendy.jpg', + caption: 'Wendy and the boys', + rect: const Rect.fromLTWH(10.0, 20.0, 300.0, 200.0), + pixelWidth: 600, + pixelHeight: 400, + ); + + final restored = ImageTapEvent.fromJson(event.toJson()); + expect(restored.href, event.href); + expect(restored.caption, event.caption); + expect(restored.alt, isNull); + expect(restored.rect!.left, closeTo(10.0, 1e-9)); + expect(restored.rect!.height, closeTo(200.0, 1e-9)); + expect(restored.pixelWidth, 600); + expect(restored.pixelHeight, 400); + }, + ); test('round-trips a fully-populated web-style event (with alt)', () { final event = ImageTapEvent(