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.
Publication
This 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.
Publication
We 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::positions
Subclassing 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.PositionsService
Provides 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())
}
}
CoverService
Provides 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)?.cover
coverFitting(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.