From aeef8c02d85f4514f6710322a80bd70c18fb6aee Mon Sep 17 00:00:00 2001 From: len Date: Sun, 5 Feb 2017 12:01:58 +0100 Subject: [PATCH] Basic epub support --- .../eu/kanade/tachiyomi/source/LocalSource.kt | 225 +++++++++++++----- 1 file changed, 166 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt index 2cdbfdefdb..0c43ffb56a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/LocalSource.kt @@ -8,6 +8,8 @@ import eu.kanade.tachiyomi.util.ChapterRecognition import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.ZipContentProvider import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator +import org.jsoup.Jsoup +import org.jsoup.nodes.Document import rx.Observable import timber.log.Timber import java.io.File @@ -57,64 +59,6 @@ class LocalSource(private val context: Context) : CatalogueSource { override fun toString() = context.getString(R.string.local_source) - override fun fetchMangaDetails(manga: SManga) = Observable.just(manga) - - override fun fetchChapterList(manga: SManga): Observable> { - val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() - val chapters = getBaseDirectories(context) - .mapNotNull { File(it, manga.url).listFiles()?.toList() } - .flatten() - .filter { it.isDirectory || isSupportedFormat(it.extension) } - .map { chapterFile -> - SChapter.create().apply { - url = "${manga.url}/${chapterFile.name}" - val chapName = if (chapterFile.isDirectory) { - chapterFile.name - } else { - chapterFile.nameWithoutExtension - } - val chapNameCut = chapName.replace(manga.title, "", true).trim() - name = if (chapNameCut.isEmpty()) chapName else chapNameCut - date_upload = chapterFile.lastModified() - ChapterRecognition.parseChapterNumber(this, manga) - } - } - .sortedWith(Comparator { c1, c2 -> - val c = c2.chapter_number.compareTo(c1.chapter_number) - if (c == 0) comparator.compare(c2.name, c1.name) else c - }) - - return Observable.just(chapters) - } - - override fun fetchPageList(chapter: SChapter): Observable> { - val baseDirs = getBaseDirectories(context) - - for (dir in baseDirs) { - val chapFile = File(dir, chapter.url) - if (!chapFile.exists()) continue - - val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() - - val pageList = if (chapFile.isDirectory) { - chapFile.listFiles() - .filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) } - .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) - .map { Uri.fromFile(it) } - } else { - val zip = ZipFile(chapFile) - zip.entries().toList() - .filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) } - .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) - .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${chapFile.absolutePath}!/${it.name}") } - }.mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } - - return Observable.just(pageList) - } - - return Observable.error(Exception("Chapter not found")) - } - override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { @@ -181,11 +125,174 @@ class LocalSource(private val context: Context) : CatalogueSource { override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) + override fun fetchMangaDetails(manga: SManga) = Observable.just(manga) + + override fun fetchChapterList(manga: SManga): Observable> { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + val chapters = getBaseDirectories(context) + .mapNotNull { File(it, manga.url).listFiles()?.toList() } + .flatten() + .filter { it.isDirectory || isSupportedFormat(it.extension) } + .map { chapterFile -> + SChapter.create().apply { + url = "${manga.url}/${chapterFile.name}" + val chapName = if (chapterFile.isDirectory) { + chapterFile.name + } else { + chapterFile.nameWithoutExtension + } + val chapNameCut = chapName.replace(manga.title, "", true).trim() + name = if (chapNameCut.isEmpty()) chapName else chapNameCut + date_upload = chapterFile.lastModified() + ChapterRecognition.parseChapterNumber(this, manga) + } + } + .sortedWith(Comparator { c1, c2 -> + val c = c2.chapter_number.compareTo(c1.chapter_number) + if (c == 0) comparator.compare(c2.name, c1.name) else c + }) + + return Observable.just(chapters) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + val baseDirs = getBaseDirectories(context) + + for (dir in baseDirs) { + val chapFile = File(dir, chapter.url) + if (!chapFile.exists()) continue + + return Observable.just(getLoader(chapFile).load()) + } + + return Observable.error(Exception("Chapter not found")) + } + private fun isSupportedFormat(extension: String): Boolean { - return extension.equals("zip", true) || extension.equals("cbz", true) + return extension.equals("zip", true) || extension.equals("cbz", true) || extension.equals("epub", true) + } + + private fun getLoader(file: File): Loader { + val extension = file.extension + return if (file.isDirectory) { + DirectoryLoader(file) + } else if (extension.equals("zip", true) || extension.equals("cbz", true)) { + ZipLoader(file) + } else if (extension.equals("epub", true)) { + EpubLoader(file) + } else { + throw Exception("Invalid chapter format") + } } private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Filter.Sort.Selection(0, true)) override fun getFilterList() = FilterList(OrderBy()) + + interface Loader { + fun load(): List + } + + class DirectoryLoader(val file: File) : Loader { + override fun load(): List { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + return file.listFiles() + .filter { !it.isDirectory && DiskUtil.isImage(it.name, { FileInputStream(it) }) } + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) + .map { Uri.fromFile(it) } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + + class ZipLoader(val file: File) : Loader { + override fun load(): List { + val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance() + ZipFile(file).use { zip -> + return zip.entries().toList() + .filter { !it.isDirectory && DiskUtil.isImage(it.name, { zip.getInputStream(it) }) } + .sortedWith(Comparator { f1, f2 -> comparator.compare(f1.name, f2.name) }) + .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/${it.name}") } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + } + + class EpubLoader(val file: File) : Loader { + + override fun load(): List { + ZipFile(file).use { zip -> + val allEntries = zip.entries().toList() + val ref = getPackageHref(zip) + val doc = getPackageDocument(zip, ref) + val pages = getPagesFromDocument(doc) + val hrefs = getHrefMap(ref, allEntries.map { it.name }) + return getImagesFromPages(zip, pages, hrefs) + .map { Uri.parse("content://${ZipContentProvider.PROVIDER}${file.absolutePath}!/$it") } + .mapIndexed { i, uri -> Page(i, uri = uri).apply { status = Page.READY } } + } + } + + /** + * Returns the path to the package document. + */ + private fun getPackageHref(zip: ZipFile): String { + val meta = zip.getEntry("META-INF/container.xml") + if (meta != null) { + val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } + val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") + if (path != null) { + return path + } + } + return "OEBPS/content.opf" + } + + /** + * Returns the package document where all the files are listed. + */ + private fun getPackageDocument(zip: ZipFile, ref: String): Document { + val entry = zip.getEntry(ref) + return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + } + + /** + * Returns all the pages from the epub. + */ + private fun getPagesFromDocument(document: Document): List { + val pages = document.select("manifest > item") + .filter { "application/xhtml+xml" == it.attr("media-type") } + .associateBy { it.attr("id") } + + val spine = document.select("spine > itemref").map { it.attr("idref") } + return spine.mapNotNull { pages[it] }.map { it.attr("href") } + } + + /** + * Returns all the images contained in every page from the epub. + */ + private fun getImagesFromPages(zip: ZipFile, pages: List, hrefs: Map): List { + return pages.map { page -> + val entry = zip.getEntry(hrefs[page]) + val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } + document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] } + }.flatten() + } + + /** + * Returns a map with a relative url as key and abolute url as path. + */ + private fun getHrefMap(packageHref: String, entries: List): Map { + val lastSlashPos = packageHref.lastIndexOf('/') + if (lastSlashPos < 0) { + return entries.associateBy { it } + } + return entries.associateBy { entry -> + if (entry.isNotBlank() && entry.length > lastSlashPos) { + entry.substring(lastSlashPos + 1) + } else { + entry + } + } + } + } } \ No newline at end of file