feat(api): epub ebook support

Closes: #221
This commit is contained in:
Gauthier Roebroeck 2023-11-28 12:26:17 +08:00
parent dedb01fe08
commit a7252f8429
52 changed files with 1282 additions and 350 deletions

View File

@ -0,0 +1,16 @@
alter table MEDIA_FILE
add column MEDIA_TYPE varchar NULL;
alter table MEDIA_FILE
add column SUB_TYPE varchar NULL;
alter table MEDIA_FILE
add column FILE_SIZE int8 NULL;
alter table MEDIA
add column EXTENSION_CLASS varchar NULL;
alter table MEDIA
add column EXTENSION_VALUE varchar NULL;
update media
set STATUS = 'OUTDATED'
where MEDIA_TYPE = 'application/epub+zip'
and STATUS = 'READY';

View File

@ -0,0 +1,7 @@
package org.gotson.komga.domain.model
data class EpubTocEntry(
val title: String,
val href: String?,
val children: List<EpubTocEntry> = emptyList(),
)

View File

@ -7,8 +7,9 @@ data class Media(
val mediaType: String? = null,
val pages: List<BookPage> = emptyList(),
val pageCount: Int = pages.size,
val files: List<String> = emptyList(),
val files: List<MediaFile> = emptyList(),
val comment: String? = null,
val extension: MediaExtension? = null,
val bookId: String = "",
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = createdDate,

View File

@ -0,0 +1,9 @@
package org.gotson.komga.domain.model
interface MediaExtension
data class MediaExtensionEpub(
val toc: List<EpubTocEntry> = emptyList(),
val landmarks: List<EpubTocEntry> = emptyList(),
val pageList: List<EpubTocEntry> = emptyList(),
) : MediaExtension

View File

@ -0,0 +1,12 @@
package org.gotson.komga.domain.model
data class MediaFile(
val fileName: String,
val mediaType: String? = null,
val subType: SubType? = null,
val fileSize: Long? = null,
) {
enum class SubType {
EPUB_PAGE, EPUB_ASSET
}
}

View File

@ -3,4 +3,5 @@ package org.gotson.komga.domain.model
enum class MediaProfile {
DIVINA,
PDF,
EPUB,
}

View File

@ -4,7 +4,7 @@ enum class MediaType(val type: String, val profile: MediaProfile, val fileExtens
ZIP("application/zip", MediaProfile.DIVINA, "cbz", "application/vnd.comicbook+zip"),
RAR_GENERIC("application/x-rar-compressed", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"),
RAR_4("application/x-rar-compressed; version=4", MediaProfile.DIVINA, "cbr", "application/vnd.comicbook-rar"),
EPUB("application/epub+zip", MediaProfile.DIVINA, "epub"),
EPUB("application/epub+zip", MediaProfile.EPUB, "epub"),
PDF("application/pdf", MediaProfile.PDF, "pdf"),
;

View File

@ -7,6 +7,8 @@ import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.MediaType
@ -18,9 +20,9 @@ import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.mediacontainer.CoverExtractor
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
import org.gotson.komga.infrastructure.mediacontainer.PdfExtractor
import org.gotson.komga.infrastructure.mediacontainer.divina.DivinaExtractor
import org.gotson.komga.infrastructure.mediacontainer.epub.EpubExtractor
import org.gotson.komga.infrastructure.mediacontainer.pdf.PdfExtractor
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@ -34,8 +36,9 @@ private val logger = KotlinLogging.logger {}
@Service
class BookAnalyzer(
private val contentDetector: ContentDetector,
extractors: List<MediaContainerExtractor>,
extractors: List<DivinaExtractor>,
private val pdfExtractor: PdfExtractor,
private val epubExtractor: EpubExtractor,
private val imageConverter: ImageConverter,
private val imageAnalyzer: ImageAnalyzer,
private val hasher: Hasher,
@ -47,67 +50,80 @@ class BookAnalyzer(
private val pdfImageType: ImageType,
) {
val supportedMediaTypes = extractors
val divinaExtractors = extractors
.flatMap { e -> e.mediaTypes().map { it to e } }
.toMap()
fun analyze(book: Book, analyzeDimensions: Boolean): Media {
logger.info { "Trying to analyze book: $book" }
try {
return try {
val mediaType = contentDetector.detectMediaType(book.path).let {
logger.info { "Detected media type: $it" }
MediaType.fromMediaType(it) ?: return Media(mediaType = it, status = Media.Status.UNSUPPORTED, comment = "ERR_1001", bookId = book.id)
}
if (mediaType.profile == MediaProfile.PDF) {
val pages = pdfExtractor.getPages(book.path, analyzeDimensions).map { BookPage(it.name, "", it.dimension) }
return Media(mediaType = mediaType.type, status = Media.Status.READY, pages = pages, bookId = book.id)
}
val entries = try {
supportedMediaTypes.getValue(mediaType.type).getEntries(book.path, analyzeDimensions)
} catch (ex: MediaUnsupportedException) {
return Media(mediaType = mediaType.type, status = Media.Status.UNSUPPORTED, comment = ex.code, bookId = book.id)
} catch (ex: Exception) {
logger.error(ex) { "Error while analyzing book: $book" }
return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1008", bookId = book.id)
}
val (pages, others) = entries
.partition { entry ->
entry.mediaType?.let { contentDetector.isImage(it) } ?: false
}.let { (images, others) ->
Pair(
images.map { BookPage(fileName = it.name, mediaType = it.mediaType!!, dimension = it.dimension, fileSize = it.fileSize) },
others,
)
}
val entriesErrorSummary = others
.filter { it.mediaType.isNullOrBlank() }
.map { it.name }
.ifEmpty { null }
?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it }
if (pages.isEmpty()) {
logger.warn { "Book $book does not contain any pages" }
return Media(mediaType = mediaType.type, status = Media.Status.ERROR, comment = "ERR_1006", bookId = book.id)
}
logger.info { "Book has ${pages.size} pages" }
val files = others.map { it.name }
return Media(mediaType = mediaType.type, status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary, bookId = book.id)
when (mediaType.profile) {
MediaProfile.DIVINA -> analyzeDivina(book, mediaType, analyzeDimensions)
MediaProfile.PDF -> analyzePdf(book, analyzeDimensions)
MediaProfile.EPUB -> analyzeEpub(book)
}.copy(mediaType = mediaType.type)
} catch (ade: AccessDeniedException) {
logger.error(ade) { "Error while analyzing book: $book" }
return Media(status = Media.Status.ERROR, comment = "ERR_1000", bookId = book.id)
Media(status = Media.Status.ERROR, comment = "ERR_1000")
} catch (ex: NoSuchFileException) {
logger.error(ex) { "Error while analyzing book: $book" }
return Media(status = Media.Status.ERROR, comment = "ERR_1018", bookId = book.id)
Media(status = Media.Status.ERROR, comment = "ERR_1018")
} catch (ex: Exception) {
logger.error(ex) { "Error while analyzing book: $book" }
return Media(status = Media.Status.ERROR, comment = "ERR_1005", bookId = book.id)
Media(status = Media.Status.ERROR, comment = "ERR_1005")
}.copy(bookId = book.id)
}
private fun analyzeDivina(book: Book, mediaType: MediaType, analyzeDimensions: Boolean): Media {
val entries = try {
divinaExtractors.getValue(mediaType.type).getEntries(book.path, analyzeDimensions)
} catch (ex: MediaUnsupportedException) {
return Media(status = Media.Status.UNSUPPORTED, comment = ex.code)
} catch (ex: Exception) {
logger.error(ex) { "Error while analyzing book: $book" }
return Media(status = Media.Status.ERROR, comment = "ERR_1008")
}
val (pages, others) = entries
.partition { entry ->
entry.mediaType?.let { contentDetector.isImage(it) } ?: false
}.let { (images, others) ->
Pair(
images.map { BookPage(fileName = it.name, mediaType = it.mediaType!!, dimension = it.dimension, fileSize = it.fileSize) },
others,
)
}
val entriesErrorSummary = others
.filter { it.mediaType.isNullOrBlank() }
.map { it.name }
.ifEmpty { null }
?.joinToString(prefix = "ERR_1007 [", postfix = "]") { it }
if (pages.isEmpty()) {
logger.warn { "Book $book does not contain any pages" }
return Media(status = Media.Status.ERROR, comment = "ERR_1006")
}
logger.info { "Book has ${pages.size} pages" }
val files = others.map { MediaFile(fileName = it.name, mediaType = it.mediaType, fileSize = it.fileSize) }
return Media(status = Media.Status.READY, pages = pages, pageCount = pages.size, files = files, comment = entriesErrorSummary)
}
private fun analyzeEpub(book: Book): Media {
val manifest = epubExtractor.getManifest(book.path)
return Media(status = Media.Status.READY, files = manifest.resources, pageCount = manifest.pageCount, extension = MediaExtensionEpub(toc = manifest.toc, landmarks = manifest.landmarks, pageList = manifest.pageList))
}
private fun analyzePdf(book: Book, analyzeDimensions: Boolean): Media {
val pages = pdfExtractor.getPages(book.path, analyzeDimensions).map { BookPage(it.name, "", it.dimension) }
return Media(status = Media.Status.READY, pages = pages)
}
@Throws(MediaNotReadyException::class)
@ -120,23 +136,12 @@ class BookAnalyzer(
}
val thumbnail = try {
val extractor = supportedMediaTypes[book.media.mediaType!!]
// try to get the cover from a CoverExtractor first
var coverBytes: ByteArray? = if (extractor is CoverExtractor) {
try {
extractor.getCoverStream(book.book.path)
} catch (e: Exception) {
logger.warn(e) { "Error while extracting cover. Falling back to first page. Book: $book" }
null
}
} else null
// if no cover could be found, get the first page
if (coverBytes == null) {
coverBytes = if (book.media.profile == MediaProfile.PDF) pdfExtractor.getPageContentAsImage(book.book.path, 1).content
else extractor?.getEntryStream(book.book.path, book.media.pages.first().fileName)
}
coverBytes?.let { cover ->
when (book.media.profile) {
MediaProfile.DIVINA -> divinaExtractors[book.media.mediaType]?.getEntryStream(book.book.path, book.media.pages.first().fileName)
MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, 1).content
MediaProfile.EPUB -> epubExtractor.getCover(book.book.path)?.content
null -> null
}?.let { cover ->
imageConverter.resizeImageToByteArray(cover, thumbnailType, komgaSettingsProvider.thumbnailSize.maxEdge)
}
} catch (ex: Exception) {
@ -172,8 +177,9 @@ class BookAnalyzer(
}
return when (book.media.profile) {
MediaProfile.DIVINA -> supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName)
MediaProfile.DIVINA -> divinaExtractors.getValue(book.media.mediaType!!).getEntryStream(book.book.path, book.media.pages[number - 1].fileName)
MediaProfile.PDF -> pdfExtractor.getPageContentAsImage(book.book.path, number).content
MediaProfile.EPUB -> throw MediaUnsupportedException("Epub profile does not support getting page content")
null -> throw MediaNotReadyException()
}
}
@ -184,6 +190,7 @@ class BookAnalyzer(
)
fun getPageContentRaw(book: BookWithMedia, number: Int): BookPageContent {
logger.debug { "Get raw page #$number for book: $book" }
if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages")
if (book.media.status != Media.Status.READY) {
logger.warn { "Book media is not ready, cannot get pages" }
@ -195,8 +202,6 @@ class BookAnalyzer(
throw IndexOutOfBoundsException("Page $number does not exist")
}
if (book.media.profile != MediaProfile.PDF) throw MediaUnsupportedException("Extractor does not support raw extraction of pages")
return pdfExtractor.getPageContentAsPdf(book.book.path, number)
}
@ -211,9 +216,11 @@ class BookAnalyzer(
throw MediaNotReadyException()
}
if (book.media.profile != MediaProfile.DIVINA) throw MediaUnsupportedException("Extractor does not support extraction of files")
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName)
return when (book.media.profile) {
MediaProfile.DIVINA -> divinaExtractors.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName)
MediaProfile.EPUB -> epubExtractor.getEntryStream(book.book.path, fileName)
MediaProfile.PDF, null -> throw MediaUnsupportedException("Extractor does not support extraction of files")
}
}
/**

View File

@ -102,7 +102,7 @@ class BookConverter(
media
.pages.map { it.fileName }
.union(media.files)
.union(media.files.map { it.fileName })
.forEach { entry ->
zipStream.putArchiveEntry(ZipArchiveEntry(entry))
zipStream.write(bookAnalyzer.getFileContent(BookWithMedia(book, media), entry))
@ -133,8 +133,8 @@ class BookConverter(
.containsAll(media.pages.map { FilenameUtils.getName(it.fileName) to it.mediaType })
-> throw BookConversionException("Converted file does not contain all pages from existing file, aborting conversion")
!convertedMedia.files.map { FilenameUtils.getName(it) }
.containsAll(media.files.map { FilenameUtils.getName(it) })
!convertedMedia.files.map { FilenameUtils.getName(it.fileName) }
.containsAll(media.files.map { FilenameUtils.getName(it.fileName) })
-> throw BookConversionException("Converted file does not contain all files from existing file, aborting conversion")
}
} catch (e: BookConversionException) {

View File

@ -100,7 +100,7 @@ class BookPageEditor(
zipStream.setLevel(Deflater.NO_COMPRESSION)
pagesToKeep.map { it.fileName }
.union(media.files)
.union(media.files.map { it.fileName })
.forEach { entry ->
zipStream.putArchiveEntry(ZipArchiveEntry(entry))
zipStream.write(bookAnalyzer.getFileContent(BookWithMedia(book, media), entry))
@ -131,8 +131,8 @@ class BookPageEditor(
.containsAll(pagesToKeep.map { FilenameUtils.getName(it.fileName) to it.mediaType })
-> throw BookConversionException("Created file does not contain all pages to keep from existing file, aborting conversion")
!createdMedia.files.map { FilenameUtils.getName(it) }
.containsAll(media.files.map { FilenameUtils.getName(it) })
!createdMedia.files.map { FilenameUtils.getName(it.fileName) }
.containsAll(media.files.map { FilenameUtils.getName(it.fileName) })
-> throw BookConversionException("Created file does not contain all files from existing file, aborting page removal")
}
} catch (e: BookConversionException) {

View File

@ -1,13 +1,18 @@
package org.gotson.komga.infrastructure.jooq.main
import com.fasterxml.jackson.databind.ObjectMapper
import mu.KotlinLogging
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtension
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.infrastructure.jooq.insertTempStrings
import org.gotson.komga.infrastructure.jooq.selectTempStrings
import org.gotson.komga.infrastructure.jooq.toCurrentTimeZone
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.jooq.main.tables.records.MediaFileRecord
import org.gotson.komga.jooq.main.tables.records.MediaPageRecord
import org.gotson.komga.jooq.main.tables.records.MediaRecord
import org.jooq.DSLContext
@ -18,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
private val logger = KotlinLogging.logger {}
@Component
class MediaDao(
private val dsl: DSLContext,
@Value("#{@komgaProperties.database.batchChunkSize}") private val batchSize: Int,
private val mapper: ObjectMapper,
) : MediaRepository {
private val m = Tables.MEDIA
@ -84,9 +92,8 @@ class MediaDao(
val files = dsl.selectFrom(f)
.where(f.BOOK_ID.eq(bookId))
.fetchInto(f)
.map { it.fileName }
mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files)
mr.toDomain(pr.filterNot { it.bookId == null }.map { it.toDomain() }, files.map { it.toDomain() })
}.firstOrNull()
@Transactional
@ -106,15 +113,19 @@ class MediaDao(
m.MEDIA_TYPE,
m.COMMENT,
m.PAGE_COUNT,
).values(null as String?, null, null, null, null),
m.EXTENSION_CLASS,
m.EXTENSION_VALUE,
).values(null as String?, null, null, null, null, null, null),
).also { step ->
chunk.forEach {
chunk.forEach { media ->
step.bind(
it.bookId,
it.status,
it.mediaType,
it.comment,
it.pageCount,
media.bookId,
media.status,
media.mediaType,
media.comment,
media.pageCount,
media.extension?.let { it::class.qualifiedName },
media.extension?.let { mapper.writeValueAsString(it) },
)
}
}.execute()
@ -168,13 +179,19 @@ class MediaDao(
f,
f.BOOK_ID,
f.FILE_NAME,
).values(null as String?, null),
f.MEDIA_TYPE,
f.SUB_TYPE,
f.FILE_SIZE,
).values(null as String?, null, null, null, null),
).also { step ->
chunk.forEach { media ->
media.files.forEach {
step.bind(
media.bookId,
it,
it.fileName,
it.mediaType,
it.subType,
it.fileSize,
)
}
}
@ -190,6 +207,8 @@ class MediaDao(
.set(m.MEDIA_TYPE, media.mediaType)
.set(m.COMMENT, media.comment)
.set(m.PAGE_COUNT, media.pageCount)
.set(m.EXTENSION_CLASS, media.extension?.let { it::class.qualifiedName })
.set(m.EXTENSION_VALUE, media.extension?.let { mapper.writeValueAsString(it) })
.set(m.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
.where(m.BOOK_ID.eq(media.bookId))
.execute()
@ -224,19 +243,30 @@ class MediaDao(
override fun count(): Long = dsl.fetchCount(m).toLong()
private fun MediaRecord.toDomain(pages: List<BookPage>, files: List<String>) =
private fun MediaRecord.toDomain(pages: List<BookPage>, files: List<MediaFile>) =
Media(
status = Media.Status.valueOf(status),
mediaType = mediaType,
pages = pages,
pageCount = pageCount,
files = files,
extension = deserializeExtension(extensionClass, extensionValue),
comment = comment,
bookId = bookId,
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
)
private fun deserializeExtension(extensionClass: String?, extensionValue: String?): MediaExtension? {
if (extensionClass == null && extensionValue == null) return null
return try {
mapper.readValue(extensionValue, Class.forName(extensionClass)) as MediaExtension
} catch (e: Exception) {
logger.error(e) { "Could not deserialize media extension class: $extensionClass, value: $extensionValue" }
null
}
}
private fun MediaPageRecord.toDomain() =
BookPage(
fileName = fileName,
@ -245,4 +275,12 @@ class MediaDao(
fileHash = fileHash,
fileSize = fileSize,
)
private fun MediaFileRecord.toDomain() =
MediaFile(
fileName = fileName,
mediaType = mediaType,
subType = subType?.let { MediaFile.SubType.valueOf(it) },
fileSize = fileSize,
)
}

View File

@ -1,7 +0,0 @@
package org.gotson.komga.infrastructure.mediacontainer
import java.nio.file.Path
interface CoverExtractor {
fun getCoverStream(path: Path): ByteArray?
}

View File

@ -1,137 +0,0 @@
package org.gotson.komga.infrastructure.mediacontainer
import mu.KotlinLogging
import org.apache.commons.compress.archivers.ArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile
import org.gotson.komga.domain.model.MediaContainerEntry
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.springframework.stereotype.Service
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.invariantSeparatorsPathString
private val logger = KotlinLogging.logger {}
@Service
class EpubExtractor(
private val zipExtractor: ZipExtractor,
private val contentDetector: ContentDetector,
private val imageAnalyzer: ImageAnalyzer,
) : MediaContainerExtractor, CoverExtractor {
override fun mediaTypes(): List<String> = listOf(MediaType.EPUB.type)
override fun getEntries(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> {
ZipFile(path.toFile()).use { zip ->
try {
val opfFile = getPackagePath(zip)
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
val opfDir = Paths.get(opfFile).parent
val manifest = opfDoc.getManifest()
val pages = opfDoc.select("spine > itemref").map { it.attr("idref") }
.mapNotNull { manifest[it] }
.map { it.href }
val images = pages
.map { opfDir?.resolve(it)?.normalize() ?: Paths.get(it) }
.flatMap { pagePath ->
val doc = zip.getInputStream(zip.getEntry(pagePath.invariantSeparatorsPathString)).use { Jsoup.parse(it, null, "") }
val img = doc.getElementsByTag("img")
.map { it.attr("src") } // get the src, which can be a relative path
val svg = doc.select("svg > image[xlink:href]")
.map { it.attr("xlink:href") } // get the source, which can be a relative path
(img + svg).map { pagePath.parentOrEmpty().resolve(it).normalize() } // resolve it against the page folder
}
return images.map { image ->
val name = image.invariantSeparatorsPathString
val mediaType = manifest.values.first {
it.href == (opfDir?.relativize(image) ?: image).invariantSeparatorsPathString
}.mediaType
val zipEntry = zip.getEntry(name)
val dimension = if (analyzeDimensions && contentDetector.isImage(mediaType))
zip.getInputStream(zipEntry).use { imageAnalyzer.getDimension(it) }
else
null
val fileSize = if (zipEntry.size == ArchiveEntry.SIZE_UNKNOWN) null else zipEntry.size
MediaContainerEntry(name = name, mediaType = mediaType, dimension = dimension, fileSize = fileSize)
}
} catch (e: Exception) {
logger.error(e) { "File is not a proper Epub, treating it as a zip file" }
return zipExtractor.getEntries(path, analyzeDimensions)
}
}
}
override fun getEntryStream(path: Path, entryName: String): ByteArray {
return zipExtractor.getEntryStream(path, entryName)
}
private fun getPackagePath(zip: ZipFile): String =
zip.getEntry("META-INF/container.xml").let { entry ->
val container = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
container.getElementsByTag("rootfile").first()?.attr("full-path")
?: throw MediaUnsupportedException("META-INF/container.xml does not contain rootfile tag")
}
private fun Document.getManifest() =
select("manifest > item")
.associate {
it.attr("id") to ManifestItem(
it.attr("id"),
it.attr("href"),
it.attr("media-type"),
it.attr("properties").split(" ").toSet(),
)
}
fun getPackageFile(path: Path): String? =
ZipFile(path.toFile()).use { zip ->
try {
zip.getInputStream(zip.getEntry(getPackagePath(zip))).reader().use { it.readText() }
} catch (e: Exception) {
null
}
}
fun Path.parentOrEmpty(): Path = parent ?: Paths.get("")
private data class ManifestItem(
val id: String,
val href: String,
val mediaType: String,
val properties: Set<String> = emptySet(),
)
override fun getCoverStream(path: Path): ByteArray? {
ZipFile(path.toFile()).use { zip ->
val opfFile = getPackagePath(zip)
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
val opfDir = Paths.get(opfFile).parent
val manifest = opfDoc.getManifest()
val coverManifestItem =
// EPUB 3 - try to get cover from manifest properties 'cover-image'
manifest.values.firstOrNull { it.properties.contains("cover-image") }
?: // EPUB 2 - get cover from meta element with name="cover"
opfDoc.selectFirst("metadata > meta[name=cover]")?.attr("content")?.ifBlank { null }?.let { manifest[it] }
if (coverManifestItem != null) {
val href = coverManifestItem.href
val coverPath = opfDir?.resolve(href)?.normalize() ?: Paths.get(href)
return zip.getInputStream(zip.getEntry(coverPath.invariantSeparatorsPathString)).readAllBytes()
} else return null
}
}
}

View File

@ -1,10 +1,10 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.divina
import org.gotson.komga.domain.model.MediaContainerEntry
import org.gotson.komga.domain.model.MediaUnsupportedException
import java.nio.file.Path
interface MediaContainerExtractor {
interface DivinaExtractor {
fun mediaTypes(): List<String>
@Throws(MediaUnsupportedException::class)

View File

@ -1,4 +1,4 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.divina
import com.github.junrar.Archive
import mu.KotlinLogging
@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.MediaContainerEntry
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.springframework.stereotype.Service
import java.nio.file.Path
@ -16,7 +17,7 @@ private val logger = KotlinLogging.logger {}
class RarExtractor(
private val contentDetector: ContentDetector,
private val imageAnalyzer: ImageAnalyzer,
) : MediaContainerExtractor {
) : DivinaExtractor {
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()

View File

@ -1,4 +1,4 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.divina
import mu.KotlinLogging
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
@ -7,6 +7,7 @@ import org.apache.commons.compress.archivers.zip.ZipFile
import org.gotson.komga.domain.model.MediaContainerEntry
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.springframework.stereotype.Service
import java.nio.file.Path
@ -16,7 +17,7 @@ private val logger = KotlinLogging.logger {}
class ZipExtractor(
private val contentDetector: ContentDetector,
private val imageAnalyzer: ImageAnalyzer,
) : MediaContainerExtractor {
) : DivinaExtractor {
private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNaturalComparator.getInstance()

View File

@ -0,0 +1,38 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.apache.commons.compress.archivers.zip.ZipFile
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.nio.file.Path
import java.nio.file.Paths
data class EpubPackage(
val zip: ZipFile,
val opfDoc: Document,
val opfDir: Path?,
val manifest: Map<String, ManifestItem>,
)
inline fun <R> Path.epub(block: (EpubPackage) -> R): R =
ZipFile(this.toFile()).use { zip ->
val opfFile = zip.getPackagePath()
val opfDoc = zip.getInputStream(zip.getEntry(opfFile)).use { Jsoup.parse(it, null, "") }
val opfDir = Paths.get(opfFile).parent
block(EpubPackage(zip, opfDoc, opfDir, opfDoc.getManifest()))
}
fun ZipFile.getPackagePath(): String =
getEntry("META-INF/container.xml").let { entry ->
val container = getInputStream(entry).use { Jsoup.parse(it, null, "") }
container.getElementsByTag("rootfile").first()?.attr("full-path") ?: throw MediaUnsupportedException("META-INF/container.xml does not contain rootfile tag")
}
fun getPackageFile(path: Path): String? =
ZipFile(path.toFile()).use { zip ->
try {
zip.getInputStream(zip.getEntry(zip.getPackagePath())).reader().use { it.readText() }
} catch (e: Exception) {
null
}
}

View File

@ -0,0 +1,6 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
enum class Epub2Nav(val level1: String, val level2: String) {
TOC("navMap", "navPoint"),
PAGELIST("pageList", "pageTarget"),
}

View File

@ -0,0 +1,7 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
enum class Epub3Nav(val value: String) {
TOC("toc"),
LANDMARKS("landmarks"),
PAGELIST("page-list"),
}

View File

@ -0,0 +1,112 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.apache.commons.compress.archivers.ArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile
import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.EpubTocEntry
import org.gotson.komga.domain.model.MediaFile
import org.springframework.stereotype.Service
import java.nio.file.Path
import kotlin.math.ceil
@Service
class EpubExtractor {
/**
* Retrieves a specific entry by name from the zip archive
*/
fun getEntryStream(path: Path, entryName: String): ByteArray =
ZipFile(path.toFile()).use { zip ->
zip.getInputStream(zip.getEntry(entryName)).use { it.readBytes() }
}
/**
* Retrieves the book cover along with its mediaType from the epub 2/3 manifest
*/
fun getCover(path: Path): BookPageContent? =
path.epub { (zip, opfDoc, opfDir, manifest) ->
val coverManifestItem =
// EPUB 3 - try to get cover from manifest properties 'cover-image'
manifest.values.firstOrNull { it.properties.contains("cover-image") }
?: // EPUB 2 - get cover from meta element with name="cover"
opfDoc.selectFirst("metadata > meta[name=cover]")?.attr("content")?.ifBlank { null }?.let { manifest[it] }
if (coverManifestItem != null) {
val href = coverManifestItem.href
val mediaType = coverManifestItem.mediaType
val coverPath = normalizeHref(opfDir, href)
BookPageContent(
zip.getInputStream(zip.getEntry(coverPath)).readAllBytes(),
mediaType,
)
} else null
}
fun getManifest(path: Path): EpubManifest =
path.epub { epub ->
EpubManifest(
resources = getResources(epub),
toc = getToc(epub),
landmarks = getLandmarks(epub),
pageList = getPageList(epub),
pageCount = computePageCount(epub),
)
}
private fun getResources(epub: EpubPackage): List<MediaFile> {
val spine = epub.opfDoc.select("spine > itemref").map { it.attr("idref") }.mapNotNull { epub.manifest[it] }
val pages = spine.map { page ->
MediaFile(
normalizeHref(epub.opfDir, page.href),
page.mediaType,
MediaFile.SubType.EPUB_PAGE,
)
}
val assets = epub.manifest.values.filterNot { spine.contains(it) }.map {
MediaFile(
normalizeHref(epub.opfDir, it.href),
it.mediaType,
MediaFile.SubType.EPUB_ASSET,
)
}
val zipEntries = epub.zip.entries.toList()
return (pages + assets).map { resource ->
resource.copy(fileSize = zipEntries.firstOrNull { it.name == resource.fileName }?.let { if (it.size == ArchiveEntry.SIZE_UNKNOWN) null else it.size })
}
}
private fun computePageCount(epub: EpubPackage): Int {
val spine = epub.opfDoc.select("spine > itemref")
.map { it.attr("idref") }
.mapNotNull { idref -> epub.manifest[idref]?.href?.let { normalizeHref(epub.opfDir, it) } }
return epub.zip.entries.toList().filter { it.name in spine }.sumOf { ceil(it.compressedSize / 1024.0).toInt() }
}
private fun getToc(epub: EpubPackage): List<EpubTocEntry> {
// Epub 3
epub.getNavResource()?.let { return processNav(it, Epub3Nav.TOC) }
// Epub 2
epub.getNcxResource()?.let { return processNcx(it, Epub2Nav.TOC) }
return emptyList()
}
private fun getPageList(epub: EpubPackage): List<EpubTocEntry> {
// Epub 3
epub.getNavResource()?.let { return processNav(it, Epub3Nav.PAGELIST) }
// Epub 2
epub.getNcxResource()?.let { return processNcx(it, Epub2Nav.PAGELIST) }
return emptyList()
}
private fun getLandmarks(epub: EpubPackage): List<EpubTocEntry> {
// Epub 3
epub.getNavResource()?.let { return processNav(it, Epub3Nav.LANDMARKS) }
// Epub 2
return processOpfGuide(epub.opfDoc, epub.opfDir)
}
}

View File

@ -0,0 +1,12 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.gotson.komga.domain.model.EpubTocEntry
import org.gotson.komga.domain.model.MediaFile
data class EpubManifest(
val resources: List<MediaFile>,
val toc: List<EpubTocEntry>,
val landmarks: List<EpubTocEntry>,
val pageList: List<EpubTocEntry>,
val pageCount: Int,
)

View File

@ -0,0 +1,8 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
data class ManifestItem(
val id: String,
val href: String,
val mediaType: String,
val properties: Set<String> = emptySet(),
)

View File

@ -0,0 +1,30 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.gotson.komga.domain.model.EpubTocEntry
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.springframework.web.util.UriUtils
import java.nio.file.Path
import kotlin.io.path.Path
fun EpubPackage.getNavResource(): ResourceContent? =
manifest.values.firstOrNull { it.properties.contains("nav") }?.let { nav ->
val href = normalizeHref(opfDir, nav.href)
zip.getInputStream(zip.getEntry(href)).use { ResourceContent(Path(href), it.readBytes().decodeToString()) }
}
fun processNav(document: ResourceContent, navElement: Epub3Nav): List<EpubTocEntry> {
val doc = Jsoup.parse(document.content)
val nav = doc.select("nav")
// Jsoup selectors cannot find an attribute with namespace
.firstOrNull { it.attributes().any { attr -> attr.key.endsWith("type") && attr.value == navElement.value } }
return nav?.select(":root > ol > li")?.toList()?.mapNotNull { navLiElementToTocEntry(it, document.path.parent) } ?: emptyList()
}
private fun navLiElementToTocEntry(element: Element, navDir: Path?): EpubTocEntry? {
val title = element.selectFirst(":root > a, span")?.text()
val href = element.selectFirst(":root > a")?.attr("href")?.let { UriUtils.decode(it, Charsets.UTF_8) }
val children = element.select(":root > ol > li").mapNotNull { navLiElementToTocEntry(it, navDir) }
if (title != null) return EpubTocEntry(title, href?.let { normalizeHref(navDir, it) }, children)
return null
}

View File

@ -0,0 +1,30 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.gotson.komga.domain.model.EpubTocEntry
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.springframework.web.util.UriUtils
import java.nio.file.Path
import kotlin.io.path.Path
private val possibleNcxItemIds = listOf("toc", "ncx", "ncxtoc")
fun EpubPackage.getNcxResource(): ResourceContent? =
(manifest.values.firstOrNull { it.mediaType == "application/x-dtbncx+xml" } ?: manifest.values.firstOrNull { possibleNcxItemIds.contains(it.id) })?.let { ncx ->
val href = normalizeHref(opfDir, ncx.href)
zip.getInputStream(zip.getEntry(href)).use { ResourceContent(Path(href), it.readBytes().decodeToString()) }
}
fun processNcx(document: ResourceContent, navType: Epub2Nav): List<EpubTocEntry> =
Jsoup.parse(document.content)
.select("${navType.level1} > ${navType.level2}")
.toList()
.mapNotNull { ncxElementToTocEntry(navType, it, document.path.parent) }
private fun ncxElementToTocEntry(navType: Epub2Nav, element: Element, ncxDir: Path?): EpubTocEntry? {
val title = element.selectFirst("navLabel > text")?.text()
val href = element.selectFirst("content")?.attr("src")?.let { UriUtils.decode(it, Charsets.UTF_8) }
val children = element.select(":root > ${navType.level2}").toList().mapNotNull { ncxElementToTocEntry(navType, it, ncxDir) }
if (title != null) return EpubTocEntry(title, href?.let { normalizeHref(ncxDir, it) }, children)
return null
}

View File

@ -0,0 +1,33 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.gotson.komga.domain.model.EpubTocEntry
import org.jsoup.nodes.Document
import org.springframework.web.util.UriUtils
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.invariantSeparatorsPathString
fun Document.getManifest() = select("manifest > item").associate {
it.attr("id") to ManifestItem(
it.attr("id"),
it.attr("href"),
it.attr("media-type"),
it.attr("properties").split(" ").toSet(),
)
}
fun normalizeHref(opfDir: Path?, href: String) = (opfDir?.resolve(href)?.normalize() ?: Paths.get(href)).invariantSeparatorsPathString
/**
* Process an OPF document and extracts TOC entries
* from the <guide> section.
*/
fun processOpfGuide(opf: Document, opfDir: Path?): List<EpubTocEntry> {
val guide = opf.selectFirst("guide") ?: return emptyList()
return guide.select("reference").map { ref ->
EpubTocEntry(
ref.attr("title"),
ref.attr("href").ifBlank { null }?.let { normalizeHref(opfDir, UriUtils.decode(it, Charsets.UTF_8)) },
)
}
}

View File

@ -0,0 +1,8 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import java.nio.file.Path
data class ResourceContent(
val path: Path,
val content: String,
)

View File

@ -1,4 +1,4 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.pdf
import mu.KotlinLogging
import org.apache.pdfbox.io.MemoryUsageSetting
@ -28,8 +28,6 @@ class PdfExtractor(
@Qualifier("pdfResolution")
private val resolution: Float,
) {
fun getPageCount(path: Path): Int = PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf -> pdf.numberOfPages }
fun getPages(path: Path, analyzeDimensions: Boolean): List<MediaContainerEntry> =
PDDocument.load(path.toFile(), MemoryUsageSetting.setupTempFileOnly()).use { pdf ->
(0 until pdf.numberOfPages).map { index ->

View File

@ -157,7 +157,7 @@ class ComicInfoProvider(
private fun getComicInfo(book: BookWithMedia): ComicInfo? {
try {
if (book.media.files.none { it == COMIC_INFO }) {
if (book.media.files.none { it.fileName == COMIC_INFO }) {
logger.debug { "Book does not contain any $COMIC_INFO file: $book" }
return null
}

View File

@ -11,7 +11,7 @@ import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.MetadataPatchTarget
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.SeriesMetadataPatch
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFile
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
import org.gotson.komga.infrastructure.metadata.SeriesMetadataFromBookProvider
import org.gotson.komga.language.stripAccents
@ -24,7 +24,6 @@ import java.time.format.DateTimeFormatter
@Service
class EpubMetadataProvider(
private val epubExtractor: EpubExtractor,
private val isbnValidator: ISBNValidator,
) : BookMetadataProvider, SeriesMetadataFromBookProvider {
@ -49,7 +48,7 @@ class EpubMetadataProvider(
override fun getBookMetadataFromBook(book: BookWithMedia): BookMetadataPatch? {
if (book.media.mediaType != MediaType.EPUB.type) return null
epubExtractor.getPackageFile(book.book.path)?.let { packageFile ->
getPackageFile(book.book.path)?.let { packageFile ->
val opf = Jsoup.parse(packageFile, "", Parser.xmlParser())
val title = opf.selectFirst("metadata > dc|title")?.text()?.ifBlank { null }
@ -87,7 +86,7 @@ class EpubMetadataProvider(
override fun getSeriesMetadataFromBook(book: BookWithMedia, library: Library): SeriesMetadataPatch? {
if (book.media.mediaType != MediaType.EPUB.type) return null
epubExtractor.getPackageFile(book.book.path)?.let { packageFile ->
getPackageFile(book.book.path)?.let { packageFile ->
val opf = Jsoup.parse(packageFile, "", Parser.xmlParser())
val series = opf.selectFirst("metadata > *|meta[property=belongs-to-collection]")?.text()?.ifBlank { null }

View File

@ -74,6 +74,8 @@ class SecurityConfiguration(
"/api/v1/claim",
// used by webui
"/api/v1/oauth2/providers",
// epub resources - fonts are always requested anonymously, so we check for authorization within the controller method directly
"api/v1/books/{bookId}/resource/**",
).permitAll()
// all other endpoints are restricted to authenticated users

View File

@ -1,7 +1,10 @@
package org.gotson.komga.interfaces.api
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.EpubTocEntry
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.MediaProfile
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.service.BookAnalyzer
@ -16,6 +19,7 @@ import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON
import org.gotson.komga.interfaces.api.dto.MEDIATYPE_WEBPUB_JSON_VALUE
import org.gotson.komga.interfaces.api.dto.OpdsLinkRel
import org.gotson.komga.interfaces.api.dto.PROFILE_DIVINA
import org.gotson.komga.interfaces.api.dto.PROFILE_EPUB
import org.gotson.komga.interfaces.api.dto.PROFILE_PDF
import org.gotson.komga.interfaces.api.dto.WPBelongsToDto
import org.gotson.komga.interfaces.api.dto.WPContributorDto
@ -36,8 +40,6 @@ import org.gotson.komga.domain.model.MediaType as KomgaMediaType
@Service
class WebPubGenerator(
@Qualifier("pdfImageType")
private val pdfImageType: ImageType,
@Qualifier("thumbnailType")
private val thumbnailType: ImageType,
private val imageConverter: ImageConverter,
@ -128,6 +130,45 @@ class WebPubGenerator(
}
}
fun toManifestEpub(bookDto: BookDto, media: Media, seriesMetadata: SeriesMetadata): WPPublicationDto {
val uriBuilder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("api", "v1")
val extension = media.extension as MediaExtensionEpub?
return bookDto.toBasePublicationDto().let { publication ->
publication.copy(
mediaType = MEDIATYPE_WEBPUB_JSON,
metadata = publication.metadata
.withSeriesMetadata(seriesMetadata)
.copy(conformsTo = PROFILE_EPUB),
readingOrder = media.files.filter { it.subType == MediaFile.SubType.EPUB_PAGE }.map {
WPLinkDto(
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
type = it.mediaType,
)
},
resources = buildThumbnailLinkDtos(bookDto.id) +
media.files.filter { it.subType == MediaFile.SubType.EPUB_ASSET }.map {
WPLinkDto(
href = uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/").path(it.fileName).toUriString(),
type = it.mediaType,
)
},
toc = extension?.toc?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
landmarks = extension?.landmarks?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
pageList = extension?.pageList?.map { it.toWPLinkDto(uriBuilder.cloneBuilder().path("books/${bookDto.id}/resource/")) } ?: emptyList(),
)
}
}
private fun EpubTocEntry.toWPLinkDto(uriBuilder: UriComponentsBuilder): WPLinkDto = WPLinkDto(
title = title,
href = href?.let {
val fragment = it.substringAfterLast("#", "")
val h = it.removeSuffix("#$fragment")
uriBuilder.cloneBuilder().path(h).toUriString() + if (fragment.isNotEmpty()) "#$fragment" else ""
},
children = children.map { it.toWPLinkDto(uriBuilder) },
)
private fun BookDto.toWPMetadataDto(includeOpdsLinks: Boolean = false) = WPMetadataDto(
title = metadata.title,
description = metadata.summary,
@ -196,6 +237,7 @@ class WebPubGenerator(
private fun mediaProfileToWebPub(profile: MediaProfile?): String = when (profile) {
MediaProfile.DIVINA -> MEDIATYPE_DIVINA_JSON_VALUE
MediaProfile.PDF -> MEDIATYPE_WEBPUB_JSON_VALUE
MediaProfile.EPUB -> MEDIATYPE_WEBPUB_JSON_VALUE
null -> MEDIATYPE_WEBPUB_JSON_VALUE
}
}

View File

@ -7,6 +7,7 @@ const val MEDIATYPE_DIVINA_JSON_VALUE = "application/divina+json"
const val MEDIATYPE_WEBPUB_JSON_VALUE = "application/webpub+json"
const val PROFILE_DIVINA = "https://readium.org/webpub-manifest/profiles/divina"
const val PROFILE_EPUB = "https://readium.org/webpub-manifest/profiles/epub"
const val PROFILE_PDF = "https://readium.org/webpub-manifest/profiles/pdf"
val MEDIATYPE_OPDS_PUBLICATION_JSON = MediaType("application", "opds-publication+json")

View File

@ -21,9 +21,10 @@ data class WPLinkDto(
@Positive
val height: Int? = null,
val alternate: List<WPLinkDto> = emptyList(),
val children: List<WPLinkDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonInclude(JsonInclude.Include.NON_NULL)
data class WPPublicationDto(
@JsonIgnore
val mediaType: MediaType,
@ -35,6 +36,8 @@ data class WPPublicationDto(
val readingOrder: List<WPLinkDto> = emptyList(),
val resources: List<WPLinkDto> = emptyList(),
val toc: List<WPLinkDto> = emptyList(),
val landmarks: List<WPLinkDto> = emptyList(),
val pageList: List<WPLinkDto> = emptyList(),
)
@JsonInclude(JsonInclude.Include.NON_EMPTY)

View File

@ -689,14 +689,16 @@ class OpdsController(
val mediaTypes = when (media.profile) {
MediaProfile.DIVINA -> media.pages.map { it.mediaType }.distinct()
MediaProfile.PDF -> listOf(pdfImageType.mediaType)
null -> emptyList()
MediaProfile.EPUB, null -> emptyList()
}
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
OpdsLinkPageStreaming(mediaTypes.first(), uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}", media.pageCount, readProgress?.page, readProgress?.readDate)
} else {
OpdsLinkPageStreaming("image/jpeg", uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}?convert=jpeg", media.pageCount, readProgress?.page, readProgress?.readDate)
}
val opdsLinkPageStreaming =
if (mediaTypes.isEmpty()) null
else if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
OpdsLinkPageStreaming(mediaTypes.first(), uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}", media.pageCount, readProgress?.page, readProgress?.readDate)
} else {
OpdsLinkPageStreaming("image/jpeg", uriBuilder("books/$id/pages/").toUriString() + "{pageNumber}?convert=jpeg", media.pageCount, readProgress?.page, readProgress?.readDate)
}
return OpdsEntryAcquisition(
title = "${prepend(this)}${metadata.title}",
@ -707,7 +709,7 @@ class OpdsController(
if (metadata.summary.isNotBlank()) append("\n\n${metadata.summary}")
},
authors = metadata.authors.map { OpdsAuthor(it.name) },
links = listOf(
links = listOfNotNull(
OpdsLinkImageThumbnail("image/jpeg", uriBuilder("books/$id/thumbnail/small").toUriString()),
OpdsLinkImage(if (media.profile == MediaProfile.PDF) pdfImageType.mediaType else media.pages[0].mediaType, uriBuilder("books/$id/thumbnail").toUriString()),
OpdsLinkFileAcquisition(media.mediaType, uriBuilder("books/$id/file/${sanitize(FilenameUtils.getName(url))}").toUriString()),

View File

@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid
import mu.KotlinLogging
import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.IOUtils
import org.gotson.komga.application.tasks.HIGHEST_PRIORITY
import org.gotson.komga.application.tasks.HIGH_PRIORITY
@ -38,7 +40,6 @@ import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.gotson.komga.domain.service.BookAnalyzer
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
@ -77,6 +78,7 @@ import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.util.AntPathMatcher
import org.springframework.util.MimeTypeUtils
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
@ -94,7 +96,9 @@ import org.springframework.web.context.request.ServletWebRequest
import org.springframework.web.context.request.WebRequest
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.HandlerMapping
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import org.springframework.web.util.UriUtils
import java.io.FileNotFoundException
import java.io.OutputStream
import java.nio.charset.StandardCharsets.UTF_8
@ -105,6 +109,7 @@ import kotlin.io.path.name
import org.gotson.komga.domain.model.MediaType as KomgaMediaType
private val logger = KotlinLogging.logger {}
private val FONT_EXTENSIONS = listOf("otf", "woff", "woff2", "eot", "ttf", "svg")
@RestController
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
@ -122,7 +127,6 @@ class BookController(
private val imageAnalyzer: ImageAnalyzer,
private val eventPublisher: ApplicationEventPublisher,
private val thumbnailBookRepository: ThumbnailBookRepository,
private val imageConverter: ImageConverter,
private val webPubGenerator: WebPubGenerator,
) {
@ -657,10 +661,75 @@ class BookController(
when (KomgaMediaType.fromMediaType(media.mediaType)?.profile) {
MediaProfile.DIVINA -> getWebPubManifestDivina(principal, bookId)
MediaProfile.PDF -> getWebPubManifestPdf(principal, bookId)
MediaProfile.EPUB -> getWebPubManifestEpub(principal, bookId)
null -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed")
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/resource/**"],
produces = ["*/*"],
)
fun getBookResource(
request: HttpServletRequest,
@AuthenticationPrincipal principal: KomgaPrincipal?,
@PathVariable bookId: String,
): ResponseEntity<ByteArray> {
val resourceName = AntPathMatcher().extractPathWithinPattern(request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE) as String, request.requestURI).let { UriUtils.decode(it, UTF_8) }
val isFont = FONT_EXTENSIONS.contains(FilenameUtils.getExtension(resourceName).lowercase())
if (!isFont && principal == null) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
val book = bookRepository.findByIdOrNull(bookId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
val media = mediaRepository.findById(book.id)
if (ServletWebRequest(request).checkNotModified(getBookLastModified(media))) {
return ResponseEntity
.status(HttpStatus.NOT_MODIFIED)
.setNotModified(media)
.body(ByteArray(0))
}
if (media.profile != MediaProfile.EPUB) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${media.mediaType}' not compatible with requested profile")
if (!isFont) principal!!.user.checkContentRestriction(book)
val res = media.files.firstOrNull { it.fileName == resourceName } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
val bytes = bookAnalyzer.getFileContent(BookWithMedia(book, media), resourceName)
return ResponseEntity.ok()
.headers(
HttpHeaders().apply {
contentDisposition = ContentDisposition.builder("inline")
.filename(FilenameUtils.getName(resourceName), UTF_8)
.build()
},
)
.contentType(getMediaTypeOrDefault(res.mediaType))
.setNotModified(media)
.body(bytes)
}
@GetMapping(
value = ["api/v1/books/{bookId}/manifest/epub"],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],
)
fun getWebPubManifestEpub(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: String,
): ResponseEntity<WPPublicationDto> =
bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let { bookDto ->
if (bookDto.media.mediaProfile != MediaProfile.EPUB.name) throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Book media type '${bookDto.media.mediaType}' not compatible with requested profile")
principal.user.checkContentRestriction(bookDto)
val manifest = webPubGenerator.toManifestEpub(
bookDto,
mediaRepository.findById(bookId),
seriesMetadataRepository.findById(bookDto.seriesId),
)
ResponseEntity.ok()
.contentType(manifest.mediaType)
.body(manifest)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping(
value = ["api/v1/books/{bookId}/manifest/pdf"],
produces = [MEDIATYPE_WEBPUB_JSON_VALUE],

View File

@ -102,7 +102,7 @@ class TransientBooksController(
sizeBytes = bookPage.fileSize,
)
},
files = media.files,
files = media.files.map { it.fileName },
comment = media.comment ?: "",
)
}

View File

@ -130,7 +130,7 @@ class BookAnalyzerTest(
assertThat(media.mediaType).isEqualTo("application/epub+zip")
assertThat(media.status).isEqualTo(Media.Status.READY)
assertThat(media.pages).hasSize(2)
assertThat(media.pages).hasSize(0)
}
@Test

View File

@ -4,7 +4,10 @@ import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.domain.model.EpubTocEntry
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaExtensionEpub
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.MediaType
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
@ -73,7 +76,11 @@ class MediaDaoTest(
fileSize = 10,
),
),
files = listOf("ComicInfo.xml"),
files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 3)),
extension = MediaExtensionEpub(
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
),
comment = "comment",
bookId = book.id,
)
@ -96,7 +103,15 @@ class MediaDaoTest(
assertThat(fileSize).isEqualTo(media.pages.first().fileSize)
}
assertThat(created.files).hasSize(1)
assertThat(created.files.first()).isEqualTo(media.files.first())
with(created.files.first()) {
assertThat(fileName).isEqualTo(media.files.first().fileName)
assertThat(mediaType).isEqualTo(media.files.first().mediaType)
assertThat(subType).isEqualTo(media.files.first().subType)
assertThat(fileSize).isEqualTo(media.files.first().fileSize)
}
assertThat(created.extension).isNotNull
assertThat(created.extension).isInstanceOf(MediaExtensionEpub::class.java)
assertThat(created.extension).isEqualTo(media.extension)
}
@Test
@ -125,7 +140,10 @@ class MediaDaoTest(
mediaType = "image/jpeg",
),
),
files = listOf("ComicInfo.xml"),
files = listOf(MediaFile("ComicInfo.xml", "application/xml", MediaFile.SubType.EPUB_ASSET, 5)),
extension = MediaExtensionEpub(
landmarks = listOf(EpubTocEntry("title2", "href2", listOf(EpubTocEntry("subtitle2", "subhref2")))),
),
comment = "comment",
bookId = book.id,
)
@ -146,7 +164,10 @@ class MediaDaoTest(
fileSize = 10,
),
),
files = listOf("id.txt"),
files = listOf(MediaFile("id.txt")),
extension = MediaExtensionEpub(
toc = listOf(EpubTocEntry("title", "href", listOf(EpubTocEntry("subtitle", "subhref")))),
),
comment = "comment2",
)
}
@ -167,7 +188,11 @@ class MediaDaoTest(
assertThat(modified.pages.first().dimension).isEqualTo(updated.pages.first().dimension)
assertThat(modified.pages.first().fileHash).isEqualTo(updated.pages.first().fileHash)
assertThat(modified.pages.first().fileSize).isEqualTo(updated.pages.first().fileSize)
assertThat(modified.files.first()).isEqualTo(updated.files.first())
assertThat(modified.files.first().fileName).isEqualTo(updated.files.first().fileName)
assertThat(modified.files.first().mediaType).isEqualTo(updated.files.first().mediaType)
assertThat(modified.files.first().subType).isEqualTo(updated.files.first().subType)
assertThat(modified.files.first().fileSize).isEqualTo(updated.files.first().fileSize)
assertThat(modified.extension).isEqualTo(updated.extension)
}
@Test
@ -181,7 +206,7 @@ class MediaDaoTest(
mediaType = "image/jpeg",
),
),
files = listOf("ComicInfo.xml"),
files = listOf(MediaFile("ComicInfo.xml")),
comment = "comment",
bookId = book.id,
)

View File

@ -113,7 +113,6 @@ class PageHashDaoTest(
fileSize = it.toLong(),
)
},
files = listOf("ComicInfo.xml"),
comment = "comment",
bookId = books.first().id,
)

View File

@ -282,7 +282,7 @@ class SeriesDtoDaoTest(
seriesLifecycle.createSeries(makeSeries("Batman", library.id))
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -307,7 +307,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -332,7 +332,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -357,7 +357,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -382,7 +382,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -407,7 +407,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -438,7 +438,7 @@ class SeriesDtoDaoTest(
seriesMetadataLifecycle.aggregateMetadata(series)
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val foundByBookTag = seriesDtoDao.findAll(
@ -506,7 +506,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -531,7 +531,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -564,7 +564,7 @@ class SeriesDtoDaoTest(
}
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -598,7 +598,7 @@ class SeriesDtoDaoTest(
seriesMetadataLifecycle.aggregateMetadata(series)
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val foundGeneric = seriesDtoDao.findAll(
@ -643,7 +643,7 @@ class SeriesDtoDaoTest(
seriesMetadataLifecycle.aggregateMetadata(series)
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(
@ -664,7 +664,7 @@ class SeriesDtoDaoTest(
seriesLifecycle.createSeries(makeSeries("Batman and Robin", library.id))
searchIndexLifecycle.rebuildIndex()
Thread.sleep(100) // index rebuild is done asynchronously, and need a slight delay to be updated
Thread.sleep(500) // index rebuild is done asynchronously, and need a slight delay to be updated
// when
val found = seriesDtoDao.findAll(

View File

@ -1,46 +0,0 @@
package org.gotson.komga.infrastructure.mediacontainer
import org.apache.tika.config.TikaConfig
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.junit.jupiter.api.Test
import org.springframework.core.io.ClassPathResource
class EpubExtractorTest {
private val contentDetector = ContentDetector(TikaConfig())
private val imageAnalyzer = ImageAnalyzer()
private val zipExtractor = ZipExtractor(contentDetector, imageAnalyzer)
private val epubExtractor = EpubExtractor(zipExtractor, contentDetector, imageAnalyzer)
@Test
fun `given epub 3 file when parsing for entries then returns all images contained in pages`() {
val epubResource = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub")
val entries = epubExtractor.getEntries(epubResource.file.toPath(), true)
assertThat(entries).hasSize(1)
with(entries.first()) {
assertThat(name).isEqualTo("cover.jpeg")
assertThat(mediaType).isEqualTo("image/jpeg")
assertThat(dimension).isEqualTo(Dimension(461, 616))
assertThat(fileSize).isEqualTo(56756)
}
}
@Test
fun `given epub 3 file when parsing for entries without analyzing dimensions then returns all images contained in pages without dimensions`() {
val epubResource = ClassPathResource("epub/The Incomplete Theft - Ralph Burke.epub")
val entries = epubExtractor.getEntries(epubResource.file.toPath(), false)
assertThat(entries).hasSize(1)
with(entries.first()) {
assertThat(name).isEqualTo("cover.jpeg")
assertThat(mediaType).isEqualTo("image/jpeg")
assertThat(dimension).isNull()
}
}
}

View File

@ -1,9 +1,10 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.divina
import org.apache.tika.config.TikaConfig
import org.assertj.core.api.Assertions
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.junit.jupiter.api.Test
import org.springframework.core.io.ClassPathResource

View File

@ -1,9 +1,10 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.divina
import org.apache.tika.config.TikaConfig
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Dimension
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.junit.jupiter.api.Test
import org.springframework.core.io.ClassPathResource

View File

@ -0,0 +1,73 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.EpubTocEntry
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.core.io.ClassPathResource
import java.util.stream.Stream
class NavTest {
@ParameterizedTest
@MethodSource("paramSource")
fun `given nav document when parsing nav section then nav entries are valid`(navType: Epub3Nav, prefix: String?, expectedProvider: (String) -> List<EpubTocEntry>) {
// given
val navResource = ClassPathResource("epub/nav.xhtml")
val navString = navResource.inputStream.readAllBytes().decodeToString()
// when
val nav = processNav(navString, navType)
// then
val expectedNav = expectedProvider(prefix?.let { "$it/" } ?: "")
assertThat(nav).isEqualTo(expectedNav)
}
private fun paramSource(): Stream<Arguments> {
return Stream.of(
Arguments.of(Epub3Nav.TOC, null, ::getExpectedNavToc),
Arguments.of(Epub3Nav.TOC, "PREFIX", ::getExpectedNavToc),
Arguments.of(Epub3Nav.LANDMARKS, null, ::getExpectedNavLandmarks),
Arguments.of(Epub3Nav.LANDMARKS, "PREFIX", ::getExpectedNavLandmarks),
Arguments.of(Epub3Nav.PAGELIST, null, ::getExpectedNavPageList),
Arguments.of(Epub3Nav.PAGELIST, "PREFIX", ::getExpectedNavPageList),
)
}
private fun getExpectedNavToc(prefix: String = "") = listOf(
EpubTocEntry("Cover", "${prefix}cover.xhtml"),
EpubTocEntry("Title Page", "${prefix}titlepage.xhtml"),
EpubTocEntry("Copyright", "${prefix}copyright.xhtml"),
EpubTocEntry("Table of Contents", "${prefix}toc.xhtml"),
EpubTocEntry("An unlinked heading", null),
EpubTocEntry(
"Introduction",
"${prefix}introduction.xhtml",
children = listOf(
EpubTocEntry("Spring", "${prefix}chapter 001.xhtml"),
EpubTocEntry("Summer", "${prefix}chapter 027.xhtml"),
EpubTocEntry("Fall", "${prefix}chapter053.xhtml"),
EpubTocEntry("Winter", "${prefix}chapter079.xhtml"),
),
),
EpubTocEntry("Acknowledgments", "${prefix}acknowledgements.xhtml"),
)
private fun getExpectedNavLandmarks(prefix: String = "") = listOf(
EpubTocEntry("Begin Reading", "${prefix}cover.xhtml#coverimage"),
EpubTocEntry("Table of Contents", "${prefix}toc.xhtml"),
)
private fun getExpectedNavPageList(prefix: String = "") = listOf(
EpubTocEntry("Cover Page", "${prefix}xhtml/cover.xhtml"),
EpubTocEntry("iii", "${prefix}xhtml/title.xhtml#pg_iii"),
EpubTocEntry("1", "${prefix}xhtml/chapter1.xhtml#pg_1"),
EpubTocEntry("2", "${prefix}xhtml/chapter1.xhtml#pg_2"),
EpubTocEntry("107", "${prefix}xhtml/acknowledgments.xhtml#pg_107"),
EpubTocEntry("ii", "${prefix}xhtml/adcard.xhtml#pg_ii"),
EpubTocEntry("109", "${prefix}xhtml/abouttheauthor.xhtml#pg_109"),
EpubTocEntry("iv", "${prefix}xhtml/copyright.xhtml#pg_iv"),
)
}

View File

@ -0,0 +1,75 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.EpubTocEntry
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.core.io.ClassPathResource
import java.util.stream.Stream
class NcxTest {
@ParameterizedTest
@MethodSource("paramSource")
fun `given ncx document when parsing nav then nav entries are valid`(navType: Epub2Nav, prefix: String?, expectedProvider: (String) -> List<EpubTocEntry>) {
// given
val ncxResource = ClassPathResource("epub/toc.ncx")
val ncxString = ncxResource.inputStream.readAllBytes().decodeToString()
// when
val ncxNav = processNcx(ncxString, navType)
// then
val expectedNav = expectedProvider(prefix?.let { "$it/" } ?: "")
assertThat(ncxNav).isEqualTo(expectedNav)
}
private fun paramSource(): Stream<Arguments> {
return Stream.of(
Arguments.of(Epub2Nav.TOC, null, ::getExpectedNcxToc),
Arguments.of(Epub2Nav.TOC, "PREFIX", ::getExpectedNcxToc),
Arguments.of(Epub2Nav.PAGELIST, null, ::getExpectedNcxPageList),
Arguments.of(Epub2Nav.PAGELIST, "PREFIX", ::getExpectedNcxPageList),
)
}
private fun getExpectedNcxToc(prefix: String = "") = listOf(
EpubTocEntry("COVER", "${prefix}Text/Mart_9780553897852_epub_cvi_r1.htm#b02-cvi"),
EpubTocEntry("BRAN", "${prefix}Text/Mart_9780553897852_epub_c69_r1.htm"),
EpubTocEntry(
"APPENDIX",
"${prefix}Text/Mart_9780553897852_epub_app_r1.htm",
children = listOf(
EpubTocEntry("THE KINGS AND THEIR COURTS", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.00"),
EpubTocEntry("THE KING ON THE IRON THRONE", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.01"),
EpubTocEntry("THE KING IN THE NARROW SEA", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.02"),
EpubTocEntry("THE KING IN HIGHGARDEN", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.03"),
EpubTocEntry("THE KING IN THE NORTH", "${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.04"),
EpubTocEntry(
"THE QUEEN ACROSS THE WATER",
"${prefix}Text/Mart_9780553897852_epub_app_r1.htm#apps01.05",
children = listOf(
EpubTocEntry("Another level", "${prefix}Text/Mart_9780553897852 epub_app_r1.htm#apps01.06"),
EpubTocEntry("Yet another level", "${prefix}Text/Mart_9780553897852 epub_app_r1.htm#apps01.07"),
),
),
),
),
EpubTocEntry("ACKNOWLEDGMENTS", "${prefix}Text/Mart_9780553897852_epub_ack_r1.htm"),
)
private fun getExpectedNcxPageList(prefix: String = "") = listOf(
EpubTocEntry("Cover Page", "${prefix}xhtml/cover.xhtml"),
EpubTocEntry("iii", "${prefix}xhtml/title.xhtml#pg_iii"),
EpubTocEntry("v", "${prefix}xhtml/dedication.xhtml#pg_v"),
EpubTocEntry("vii", "${prefix}xhtml/formoreinformation.xhtml#pg_vii"),
EpubTocEntry("viii", "${prefix}xhtml/formoreinformation.xhtml#pg_viii"),
EpubTocEntry("ix", "${prefix}xhtml/formoreinformation.xhtml#pg_ix"),
EpubTocEntry("x", "${prefix}xhtml/formoreinformation.xhtml#pg_x"),
EpubTocEntry("xi", "${prefix}xhtml/formoreinformation.xhtml#pg_xi"),
EpubTocEntry("1", "${prefix}xhtml/chapter1.xhtml#pg_1"),
EpubTocEntry("2", "${prefix}xhtml/chapter1.xhtml#pg_2"),
EpubTocEntry("3", "${prefix}xhtml/chapter1.xhtml#pg_3"),
)
}

View File

@ -0,0 +1,34 @@
package org.gotson.komga.infrastructure.mediacontainer.epub
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.EpubTocEntry
import org.jsoup.Jsoup
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.core.io.ClassPathResource
import java.nio.file.Path
class OpfTest {
@ParameterizedTest
@ValueSource(strings = ["", "PREFIX"])
fun `given ncx document and opfDir when landmarks TOC then TOC entries are valid`(prefix: String) {
// given
val opfResource = ClassPathResource("epub/clash.opf")
val opfString = opfResource.inputStream.readAllBytes().decodeToString()
val opfDoc = Jsoup.parse(opfString)
// when
val opfLandmarks = processOpfGuide(opfDoc, if (prefix.isBlank()) null else Path.of(prefix))
// then
val expectedToc = getExpectedOpfLandmarks(if (prefix.isBlank()) "" else "$prefix/")
assertThat(opfLandmarks).isEqualTo(expectedToc)
}
private fun getExpectedOpfLandmarks(prefix: String = "") = listOf(
EpubTocEntry("Table Of Contents", "${prefix}Text/Mart_9780553897852_epub_toc_r1.htm"),
EpubTocEntry("Text", "${prefix}Text/Mart_9780553897852_epub_prl_r1.htm"),
EpubTocEntry("Cover", "${prefix}Text/Mart_9780553897852_epub_cvi_r1.htm"),
)
}

View File

@ -1,4 +1,4 @@
package org.gotson.komga.infrastructure.mediacontainer
package org.gotson.komga.infrastructure.mediacontainer.pdf
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.infrastructure.image.ImageType

View File

@ -8,6 +8,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.BookMetadataPatch
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaFile
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.WebLink
import org.gotson.komga.domain.model.makeBook
@ -39,7 +40,7 @@ class ComicInfoProviderTest {
private val media = Media(
status = Media.Status.READY,
mediaType = "application/zip",
files = listOf("ComicInfo.xml"),
files = listOf(MediaFile("ComicInfo.xml")),
)
@Nested

View File

@ -1,9 +1,9 @@
package org.gotson.komga.infrastructure.metadata.epub
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.apache.commons.validator.routines.ISBNValidator
import org.apache.tika.config.TikaConfig
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookWithMedia
@ -11,10 +11,8 @@ import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.SeriesMetadata
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.infrastructure.image.ImageAnalyzer
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.mediacontainer.EpubExtractor
import org.gotson.komga.infrastructure.mediacontainer.ZipExtractor
import org.gotson.komga.infrastructure.mediacontainer.epub.getPackageFile
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.springframework.core.io.ClassPathResource
@ -22,13 +20,10 @@ import java.time.LocalDate
class EpubMetadataProviderTest {
private val mockExtractor = mockk<EpubExtractor>()
private val isbnValidator = ISBNValidator(true)
private val epubMetadataProvider = EpubMetadataProvider(mockExtractor, isbnValidator)
private val epubMetadataProvider = EpubMetadataProvider(isbnValidator)
private val contentDetector = ContentDetector(TikaConfig())
private val imageAnalyzer = ImageAnalyzer()
private val epubMetadataProviderProper = EpubMetadataProvider(EpubExtractor(ZipExtractor(contentDetector, imageAnalyzer), contentDetector, imageAnalyzer), ISBNValidator(true))
private val epubMetadataProviderProper = EpubMetadataProvider(ISBNValidator(true))
private val book = makeBook("book")
private val media = Media(
@ -36,13 +31,19 @@ class EpubMetadataProviderTest {
mediaType = "application/epub+zip",
)
@AfterEach
fun cleanup() {
unmockkStatic(::getPackageFile)
}
@Nested
inner class Book {
@Test
fun `given epub 3 opf when getting book metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/Panik im Paradies.opf")
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media))
@ -61,7 +62,8 @@ class EpubMetadataProviderTest {
@Test
fun `given another epub 3 opf when getting book metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/Die Drei 3.opf")
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media))
@ -99,7 +101,8 @@ class EpubMetadataProviderTest {
@Test
fun `given epub 2 opf when getting book metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/1979.opf")
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getBookMetadataFromBook(BookWithMedia(book, media))
@ -124,7 +127,8 @@ class EpubMetadataProviderTest {
@Test
fun `given epub 3 opf when getting series metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/Panik im Paradies.opf")
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
@ -141,7 +145,8 @@ class EpubMetadataProviderTest {
@Test
fun `given another epub 3 opf when getting series metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/Die Drei 3.opf")
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)
@ -158,7 +163,8 @@ class EpubMetadataProviderTest {
@Test
fun `given epub 2 opf when getting series metadata then metadata patch is valid`() {
val opf = ClassPathResource("epub/1979.opf")
every { mockExtractor.getPackageFile(any()) } returns opf.file.readText()
mockkStatic(::getPackageFile)
every { getPackageFile(any()) } returns opf.file.readText()
val patch = epubMetadataProvider.getSeriesMetadataFromBook(BookWithMedia(book, media), library)

View File

@ -0,0 +1,207 @@
<?xml version="1.0" encoding="utf-8"?>
<package version="2.0" unique-identifier="PrimaryID" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:identifier id="PrimaryID" opf:scheme="ISBN">978-0-553-89784-5</dc:identifier>
<dc:title>A Clash of Kings</dc:title>
<dc:rights>Copyright © 1996 by George R.R. Martin</dc:rights>
<dc:creator opf:file-as="Martin, George R. R." opf:role="aut">George R. R. Martin</dc:creator>
<dc:description>&lt;p&gt;</dc:description>
<dc:publisher>Random House Publishing Group</dc:publisher>
<dc:date opf:event="publication">2011-03-22</dc:date>
<dc:language>en</dc:language>
<meta name="cover" content="b02-cover-image" />
<meta content="1.1" name="epubcheckversion" />
<meta content="2011-03-15" name="epubcheckdate" />
<meta content="0.4.0" name="Sigil version" />
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="b02-css" href="Styles/Mart_9780553897852_epub_css_r1.css" media-type="text/css"/>
<item id="b02-cvi" href="Text/Mart_9780553897852_epub_cvi_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-tp" href="Text/Mart_9780553897852_epub_tp_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-toc" href="Text/Mart_9780553897852_epub_toc_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-ded" href="Text/Mart_9780553897852_epub_ded_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-fm1" href="Text/Mart_9780553897852_epub_fm1_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-fm2" href="Text/Mart_9780553897852_epub_fm2_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-prl" href="Text/Mart_9780553897852_epub_prl_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c01" href="Text/Mart_9780553897852_epub_c01_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c02" href="Text/Mart_9780553897852_epub_c02_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c03" href="Text/Mart_9780553897852_epub_c03_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c04" href="Text/Mart_9780553897852_epub_c04_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c05" href="Text/Mart_9780553897852_epub_c05_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c06" href="Text/Mart_9780553897852_epub_c06_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c07" href="Text/Mart_9780553897852_epub_c07_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c08" href="Text/Mart_9780553897852_epub_c08_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c09" href="Text/Mart_9780553897852_epub_c09_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c10" href="Text/Mart_9780553897852_epub_c10_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c11" href="Text/Mart_9780553897852_epub_c11_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c12" href="Text/Mart_9780553897852_epub_c12_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c13" href="Text/Mart_9780553897852_epub_c13_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c14" href="Text/Mart_9780553897852_epub_c14_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c15" href="Text/Mart_9780553897852_epub_c15_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c16" href="Text/Mart_9780553897852_epub_c16_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c17" href="Text/Mart_9780553897852_epub_c17_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c18" href="Text/Mart_9780553897852_epub_c18_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c19" href="Text/Mart_9780553897852_epub_c19_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c20" href="Text/Mart_9780553897852_epub_c20_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c21" href="Text/Mart_9780553897852_epub_c21_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c22" href="Text/Mart_9780553897852_epub_c22_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c23" href="Text/Mart_9780553897852_epub_c23_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c24" href="Text/Mart_9780553897852_epub_c24_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c25" href="Text/Mart_9780553897852_epub_c25_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c26" href="Text/Mart_9780553897852_epub_c26_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c27" href="Text/Mart_9780553897852_epub_c27_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c28" href="Text/Mart_9780553897852_epub_c28_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c29" href="Text/Mart_9780553897852_epub_c29_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c30" href="Text/Mart_9780553897852_epub_c30_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c31" href="Text/Mart_9780553897852_epub_c31_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c32" href="Text/Mart_9780553897852_epub_c32_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c33" href="Text/Mart_9780553897852_epub_c33_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c34" href="Text/Mart_9780553897852_epub_c34_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c35" href="Text/Mart_9780553897852_epub_c35_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c36" href="Text/Mart_9780553897852_epub_c36_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c37" href="Text/Mart_9780553897852_epub_c37_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c38" href="Text/Mart_9780553897852_epub_c38_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c39" href="Text/Mart_9780553897852_epub_c39_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c40" href="Text/Mart_9780553897852_epub_c40_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c41" href="Text/Mart_9780553897852_epub_c41_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c42" href="Text/Mart_9780553897852_epub_c42_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c43" href="Text/Mart_9780553897852_epub_c43_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c44" href="Text/Mart_9780553897852_epub_c44_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c45" href="Text/Mart_9780553897852_epub_c45_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c46" href="Text/Mart_9780553897852_epub_c46_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c47" href="Text/Mart_9780553897852_epub_c47_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c48" href="Text/Mart_9780553897852_epub_c48_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c49" href="Text/Mart_9780553897852_epub_c49_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c50" href="Text/Mart_9780553897852_epub_c50_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c51" href="Text/Mart_9780553897852_epub_c51_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c52" href="Text/Mart_9780553897852_epub_c52_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c53" href="Text/Mart_9780553897852_epub_c53_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c54" href="Text/Mart_9780553897852_epub_c54_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c55" href="Text/Mart_9780553897852_epub_c55_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c56" href="Text/Mart_9780553897852_epub_c56_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c57" href="Text/Mart_9780553897852_epub_c57_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c58" href="Text/Mart_9780553897852_epub_c58_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c59" href="Text/Mart_9780553897852_epub_c59_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c60" href="Text/Mart_9780553897852_epub_c60_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c61" href="Text/Mart_9780553897852_epub_c61_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c62" href="Text/Mart_9780553897852_epub_c62_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c63" href="Text/Mart_9780553897852_epub_c63_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c64" href="Text/Mart_9780553897852_epub_c64_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c65" href="Text/Mart_9780553897852_epub_c65_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c66" href="Text/Mart_9780553897852_epub_c66_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c67" href="Text/Mart_9780553897852_epub_c67_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c68" href="Text/Mart_9780553897852_epub_c68_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-c69" href="Text/Mart_9780553897852_epub_c69_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-app" href="Text/Mart_9780553897852_epub_app_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-bm" href="Text/Mart_9780553897852_epub_bm_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-ack" href="Text/Mart_9780553897852_epub_ack_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-cop" href="Text/Mart_9780553897852_epub_cop_r1.htm" media-type="application/xhtml+xml"/>
<item id="b02-cover-image" href="Images/Mart_9780553897852_epub_cvi_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2182224" href="Images/Mart_9780553897852_epub_a01_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2235231" href="Images/Mart_9780553897852_epub_a02_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1208082" href="Images/Mart_9780553897852_epub_a03_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1190433" href="Images/Mart_9780553897852_epub_a04_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1344120" href="Images/Mart_9780553897852_epub_a05_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2517717" href="Images/Mart_9780553897852_epub_a06_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1233482" href="Images/Mart_9780553897852_epub_a07_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1491108" href="Images/Mart_9780553897852_epub_a08_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1500203" href="Images/Mart_9780553897852_epub_a09_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1184086" href="Images/Mart_9780553897852_epub_a10_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1371281" href="Images/Mart_9780553897852_epub_a11_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id1219283" href="Images/Mart_9780553897852_epub_a12_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2384769" href="Images/Mart_9780553897852_epub_a13_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2222502" href="Images/Mart_9780553897852_epub_a14_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2545288" href="Images/Mart_9780553897852_epub_a15_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2238633" href="Images/Mart_9780553897852_epub_a16_r1.jpg" media-type="image/jpeg"/>
<item id="b02-id2277767" href="Images/Mart_9780553897852_epub_a17_r1.jpg" media-type="image/jpeg"/>
<item id="page-template.xpgt" href="Styles/page-template.xpgt" media-type="application/vnd.adobe-page-template+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="b02-cvi" linear="yes"/>
<itemref idref="b02-tp"/>
<itemref idref="b02-cop"/>
<itemref idref="b02-toc"/>
<itemref idref="b02-ded"/>
<itemref idref="b02-fm1"/>
<itemref idref="b02-fm2"/>
<itemref idref="b02-prl"/>
<itemref idref="b02-c01"/>
<itemref idref="b02-c02"/>
<itemref idref="b02-c03"/>
<itemref idref="b02-c04"/>
<itemref idref="b02-c05"/>
<itemref idref="b02-c06"/>
<itemref idref="b02-c07"/>
<itemref idref="b02-c08"/>
<itemref idref="b02-c09"/>
<itemref idref="b02-c10"/>
<itemref idref="b02-c11"/>
<itemref idref="b02-c12"/>
<itemref idref="b02-c13"/>
<itemref idref="b02-c14"/>
<itemref idref="b02-c15"/>
<itemref idref="b02-c16"/>
<itemref idref="b02-c17"/>
<itemref idref="b02-c18"/>
<itemref idref="b02-c19"/>
<itemref idref="b02-c20"/>
<itemref idref="b02-c21"/>
<itemref idref="b02-c22"/>
<itemref idref="b02-c23"/>
<itemref idref="b02-c24"/>
<itemref idref="b02-c25"/>
<itemref idref="b02-c26"/>
<itemref idref="b02-c27"/>
<itemref idref="b02-c28"/>
<itemref idref="b02-c29"/>
<itemref idref="b02-c30"/>
<itemref idref="b02-c31"/>
<itemref idref="b02-c32"/>
<itemref idref="b02-c33"/>
<itemref idref="b02-c34"/>
<itemref idref="b02-c35"/>
<itemref idref="b02-c36"/>
<itemref idref="b02-c37"/>
<itemref idref="b02-c38"/>
<itemref idref="b02-c39"/>
<itemref idref="b02-c40"/>
<itemref idref="b02-c41"/>
<itemref idref="b02-c42"/>
<itemref idref="b02-c43"/>
<itemref idref="b02-c44"/>
<itemref idref="b02-c45"/>
<itemref idref="b02-c46"/>
<itemref idref="b02-c47"/>
<itemref idref="b02-c48"/>
<itemref idref="b02-c49"/>
<itemref idref="b02-c50"/>
<itemref idref="b02-c51"/>
<itemref idref="b02-c52"/>
<itemref idref="b02-c53"/>
<itemref idref="b02-c54"/>
<itemref idref="b02-c55"/>
<itemref idref="b02-c56"/>
<itemref idref="b02-c57"/>
<itemref idref="b02-c58"/>
<itemref idref="b02-c59"/>
<itemref idref="b02-c60"/>
<itemref idref="b02-c61"/>
<itemref idref="b02-c62"/>
<itemref idref="b02-c63"/>
<itemref idref="b02-c64"/>
<itemref idref="b02-c65"/>
<itemref idref="b02-c66"/>
<itemref idref="b02-c67"/>
<itemref idref="b02-c68"/>
<itemref idref="b02-c69"/>
<itemref idref="b02-app"/>
<itemref idref="b02-bm"/>
<itemref idref="b02-ack"/>
</spine>
<guide>
<reference type="toc" title="Table Of Contents" href="Text/Mart_9780553897852_epub_toc_r1.htm"/>
<reference type="text" title="Text" href="Text/Mart_9780553897852_epub_prl_r1.htm"/>
<reference type="cover" title="Cover" href="Text/Mart_9780553897852_epub_cvi_r1.htm"/>
</guide>
</package>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns:epub="http://www.idpf.org/2007/ops" xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="default-style" content="text/html; charset=UTF-8"/>
<title>Best Bear Ever</title>
<link rel="stylesheet" href="css/stylesheet.css" type="text/css"/>
</head>
<body>
<nav id="toc" epub:type="toc">
<h1 class="toc-title">Contents</h1>
<ol epub:type="list">
<li id="cover"><a href="cover.xhtml">Cover</a></li>
<li id="titlepage"><a href="titlepage.xhtml">Title Page</a></li>
<li id="toc-copyright"><a href="copyright.xhtml">Copyright</a></li>
<li><a href="toc.xhtml">Table of Contents</a></li>
<li><span>An unlinked heading</span></li>
<li id="toc-introduction"><a href="introduction.xhtml">Introduction</a>
<ol>
<li id="toc-chapter001"><a href="chapter 001.xhtml">Spring</a></li>
<li id="toc-chapter002"><a href="chapter%20027.xhtml">Summer</a></li>
<li id="toc-chapter003"><a href="chapter053.xhtml">Fall</a></li>
<li id="toc-chapter004"><a href="chapter079.xhtml">Winter</a></li>
</ol>
</li>
<li id="toc-acknowledgements"><a href="acknowledgements.xhtml">Acknowledgments</a></li>
</ol>
</nav>
<nav epub:type="landmarks" class="hidden-tag" hidden="hidden">
<h1>Navigation</h1>
<ol epub:type="list">
<li><a epub:type="cover" href="cover.xhtml#coverimage">Begin Reading</a></li>
<li><a epub:type="toc" href="toc.xhtml">Table of Contents</a></li>
</ol>
</nav>
<nav role="doc-pagelist" aria-label="Page list" epub:type="page-list">
<h2>Pagebreaks of the print version</h2>
<ol>
<li><a href="xhtml/cover.xhtml">Cover Page</a></li>
<li><a href="xhtml/title.xhtml#pg_iii">iii</a></li>
<li><a href="xhtml/chapter1.xhtml#pg_1">1</a></li>
<li><a href="xhtml/chapter1.xhtml#pg_2">2</a></li>
<li><a href="xhtml/acknowledgments.xhtml#pg_107">107</a></li>
<li><a href="xhtml/adcard.xhtml#pg_ii">ii</a></li>
<li><a href="xhtml/abouttheauthor.xhtml#pg_109">109</a></li>
<li><a href="xhtml/copyright.xhtml#pg_iv">iv</a></li>
</ol>
</nav>
</body>
</html>

View File

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?><!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"
"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="en-US">
<head>
<meta content="978-0-553-89784-5" name="dtb:uid"/>
<meta content="2" name="dtb:depth"/>
<meta content="0" name="dtb:totalPageCount"/>
<meta content="0" name="dtb:maxPageNumber"/>
</head>
<docTitle>
<text>A Clash of Kings</text>
</docTitle>
<docAuthor>
<text>George R. R. Martin</text>
</docAuthor>
<navMap>
<navPoint id="b02" playOrder="95">
<navLabel>
<text>COVER</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_cvi_r1.htm#b02-cvi"/>
</navPoint>
<navPoint id="b02-c69" playOrder="171">
<navLabel>
<text>BRAN</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_c69_r1.htm"/>
</navPoint>
<navPoint id="b02-app" playOrder="172">
<navLabel>
<text>APPENDIX</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm"/>
<navPoint id="b02-apps01.00" playOrder="173">
<navLabel>
<text>THE KINGS AND THEIR COURTS</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.00"/>
</navPoint>
<navPoint id="b02-apps01.01" playOrder="174">
<navLabel>
<text>THE KING ON THE IRON THRONE</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.01"/>
</navPoint>
<navPoint id="b02-apps01.02" playOrder="175">
<navLabel>
<text>THE KING IN THE NARROW SEA</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.02"/>
</navPoint>
<navPoint id="b02-apps01.03" playOrder="176">
<navLabel>
<text>THE KING IN HIGHGARDEN</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.03"/>
</navPoint>
<navPoint id="b02-apps01.04" playOrder="177">
<navLabel>
<text>THE KING IN THE NORTH</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.04"/>
</navPoint>
<navPoint id="b02-apps01.05" playOrder="178">
<navLabel>
<text>THE QUEEN ACROSS THE WATER</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_app_r1.htm#apps01.05"/>
<navPoint id="b02-apps01.00" playOrder="173">
<navLabel>
<text>Another level</text>
</navLabel>
<content src="Text/Mart_9780553897852 epub_app_r1.htm#apps01.06"/>
</navPoint>
<navPoint id="b02-apps01.01" playOrder="174">
<navLabel>
<text>Yet another level</text>
</navLabel>
<content src="Text/Mart_9780553897852%20epub_app_r1.htm#apps01.07"/>
</navPoint>
</navPoint>
</navPoint>
<navPoint id="b02-ack" playOrder="188">
<navLabel>
<text>ACKNOWLEDGMENTS</text>
</navLabel>
<content src="Text/Mart_9780553897852_epub_ack_r1.htm"/>
</navPoint>
</navMap>
<pageList>
<pageTarget type="normal" id="id_cov" value="Cover Page">
<navLabel>
<text>Cover Page</text>
</navLabel>
<content src="xhtml/cover.xhtml"/>
</pageTarget>
<pageTarget type="normal" id="id_iii" value="iii">
<navLabel>
<text>iii</text>
</navLabel>
<content src="xhtml/title.xhtml#pg_iii"/>
</pageTarget>
<pageTarget type="normal" id="id_v" value="v">
<navLabel>
<text>v</text>
</navLabel>
<content src="xhtml/dedication.xhtml#pg_v"/>
</pageTarget>
<pageTarget type="normal" id="id_vii" value="vii">
<navLabel>
<text>vii</text>
</navLabel>
<content src="xhtml/formoreinformation.xhtml#pg_vii"/>
</pageTarget>
<pageTarget type="normal" id="id_viii" value="viii">
<navLabel>
<text>viii</text>
</navLabel>
<content src="xhtml/formoreinformation.xhtml#pg_viii"/>
</pageTarget>
<pageTarget type="normal" id="id_ix" value="ix">
<navLabel>
<text>ix</text>
</navLabel>
<content src="xhtml/formoreinformation.xhtml#pg_ix"/>
</pageTarget>
<pageTarget type="normal" id="id_x" value="x">
<navLabel>
<text>x</text>
</navLabel>
<content src="xhtml/formoreinformation.xhtml#pg_x"/>
</pageTarget>
<pageTarget type="normal" id="id_xi" value="xi">
<navLabel>
<text>xi</text>
</navLabel>
<content src="xhtml/formoreinformation.xhtml#pg_xi"/>
</pageTarget>
<pageTarget type="normal" id="id_1" value="1">
<navLabel>
<text>1</text>
</navLabel>
<content src="xhtml/chapter1.xhtml#pg_1"/>
</pageTarget>
<pageTarget type="normal" id="id_2" value="2">
<navLabel>
<text>2</text>
</navLabel>
<content src="xhtml/chapter1.xhtml#pg_2"/>
</pageTarget>
<pageTarget type="normal" id="id_3" value="3">
<navLabel>
<text>3</text>
</navLabel>
<content src="xhtml/chapter1.xhtml#pg_3"/>
</pageTarget>
</pageList>
</ncx>