Kapow

View the Project on GitHub sanchezpaco/kapow

File formats

Kapow reads comic archives the user owns. A single abstraction hides the container format from the reader.

Abstraction

interface ComicSource {
    val pageCount: Int
    suspend fun pageNames(): List<String>
    suspend fun decodePage(index: Int, targetWidth: Int): Bitmap
    suspend fun pageAspect(index: Int): Float
}

CBZ (Phase 1)

Format detection by content

Extensions lie: many .cbr files are actually ZIP archives. ComicSourceFactory opens the picked document as a read-only ParcelFileDescriptor, reads the leading magic bytes through a positional FileChannel read, and routes on content, not extension. Only ZIP is copied to a cache file (java.util.zip.ZipFile needs a path, and reopening the descriptor through /proc/self/fd is refused with EACCES by scoped storage); RAR, 7z, tar and PDF read the descriptor directly, so opening no longer scales with the archive size (Blacksad #1, 165 MB RAR, on the Fold: first page 509 → 361 ms):

Magic bytes Offset Format Source
Rar! 0 RAR4 and RAR5 (.cbr) CbrComicSource
%PDF 0 PDF PdfComicSource
PK 0 ZIP (.cbz) CbzComicSource
37 7A BC AF 27 1C 0 7z (.cb7) CbrComicSource
ustar 257 tar (.cbt) CbrComicSource

Anything else is ComicOpenError.UnsupportedFormat. The read is MAGIC_BYTE_COUNT = 262 bytes, because tar has no leading signature: its magic sits inside the first header block, at offset 257. Matching only ustar covers both the POSIX (ustar\0 + version 00) and the GNU (ustar \0) spellings of that field.

The pure magic-byte matcher (detectComicFileFormat) lives in ComicFileFormat.kt and is unit-tested independently of any file IO.

CBR (RAR4 and RAR5), CB7 (7z) and CBT (tar)

Plain image folder

PDF (Phase 5)

ComicInfo.xml

Every ComicSource can hand back the raw text of the archive’s ComicInfo.xml (comicInfoXml()), which the library parses into metadata — see library.md#metadata. The entry is matched on its file name, case-insensitively and ignoring any folder, so both ComicInfo.xml at the archive root and a nested comicinfo.xml are found; the first match wins.

Only the cover/metadata pass calls it, so opening a comic to read never pays for it.

Open failures

ComicSourceFactory.open never leaks raw exceptions to the UI. Failures are classified into a sealed ComicOpenError (feature/reader/domain), each carried to the reader as a distinct localized message:

ComicSourceFactory throws a matching ComicSourceException (feature/reader/data) that wraps the ComicOpenError; ReaderViewModel stores the error case in ReaderUiState for ReaderScreen to render. Anything else thrown while opening is rethrown on purpose so it crashes and reaches Play Vitals instead of being reported as a generic read failure.

Natural sorting

Archive entry order is not guaranteed. Sort filenames with a natural comparator so numeric runs order numerically (p1, p2, … p10, not p1, p10, p2). This lives in core and is unit-tested.

Margin auto-crop

Some pages carry a wide solid border (near-white or near-black) that shrinks the art. After decoding, MarginCrop inspects the page: if the four corners agree on a uniform colour, it scans inward from each edge for full lines of that colour and returns the tight content rectangle as a normalized Rect. Guards keep it safe — a page with no meaningful border, disagreeing corners, or a crop that would remove more than half of either axis is left whole. The crop is computed once per page in the data layer (PageLoader.decode, behind a default-on flag) and the page is cropped there, so ambient colour, panel detection and display all operate on the same content. Validated offline against real pages with a BufferedImage harness and unit-tested on synthetic bordered pages.

Dominant color

For letterbox fill (reading-modes.md), the decoder also samples the page’s edge pixels to compute a dominant color, cached per page. Cheap: downscale to a tiny bitmap and average/quantize edge rows/columns.

Memory strategy