Download

Write a source

Pages and books

When a chapter opens, Katari calls getMedia and hands what comes back to the comic reader, the video player or the book reader.

What getMedia returns #

getMedia(chapter, selection) returns one of three kinds of EntryMedia, and the title's type decides which Katari expects:

Title type Return
MANGA EntryMedia.ImagePages(pages)
ANIME EntryMedia.Playback(descriptor), as Video describes
BOOK EntryMedia.Book(descriptor, …)

An exception thrown in getMedia shows as an error in the reader or player, with its message. selection only matters for video; comics and books ignore it.

Comic pages #

A comic chapter is a list of images in reading order. Give each page its image's address when the chapter's page already has it:

Kotlin
override suspend fun getMedia(chapter: SEntryChapter, selection: PlaybackSelection): EntryMedia {
    val pages = fetch(baseUrl + chapter.url).select(".reader img").mapIndexed { index, image ->
        EntryImagePage(index, imageUrl = image.absUrl("src"))
    }
    return EntryMedia.ImagePages(pages)
}
data class EntryImagePage
One page of the chapter.

Fields

index: Int
The page's position, from 0, unique within the chapter.
url: String = ""
The address of a page that holds the image, when the image's own address isn't known yet.
imageUrl: String? = null
The image's full address, or null to find it later.

Comic sources extend EntryImageHttpSource, since the reader and the downloader load images through it. When each image sits on a page of its own, give the page's address as url instead, and find the image in getImageUrl. Katari calls it only for pages without an imageUrl, when the reader nears them or the chapter is downloaded. Override imageRequest when the image host wants other headers:

Kotlin
override suspend fun getMedia(chapter: SEntryChapter, selection: PlaybackSelection): EntryMedia {
    val count = fetch(baseUrl + chapter.url).select(".page-select option").size
    val pages = (0 until count).map { index ->
        EntryImagePage(index, url = "$baseUrl${chapter.url}/${index + 1}")
    }
    return EntryMedia.ImagePages(pages)
}

override suspend fun getImageUrl(page: EntryImagePage): String =
    fetch(page.url).selectFirst("#page img")!!.absUrl("src")

override fun imageRequest(page: EntryImagePage, imageUrl: String): Request =
    GET(imageUrl, headers.newBuilder().set("Referer", "$baseUrl/").build())

Books #

The book reader opens two formats: a novel's chapter as HTML, and a whole book as EPUB. Which one is set by the BookContentDescriptor(format, profile, protection = "none"):

Format Descriptor
HTML chapter Exactly BookContentDescriptor("text/html", profile = "prose-chapter").
EPUB BookContentDescriptor("application/epub+zip"); the profile is ignored.

Anything else, or a protection other than none, shows as a format the reader can't open.

Kotlin
fun chapterMedia(chapter: SEntryChapter, html: String): EntryMedia.Book {
    val location = BookResourceLocation.InlineText(html, "text/html")
    return EntryMedia.Book(
        descriptor = BookContentDescriptor("text/html", profile = "prose-chapter"),
        catalog = BookResourceCatalog(
            resources = listOf(
                BookSourceResource(
                    id = chapter.url,
                    title = chapter.name,
                    order = 0,
                    mediaType = "text/html",
                    availability = BookResourceAvailability.AVAILABLE,
                    location = location,
                ),
            ),
            coverage = BookCatalogCoverage.COMPLETE,
        ),
        initialResourceId = chapter.url,
        initialResourceLocation = location,
        linkedResources = BookLinkedResources(
            baseUrl = baseUrl + chapter.url,
            headers = mapOf("Referer" to "$baseUrl/"),
        ),
    )
}
  • The HTML is the chapter's text, cleaned of the site's menus and scripts. The reader lays it out in its own style, from the elements and styles listed below.
  • For Arabic, Hebrew and other right-to-left text, put dir="rtl" and the lang on the <body>, and the reader lays the chapter out right to left.
  • The chapter opens at initialResourceId, or the catalog's only resource when that is null. Its InlineText holds the whole chapter, up to the format's 4 MiB. A RemoteRequest to its address works too, and lets Katari fetch it.
  • Pictures can stay as the site wrote them: an <img> whose src is a full address, an address relative to linkedResources.baseUrl (without one, to the chapter's RemoteRequest address), or a data: image. Katari fetches them with linkedResources.headers, reads their type from their bytes and keeps them in downloads. A src that is a resource's ID in the catalog names that resource instead.
  • Images can be JPEG, PNG, WebP, GIF, AVIF or SVG, up to 12 MiB each and 1 MiB for SVG, and at most 32,768 pixels on a side and 64 Mi pixels in all. One that is gone shows its alt text, and the chapter still opens and downloads.
  • Fonts that @font-face rules name by address, data: URI or resource ID are used too: TrueType, OpenType, WOFF or WOFF2, up to 4 MiB each.
  • CSS the site applies to every chapter, such as how it draws its notes and boxes, goes in styleSheets once. Katari applies it before each chapter's own styles and keeps it with downloads.

A downloadable book is one EPUB file, fetched and opened by Katari:

Kotlin
fun epubMedia(chapter: SEntryChapter, epubUrl: String): EntryMedia.Book {
    val location = BookResourceLocation.RemoteRequest(epubUrl)
    return EntryMedia.Book(
        descriptor = BookContentDescriptor("application/epub+zip"),
        catalog = BookResourceCatalog(
            resources = listOf(
                BookSourceResource(
                    id = chapter.url,
                    title = chapter.name,
                    order = 0,
                    mediaType = "application/epub+zip",
                    availability = BookResourceAvailability.AVAILABLE,
                    location = location,
                ),
            ),
            coverage = BookCatalogCoverage.COMPLETE,
        ),
        initialResourceId = chapter.url,
        initialResourceLocation = location,
    )
}

The resource IDs are where Katari keeps the reader's place, so keep them the same for the same text, and give every chapter an ID of its own. The book classes are in eu.kanade.tachiyomi.source.entry, and BookContentDescriptor, BookResourceAvailability and BookCatalogCoverage in app.katari.book.api.

What the HTML can hold #

The reader keeps these elements and drops the tags of all others, keeping their text:

a, abbr, address, article, aside, b, blockquote, br, caption, cite, code, dd, del, details, dfn, div, dl, dt, em, figcaption, figure, footer, h1–h6, header, hr, i, img, ins, kbd, li, main, mark, nav, ol, p, pre, q, s, samp, section, small, span, strong, sub, summary, sup, table, tbody, td, tfoot, th, thead, tr, u, ul, var.

  • script, iframe, object, embed and canvas are removed with their content. audio, video and math become a note that the content isn't supported.
  • Every element keeps id, role, dir, lang, xml:lang and epub:type. Besides those, a keeps href and name; img keeps src, alt, width and height; ol keeps start and type; li keeps value; td and th keep colspan, rowspan and scope; details keeps open.
  • Styles come from <style> elements, style attributes and the book's styleSheets, and cascade as in a browser: tag, class, ID and attribute selectors with combinators and structural pseudo-classes such as :nth-child and :not, specificity, !important, shorthands, every colour syntax, custom properties with var(), and @media matched against the reader's page. Rules with :hover or a pseudo-element never apply, and @import and @keyframes are skipped.
  • The reader draws text colour and fonts, size, weight, style, small caps, alignment including justified, indents, line height, letter spacing, case changes, decoration and text shadows. Around a passage it keeps a box with its background colour or gradient, borders, rounded corners, shadows, padding, margins and width. Flex rows and grids of more than one column sit side by side, floats let the text wrap around them, and text hidden with display: none is left out.
  • Colours are fitted to the reader's theme, and users can turn a book's styles off with Publisher styles.

A chapter that goes past any of these limits fails to open as malformed:

Limit At most
Size of the HTML 4 MiB
Elements 100,000
Text 2,000,000 characters
Nesting depth 32 levels, with lists 8 and boxes 16 inside one another
Blocks, such as paragraphs and headings 20,000
Table columns, and a cell's colspan or rowspan 24
CSS rules 256 in the chapter and 256 in the book's styleSheets, with 64 declarations each

The book's resources #

EntryMedia.Book describes a publication as a catalog of resources, each found at a location. Most sources need only the fields in the snippets above.

data class Book : EntryMedia
A publication, from getMedia.

Fields

descriptor: BookContentDescriptor
The format, as above.
catalog: BookResourceCatalog = BookResourceCatalog()
The resources Katari knows of.

Fields

resources: List<BookSourceResource> = emptyList()
Up to 10,000, with unique IDs.
revision: String? = null
The catalog's own revision, apart from the publication's.
coverage: BookCatalogCoverage = BookCatalogCoverage.UNKNOWN
Whether the list is COMPLETE, PARTIAL or ONGOING, or UNKNOWN.
initialResourceId: String? = null
The resource the chapter opens at.
initialResourceLocation: BookResourceLocation? = null
Where to find that resource. A location needs an initialResourceId.
hierarchy: List<BookResourceHierarchyNode> = emptyList()
Groups of resources, such as volumes, for the title's page, each a BookResourceHierarchyNode(id, title, resourceIds, children).
publicationRevision: String? = null
Change it when the text changes, so Katari drops what it saved of the old one.
publicationKeyOverride: String? = null
Tells apart several publications within one title.
linkedResources: BookLinkedResources = BookLinkedResources()
How Katari fetches pictures and fonts the documents name by address.

Fields

baseUrl: String? = null
The http or https address relative ones resolve against, or null for the chapter's RemoteRequest address.
headers: Map<String, String> = emptyMap()
Sent with every such request, whatever its host. A redirect to another site keeps only Accept, Accept-Language, User-Agent and the Referer's origin.
styleSheets: List<String> = emptyList()
CSS applied to each HTML chapter before its own styles, up to 262,144 characters in all. Addresses in it resolve like the chapter's own. An EPUB keeps only its own styles.

A BookSourceResource has an id, and optionally a title, an order from 0, a groupId, a mediaType, which Katari otherwise reads from the bytes, a size in bytes, a revision, an availability and a location. Only resources whose availability is AVAILABLE or UNKNOWN, the default, can be read; the others, AUTHENTICATION_REQUIRED, PURCHASE_REQUIRED, REGION_RESTRICTED, REMOVED and UNSUPPORTED_APP_ACCESS, tell the user why not.

A resource's location is one of these BookResourceLocations:

data class InlineText(text: String, mediaType: String? = null) : BookResourceLocation
In the text itself, up to 4 Mi characters. An HTML chapter may hold at most 4 MiB as UTF-8.
class InlineBytes(bytes: ByteArray, mediaType: String? = null) : BookResourceLocation
In the bytes themselves, up to 12 MiB, such as a picture the source took out of a chapter it assembled.
data class RemoteRequest(url: String, headers: Map<String, String> = emptyMap()) : BookResourceLocation
At an http or https address, fetched with those headers.
data class SourceChild(resourceId: String, sourceChildKey: String) : BookResourceLocation
From the source, later: Katari calls getMedia with a chapter whose url and name are sourceChildKey, and takes the resource with the same ID from what it returns. Use it to list a whole novel's chapters without fetching each one.

LocalUri and AppReference are for Katari's own books. Every class checks its fields when created, and an invalid one, such as a blank ID, throws IllegalArgumentException from getMedia.

Sources with several kinds #

A site with comics and video can stay one source. Set each title's type, and make each chapter's url say what it holds, so getMedia knows which to return:

Kotlin
override suspend fun getMedia(chapter: SEntryChapter, selection: PlaybackSelection): EntryMedia =
    when {
        chapter.url.startsWith("/watch/") -> playback(chapter, selection)
        chapter.url.startsWith("/read/") -> pages(chapter)
        else -> error("Unknown chapter address: ${chapter.url}")
    }

When the address alone can't tell, keep the kind in the chapter's memo, a JSON object Katari stores with it and passes back.