Our goal is to improve extensibility and customizability of the Publication type for reading apps. To achieve that, this proposal introduces two structured ways to extend a Publication with additional features: helpers and services.
Readium components need to make decisions based on the metadata available in the Publication, which sometimes involves computing additional data. For example, it wouldn’t make sense to show the font size setting for a full fixed-layout EPUB publication, so a Publication::allReadingOrderIsFixedLayout helper is useful.
These computed metadata are not part of the core models specification, so they should be added as extensions. Reading apps should be able to inject additional helpers to address custom needs.
RWPM extensions are already implemented as helpers in the mobile toolkits, by converting JSON values to type-safe data structures.
For more complex and opiniated APIs, it would be useful to allow reading apps to swap Readium default implementations with their own. For example, calculating the list of positions can be done in many different ways, and depends on the publication format.
By using a set of specified interfaces as contracts, we can have different Readium components use these services without caring for the concrete implementation.
The Publication shared models support extensibility through two structured ways: helpers and services.
Publication objectA helper extends the shared models by computing additional metadata when requested, such as:
Presentation::layoutOf(Link) returns the EPUB layout of a resource based on both publication and resource-level properties.List<Link>::allAreAudio returns whether the collection of links contains only audio resources. When applied on readingOrder, it can be used to determine if the publication can be treated as an audiobook.They are simple syntactic constructs, using the capabilities of each language, for example:
// Swift
extension Array where Element == Link {
var allAreAudio: Bool {
allSatisfy { $0.mediaType?.isAudio == true }
}
}
// Kotlin
val List<Link>.allAreAudio: Boolean get() = all {
it.mediaType?.isAudio == true
}
// JavaScript
LinkArray.prototype.everyAreAudio() = function() {
return this.every(link => link.mediaType.isAudio())
};
A service is a contract between Readium components, defined as an interface with swappable implementations stored in the Publication object, such as:
Positions which splits a publication to provide a list of sequential locations.Search which provides a way to search a publication’s content.While the known service interfaces are defined in r2-shared, their implementations are usually provided by other components, such as r2-streamer or the reading app itself.
Publication services are a point of customizability for reading apps, because you can swap a default implementation with your own, or decorate it to add cross-cutting concerns. A reading app can also add custom service interfaces for internal needs.
The Publication object holds a list of service instances. To get an instance of a service, you can use Publication::findService(), which will return the first service implementing the interface S. However, you rarely need to access directly a service instance, since a service interface usually also defines helpers on Publication for convenience.
For example, the PositionsService declares the helper property Publication::positions to directly access the list of positions.
Some publication services expose a web version of their API, to be consumed for example by a JavaScript app or a remote client. In which case, you can retrieve the WS routes from Publication::links, using the WS custom media types or link relations. Then, the response can be fetched using Publication::get(), which can be exposed through HTTP with a Publication Server.
If the web service takes parameters, then its Link object will be templated.
The media types, formats and parameters depend on the specification of each publication service.
let searchLink = publication.links.firstWithMediaType("application/vnd.readium.search+json")
// searchLink == Link(
// href: "/~readium/search{?text}",
// type: "application/vnd.readium.search+json",
// templated: true
// )
if (searchLink) {
// href == "/~readium/search?text=banana"
let queryLink = searchLink.expandTemplate({"text": "banana"})
// `results` is a JSON collection of Locator objects.
let results = await publication.get(queryLink).readAsJSON()
}
To create your own service, you must declare:
Publication.Service.Publication.Service.Factory.You should also provide helpers on Publication for a more convenient access to your service API, with fallbacks if no instances of your service is attached to the Publication.
Here’s an example for PositionsService:
// The service contract.
interface PositionsService : Publication.Service {
val positions: List<Locator>
}
// Defines a convenient `publication.positions` helper with a fallback value.
val Publication.positions: List<Locator> get() {
val service = findService(PositionsService::class)
return service?.positions ?: emptyList()
}
// A concrete implementation of the service.
class EPUBPositionsService(val readingOrder: List<Link>, val fetcher: Fetcher) : PositionsService {
override val positions: List<Locator> by lazy {
// Lazily computes the position list...
}
companion object {
// The service factory.
fun create(context: Publication.Service.Context): EPUBPositionsService {
return EPUBPositionsService(context.manifest.readingOrder, context.fetcher)
}
}
}
val publication = Publication(manifest, fetcher, servicesBuilder = Publication.ServicesBuilder(
positions = (EPUBPositionsService)::create
))
If you were providing your own implementation of the PositionListFactory, you will have to adapt it to use PositionsService instead.
The Publication models are not yet immutable in the Swift toolkit, which is required for this proposal to work properly. Since reading apps usually don’t create or modify a Publication, this should have a minimal impact.
Helpers and services rely on the fact that a Publication is immutable. If that was not the case, then any cached value could become invalid.
If a helper performs an expensive task, a warning should be logged if it is called from the UI thread. This is typically the case for helpers accessing resources from the Fetcher.
Publication.Service InterfaceBase interface to be inherited by all publication services.
links: List<Link>
Publication::links.Publication::get().href with a publication’s local resources, you should use the prefix /~readium/.Link(href: "/~readium/search{?text}", type: "application/vnd.readium.search+json", templated: true)get(link: Link) -> Resource?
Publication::get() for each request. A service can return a Resource to:
links,Resource containing the response, or null if the service doesn’t recognize this request.close()
Publication::close().Since a service might cache values computed from the current manifest and fetcher, we can’t reuse its instance when copying the Publication object. To circumvent this issue, the Publication is given service factories, which will be forwarded to its copies. During the construction of a Publication object, the provided factories are used to create the service instances.
typealias Publication.Service.Factory = (Publication.Service.Context) -> Publication.Service?
class Publication.Service.Context {
val manifest: Manifest,
val fetcher: Fetcher
}
A Publication.Service.Context is used instead of passing directly the arguments, to be able to add more parameters later on without modifying all the existing service factories.
Publication.ServicesBuilder ClassBuilds a list of Publication.Service using Publication.Service.Factory instances.
Provides helpers to manipulate the list of services of a Publication.
This class holds a map between a key – computed from a service interface – and a factory instance for this service.
Publication.ServicesBuilder(positions: ((Publication.Service.Context) -> PositionsService?)? = null, cover: ((Publication.Service.Context) -> CoverService?)? = null, ...)
ServicesBuilder with a list of service factories.Each publication service should define helpers on Publication.ServicesBuilder to set its factory.
coverServiceFactory: ((Publication.Service.Context) -> CoverService?)?positionsServiceFactory: ((Publication.Service.Context) -> PositionsService?)?build(context: Publication.Service.Context) -> List<Publication.Service>
Publication.context: Publication.Service.Context
set(serviceType: Publication.Service::class, factory: Publication.Service.Factory)
remove(serviceType: Publication.Service::class)
decorate(serviceType: Publication.Service::class, transform: (Publication.Service.Factory?) -> Publication.Service.Factory)
transform.Publication AdditionsfindService<T: Publication.Service>(serviceType: T::class): T?
T.findService<T: Publication.Service>(serviceType: T::class): T?
T.Two alternatives to extend Publication were considered: decoration and inheritance.
PublicationThis doesn’t require any change in the Readium toolkit. With this technique, the reading app creates a new type that will embed and proxy the Publication object, while adding more features.
While this solution is easy to implement, it doesn’t allow customizing the behavior of Readium since other components are expecting a Publication object.
PublicationWe could allow the reading app to provide its own Publication subclass to the parsers. In this case, the reading app could override:
Publication::get() to serve custom resources and web services,Publication::positionsSubclassing Publication might look more straightforward, but it could transform Publication into a bulky god object. The solution introduced in this proposal has the advantage of offering a clear structure to encapsulate different services, and the possibility of swapping service implementations easily – e.g. the positions are computed differently for each publication format.
Publication shared models to be immutable to work properly. Otherwise, we might end up with invalid lazy-cached values. While this is a limitation, having immutable models is generally considered safer.Publication with irrelevant helpers. For example, Properties.price is always accessible, but only makes sense for an OPDS publication.Specifying new publication services and helpers should become a natural part of future proposals related to the Publication models.
We could encounter web publications served by a remote HTTP Streamer providing web services, such as a positions list. In which case, a native toolkit might fetch the positions from the web service and convert them to in-memory Locator models, as a fallback if no instance of PositionsService is provided.
readingOrder from a full FXL publication?SearchService to search through a publication’s content.ContentProtectionService to manage the DRM rights consumption.ThumbnailsService to generate and cache thumbnails for each resource/page in an EPUB FXL or DiViNa.ReferenceService to generate an index or glossary, or something akin to Amazon X-Ray for Kindle.ReflowService to generate a reflowable view of fixed resources such as EPUB FXL or PDF.UpdateService to update a publication file from a remote server.PackageService to download and package a web publication to the file system.PositionsServiceProvides a list of discrete locations in the publication, no matter what the original format is.
This service is described in more details in this specification.
positionsByReadingOrder: List<List<Locator>>
positions: List<Locator>
positionsByReadingOrder, if not implemented.Publication HelperspositionsByReadingOrder: List<Locator> = findService(PositionsService::class)?.positionsByReadingOrder ?: []positions: List<Locator> = findService(PositionsService::class)?.positions ?: []PositionsService exposes the positions list as a web service.
It provides a default implementation to avoid rewriting the JSON serialization for all concrete implementations of PositionsService.
private const val positionsLink = Link(
href = "/~readium/positions",
type = "application/vnd.readium.position-list+json"
)
interface PositionsService : Publication.Service {
/* List of all the positions in the publication as Locator objects. */
val positions: List<Locator>
/* List of all the positions in the publication, grouped by the resource reading order index. */
val positionsByReadingOrder: List<List<Locator>>
override val links: List<Link>
get() = listOf(positionsLink)
override fun get(link: Link): Resource? {
if (link.href != positionsLink.href) {
return null
}
val json = JSONObject().apply {
put("total", positions.size)
put("positions", positions.map { it.toJSON() })
}
return BytesResource(positionsLink, json.toString())
}
}
CoverServiceProvides an easy access to a bitmap version of the publication cover.
While at first glance, getting the cover could be seen as a helper, the implementation actually depends on the publication format:
Furthermore, a reading app might want to use a custom strategy to choose the cover image, for example by:
images collection for a publication parsed from an OPDS 2 feed.cover: Bitmap?
coverFitting(maxSize: Size) -> Bitmap?
maxSize.maxSize.Publication Helperscover: Bitmap? = findService(CoverService::class)?.covercoverFitting(maxSize: Size) -> Bitmap? = findService(CoverService::class)?.coverFitting(maxSize)If the cover selected by the CoverService is not already part of Publication::links with a cover relation, then the CoverService should add it to CoverService::links and serve the bitmap in CoverService::get() at the maximum available size. If the source format is a vector image, it will be fitted to the device’s screen size.
This is an overview of the helpers implemented natively by the Readium toolkit.
Publication HelperslinkWithHREF(String) -> Link?
readingOrder, resources and links, following recursively alternates and children.
href.linkWithRel(String) -> Link?
Manifest::linkWithRel().linksWithRel(String) -> List<Link>
Manifest::linksWithRel().pageList: List<Link>
subcollections["page-list"]?.first?.links ?: [].landmarks: List<Link>
subcollections["landmarks"]?.first?.links ?: [].listOfAudioClips: List<Link>
subcollections["loa"]?.flatten() ?: [].listOfIllustrations: List<Link>
subcollections["loi"]?.flatten() ?: [].listOfTables: List<Link>
subcollections["lot"]?.flatten() ?: [].listOfVideoClips: List<Link>
subcollections["lov"]?.flatten() ?: [].images: List<Link>
subcollections["images"]?.flatten() ?: [].Manifest HelperslinkWithRel(String) -> Link?
readingOrder, resources and links.linksWithRel(String) -> List<Link>
readingOrder, resources and links.Metadata HelperseffectiveReadingProgression: ReadingProgression
ReadingProgression when the value of Metadata::readingProgression is set to auto, using the publication language.presentation: Presentation
Presentation object for this metadata.Link HelperstemplateParameters: List<String>
Link is templated.expandTemplate(parameters: Map<String, String>) -> Link
Link’s HREF by replacing URI template variables by the given parameters.List<Link> HelpersfirstWithRel(String) -> Link?
filterByRel(String) -> List<Link>
firstWithHREF(String) -> Link?
indexOfFirstWithHREF(String) -> Int?
firstWithMediaType(MediaType) -> Link?
filterByMediaType(MediaType) -> List<Link>
filterByMediaTypes(List<MediaType>) -> List<Link>
allAreBitmap: Boolean
allAreAudio: Boolean
allAreVideo: Boolean
allAreHTML: Boolean
allMatchMediaType(MediaType) -> Boolean
allMatchMediaTypes(List<MediaType>) -> Boolean
Properties Helpersclipped: Boolean?
fit: Presentation.Fit?
orientation: Presentation.Orientation?
overflow: Presentation.Overflow?
page: Presentation.Page?
spread: Presentation.Spread?
encryption: Encryption?
contains: Set<String>
layout: EPUBLayout?
numberOfItems: Int?
price: Price?
indirectAcquisitions: List<Acquisition>
holds: Holds?
copies: Copies?
availability: Availability?
Presentation HelperslayoutOf(Link) -> EPUBLayout
link.properties.layout, presentation.layout and EPUBLayout.Reflowable as a fallback.