diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..6bbe2ae
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..7643783
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7b3006b..cd8efe7 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -11,6 +11,7 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 2b83f24..ae15f8b 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -10,7 +10,7 @@
-
+
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 0c44360..feb2eef 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,10 @@ LightNovelReader *重构版* 是一款开源的轻小说阅读软件
| ![image](https://github.com/dmzz-yyhyy/LightNovelReader/blob/refactoring/assets/reading_light.png) |
| ![image](https://github.com/dmzz-yyhyy/LightNovelReader/blob/refactoring/assets/reading_dark.png) |
+### EpubLib
+为了处理epub的导出问题,我们单独创建了一个epub处理模块,如果您感兴趣,可以看[**这里**](https://github.com/dmzz-yyhyy/LightNovelReader/blob/refactoring/epub.md)
+
+
## License
```
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6eb4113..c68b4ce 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -21,7 +21,7 @@ android {
minSdk = 24
targetSdk = 34
// 版本号为x.y.z则versionCode为x*1000000+y*10000+z*100+debug版本号(开发需要时迭代, 两位数)
- versionCode = 1_00_00_017
+ versionCode = 1_00_00_018
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -78,19 +78,19 @@ android {
dependencies {
// desugaring support
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
// android lib
implementation("androidx.core:core-ktx:1.13.1")
implementation ("androidx.core:core-splashscreen:1.0.1")
- implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
- implementation("androidx.lifecycle:lifecycle-runtime-compose-android:2.8.6")
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// compose
- implementation("androidx.activity:activity-compose:1.9.2")
- implementation("androidx.compose.animation:animation-graphics-android:1.7.1")
- implementation(platform("androidx.compose:compose-bom:2024.09.01"))
- implementation("androidx.compose.material3:material3:1.3.0")
- androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.01"))
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.compose.animation:animation-graphics-android:1.7.6")
+ implementation(platform("androidx.compose:compose-bom:2024.12.01"))
+ implementation("androidx.compose.material3:material3:1.3.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -112,7 +112,7 @@ dependencies {
implementation("androidx.hilt:hilt-work:$androidXHilt")
implementation("androidx.hilt:hilt-navigation-compose:$androidXHilt")
// navigation
- val navVersion = "2.8.0"
+ val navVersion = "2.8.5"
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
implementation("androidx.navigation:navigation-dynamic-features-fragment:$navVersion")
@@ -151,6 +151,7 @@ dependencies {
implementation("androidx.work:work-gcm:$workVersion")
androidTestImplementation("androidx.work:work-testing:$workVersion")
implementation("androidx.work:work-multiprocess:$workVersion")
+ implementation(project(":epub"))
}
kapt {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c8ed78..052309e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,7 @@
+
()
+ val epub = EpubBuilder().apply {
+ val bookInformation = webBookDataSource.getBookInformation(bookId) ?: return Result.failure()
+ val bookVolumes = webBookDataSource.getBookVolumes(bookId) ?: return Result.failure()
+ val bookContentMap = mutableMapOf()
+ bookVolumes.volumes.forEach { volume ->
+ volume.chapters.forEach {
+ bookContentMap[it.id] = webBookDataSource.getChapterContent(it.id, bookId) ?: return Result.failure()
+ }
+ }
+ title = bookInformation.title
+ modifier = LocalDateTime.now()
+ creator = bookInformation.author
+ description = bookInformation.description
+ publisher = bookInformation.publishingHouse
+ tasks.add(ImageDownloader.Task(cover, bookInformation.coverUrl))
+ if (!tempDir.exists())
+ tempDir.mkdirs()
+ else
+ tempDir.listFiles()?.forEach(File::delete)
+ cover(cover)
+ bookVolumes.volumes.forEach { volume ->
+ chapter {
+ title(volume.volumeTitle)
+ volume.chapters.forEach {
+ chapter {
+ title(it.title)
+ content {
+ bookContentMap[it.id]!!.content.split("[image]").filter { it.isNotEmpty() }.forEach { singleText ->
+ if (singleText.startsWith("http://") || singleText.startsWith("https://")) {
+ val image = tempDir.resolve(singleText.hashCode().toString() + ".jpg")
+ tasks.add(ImageDownloader.Task(image, singleText))
+ image(image)
+ }
+ else {
+ singleText.split("\n").forEach {
+ text(it)
+ br()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ val imageDownloader = ImageDownloader(
+ tasks = tasks,
+ coroutineScope = coroutineScope,
+ onFinished = {
+ val file = tempDir.resolve("epub")
+ epub.build().save(file)
+ applicationContext.contentResolver.openOutputStream(fileUri).use {
+ it?.write(file.readBytes())
+ }
+ tempDir.delete()
+ }
+ )
+ while (!imageDownloader.isDone) { //
+ }
+ return Result.success()
+ }
+
+ override fun onStopped() {
+ super.onStopped()
+ coroutineScope.cancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/BookScreen.kt b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/BookScreen.kt
index 8673daf..e7164a9 100644
--- a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/BookScreen.kt
+++ b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/BookScreen.kt
@@ -55,10 +55,10 @@ fun BookScreen(
}
DetailScreen(
paddingValues = paddingValues,
+ onClickBackButton = onClickBackButton,
onClickChapter = {
navController.navigate(Screen.Book.Content.createRoute(it))
},
- onClickBackButton = onClickBackButton,
topBar = { newTopBar -> topBar = newTopBar },
id = bookId,
cacheBook = cacheBook,
diff --git a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailScreen.kt b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailScreen.kt
index cdc6b26..89ed847 100644
--- a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailScreen.kt
+++ b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailScreen.kt
@@ -1,5 +1,10 @@
package indi.dmzz_yyhyy.lightnovelreader.ui.book.detail
+import android.content.Intent
+import android.provider.DocumentsContract
+import android.widget.Toast
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.result.ActivityResult
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
@@ -37,11 +42,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -49,11 +56,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.work.WorkInfo
import indi.dmzz_yyhyy.lightnovelreader.R
import indi.dmzz_yyhyy.lightnovelreader.data.book.BookInformation
import indi.dmzz_yyhyy.lightnovelreader.data.book.Volume
import indi.dmzz_yyhyy.lightnovelreader.ui.components.Cover
import indi.dmzz_yyhyy.lightnovelreader.ui.components.Loading
+import indi.dmzz_yyhyy.lightnovelreader.ui.home.settings.list.launcher
+import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -75,8 +85,6 @@ fun DetailScreen(
else
onClickChapter(viewModel.uiState.userReadingData.lastReadChapterId)
},
- onClickMore: () -> Unit = {
- },
topBar: (@Composable (TopAppBarScrollBehavior) -> Unit) -> Unit,
id: Int,
cacheBook: (Int) -> Unit,
@@ -89,7 +97,6 @@ fun DetailScreen(
onClickChapter = onClickChapter,
onClickReadFromStart = onClickReadFromStart,
onClickContinueReading = onClickContinueReading,
- onClickMore = onClickMore,
topBar = topBar,
id = id,
cacheBook = cacheBook,
@@ -117,19 +124,36 @@ private fun Content(
else
onClickChapter(viewModel.uiState.userReadingData.lastReadChapterId)
},
- onClickMore: () -> Unit = {
- },
topBar: (@Composable (TopAppBarScrollBehavior) -> Unit) -> Unit,
id: Int,
cacheBook: (Int) -> Unit,
requestAddBookToBookshelf: (Int) -> Unit
) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
val uiState = viewModel.uiState
-
+ @Suppress("SENSELESS_COMPARISON")
+ val exportToEPUBLauncher = launcher {
+ scope.launch {
+ Toast.makeText(context, "开始导出书本 ${viewModel.uiState.bookInformation.title}", Toast.LENGTH_SHORT).show()
+ viewModel.exportToEpub(it, id).collect {
+ if (it != null)
+ when (it.state) {
+ WorkInfo.State.SUCCEEDED -> {
+ Toast.makeText(context, "成功导出书本 ${viewModel.uiState.bookInformation.title}", Toast.LENGTH_SHORT).show()
+ }
+ WorkInfo.State.FAILED -> {
+ Toast.makeText(context, "导出书本 ${viewModel.uiState.bookInformation.title} 失败", Toast.LENGTH_SHORT).show()
+ }
+ else -> {}
+ }
+ }
+ }
+ }
topBar {
TopBar(
onClickBackButton = onClickBackButton,
- onClickMore = onClickMore,
+ onClickExport = { createDataFile(viewModel.uiState.bookInformation.title, exportToEPUBLauncher) },
scrollBehavior = it
)
}
@@ -137,7 +161,7 @@ private fun Content(
viewModel.init(id)
}
AnimatedVisibility(
- visible = viewModel.uiState.bookInformation.isEmpty(),
+ visible = viewModel.uiState.bookInformation.isEmpty(),
enter = fadeIn(),
exit = fadeOut()
) {
@@ -229,7 +253,7 @@ private fun Content(
@Composable
private fun TopBar(
onClickBackButton: () -> Unit,
- onClickMore: () -> Unit,
+ onClickExport: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior
) {
TopAppBar(
@@ -248,9 +272,9 @@ private fun TopBar(
},
actions = {
IconButton(
- onClick = onClickMore
+ onClick = onClickExport
) {
- Icon(painterResource(id = R.drawable.more_vert_24px), "more")
+ Icon(painterResource(id = R.drawable.file_export_24px), "export to epub")
}
},
navigationIcon = {
@@ -566,4 +590,16 @@ private fun VolumeItem(
}
}
}
+}
+
+@Suppress("DuplicatedCode")
+fun createDataFile(fileName: String, launcher: ManagedActivityResultLauncher) {
+ val initUri = DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Documents")
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/epub+zip"
+ putExtra(DocumentsContract.EXTRA_INITIAL_URI, initUri)
+ putExtra(Intent.EXTRA_TITLE, fileName)
+ }
+ launcher.launch(Intent.createChooser(intent, "选择一位置"))
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailViewModel.kt b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailViewModel.kt
index 8bf1371..c860f09 100644
--- a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailViewModel.kt
+++ b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/ui/book/detail/DetailViewModel.kt
@@ -1,18 +1,27 @@
package indi.dmzz_yyhyy.lightnovelreader.ui.book.detail
+import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
import dagger.hilt.android.lifecycle.HiltViewModel
import indi.dmzz_yyhyy.lightnovelreader.data.BookRepository
import indi.dmzz_yyhyy.lightnovelreader.data.bookshelf.BookshelfRepository
-import javax.inject.Inject
+import indi.dmzz_yyhyy.lightnovelreader.data.work.ExportBookToEPUBWork
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
+import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(
private val bookRepository: BookRepository,
- private val bookshelfRepository: BookshelfRepository
+ private val bookshelfRepository: BookshelfRepository,
+ private val workManager: WorkManager,
) : ViewModel() {
private val _uiState = MutableDetailUiState()
val uiState: DetailUiState = _uiState
@@ -41,4 +50,21 @@ class DetailViewModel @Inject constructor(
}
}
}
+
+ fun exportToEpub(uri: Uri, bookId: Int): Flow {
+ val workRequest = OneTimeWorkRequestBuilder()
+ .setInputData(
+ workDataOf(
+ "bookId" to bookId,
+ "uri" to uri.toString(),
+ )
+ )
+ .build()
+ workManager.enqueueUniqueWork(
+ bookId.toString(),
+ ExistingWorkPolicy.KEEP,
+ workRequest
+ )
+ return workManager.getWorkInfoByIdFlow(workRequest.id)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/utils/ImageDownloader.kt b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/utils/ImageDownloader.kt
new file mode 100644
index 0000000..5adef26
--- /dev/null
+++ b/app/src/main/kotlin/indi/dmzz_yyhyy/lightnovelreader/utils/ImageDownloader.kt
@@ -0,0 +1,81 @@
+package indi.dmzz_yyhyy.lightnovelreader.utils
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+
+class ImageDownloader(
+ private val tasks: List,
+ coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO),
+ onFinished: () -> Unit,
+) {
+ private var count = 0
+ val isDone get() = count == tasks.size
+ data class Task(val file: File, val url: String)
+
+ init {
+ Log.i("ImageDownloader", "total tasks: ${tasks.size}")
+ tasks.forEach {task ->
+ coroutineScope.launch {
+ getImageFromNetByUrl(task.url)?.let { writeImageToDisk(it, task.file) }
+ count++
+ Log.i("ImageDownloader", "tasks: ${count}/${tasks.size}")
+ if (count == tasks.size) {
+ onFinished.invoke()
+ }
+ }
+ }
+ }
+
+ private fun writeImageToDisk(data: ByteArray, file: File) {
+ try {
+ val fileParent = file.parentFile
+ if (fileParent != null) {
+ if (!fileParent.exists()) {
+ fileParent.mkdirs()
+ file.createNewFile()
+ }
+ }
+ val fops = FileOutputStream(file)
+ fops.write(data)
+ fops.flush()
+ fops.close()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun getImageFromNetByUrl(strUrl: String): ByteArray? {
+ try {
+ val url = URL(strUrl)
+ val conn = url.openConnection() as HttpURLConnection
+ conn.requestMethod = "GET"
+ conn.connectTimeout = 5 * 1000
+ val inStream = conn.inputStream
+ val btData = readInputStream(inStream)
+ return btData
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return null
+ }
+
+ private fun readInputStream(inStream: InputStream): ByteArray {
+ val outStream = ByteArrayOutputStream()
+ val buffer = ByteArray(1024)
+ var len: Int
+ while ((inStream.read(buffer).also { len = it }) != -1) {
+ outStream.write(buffer, 0, len)
+ }
+ inStream.close()
+ return outStream.toByteArray()
+ }
+}
diff --git a/app/src/main/res/drawable/file_export_24px.xml b/app/src/main/res/drawable/file_export_24px.xml
new file mode 100644
index 0000000..783669e
--- /dev/null
+++ b/app/src/main/res/drawable/file_export_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/epub.md b/epub.md
new file mode 100644
index 0000000..de7f238
--- /dev/null
+++ b/epub.md
@@ -0,0 +1,122 @@
+# PotatoEpubLib
+这是一个专门用于生成Epub的模块
+## 示例
+```kotlin
+import io.nightfish.potatoepub.builder.EpubBuilder
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import java.io.File
+import java.time.LocalDateTime
+
+fun main() {
+ val rootPath = ClassLoader.getSystemClassLoader().getResource("")!!.toURI()
+ EpubBuilder()
+ .apply {
+ title = "yuk的超级大"
+ modifier = LocalDateTime.now()
+ cover(File(ClassLoader.getSystemClassLoader().getResource("cover.jpg")?.toURI()!!))
+ chapter {
+ title("干夜鱼的一百种方法")
+ content {
+ title("干夜鱼的一百种方法")
+ text("话说很久以前,有只叫做夜鱼的狐狸,他终日与他人尾交......")
+ image(File(ClassLoader.getSystemClassLoader().getResource("70d0a8f050ac1b4dfffb5665ce052498.jpg")?.toURI()!!))
+ }
+ }
+ chapter {
+ title("附录")
+ chapter {
+ title("夜鱼的学校生活")
+ content {
+ title("夜鱼的学校生活")
+ text("话说很久以前,有只叫做夜鱼的狐狸,他每日接受着A高附中的折磨")
+ }
+ }
+ chapter {
+ title("神秘章节")
+ content(
+ xml("html", "http://www.w3.org/1999/xhtml") {
+ "head" {
+ "title" { "神秘章节" }
+ }
+ "body"{
+ "a"("href" to "https://pornhub.com") {
+ "click for 0721"
+ }
+ "p" { "Ciallo~(∠・ω< )⌒☆" }
+ }
+ }.addDocType("html", "", "")
+ )
+ }
+ }
+ }
+ .build()
+ .save(File(rootPath.resolve("generate/test.epub")))
+}
+```
+## 详解
+### 基础属性
+首先你需要创建一个EpubBuilder
+```kotlin
+EpubBuilder()
+```
+然后在内部进行操作
+必须被设置的属性有``title`` ``modifier``他们分别是书本的标题和创建时间
+```kotlin
+EpubBuilder().apply {
+ title = "yuk的超级大"
+ modifier = LocalDateTime.now()
+}
+```
+同时,你可选的可以指定一个``cover``同时向里面传入一个``File``实体(请确保该File的有效性)
+### 添加章节
+这之后你可以开始向epub中添加章节了,章节是有层级的,这可以很明显的看出来
+```kotlin
+chapte {
+ title("xxx")
+ chapter {
+ title("xxxx")
+ content(...)
+ }
+ chapter {
+ title("xxxx")
+ content(...)
+ }
+}
+chaper {
+ title("xxxx")
+ content(...)
+}
+```
+但值得注意的是,当一个``chapter``中存在了内容时,就不可以存在``chapter``,反之亦然,``chapter``和``content``互斥。同时一个``chapter``可以包含多个``chapter``,但仅可以包含一个``content``。每个``chapter``必须指定``title``。
+### 章节内容
+``content``的本质是``Document``,你可以选择使用``SimpleContentBuilder``来创建或者手动创建
+#### SimpleContentBuilder
+你无需手动创建,也不应手动创建该类,而是直接使用chapter下的content提供的环境
+```kotlin
+chaper {
+ ......
+ content {
+ title("干夜鱼的一百种方法")
+ text("话说很久以前,有只叫做夜鱼的狐狸,他终日与他人尾交......")
+ image(File(ClassLoader.getSystemClassLoader().getResource("70d0a8f050ac1b4dfffb5665ce052498.jpg")?.toURI()!!))
+ }
+}
+```
+你可以添加三种内容,``title``, ``text``, ``image``
+其中title限制一个,其余不做限制,类容将会按照你的调用顺序排序,并自动生成为``Document``后传入``chapter``
+#### XmlBuilder
+这是用于快速创建xml文件工具,以下为示例
+```kotlin
+xml("html", "http://www.w3.org/1999/xhtml") {
+ "head" {
+ "title" { "神秘章节" }
+ }
+ "body"{
+ "a"("href" to "https://pornhub.com") {
+ "click for 0721"
+ }
+ "p" { "Ciallo~(∠・ω< )⌒☆" }
+ }
+}
+```
+以上代码会返回一个``Document``对象
\ No newline at end of file
diff --git a/epub/build.gradle.kts b/epub/build.gradle.kts
new file mode 100644
index 0000000..d80b116
--- /dev/null
+++ b/epub/build.gradle.kts
@@ -0,0 +1,14 @@
+version = "0.0.1-SNAPSHOT"
+
+plugins {
+ kotlin("jvm")
+}
+
+dependencies {
+ testImplementation(kotlin("test"))
+ implementation("org.dom4j:dom4j:2.1.4")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
\ No newline at end of file
diff --git a/epub/gradle.properties b/epub/gradle.properties
new file mode 100644
index 0000000..7fc6f1f
--- /dev/null
+++ b/epub/gradle.properties
@@ -0,0 +1 @@
+kotlin.code.style=official
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/Epub.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/Epub.kt
new file mode 100644
index 0000000..60f8172
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/Epub.kt
@@ -0,0 +1,75 @@
+package io.nightfish.potatoepub
+
+import io.nightfish.potatoepub.otf.Nav
+import io.nightfish.potatoepub.otf.OpfPackage
+import io.nightfish.potatoepub.otf.TocNcx
+import io.nightfish.potatoepub.otf.metaInf.Container
+import io.nightfish.potatoepub.xml.asFormatedXml
+import org.dom4j.Document
+import java.io.File
+import java.io.FileInputStream
+import java.util.zip.CRC32
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+/**
+ * Epub entity
+ * The implementation standard is EPUB 3.3
+ * @see EPUB 3.3
+ * @param container Container
+ * @param opfPackage OpfPackage
+ * @param nav Nav
+ * @param tocNcx ToxNcx(it's used to opf-201)
+ * @param res Additional resource file map.Key is the relative path, value is the corresponding File, the user should ensure the validity of the File when calling method 'save'.
+ * @param documents Additional resource file map.Key is the relative path, value is the corresponding Document
+ */
+@Suppress("MemberVisibilityCanBePrivate")
+class Epub(
+ val container: Container,
+ val opfPackage: OpfPackage,
+ val nav: Nav,
+ val tocNcx: TocNcx,
+ val documents: Map = mapOf(),
+ private val res: Map
+) {
+ companion object {
+ const val MIME = "application/epub+zip"
+ }
+
+ /**
+ * Save the epub object to epub file.
+ * It will create a new file if the target file is not exist.
+ *
+ * @param target target file
+ */
+ fun save(target: File) {
+ target.parentFile.mkdirs()
+ if (!target.exists()) {
+ target.createNewFile()
+ }
+ ZipOutputStream(target.outputStream()).use { out ->
+ val mineTypeEntry = ZipEntry("mimetype")
+ mineTypeEntry.method = ZipEntry.STORED
+ mineTypeEntry.size = MIME.toByteArray().size.toLong()
+ val crc = CRC32()
+ crc.update(MIME.toByteArray())
+ mineTypeEntry.crc = crc.value
+ out.putNextEntry(mineTypeEntry)
+ out.write(MIME.toByteArray())
+ container.writeToZip(out)
+ opfPackage.writeToZip(out)
+ nav.writeToZip(out)
+ tocNcx.writeToZip(out)
+ res.forEach { entry ->
+ out.putNextEntry(ZipEntry("EPUB/" + entry.key))
+ FileInputStream(entry.value).use {
+ out.write(it.readBytes())
+ }
+ }
+ documents.forEach { entry ->
+ out.putNextEntry(ZipEntry("EPUB/" + entry.key))
+ out.write(entry.value.asFormatedXml().toByteArray(Charsets.UTF_8))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/builder/Chapter.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/Chapter.kt
new file mode 100644
index 0000000..d4776d3
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/Chapter.kt
@@ -0,0 +1,13 @@
+package io.nightfish.potatoepub.builder
+
+import org.dom4j.Document
+
+class Chapter(
+ val title: String,
+ val chapterContent: Document?,
+ val chapters: List?
+) {
+ val id: String = "chapter_" + (if (chapters != null) (chapters.first().id + title.hashCode()).hashCode() else (chapterContent.hashCode() + title.hashCode())).hashCode()
+ constructor(title: String, chapterContent: Document): this(title, chapterContent, chapters = null)
+ constructor(title: String, chapters: List): this(title, null, chapters)
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/builder/ChapterBuilder.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/ChapterBuilder.kt
new file mode 100644
index 0000000..f8db76b
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/ChapterBuilder.kt
@@ -0,0 +1,52 @@
+package io.nightfish.potatoepub.builder
+
+import org.dom4j.Document
+
+class ChapterBuilder {
+ private var title: String? = null
+ private var content: Document? = null
+ private val chapters: MutableList = mutableListOf()
+ private val _contentBuilders: MutableList = mutableListOf()
+ val contentBuilders: List = _contentBuilders
+
+ fun title(title: String) {
+ this.title = title
+ }
+
+ fun content(content: Document) {
+ if (chapters.isNotEmpty()) throw Error("You can only use either 'content' or 'chapters' method")
+ this.content = content
+ }
+
+ fun content(builder: SimpleContentBuilder.() -> Unit) {
+ if (chapters.isNotEmpty()) throw Error("You can only use either 'content' or 'chapters' method")
+ val content = SimpleContentBuilder().let {
+ builder.invoke(it)
+ _contentBuilders.add(it)
+ it.build()
+ }
+ this.content = content
+ }
+
+ fun chapter(chapter: Chapter) {
+ if (content != null) throw Error("You can only use either 'content' or 'chapters' method")
+ chapters.add(chapter)
+ }
+
+ fun chapter(builder: ChapterBuilder.() -> Unit) {
+ if (content != null) throw Error("You can only use either 'content' or 'chapters' method")
+ val chapter = ChapterBuilder().let {
+ builder.invoke(it)
+ val chapter = it.build()
+ this._contentBuilders.addAll(it.contentBuilders)
+ chapter
+ }
+ chapters.add(chapter)
+ }
+
+ fun build(): Chapter {
+ title ?: throw Error("Missing 'title'")
+ if (content == null && chapters.isEmpty()) throw Error("Missing 'content' or 'chapters'")
+ return content?.let { Chapter(title!!, it) } ?: Chapter(title!!, chapters)
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/builder/EpubBuilder.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/EpubBuilder.kt
new file mode 100644
index 0000000..5c0ca3d
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/EpubBuilder.kt
@@ -0,0 +1,214 @@
+package io.nightfish.potatoepub.builder
+
+import io.nightfish.potatoepub.Epub
+import io.nightfish.potatoepub.otf.EpubManifest
+import io.nightfish.potatoepub.otf.Metadata
+import io.nightfish.potatoepub.otf.Nav
+import io.nightfish.potatoepub.otf.OpfPackage
+import io.nightfish.potatoepub.otf.Spine
+import io.nightfish.potatoepub.otf.TocNcx
+import io.nightfish.potatoepub.otf.metaInf.Container
+import io.nightfish.potatoepub.xml.TextDirection
+import org.dom4j.Document
+import java.io.File
+import java.time.LocalDateTime
+import java.util.Locale
+
+@Suppress("MemberVisibilityCanBePrivate")
+class EpubBuilder {
+ var id: String? = null
+ var title: String? = null
+ var modifier: LocalDateTime? = null
+ var titleLang: Locale = Locale.ENGLISH
+ var titleDir: TextDirection = TextDirection.LTR
+ var language: Locale = Locale.ENGLISH
+ var creator: String? = null
+ var description: String? = null
+ var publisher: String? = null
+ var manifestId: String? = null
+ var manifestItems: MutableSet = mutableSetOf(
+ EpubManifest.Item(href = "toc.ncx", id = "ncx", mediaType = "application/x-dtbncx+xml"),
+ EpubManifest.Item(href = "nav.xhtml", id = "nav", mediaType = "application/xhtml+xml", properties = "nav"),
+ EpubManifest.Item(href = "cover.jpg", id = "cover", mediaType = "image/jpeg", properties = "cover-image"),
+ )
+ var spineId: String? = null
+ var spineItems: MutableList = mutableListOf()
+ var hasCover: Boolean = false
+ var chapters: MutableList = mutableListOf()
+ var contentChapters: MutableList = mutableListOf()
+ var resFiles: MutableMap = mutableMapOf()
+ var documents: MutableMap = mutableMapOf()
+
+ fun chapter(builder: ChapterBuilder.() -> Unit) {
+ val chapter = ChapterBuilder().let { chapterBuilder ->
+ builder.invoke(chapterBuilder)
+ val chapter = chapterBuilder.build()
+ chapterBuilder.contentBuilders.forEach { simpleContentBuilder ->
+ simpleContentBuilder.images.forEach {
+ manifestItems.add(
+ EpubManifest.Item(
+ href = it.key.second,
+ id = it.key.first,
+ mediaType = "image/jpeg",
+ )
+ )
+ resFiles[it.key.second] = it.value
+ }
+ }
+ chapter
+ }
+ chapters.add(chapter)
+ }
+
+ fun res(
+ id: String,
+ path: String,
+ mediaType: String,
+ file: File,
+ properties: String? = null,
+ mediaOverride: String? = null,
+ fallback: String? = null
+ ) {
+ resFiles[path] = file
+ manifestItems.add(EpubManifest.Item(
+ href = path,
+ id = id,
+ mediaType = mediaType,
+ mediaOverride = mediaOverride,
+ fallback = fallback,
+ properties = properties)
+ )
+ }
+
+ /**
+ * the cover file must be jpg
+ */
+ fun cover(file: File) {
+ hasCover = true
+ res(
+ id = "cover",
+ path = "cover.jpg",
+ mediaType = "image/jpeg",
+ properties = "cover-image",
+ file = file
+ )
+ }
+
+ private fun List.toOl(): Nav.Ol = Nav.Ol(
+ this.map {
+ Nav.Li(
+ title = it.title,
+ href = if (it.chapterContent != null) {
+ contentChapters.add(it)
+ "${it.id}.xhtml"
+ } else null,
+ ol = if (it.chapters != null) it.chapters.toOl() else null
+ )
+ }
+ )
+
+ private fun Chapter.toNavPoint(): TocNcx.NavPoint {
+ if (this.chapterContent != null)
+ return TocNcx.NavPoint(
+ id = this.id,
+ label = this.title,
+ content = "${this.id}.xhtml"
+ )
+ this.chapters!!
+ if (this.chapters.isEmpty()) throw Error("TheChapterList is empty")
+ fun List.fistChapterWithContent(): Chapter {
+ return if (this.first().chapterContent != null)
+ this.first()
+ else
+ this.first().chapters!!.fistChapterWithContent()
+ }
+ val firstChapter = this.chapters.fistChapterWithContent()
+ return TocNcx.NavPoint(
+ id = "sep_${this.id}",
+ label = this.title,
+ navPoints = this.chapters.map { it.toNavPoint() },
+ content = "${firstChapter.id}.xhtml"
+ )
+ }
+
+ private fun checkXmlFileHref() {
+ for (chapter in this.contentChapters) {
+ val xml = chapter.chapterContent!!.asXML()
+ val regex1 = Regex("src=\"(.*?)\"")
+ regex1.findAll(xml).toList().forEach {
+ if (!resFiles.containsKey(it.groupValues[1]))
+ throw Error("Didn't find res '${it.groupValues[1]}' which appear in file {${chapter.title}}. Pleas make sure use method 'res' to add the res into the EPUB.")
+ }
+ val regex2 = Regex("herf=\"(.*?)\"")
+ regex2.findAll(xml).toList().forEach {
+ if (!resFiles.containsKey(it.groupValues[1]))
+ throw Error("Didn't find res '${it.groupValues[1]}' which appear in file {${chapter.title}}. Pleas make sure use method 'res' to add the res into the EPUB.")
+ }
+ }
+ }
+
+ fun build(): Epub {
+ id = id ?: title
+ val ol = chapters.toOl()
+ val navPoints = chapters.map { it.toNavPoint() }
+ val container = Container(
+ rootFilePaths = listOf("EPUB/content.opf")
+ )
+ manifestItems.addAll(contentChapters.map {
+ EpubManifest.Item(
+ href = it.id + ".xhtml",
+ id = it.id,
+ mediaType = "application/xhtml+xml"
+ )
+ })
+ spineItems.addAll(contentChapters.map {
+ Spine.Itemref(idref = it.id)
+ })
+ contentChapters.forEach {
+ documents["${it.id}.xhtml"] = it.chapterContent!!
+ }
+ checkXmlFileHref()
+ val metadata = Metadata(
+ id = id ?: throw Error("Missing 'id'"),
+ title = title ?: throw Error("Missing 'title'"),
+ titleLang = titleLang,
+ titleDir = titleDir,
+ language = language,
+ modified = modifier ?: throw Error("Missing 'modifier'"),
+ coverId = if (hasCover) "cover" else null,
+ creator = creator,
+ description = description,
+ publisher = publisher
+ )
+ val manifest = EpubManifest(
+ id = manifestId,
+ items = manifestItems.toList()
+ )
+ val spine = Spine(
+ id = spineId,
+ itemrefList = spineItems
+ )
+ val opfPackage = OpfPackage(
+ metadata = metadata,
+ manifest = manifest,
+ spine = spine
+ )
+ val nav = Nav(
+ title = id ?: throw Error("Missing 'id'"),
+ ol = ol
+ )
+ val tocNcx = TocNcx(
+ uid = id ?: throw Error("Missing 'id'"),
+ title = title ?: throw Error("Missing 'title'"),
+ navPoints = navPoints,
+ )
+ return Epub(
+ container = container,
+ opfPackage = opfPackage,
+ nav = nav,
+ tocNcx = tocNcx,
+ res = resFiles,
+ documents = documents
+ )
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/builder/SimpleContentBuilder.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/SimpleContentBuilder.kt
new file mode 100644
index 0000000..46149b9
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/builder/SimpleContentBuilder.kt
@@ -0,0 +1,62 @@
+package io.nightfish.potatoepub.builder
+
+import io.nightfish.potatoepub.xml.Attribute
+import io.nightfish.potatoepub.xml.XmlBuilder
+import java.io.File
+import org.dom4j.Document
+import org.dom4j.DocumentHelper
+import org.dom4j.Element
+
+@Suppress("MemberVisibilityCanBePrivate")
+class SimpleContentBuilder {
+ private val _images: MutableMap, File> = mutableMapOf()
+ val images: Map, File> get() = _images
+ val document: Document = DocumentHelper.createDocument()
+ val rootElement: Element = document.addElement("html", "http://www.w3.org/1999/xhtml")
+ val headElement: Element = rootElement.addElement("head")
+ val bodyElement: Element = rootElement.addElement("body")
+ val contentElement: Element = bodyElement.addElement("div").addAttribute("id", "content")
+
+ init {
+ document.addDocType("htlm", "", "")
+ rootElement
+ .addAttribute("xmlns", "http://www.w3.org/1999/xhtml")
+ .addAttribute("xmlns:epub", "http://www.idpf.org/2007/ops")
+ .addAttribute("lang", "en")
+ .addAttribute("xml:lang", "en")
+ }
+
+ fun title(src: String) {
+ headElement.addElement("title").addText(src)
+ }
+
+ fun headline(level: Int, content: String) {
+ contentElement.addElement("h$level").addText(content)
+ }
+
+ fun br() {
+ contentElement.addElement("br")
+ }
+
+ fun text(content: String) {
+ contentElement.addText(content)
+ }
+
+ /**
+ * make sure image is jpeg file
+ */
+ fun image(image: File, id: String = "image_${image.hashCode()}", src: String = "image/$id.jpg") {
+ _images[Pair(id, src)] = image
+ XmlBuilder.ElementBuilder(contentElement, "div", arrayOf(Attribute("class", "div_image"))) {
+ "img"(
+ "border" to 0,
+ "class" to "image_content",
+ "src" to src
+ )
+ }
+ }
+
+ fun build(): Document {
+ return document
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/EpubManifest.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/EpubManifest.kt
new file mode 100644
index 0000000..68f7654
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/EpubManifest.kt
@@ -0,0 +1,40 @@
+package io.nightfish.potatoepub.otf
+
+import io.nightfish.potatoepub.xml.XmlBuilder
+
+data class EpubManifest(
+ val id: String? = null,
+ val items: List-
+) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "manifest"("id" to id) {
+ items.forEach {
+ it.element(this)
+ }
+ }
+ }
+ }
+
+ data class Item(
+ val fallback: String? = null,
+ val href: String,
+ val id: String,
+ val mediaOverride: String? = null,
+ val mediaType: String,
+ val properties: String? = null,
+ ) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "item"(
+ "fallback" to fallback,
+ "href" to href,
+ "id" to id,
+ "media-override" to mediaOverride,
+ "media-type" to mediaType,
+ "properties" to properties
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Metadata.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Metadata.kt
new file mode 100644
index 0000000..b40d0aa
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Metadata.kt
@@ -0,0 +1,76 @@
+package io.nightfish.potatoepub.otf
+
+import io.nightfish.potatoepub.xml.Attribute
+import io.nightfish.potatoepub.xml.TextDirection
+import io.nightfish.potatoepub.xml.XmlBuilder
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+data class Metadata(
+ val id: String,
+ val identifierType: String? = null,
+ val title: String,
+ val titleLang: Locale = Locale.ENGLISH,
+ val titleDir: TextDirection = TextDirection.LTR,
+ val language: Locale = Locale.ENGLISH,
+ val modified: LocalDateTime,
+ val creator: String? = null,
+ val description: String? = null,
+ val publisher: String? = null,
+ //This is to adapt to the meta element added by adding cover in opf-201
+ val coverId: String?
+) {
+ companion object {
+ val dataTimeFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'")
+ }
+
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "metadata"(
+ "xmlns:dc" to "http://purl.org/dc/elements/1.1/",
+ "xmlns:opt" to "http://www.idpf.org/2007/opf"
+ ) {
+ "dc:identifier"("id" to "pub-id") { id }
+ if (identifierType != null)
+ "dc:identifier"("id" to "pub-id", "identifier-type" to identifierType) { id }
+ "dc:title"(
+ "id" to "title",
+ "xml:lang" to titleLang,
+ titleDir,
+ ) { title }
+ "dc:language" { language.toString() }
+ meta(
+ property = "dcterms:modified",
+ value = modified.format(dataTimeFormat)
+ )
+ creator?.let { "dc:creator" { creator } }
+ description?.let { "dc:description" { description } }
+ publisher?.let { "dc:publisher" { publisher } }
+ //This is to adapt to the meta element added by adding cover in opf-201
+ if (coverId != null)
+ "meta"(
+ "name" to "cover",
+ "content" to coverId,
+ )
+ }
+ }
+ }
+
+ private fun XmlBuilder.ElementBuilder.meta(
+ dir: TextDirection? = null,
+ id: String? = null,
+ property: String,
+ language: Locale? = null,
+ value: String
+ ) {
+ "meta"(
+ dir ?: Attribute.empty,
+ "id" to id,
+ "property" to property,
+ "lang" to language,
+ ) {
+ value
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Nav.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Nav.kt
new file mode 100644
index 0000000..3bcdbfd
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Nav.kt
@@ -0,0 +1,71 @@
+package io.nightfish.potatoepub.otf
+
+import io.nightfish.potatoepub.xml.Attribute
+import io.nightfish.potatoepub.xml.XmlBuilder
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import io.nightfish.potatoepub.xml.WriteToZipAble
+import io.nightfish.potatoepub.xml.asFormatedXml
+import java.util.Locale
+import java.util.zip.ZipEntry
+
+data class Nav(
+ val language: Locale = Locale.ENGLISH,
+ val title: String,
+ val headline: Int = 2,
+ val ol: Ol
+): WriteToZipAble {
+ override val zipEntry: ZipEntry = ZipEntry("EPUB/nav.xhtml")
+ override fun toByteArray(): ByteArray =
+ xml("html",
+ "http://www.w3.org/1999/xhtml",
+ Attribute("xmlns:epub", "http://www.idpf.org/2007/ops"),
+ Attribute("lang", language.toString()),
+ Attribute("xml:lang", language.toString()),
+ ) {
+ "head" {
+ "title" { title }
+ }
+ "body" {
+ "nav"(
+ "epub:type" to "toc",
+ "role" to "doc-toc"
+ ) {
+ "h$headline" { title }
+ ol.element(this)
+ }
+ }
+ }.asFormatedXml().toByteArray(Charsets.UTF_8)
+
+ data class Li(
+ val title: String,
+ val href: String? = null,
+ val ol: Ol? = null
+ ) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "li" {
+ if (href == null)
+ "span" { title }
+ else
+ "a"("href" to href) { title }
+ ol?.element(this)
+ Any()
+ }
+ }
+ }
+ }
+
+ data class Ol(
+ val items: List
+ ) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "ol" {
+ for (item in items) {
+ item.element(this)
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/OpfPackage.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/OpfPackage.kt
new file mode 100644
index 0000000..6425624
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/OpfPackage.kt
@@ -0,0 +1,41 @@
+package io.nightfish.potatoepub.otf
+
+import io.nightfish.potatoepub.xml.Attribute
+import io.nightfish.potatoepub.xml.TextDirection
+import io.nightfish.potatoepub.xml.Version
+import io.nightfish.potatoepub.xml.WriteToZipAble
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import io.nightfish.potatoepub.xml.XmlLang
+import io.nightfish.potatoepub.xml.asFormatedXml
+import java.util.zip.ZipEntry
+
+data class OpfPackage(
+ val dir: TextDirection = TextDirection.LTR,
+ val id: String = "opf-package",
+ val lang: String = "en",
+ val metadata: Metadata,
+ val manifest: EpubManifest,
+ val spine: Spine
+) : WriteToZipAble {
+ override val zipEntry: ZipEntry = ZipEntry("EPUB/content.opf")
+ override fun toByteArray(): ByteArray =
+ xml(
+ "package",
+ "http://www.idpf.org/2007/opf",
+ Attribute("unique-identifier", "pub-id"),
+ Version("3.0"),
+ Attribute("prefix", "rendition: http://www.idpf.org/vocab/rendition/#"),
+ Attribute("xmlns:dc", "http://purl.org/dc/elements/1.1/"),
+ Attribute("xmlns:dc", "http://www.idpf.org/2007/opf"),
+ dir,
+ XmlLang(lang)
+ ) {
+ element
+ .addNamespace("opt", "http://www.idpf.org/2007/opf")
+ .addNamespace("dc", "http://purl.org/dc/elements/1.1/")
+ metadata.element(this)
+ manifest.element(this)
+ spine.element(this)
+ }
+ .asFormatedXml().toByteArray(Charsets.UTF_8)
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Spine.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Spine.kt
new file mode 100644
index 0000000..b81b4c0
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/Spine.kt
@@ -0,0 +1,35 @@
+package io.nightfish.potatoepub.otf
+
+import io.nightfish.potatoepub.xml.XmlBuilder
+
+data class Spine(
+ val id: String? = null,
+ val itemrefList: List
+) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "spine"("toc" to "ncx") {
+ itemrefList.forEach {
+ it.element(this)
+ }
+ }
+ }
+ }
+ data class Itemref(
+ val id: String? = null,
+ val idref: String,
+ val linear: Boolean? = null,
+ val properties: String? = null,
+ ) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "itemref"(
+ "id" to id,
+ "idref" to idref,
+ "linear" to if(linear != null) (if (linear) "yes" else "no") else null,
+ "properties" to properties
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/TocNcx.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/TocNcx.kt
new file mode 100644
index 0000000..405604d
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/TocNcx.kt
@@ -0,0 +1,57 @@
+package io.nightfish.potatoepub.otf
+
+import io.nightfish.potatoepub.xml.Version
+import io.nightfish.potatoepub.xml.XmlBuilder
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import io.nightfish.potatoepub.xml.WriteToZipAble
+import io.nightfish.potatoepub.xml.asFormatedXml
+import java.util.zip.ZipEntry
+
+/**
+ * This is to adapt to the classes that NCX added in opf-201 to add cover.
+ */
+data class TocNcx(
+ val uid: String,
+ val title: String,
+ val navPoints: List
+): WriteToZipAble {
+ override val zipEntry = ZipEntry("EPUB/toc.ncx")
+ override fun toByteArray(): ByteArray =
+ xml("ncx",
+ "http://www.daisy.org/z3986/2005/ncx/",
+ Version("2005-1")
+ ) {
+ "head" {
+ "meta"(
+ "content" to uid,
+ "name" to "dtb:uid"
+ )
+ }
+ "docTitle" {
+ "text" { title }
+ }
+ "navMap" {
+ navPoints.forEach { it.element(this) }
+ }
+ }.asFormatedXml().toByteArray(Charsets.UTF_8)
+
+ data class NavPoint(
+ val id: String,
+ val label: String,
+ val content: String,
+ val navPoints: List? = null
+ ) {
+ fun element(builder: XmlBuilder.ElementBuilder) {
+ builder.apply {
+ "navPoint"("id" to id) {
+ "navLabel" {
+ "text" { label }
+ }
+ "content"("src" to content)
+ navPoints?.forEach { it.element(this) }
+ Any()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/otf/metaInf/Container.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/metaInf/Container.kt
new file mode 100644
index 0000000..9495d44
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/otf/metaInf/Container.kt
@@ -0,0 +1,25 @@
+package io.nightfish.potatoepub.otf.metaInf
+
+import io.nightfish.potatoepub.xml.Version
+import io.nightfish.potatoepub.xml.WriteToZipAble
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import io.nightfish.potatoepub.xml.asFormatedXml
+import java.util.zip.ZipEntry
+
+data class Container(val rootFilePaths: List): WriteToZipAble {
+ override val zipEntry: ZipEntry = ZipEntry("META-INF/container.xml")
+ override fun toByteArray(): ByteArray =
+ xml("container",
+ "urn:oasis:names:tc:opendocument:xmlns:container",
+ Version("1.0")
+ ) {
+ "rootfiles" {
+ for (path in rootFilePaths) {
+ "rootfile"(
+ "media-type" to "application/oebps-package+xml",
+ "full-path" to path
+ )
+ }
+ }
+ }.asFormatedXml().toByteArray(Charsets.UTF_8)
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/xml/Attribute.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/Attribute.kt
new file mode 100644
index 0000000..8fea4aa
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/Attribute.kt
@@ -0,0 +1,7 @@
+package io.nightfish.potatoepub.xml
+
+open class Attribute(val name: String, val value: Any?) {
+ companion object {
+ val empty = Attribute(name = "", value = null)
+ }
+}
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/xml/Attributes.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/Attributes.kt
new file mode 100644
index 0000000..a128915
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/Attributes.kt
@@ -0,0 +1,9 @@
+package io.nightfish.potatoepub.xml
+
+class Version(version: String): Attribute("version", version)
+sealed class TextDirection(textDirection: String): Attribute("dir", textDirection) {
+ data object LTR: TextDirection("ltr")
+ data object RTL: TextDirection("rtl")
+ data object AUTO: TextDirection("auto")
+}
+class XmlLang(local: String): Attribute("xml:lang", local)
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/xml/WriteToZipAble.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/WriteToZipAble.kt
new file mode 100644
index 0000000..f3e73eb
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/WriteToZipAble.kt
@@ -0,0 +1,14 @@
+package io.nightfish.potatoepub.xml
+
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+interface WriteToZipAble {
+ val zipEntry: ZipEntry
+ fun toByteArray(): ByteArray
+ fun writeToZip(zipOutStream: ZipOutputStream) {
+ zipOutStream.putNextEntry(zipEntry)
+ zipOutStream.write(toByteArray())
+ zipOutStream.closeEntry()
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/xml/XmlBuilder.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/XmlBuilder.kt
new file mode 100644
index 0000000..beda0d5
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/XmlBuilder.kt
@@ -0,0 +1,82 @@
+package io.nightfish.potatoepub.xml
+
+import org.dom4j.Branch
+import org.dom4j.Document
+import org.dom4j.DocumentHelper
+import org.dom4j.Element
+
+class XmlBuilder {
+ class ElementBuilder(
+ attrs: Array = emptyArray(),
+ val element: Element,
+ builder: (ElementBuilder.() -> Any)? = null
+ ) {
+ constructor(
+ branch: Branch,
+ name: String,
+ xmlns: String,
+ attrs: Array = emptyArray(),
+ builder: (ElementBuilder.() -> Any)? = null
+ ): this(attrs, branch.addElement(name, xmlns), builder)
+
+ constructor(
+ branch: Branch,
+ name: String,
+ attrs: Array = emptyArray(),
+ builder: (ElementBuilder.() -> Any)? = null
+ ): this(attrs, branch.addElement(name), builder)
+
+ init {
+ element.apply {
+ attrs
+ .filter { it.value != null }
+ .forEach { addAttribute(it.name, it.value.toString()) }
+ }
+ builder?.invoke(this).let {
+ if (it is String && it.isNotEmpty()) {
+ element.text = it
+ }
+ }
+ }
+
+ fun element(
+ name: String,
+ vararg attrs: Attribute = emptyArray(),
+ builder: (ElementBuilder.() -> Any)? = null
+ ) {
+ ElementBuilder(element, name, attrs, builder)
+ }
+
+ infix fun String.to(that: Any?) = Attribute(this, that)
+ operator fun String.invoke(
+ vararg attrs: Attribute = emptyArray(),
+ builder: (ElementBuilder.() -> Any)? = null
+ ) {
+ ElementBuilder(element, this, attrs, builder)
+ }
+ }
+ private val document: Document = DocumentHelper
+ .createDocument()
+ companion object {
+ fun xml(
+ root: String,
+ xmlns: String,
+ vararg attrs: Attribute = emptyArray(),
+ builder: (ElementBuilder.() -> Any)? = null
+ ): Document {
+ val xmlBuilder = XmlBuilder()
+ ElementBuilder(xmlBuilder.document, root, xmlns, attrs, builder)
+ return xmlBuilder.document
+ }
+
+ fun xml(
+ root: String,
+ vararg attrs: Attribute = emptyArray(),
+ builder: (ElementBuilder.() -> Any)? = null
+ ): Document {
+ val xmlBuilder = XmlBuilder()
+ ElementBuilder(xmlBuilder.document, root, attrs, builder)
+ return xmlBuilder.document
+ }
+ }
+}
\ No newline at end of file
diff --git a/epub/src/main/kotlin/io/nightfish/potatoepub/xml/XmlFormat.kt b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/XmlFormat.kt
new file mode 100644
index 0000000..88faf64
--- /dev/null
+++ b/epub/src/main/kotlin/io/nightfish/potatoepub/xml/XmlFormat.kt
@@ -0,0 +1,26 @@
+package io.nightfish.potatoepub.xml
+
+import java.io.StringWriter
+import org.dom4j.Document
+import org.dom4j.DocumentHelper
+import org.dom4j.io.OutputFormat
+import org.dom4j.io.XMLWriter
+
+
+fun Document.asFormatedXml(): String {
+ val format = OutputFormat()
+ format.encoding = "UTF-8"
+ format.isNewlines = true
+ format.indent = " "
+ format.isExpandEmptyElements = false
+ val strWtr = StringWriter()
+ val xmlWrt = XMLWriter(strWtr, format)
+ xmlWrt.write(DocumentHelper.parseText(this.asXML()))
+ xmlWrt.flush()
+ xmlWrt.close()
+ return strWtr.toString()
+ .replaceFirst(
+ "\n",
+ ""
+ )
+}
\ No newline at end of file
diff --git a/epub/src/test/kotlin/GenerateSimpleBookTest.kt b/epub/src/test/kotlin/GenerateSimpleBookTest.kt
new file mode 100644
index 0000000..dd8efa4
--- /dev/null
+++ b/epub/src/test/kotlin/GenerateSimpleBookTest.kt
@@ -0,0 +1,50 @@
+import io.nightfish.potatoepub.builder.EpubBuilder
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import java.io.File
+import java.time.LocalDateTime
+
+fun main() {
+ val rootPath = ClassLoader.getSystemClassLoader().getResource("")!!.toURI()
+ EpubBuilder()
+ .apply {
+ title = "yuk的超级大"
+ modifier = LocalDateTime.now()
+ cover(File(ClassLoader.getSystemClassLoader().getResource("cover.jpg")?.toURI()!!))
+ chapter {
+ title("干夜鱼的一百种方法")
+ content {
+ title("干夜鱼的一百种方法")
+ text("话说很久以前,有只叫做夜鱼的狐狸,他终日与他人尾交......")
+ image(File(ClassLoader.getSystemClassLoader().getResource("70d0a8f050ac1b4dfffb5665ce052498.jpg")?.toURI()!!))
+ }
+ }
+ chapter {
+ title("附录")
+ chapter {
+ title("夜鱼的学校生活")
+ content {
+ title("夜鱼的学校生活")
+ text("话说很久以前,有只叫做夜鱼的狐狸,他每日接受着A高附中的折磨")
+ }
+ }
+ chapter {
+ title("神秘章节")
+ content(
+ xml("html", "http://www.w3.org/1999/xhtml") {
+ "head" {
+ "title" { "神秘章节" }
+ }
+ "body"{
+ "a"("href" to "https://pornhub.com") {
+ "click for 0721"
+ }
+ "p" { "Ciallo~(∠・ω< )⌒☆" }
+ }
+ }.addDocType("html", "", "")
+ )
+ }
+ }
+ }
+ .build()
+ .save(File(rootPath.resolve("generate/test.epub")))
+}
\ No newline at end of file
diff --git a/epub/src/test/kotlin/XmlBuilderTest.kt b/epub/src/test/kotlin/XmlBuilderTest.kt
new file mode 100644
index 0000000..941c025
--- /dev/null
+++ b/epub/src/test/kotlin/XmlBuilderTest.kt
@@ -0,0 +1,21 @@
+import io.nightfish.potatoepub.xml.XmlBuilder.Companion.xml
+import io.nightfish.potatoepub.xml.asFormatedXml
+
+fun main() {
+ println(
+ xml("html") {
+ "html"("lang" to "zh_CN") {
+ "body"(
+ "href" to "https://pronhub.com",
+ "div" to "RTL"
+ ) {
+ "click for 0721"
+ }
+ "p" {
+ "Ciallo~(∠・ω< )⌒☆"
+ }
+ }
+ }.addDocType("html", "", "")
+ .asFormatedXml()
+ )
+}
\ No newline at end of file
diff --git a/epub/src/test/resources/70d0a8f050ac1b4dfffb5665ce052498.jpg b/epub/src/test/resources/70d0a8f050ac1b4dfffb5665ce052498.jpg
new file mode 100644
index 0000000..f8521ec
Binary files /dev/null and b/epub/src/test/resources/70d0a8f050ac1b4dfffb5665ce052498.jpg differ
diff --git a/epub/src/test/resources/cover.jpg b/epub/src/test/resources/cover.jpg
new file mode 100644
index 0000000..4c9b361
Binary files /dev/null and b/epub/src/test/resources/cover.jpg differ
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f332c68..2dc7ee8 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -15,4 +15,5 @@ dependencyResolutionManagement {
}
rootProject.name = "LightNovelReaderRefactoring"
-include(":app")
\ No newline at end of file
+include(":app")
+include(":epub")
\ No newline at end of file