This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
1 parent b86d90021c
commit 6c0757c91b
972 files changed
+6885 -7321

No files matched your search

@@ -0,0 +1,16 @@
package io.legato.kazusa.help.book
import io.legato.kazusa.data.entities.ReplaceRule
data class BookContent(
val sameTitleRemoved: Boolean,
val textList: List<String>,
//起效的替换规则
val effectiveReplaceRules: List<ReplaceRule>?
) {
override fun toString(): String {
return textList.joinToString("\n")
}
}
@@ -0,0 +1,380 @@
@file:Suppress("unused")
package io.legato.kazusa.help.book
import android.net.Uri
import com.script.buildScriptBindings
import com.script.rhino.RhinoScriptEngine
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.constant.BookSourceType
import io.legato.kazusa.constant.BookType
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.BaseBook
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.RuleBigDataHelp
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.exists
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.inputStream
import io.legato.kazusa.utils.isUri
import io.legato.kazusa.utils.toastOnUi
import splitties.init.appCtx
import java.io.File
import java.time.LocalDate
import java.time.Period.between
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.math.min
val Book.isAudio: Boolean
get() = isType(BookType.audio)
val Book.isImage: Boolean
get() = isType(BookType.image)
val Book.isLocal: Boolean
get() {
if (type == 0) {
return origin == BookType.localTag || origin.startsWith(BookType.webDavTag)
}
return isType(BookType.local)
}
val Book.isLocalTxt: Boolean
get() = isLocal && originName.endsWith(".txt", true)
val Book.isEpub: Boolean
get() = isLocal && originName.endsWith(".epub", true)
val Book.isUmd: Boolean
get() = isLocal && originName.endsWith(".umd", true)
val Book.isPdf: Boolean
get() = isLocal && originName.endsWith(".pdf", true)
val Book.isMobi: Boolean
get() = isLocal && (originName.endsWith(".mobi", true) ||
originName.endsWith(".azw3", true) ||
originName.endsWith(".azw", true))
val Book.isOnLineTxt: Boolean
get() = !isLocal && isType(BookType.text)
val Book.isWebFile: Boolean
get() = isType(BookType.webFile)
val Book.isUpError: Boolean
get() = isType(BookType.updateError)
val Book.isArchive: Boolean
get() = isType(BookType.archive)
val Book.isNotShelf: Boolean
get() = isType(BookType.notShelf)
val Book.archiveName: String
get() {
if (!isArchive) throw NoStackTraceException("Book is not deCompressed from archive")
// local_book::archive.rar
// webDav::https://...../archive.rar
return origin.substringAfter("::").substringAfterLast("/")
}
fun Book.contains(word: String?): Boolean {
if (word.isNullOrEmpty()) {
return true
}
return name.contains(word)
|| author.contains(word)
|| originName.contains(word)
|| origin.contains(word)
|| kind?.contains(word) == true
|| intro?.contains(word) == true
}
private val localUriCache by lazy {
ConcurrentHashMap<String, Uri>()
}
fun Book.getLocalUri(): Uri {
if (!isLocal) {
throw NoStackTraceException("不是本地书籍")
}
var uri = localUriCache[bookUrl]
if (uri != null) {
return uri
}
uri = if (bookUrl.isUri()) {
Uri.parse(bookUrl)
} else {
Uri.fromFile(File(bookUrl))
}
//先检测uri是否有效,这个比较快
uri.inputStream(appCtx).getOrNull()?.use {
localUriCache[bookUrl] = uri
}?.let {
return uri
}
//不同的设备书籍保存路径可能不一样, uri无效时尝试寻找当前保存路径下的文件
val defaultBookDir = AppConfig.defaultBookTreeUri
val importBookDir = AppConfig.importBookPath
// 查找书籍保存目录
if (!defaultBookDir.isNullOrBlank()) {
val treeUri = Uri.parse(defaultBookDir)
val treeFileDoc = FileDoc.fromUri(treeUri, true)
if (!treeFileDoc.exists()) {
appCtx.toastOnUi("书籍保存目录失效,请重新设置!")
} else {
val fileDoc = treeFileDoc.find(originName, 5, 100)
if (fileDoc != null) {
localUriCache[bookUrl] = fileDoc.uri
//更新bookUrl 重启不用再找一遍
bookUrl = fileDoc.toString()
save()
return fileDoc.uri
}
}
}
// 查找添加本地选择的目录
if (!importBookDir.isNullOrBlank() && defaultBookDir != importBookDir) {
val treeUri = if (importBookDir.isUri()) {
Uri.parse(importBookDir)
} else {
Uri.fromFile(File(importBookDir))
}
val treeFileDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = treeFileDoc.find(originName, 5, 100)
if (fileDoc != null) {
localUriCache[bookUrl] = fileDoc.uri
bookUrl = fileDoc.toString()
save()
return fileDoc.uri
}
}
localUriCache[bookUrl] = uri
return uri
}
fun Book.getArchiveUri(): Uri? {
val defaultBookDir = AppConfig.defaultBookTreeUri
return if (isArchive && !defaultBookDir.isNullOrBlank()) {
FileDoc.fromUri(Uri.parse(defaultBookDir), true)
.find(archiveName)?.uri
} else {
null
}
}
fun Book.cacheLocalUri(uri: Uri) {
localUriCache[bookUrl] = uri
}
fun Book.removeLocalUriCache() {
localUriCache.remove(bookUrl)
}
fun Book.getRemoteUrl(): String? {
if (origin.startsWith(BookType.webDavTag)) {
return origin.substring(BookType.webDavTag.length)
}
return null
}
fun Book.setType(@BookType.Type vararg types: Int) {
type = 0
addType(*types)
}
fun Book.addType(@BookType.Type vararg types: Int) {
types.forEach {
type = type or it
}
}
fun Book.removeType(@BookType.Type vararg types: Int) {
types.forEach {
type = type and it.inv()
}
}
fun Book.removeAllBookType() {
removeType(BookType.allBookType)
}
fun Book.clearType() {
type = 0
}
fun Book.isType(@BookType.Type bookType: Int): Boolean = type and bookType > 0
fun Book.upType() {
if (type < 8) {
type = when (type) {
BookSourceType.image -> BookType.image
BookSourceType.audio -> BookType.audio
BookSourceType.file -> BookType.webFile
else -> BookType.text
}
if (origin == BookType.localTag || origin.startsWith(BookType.webDavTag)) {
type = type or BookType.local
}
}
}
fun Book.sync(oldBook: Book) {
val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!!
durChapterTime = curBook.durChapterTime
durChapterPos = curBook.durChapterPos
if (durChapterIndex != curBook.durChapterIndex) {
durChapterIndex = curBook.durChapterIndex
val replaceRules = ContentProcessor.get(this).getTitleReplaceRules()
appDb.bookChapterDao.getChapter(bookUrl, durChapterIndex)?.let {
durChapterTitle = it.getDisplayTitle(replaceRules, getUseReplaceRule())
}
}
canUpdate = curBook.canUpdate
readConfig = curBook.readConfig
}
fun Book.update() {
appDb.bookDao.update(this)
}
fun Book.primaryStr(): String {
return origin + bookUrl
}
fun Book.updateTo(newBook: Book): Book {
newBook.durChapterIndex = durChapterIndex
newBook.durChapterTitle = durChapterTitle
newBook.durChapterPos = durChapterPos
newBook.durChapterTime = durChapterTime
newBook.group = group
newBook.order = order
newBook.customCoverUrl = customCoverUrl
newBook.customIntro = customIntro
newBook.customTag = customTag
newBook.canUpdate = canUpdate
newBook.readConfig = readConfig
val variableMap = variableMap.toMutableMap()
variableMap.keys.removeIf {
newBook.hasVariable(it)
}
newBook.variableMap.putAll(variableMap)
newBook.variable = GSON.toJson(variableMap)
return newBook
}
fun Book.hasVariable(key: String): Boolean {
return variableMap.contains(key) || RuleBigDataHelp.hasBookVariable(bookUrl, key)
}
fun Book.getFolderNameNoCache(): String {
return name.replace(AppPattern.fileNameRegex, "").let {
it.substring(0, min(9, it.length)) + MD5Utils.md5Encode16(bookUrl)
}
}
fun Book.getBookSource(): BookSource? {
return appDb.bookSourceDao.getBookSource(origin)
}
fun Book.isLocalModified(): Boolean {
return isLocal && LocalBook.getLastModified(this).getOrDefault(0L) > latestChapterTime
}
fun Book.releaseHtmlData() {
infoHtml = null
tocHtml = null
}
fun Book.isSameNameAuthor(other: Any?): Boolean {
if (other is BaseBook) {
return name == other.name && author == other.author
}
return false
}
fun Book.getExportFileName(suffix: String): String {
val jsStr = AppConfig.bookExportFileName
if (jsStr.isNullOrBlank()) {
return "$name 作者:${getRealAuthor()}.$suffix"
}
val bindings = buildScriptBindings { bindings ->
bindings["epubIndex"] = ""// 兼容老版本,修复可能存在的错误
bindings["name"] = name
bindings["author"] = getRealAuthor()
}
return kotlin.runCatching {
RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix
}.onFailure {
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
}.getOrDefault("$name 作者:${getRealAuthor()}.$suffix")
}
/**
* 获取分割文件后的文件名
*/
fun Book.getExportFileName(
suffix: String,
epubIndex: Int,
jsStr: String? = AppConfig.episodeExportFileName
): String {
// 默认规则
val default = "$name 作者:${getRealAuthor()} [${epubIndex}].$suffix"
if (jsStr.isNullOrBlank()) {
return default
}
val bindings = buildScriptBindings { bindings ->
bindings["name"] = name
bindings["author"] = getRealAuthor()
bindings["epubIndex"] = epubIndex
}
return kotlin.runCatching {
RhinoScriptEngine.eval(jsStr, bindings).toString() + "." + suffix
}.onFailure {
AppLog.put("导出书名规则错误,使用默认规则\n${it.localizedMessage}", it)
}.getOrDefault(default)
}
// 根据当前日期计算章节总数
fun Book.simulatedTotalChapterNum(): Int {
return if (readSimulating()) {
val currentDate = LocalDate.now()
val daysPassed = between(config.startDate, currentDate).days + 1
// 计算当前应该解锁到哪一章
val chaptersToUnlock =
max(0, (config.startChapter ?: 0) + (daysPassed * config.dailyChapters))
min(totalChapterNum, chaptersToUnlock)
} else {
totalChapterNum
}
}
fun Book.readSimulating(): Boolean {
return config.readSimulating
}
fun tryParesExportFileName(jsStr: String): Boolean {
val bindings = buildScriptBindings { bindings ->
bindings["name"] = "name"
bindings["author"] = "author"
bindings["epubIndex"] = "epubIndex"
}
return runCatching {
RhinoScriptEngine.eval(jsStr, bindings)
true
}.getOrDefault(false)
}
@@ -0,0 +1,607 @@
package io.legato.kazusa.help.book
import android.graphics.BitmapFactory
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.utils.ArchiveUtils
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.ImageUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.StringUtils
import io.legato.kazusa.utils.SvgUtils
import io.legato.kazusa.utils.UrlUtil
import io.legato.kazusa.utils.createFileIfNotExist
import io.legato.kazusa.utils.exists
import io.legato.kazusa.utils.externalFiles
import io.legato.kazusa.utils.getFile
import io.legato.kazusa.utils.isContentScheme
import io.legato.kazusa.utils.onEachParallel
import io.legato.kazusa.utils.postEvent
import com.script.rhino.runScriptWithContext
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.apache.commons.text.similarity.JaccardSimilarity
import splitties.init.appCtx
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern
import java.util.zip.ZipFile
import kotlin.coroutines.coroutineContext
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@Suppress("unused", "ConstPropertyName")
object BookHelp {
private val downloadDir: File = appCtx.externalFiles
private const val cacheFolderName = "book_cache"
private const val cacheImageFolderName = "images"
private const val cacheEpubFolderName = "epub"
private val downloadImages = ConcurrentHashMap<String, Mutex>()
val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
fun clearCache() {
FileUtils.delete(
FileUtils.getPath(downloadDir, cacheFolderName)
)
}
fun clearCache(book: Book) {
val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName())
FileUtils.delete(filePath)
}
fun updateCacheFolder(oldBook: Book, newBook: Book) {
val oldFolderName = oldBook.getFolderNameNoCache()
val newFolderName = newBook.getFolderNameNoCache()
if (oldFolderName == newFolderName) return
val oldFolderPath = FileUtils.getPath(
downloadDir,
cacheFolderName,
oldFolderName
)
val newFolderPath = FileUtils.getPath(
downloadDir,
cacheFolderName,
newFolderName
)
FileUtils.move(oldFolderPath, newFolderPath)
}
/**
* 清除已删除书的缓存 解压缓存
*/
suspend fun clearInvalidCache() {
withContext(IO) {
val bookFolderNames = hashSetOf<String>()
val originNames = hashSetOf<String>()
appDb.bookDao.all.forEach {
clearComicCache(it)
bookFolderNames.add(it.getFolderName())
if (it.isEpub) originNames.add(it.originName)
}
downloadDir.getFile(cacheFolderName)
.listFiles()?.forEach { bookFile ->
if (!bookFolderNames.contains(bookFile.name)) {
FileUtils.delete(bookFile.absolutePath)
}
}
downloadDir.getFile(cacheEpubFolderName)
.listFiles()?.forEach { epubFile ->
if (!originNames.contains(epubFile.name)) {
FileUtils.delete(epubFile.absolutePath)
}
}
FileUtils.delete(ArchiveUtils.TEMP_PATH)
val filesDir = appCtx.filesDir
FileUtils.delete("$filesDir/shareBookSource.json")
FileUtils.delete("$filesDir/shareRssSource.json")
FileUtils.delete("$filesDir/books.json")
}
}
//清除已经看过的漫画数据
private fun clearComicCache(book: Book) {
//只处理漫画
//为0的时候,不清除已缓存数据
if (!book.isImage || AppConfig.imageRetainNum == 0) {
return
}
//向前保留设定数量,向后保留预下载数量
val startIndex = book.durChapterIndex - AppConfig.imageRetainNum
val endIndex = book.durChapterIndex + AppConfig.preDownloadNum
val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl, startIndex, endIndex)
val imgNames = hashSetOf<String>()
//获取需要保留章节的图片信息
chapterList.forEach {
val content = getContent(book, it)
if (content != null) {
val matcher = AppPattern.imgPattern.matcher(content)
while (matcher.find()) {
val src = matcher.group(1) ?: continue
val mSrc = NetworkUtils.getAbsoluteURL(it.url, src)
imgNames.add("${MD5Utils.md5Encode16(mSrc)}.${getImageSuffix(mSrc)}")
}
}
}
downloadDir.getFile(
cacheFolderName,
book.getFolderName(),
cacheImageFolderName
).listFiles()?.forEach { imgFile ->
if (!imgNames.contains(imgFile.name)) {
imgFile.delete()
}
}
}
suspend fun saveContent(
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
content: String
) {
try {
saveText(book, bookChapter, content)
//saveImages(bookSource, book, bookChapter, content)
postEvent(EventBus.SAVE_CONTENT, Pair(book, bookChapter))
} catch (e: Exception) {
e.printStackTrace()
AppLog.put("保存正文失败 ${book.name} ${bookChapter.title}", e)
}
}
fun saveText(
book: Book,
bookChapter: BookChapter,
content: String
) {
if (content.isEmpty()) return
//保存文本
FileUtils.createFileIfNotExist(
downloadDir,
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName(),
).writeText(content)
if (book.isOnLineTxt && AppConfig.tocCountWords) {
val wordCount = StringUtils.wordCountFormat(content.length)
bookChapter.wordCount = wordCount
appDb.bookChapterDao.upWordCount(bookChapter.bookUrl, bookChapter.url, wordCount)
}
}
fun flowImages(bookChapter: BookChapter, content: String): Flow<String> {
return flow {
val matcher = AppPattern.imgPattern.matcher(content)
while (matcher.find()) {
val src = matcher.group(1) ?: continue
val mSrc = NetworkUtils.getAbsoluteURL(bookChapter.url, src)
emit(mSrc)
}
}
}
suspend fun saveImages(
bookSource: BookSource,
book: Book,
bookChapter: BookChapter,
content: String,
concurrency: Int = AppConfig.threadCount
) = coroutineScope {
flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc ->
saveImage(bookSource, book, mSrc, bookChapter)
}.collect()
}
suspend fun saveImage(
bookSource: BookSource?,
book: Book,
src: String,
chapter: BookChapter? = null
) {
if (isImageExist(book, src)) {
return
}
val mutex = synchronized(this) {
downloadImages.getOrPut(src) { Mutex() }
}
mutex.lock()
try {
if (isImageExist(book, src)) {
return
}
val analyzeUrl = AnalyzeUrl(
src, source = bookSource, coroutineContext = coroutineContext
)
val bytes = analyzeUrl.getByteArrayAwait()
//某些图片被加密,需要进一步解密
runScriptWithContext {
ImageUtils.decode(
src, bytes, isCover = false, bookSource, book
)
}?.let {
if (!checkImage(it)) {
// 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
// 所以无论如何都要将数据写入到文件里
// throw NoStackTraceException("数据异常")
AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
}
writeImage(book, src, it)
}
} catch (e: Exception) {
coroutineContext.ensureActive()
val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}"
AppLog.put(msg, e)
} finally {
downloadImages.remove(src)
mutex.unlock()
}
}
fun getImage(book: Book, src: String): File {
return downloadDir.getFile(
cacheFolderName,
book.getFolderName(),
cacheImageFolderName,
"${MD5Utils.md5Encode16(src)}.${getImageSuffix(src)}"
)
}
@Synchronized
fun writeImage(book: Book, src: String, bytes: ByteArray) {
getImage(book, src).createFileIfNotExist().writeBytes(bytes)
}
@Synchronized
fun isImageExist(book: Book, src: String): Boolean {
return getImage(book, src).exists()
}
fun getImageSuffix(src: String): String {
return UrlUtil.getSuffix(src, "jpg")
}
@Throws(IOException::class, FileNotFoundException::class)
fun getEpubFile(book: Book): ZipFile {
val uri = book.getLocalUri()
if (uri.isContentScheme()) {
FileUtils.createFolderIfNotExist(downloadDir, cacheEpubFolderName)
val path = FileUtils.getPath(downloadDir, cacheEpubFolderName, book.originName)
val file = File(path)
val doc = DocumentFile.fromSingleUri(appCtx, uri)
?: throw IOException("文件不存在")
if (!file.exists() || doc.lastModified() > book.latestChapterTime) {
LocalBook.getBookInputStream(book).use { inputStream ->
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
return ZipFile(file)
}
return ZipFile(uri.path)
}
/**
* 获取本地书籍文件的ParcelFileDescriptor
*
* @param book
* @return
*/
@Throws(IOException::class, FileNotFoundException::class)
fun getBookPFD(book: Book): ParcelFileDescriptor? {
val uri = book.getLocalUri()
return if (uri.isContentScheme()) {
appCtx.contentResolver.openFileDescriptor(uri, "r")
} else {
ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY)
}
}
fun getChapterFiles(book: Book): HashSet<String> {
val fileNames = hashSetOf<String>()
if (book.isLocalTxt) {
return fileNames
}
FileUtils.createFolderIfNotExist(
downloadDir,
subDirs = arrayOf(cacheFolderName, book.getFolderName())
).list()?.let {
fileNames.addAll(it)
}
return fileNames
}
/**
* 检测该章节是否下载
*/
fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
return if (book.isLocalTxt ||
(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title))
) {
true
} else {
downloadDir.exists(
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
)
}
}
/**
* 检测图片是否下载
*/
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
if (!hasContent(book, bookChapter)) {
return false
}
var ret = true
val op = BitmapFactory.Options()
op.inJustDecodeBounds = true
getContent(book, bookChapter)?.let {
val matcher = AppPattern.imgPattern.matcher(it)
while (matcher.find()) {
val src = matcher.group(1)!!
val image = getImage(book, src)
if (!image.exists()) {
ret = false
continue
}
BitmapFactory.decodeFile(image.absolutePath, op)
if (op.outWidth < 1 && op.outHeight < 1) {
if (SvgUtils.getSize(image.absolutePath) != null) {
continue
}
ret = false
image.delete()
}
}
}
return ret
}
private fun checkImage(bytes: ByteArray): Boolean {
val op = BitmapFactory.Options()
op.inJustDecodeBounds = true
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, op)
if (op.outWidth < 1 && op.outHeight < 1) {
return SvgUtils.getSize(ByteArrayInputStream(bytes)) != null
}
return true
}
/**
* 读取章节内容
*/
fun getContent(book: Book, bookChapter: BookChapter): String? {
val file = downloadDir.getFile(
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
)
if (file.exists()) {
return file.readText()
}
if (book.isLocal) {
val string = LocalBook.getContent(book, bookChapter)
if (string != null && book.isEpub) {
saveText(book, bookChapter, string)
}
return string
}
return null
}
/**
* 删除章节内容
*/
fun delContent(book: Book, bookChapter: BookChapter) {
FileUtils.createFileIfNotExist(
downloadDir,
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName()
).delete()
}
/**
* 设置是否禁用正文的去除重复标题,针对单个章节
*/
fun setRemoveSameTitle(book: Book, bookChapter: BookChapter, removeSameTitle: Boolean) {
val fileName = bookChapter.getFileName("nr")
val contentProcessor = ContentProcessor.get(book)
if (removeSameTitle) {
val path = FileUtils.getPath(
downloadDir,
cacheFolderName,
book.getFolderName(),
fileName
)
contentProcessor.removeSameTitleCache.remove(fileName)
File(path).delete()
} else {
FileUtils.createFileIfNotExist(
downloadDir,
cacheFolderName,
book.getFolderName(),
fileName
)
contentProcessor.removeSameTitleCache.add(fileName)
}
}
/**
* 获取是否去除重复标题
*/
fun removeSameTitle(book: Book, bookChapter: BookChapter): Boolean {
val path = FileUtils.getPath(
downloadDir,
cacheFolderName,
book.getFolderName(),
bookChapter.getFileName("nr")
)
return !File(path).exists()
}
/**
* 格式化书名
*/
fun formatBookName(name: String): String {
return name
.replace(AppPattern.nameRegex, "")
.trim { it <= ' ' }
}
/**
* 格式化作者
*/
fun formatBookAuthor(author: String): String {
return author
.replace(AppPattern.authorRegex, "")
.trim { it <= ' ' }
}
private val jaccardSimilarity by lazy {
JaccardSimilarity()
}
/**
* 根据目录名获取当前章节
*/
fun getDurChapter(
oldDurChapterIndex: Int,
oldDurChapterName: String?,
newChapterList: List<BookChapter>,
oldChapterListSize: Int = 0
): Int {
if (oldDurChapterIndex <= 0) return 0
if (newChapterList.isEmpty()) return oldDurChapterIndex
val oldChapterNum = getChapterNum(oldDurChapterName)
val oldName = getPureChapterName(oldDurChapterName)
val newChapterSize = newChapterList.size
val durIndex =
if (oldChapterListSize == 0) oldDurChapterIndex
else oldDurChapterIndex * oldChapterListSize / newChapterSize
val min = max(0, min(oldDurChapterIndex, durIndex) - 10)
val max = min(newChapterSize - 1, max(oldDurChapterIndex, durIndex) + 10)
var nameSim = 0.0
var newIndex = 0
var newNum = 0
if (oldName.isNotEmpty()) {
for (i in min..max) {
val newName = getPureChapterName(newChapterList[i].title)
val temp = jaccardSimilarity.apply(oldName, newName)
if (temp > nameSim) {
nameSim = temp
newIndex = i
}
}
}
if (nameSim < 0.96 && oldChapterNum > 0) {
for (i in min..max) {
val temp = getChapterNum(newChapterList[i].title)
if (temp == oldChapterNum) {
newNum = temp
newIndex = i
break
} else if (abs(temp - oldChapterNum) < abs(newNum - oldChapterNum)) {
newNum = temp
newIndex = i
}
}
}
return if (nameSim > 0.96 || abs(newNum - oldChapterNum) < 1) {
newIndex
} else {
min(max(0, newChapterList.size - 1), oldDurChapterIndex)
}
}
fun getDurChapter(
oldBook: Book,
newChapterList: List<BookChapter>
): Int {
return oldBook.run {
getDurChapter(durChapterIndex, durChapterTitle, newChapterList, totalChapterNum)
}
}
private val chapterNamePattern1 by lazy {
Pattern.compile(
".*?第([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话]"
)
}
@Suppress("RegExpSimplifiable")
private val chapterNamePattern2 by lazy {
Pattern.compile(
"^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*([\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、]|\\.[^\\d])"
)
}
private val regexA by lazy {
return@lazy "\\s".toRegex()
}
private fun getChapterNum(chapterName: String?): Int {
chapterName ?: return -1
val chapterName1 = StringUtils.fullToHalf(chapterName).replace(regexA, "")
return StringUtils.stringToInt(
(
chapterNamePattern1.matcher(chapterName1).takeIf { it.find() }
?: chapterNamePattern2.matcher(chapterName1).takeIf { it.find() }
)?.group(1)
?: "-1"
)
}
private val regexOther by lazy {
// 所有非字母数字中日韩文字 CJK区+扩展A-F区
@Suppress("RegExpDuplicateCharacterInClass")
return@lazy "[^\\w\\u4E00-\\u9FEF〇\\u3400-\\u4DBF\\u20000-\\u2A6DF\\u2A700-\\u2EBEF]".toRegex()
}
@Suppress("RegExpUnnecessaryNonCapturingGroup", "RegExpSimplifiable")
private val regexB by lazy {
//章节序号,排除处于结尾的状况,避免将章节名替换为空字串
return@lazy "^.*?第(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)[章节篇回集话](?!$)|^(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+[,:、])*(?:[\\d零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+)(?:[,:、](?!$)|\\.(?=[^\\d]))".toRegex()
}
private val regexC by lazy {
//前后附加内容,整个章节名都在括号中时只剔除首尾括号,避免将章节名替换为空字串
return@lazy "(?!^)(?:[〖【《〔\\[{(][^〖【《〔\\[{()〕》】〗\\]}]+)?[)〕》】〗\\]}]$|^[〖【《〔\\[{(](?:[^〖【《〔\\[{()〕》】〗\\]}]+[〕》】〗\\]})])?(?!$)".toRegex()
}
private fun getPureChapterName(chapterName: String?): String {
return if (chapterName == null) "" else StringUtils.fullToHalf(chapterName)
.replace(regexA, "")
.replace(regexB, "")
.replace(regexC, "")
.replace(regexOther, "")
}
}
@@ -0,0 +1,620 @@
package io.legato.kazusa.help.book
import java.util.regex.Pattern
import kotlin.math.max
import kotlin.math.min
@Suppress("SameParameterValue", "RegExpRedundantEscape")
object ContentHelp {
/**
* 段落重排算法入口。把整篇内容输入,连接错误的分段,再把每个段落调用其他方法重新切分
*
* @param content 正文
* @param chapterName 标题
* @return
*/
fun reSegment(content: String, chapterName: String): String {
var content1 = content
val dict = makeDict(content1)
var p = content1
.replace("&quot;".toRegex(), "“")
.replace("[::]['\"‘”“]+".toRegex(), ":“")
.replace("[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“")
.split("\n(\\s*)".toRegex()).toTypedArray()
//初始化StringBuilder的长度,在原content的长度基础上做冗余
var buffer = StringBuilder((content1.length * 1.15).toInt())
// 章节的文本格式为章节标题-空行-首段,所以处理段落时需要略过第一行文本。
buffer.append(" ")
if (chapterName.trim { it <= ' ' } != p[0].trim { it <= ' ' }) {
// 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内
buffer.append(p[0].replace("[\u3000\\s]+".toRegex(), ""))
}
//如果原文存在分段错误,需要把段落重新黏合
for (i in 1 until p.size) {
if (match(MARK_SENTENCES_END, buffer[buffer.length - 1])) buffer.append("\n")
// 段落开头以外的地方不应该有空格
// 去除段落内空格。unicode 3000 象形字间隔(中日韩符号和标点),不包含在\s内
buffer.append(p[i].replace("[\u3000\\s]".toRegex(), ""))
}
// 预分段预处理
// ”“处理为”\n“。
// ”。“处理为”。\n“。不考虑“?” “!”的情况。
// ”。xxx处理为 ”。\n xxx
p = buffer.toString()
.replace("[\"”“]+[\\s]*[\"”“]+".toRegex(), "”\n“")
.replace("[\"”“]+(?。!?!~)[\"”“]+".toRegex(), "”$1\n“")
.replace("[\"”“]+(?。!?!~)([^\"”“])".toRegex(), "”$1\n$2")
.replace(
"([问说喊唱叫骂道着答])[\\.。]".toRegex(),
"$1。\n"
)
.split("\n".toRegex()).toTypedArray()
buffer = StringBuilder((content1.length * 1.15).toInt())
for (s in p) {
buffer.append("\n")
buffer.append(findNewLines(s, dict))
}
buffer = reduceLength(buffer)
content1 = (buffer.toString() // 处理章节头部空格和换行
.replaceFirst("^\\s+".toRegex(), "")
.replace("\\s*[\"”“]+[\\s]*[\"”“][\\s\"”“]*".toRegex(), "”\n“")
.replace("[::][”“\"\\s]+".toRegex(), ":“")
.replace("\n[\"“”]([^\n\"“”]+)([,:,:][\"”“])([^\n\"“”]+)".toRegex(), "\n$1:“$3")
.replace("\n(\\s*)".toRegex(), "\n"))
return content1
}
/**
* 强制切分,减少段落内的句子
* 如果连续2对引号的段落没有提示语,进入对话模式。最后一对引号后强制切分段落
* 如果引号内的内容长于5句,可能引号状态有误,随机分段
* 如果引号外的内容长于3句,随机分段
*
* @param str
* @return
*/
private fun reduceLength(str: StringBuilder): StringBuilder {
val p = str.toString().split("\n".toRegex()).toTypedArray()
val l = p.size
val b = BooleanArray(l)
for (i in 0 until l) {
b[i] = p[i].matches(PARAGRAPH_DIAGLOG)
}
var dialogue = 0
for (i in 0 until l) {
if (b[i]) {
if (dialogue < 0) dialogue = 1 else if (dialogue < 2) dialogue++
} else {
if (dialogue > 1) {
p[i] = splitQuote(p[i])
dialogue--
} else if (dialogue > 0 && i < l - 2) {
if (b[i + 1]) p[i] = splitQuote(p[i])
}
}
}
val string = StringBuilder()
for (i in 0 until l) {
string.append('\n')
string.append(p[i])
//System.out.print(" "+b[i]);
}
//System.out.println(" " + str);
return string
}
// 强制切分进入对话模式后,未构成 “xxx” 形式的段落
private fun splitQuote(str: String): String {
val length = str.length
if (length < 3) return str
if (match(MARK_QUOTATION, str[0])) {
val i = seekIndex(str, MARK_QUOTATION, 1, length - 2, true) + 1
if (i > 1) if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) {
return "${str.substring(0, i)}\n${str.substring(i)}"
}
} else if (match(MARK_QUOTATION, str[length - 1])) {
val i = length - 1 - seekIndex(str, MARK_QUOTATION, 1, length - 2, false)
if (i > 1) {
if (!match(MARK_QUOTATION_BEFORE, str[i - 1])) {
return "${str.substring(0, i)}\n${str.substring(i)}"
}
}
}
return str
}
/**
* 计算随机插入换行符的位置。
* @param str 字符串
* @param offset 传回的结果需要叠加的偏移量
* @param min 最低几个句子,随机插入换行
* @param gain 倍率。每个句子插入换行的数学期望 = 1 / gain , gain越大越不容易插入换行
* @return
*/
private fun forceSplit(
str: String,
offset: Int,
min: Int,
gain: Int,
tigger: Int
): ArrayList<Int> {
val result = ArrayList<Int>()
val arrayEnd = seekIndexs(str, MARK_SENTENCES_END_P, 0, str.length - 2, true)
val arrayMid = seekIndexs(str, MARK_SENTENCES_MID, 0, str.length - 2, true)
if (arrayEnd.size < tigger && arrayMid.size < tigger * 3) return result
var j = 0
var i = min
while (i < arrayEnd.size) {
var k = 0
while (j < arrayMid.size) {
if (arrayMid[j] < arrayEnd[i]) k++
j++
}
if (Math.random() * gain < 0.8 + k / 2.5) {
result.add(arrayEnd[i] + offset)
i = max(i + min, i)
}
i++
}
return result
}
// 对内容重新划分段落.输入参数str已经使用换行符预分割
private fun findNewLines(str: String, dict: List<String>): String {
val string = StringBuilder(str)
// 标记string中每个引号的位置.特别的,用引号进行列举时视为只有一对引号。 如:“锅”、“碗”视为“锅、碗”,从而避免误断句。
val arrayQuote: MutableList<Int> = ArrayList()
// 标记插入换行符的位置,int为插入位置(str的char下标)
var insN = ArrayList<Int>()
//mod[i]标记str的每一段处于引号内还是引号外。范围: str.substring( array_quote.get(i), array_quote.get(i+1) )的状态。
//长度:array_quote.size(),但是初始化时未预估占用的长度,用空间换时间
//0未知,正数引号内,负数引号外。
//如果相邻的两个标记都为+1,那么需要增加1个引号。
//引号内不进行断句
val mod = IntArray(str.length)
var waitClose = false
for (i in str.indices) {
val c = str[i]
if (match(MARK_QUOTATION, c)) {
val size = arrayQuote.size
// 把“xxx”、“yy”合并为“xxx_yy”进行处理
if (size > 0) {
val quotePre = arrayQuote[size - 1]
if (i - quotePre == 2) {
var remove = false
if (waitClose) {
if (match(",,、/", str[i - 1])) {
// 考虑出现“和”这种特殊情况
remove = true
}
} else if (match(",,、/和与或", str[i - 1])) {
remove = true
}
if (remove) {
string.setCharAt(i, '“')
string.setCharAt(i - 2, '”')
arrayQuote.removeAt(size - 1)
mod[size - 1] = 1
mod[size] = -1
continue
}
}
}
arrayQuote.add(i)
// 为xxx:“xxx”做标记
if (i > 1) {
// 当前发言的正引号的前一个字符
val charB1 = str[i - 1]
// 上次发言的正引号的前一个字符
var charB2 = 0.toChar()
if (match(MARK_QUOTATION_BEFORE, charB1)) {
// 如果不是第一处引号,寻找上一处断句,进行分段
if (arrayQuote.size > 1) {
val lastQuote = arrayQuote[arrayQuote.size - 2]
var p = 0
if (charB1 == ',' || charB1 == ',') {
if (arrayQuote.size > 2) {
p = arrayQuote[arrayQuote.size - 3]
if (p > 0) {
charB2 = str[p - 1]
}
}
}
//if(char_b2=='.' || char_b2=='。')
if (match(MARK_SENTENCES_END_P, charB2)) {
insN.add(p - 1)
} else if (!match("的", charB2)) {
val lastEnd = seekLast(str, MARK_SENTENCES_END, i, lastQuote)
if (lastEnd > 0) insN.add(lastEnd) else insN.add(lastQuote)
}
}
waitClose = true
mod[size] = 1
if (size > 0) {
mod[size - 1] = -1
if (size > 1) {
mod[size - 2] = 1
}
}
} else if (waitClose) {
run {
waitClose = false
insN.add(i)
}
}
}
}
}
val size = arrayQuote.size
//标记循环状态,此位置前的引号是否已经配对
var opend = false
if (size > 0) {
//第1次遍历array_quote,令其元素的值不为0
for (i in 0 until size) {
if (mod[i] > 0) {
opend = true
} else if (mod[i] < 0) {
//连续2个反引号表明存在冲突,强制把前一个设为正引号
if (!opend) {
if (i > 0) mod[i] = 3
}
opend = false
} else {
opend = !opend
if (opend) mod[i] = 2 else mod[i] = -2
}
}
// 修正,断尾必须封闭引号
if (opend) {
if (arrayQuote[size - 1] - string.length > -3) {
//if((match(MARK_QUOTATION,string.charAt(string.length()-1)) || match(MARK_QUOTATION,string.charAt(string.length()-2)))){
if (size > 1) mod[size - 2] = 4
// 0<=i<size,故无需判断size>=1
mod[size - 1] = -4
} else if (!match(MARK_SENTENCES_SAY, string[string.length - 2])) string.append(
"”"
)
}
//第2次循环,mod[i]由负变正时,前1字符如果是句末,需要插入换行
var loop2Mod1 = -1 //上一个引号跟随内容的状态
var loop2Mod2: Int //当前引号跟随内容的状态
var i = 0
var j = arrayQuote[0] - 1 //当前引号前一字符的序号
if (j < 0) {
i = 1
loop2Mod1 = 0
}
while (i < size) {
j = arrayQuote[i] - 1
loop2Mod2 = mod[i]
if (loop2Mod1 < 0 && loop2Mod2 > 0) {
if (match(MARK_SENTENCES_END, string[j])) insN.add(j)
}
loop2Mod1 = loop2Mod2
i++
}
}
//第3次循环,匹配并插入换行。
//"xxxx" xxxx。\n xxx“xxxx”
//未实现
// 使用字典验证ins_n , 避免插入不必要的换行。
// 由于目前没有插入、的列表,无法解决 “xx”、“xx”“xx” 被插入换行的问题
val insN1 = ArrayList<Int>()
for (i in insN) {
if (match("\"'”“", string[i])) {
val start: Int = seekLast(
str,
"\"'”“",
i - 1,
i - WORD_MAX_LENGTH
)
if (start > 0) {
val word = str.substring(start + 1, i)
if (dict.contains(word)) {
//System.out.println("使用字典验证 跳过\tins_n=" + i + " word=" + word);
//引号内如果是字典词条,后方不插入换行符(前方不需要优化)
continue
} else {
//System.out.println("使用字典验证 插入\tins_n=" + i + " word=" + word);
if (match("的地得", str[start])) {
//xx的“xx”,后方不插入换行符(前方不需要优化)
continue
}
}
}
}
insN1.add(i)
}
insN = insN1
// 随机在句末插入换行符
insN = ArrayList(HashSet(insN))
insN.sort()
run {
var subs: String
var j = 0
var progress = 0
var nextLine = -1
if (insN.size > 0) nextLine = insN[j]
var gain = 3
var min = 0
var trigger = 2
for (i in arrayQuote.indices) {
val qutoe = arrayQuote[i]
if (qutoe > 0) {
gain = 4
min = 2
trigger = 4
} else {
gain = 3
min = 0
trigger = 2
}
// 把引号前的换行符与内容相间插入
while (j < insN.size) {
// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况
if (nextLine >= qutoe) break
nextLine = insN[j]
if (progress < nextLine) {
subs = string.substring(progress, nextLine)
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
progress = nextLine + 1
}
j++
}
if (progress < qutoe) {
subs = string.substring(progress, qutoe + 1)
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
progress = qutoe + 1
}
}
while (j < insN.size) {
nextLine = insN[j]
if (progress < nextLine) {
subs = string.substring(progress, nextLine)
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
progress = nextLine + 1
}
j++
}
if (progress < string.length) {
subs = string.substring(progress, string.length)
insN.addAll(forceSplit(subs, progress, min, gain, trigger))
}
}
// 根据段落状态修正引号方向、计算需要插入引号的位置
// ins_quote跟随array_quote ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”'
val insQuote = BooleanArray(size)
opend = false
for (i in 0 until size) {
val p = arrayQuote[i]
if (mod[i] > 0) {
string.setCharAt(p, '“')
if (opend) insQuote[i] = true
opend = true
} else if (mod[i] < 0) {
string.setCharAt(p, '”')
opend = false
} else {
opend = !opend
if (opend) string.setCharAt(p, '“') else string.setCharAt(p, '”')
}
}
insN = ArrayList(HashSet(insN))
insN.sort()
// 完成字符串拼接(从string复制、插入引号和换行
// ins_quote 在引号前插入一个引号。 ins_quote[i]!=0,则array_quote.get(i)的引号前需要前插入'”'
// ins_n 插入换行。数组的值表示插入换行符的位置
val buffer = StringBuilder((str.length * 1.15).toInt())
var j = 0
var progress = 0
var nextLine = -1
if (insN.size > 0) nextLine = insN[j]
for (i in arrayQuote.indices) {
val qutoe = arrayQuote[i]
// 把引号前的换行符与内容相间插入
while (j < insN.size) {
// 如果下一个换行符在当前引号前,那么需要此次处理.如果紧挨当前引号,需要考虑插入引号的情况
if (nextLine >= qutoe) break
nextLine = insN[j]
buffer.append(string, progress, nextLine + 1)
buffer.append('\n')
progress = nextLine + 1
j++
}
if (progress < qutoe) {
buffer.append(string, progress, qutoe + 1)
progress = qutoe + 1
}
if (insQuote[i] && buffer.length > 2) {
if (buffer[buffer.length - 1] == '\n') buffer.append('“') else buffer.insert(
buffer.length - 1,
"”\n"
)
}
}
while (j < insN.size) {
nextLine = insN[j]
if (progress <= nextLine) {
buffer.append(string, progress, nextLine + 1)
buffer.append('\n')
progress = nextLine + 1
}
j++
}
if (progress < string.length) {
buffer.append(string, progress, string.length)
}
return buffer.toString()
}
/**
* 从字符串提取引号包围,且不止出现一次的内容为字典
*
* @param str
* @return 词条列表
*/
private fun makeDict(str: String): List<String> {
// 引号中间不包含任何标点
val patten = Pattern.compile(
"""
(?<=["'”“])([^
\p{P}]{1,$WORD_MAX_LENGTH})(?=["'”“])
""".trimIndent()
)
//Pattern patten = Pattern.compile("(?<=[\"'”“])([^\n\"'”“]{1,16})(?=[\"'”“])");
val matcher = patten.matcher(str)
val cache: MutableList<String> = ArrayList()
val dict: MutableList<String> = ArrayList()
while (matcher.find()) {
val word = matcher.group()
if (cache.contains(word)) {
if (!dict.contains(word)) dict.add(word)
} else cache.add(word)
}
return dict
}
/**
* 计算匹配到字典的每个字符的位置
*
* @param str 待匹配的字符串
* @param key 字典
* @param from 从字符串的第几个字符开始匹配
* @param to 匹配到第几个字符结束
* @param inOrder 是否按照从前向后的顺序匹配
* @return 返回距离构成的ArrayList<Int>
*/
private fun seekIndexs(
str: String,
key: String,
from: Int,
to: Int,
inOrder: Boolean
): ArrayList<Int> {
val list = ArrayList<Int>()
if (str.length - from < 1) return list
var i = 0
if (from > i) i = from
var t = str.length
if (to > 0) t = min(t, to)
var c: Char
while (i < t) {
c = if (inOrder) str[i] else str[str.length - i - 1]
if (key.indexOf(c) != -1) {
list.add(i)
}
i++
}
return list
}
/**
* 计算字符串最后出现与字典中字符匹配的位置
*
* @param str 数据字符串
* @param key 字典字符串
* @param from 从哪个字符开始匹配,默认最末位
* @param to 匹配到哪个字符(不包含此字符)默认0
* @return 位置(正向计算)
*/
private fun seekLast(str: String, key: String, from: Int, to: Int): Int {
if (str.length - from < 1) return -1
var i = str.lastIndex
if (from < i && i > 0) i = from
var t = 0
if (to > 0) t = to
var c: Char
while (i > t) {
c = str[i]
if (key.indexOf(c) != -1) {
return i
}
i--
}
return -1
}
/**
* 计算字符串与字典中字符的最短距离
*
* @param str 数据字符串
* @param key 字典字符串
* @param from 从哪个字符开始匹配,默认0
* @param to 匹配到哪个字符(不包含此字符)默认匹配到最末位
* @param inOrder 是否从正向开始匹配
* @return 返回最短距离, 注意不是str的char的下标
*/
private fun seekIndex(str: String, key: String, from: Int, to: Int, inOrder: Boolean): Int {
if (str.length - from < 1) return -1
var i = 0
if (from > i) i = from
var t = str.length
if (to > 0) t = min(t, to)
var c: Char
while (i < t) {
c = if (inOrder) str[i] else str[str.length - i - 1]
if (key.indexOf(c) != -1) {
return i
}
i++
}
return -1
}
/* 搜寻引号并进行分段。处理了一、二、五三类常见情况
参照百科词条[引号#应用示例](https://baike.baidu.com/item/%E5%BC%95%E5%8F%B7/998963?#5)对引号内容进行矫正并分句。
一、完整引用说话内容,在反引号内侧有断句标点。例如:
1) 丫姑折断几枝扔下来,边叫我的小名儿边说:“先喂饱你!”
2)“哎呀,真是美极了!”皇帝说,“我十分满意!”
3)“怕什么!海的美就在这里!”我说道。
二、部分引用,在反引号外侧有断句标点:
4)适当地改善自己的生活,岂但“你管得着吗”,而且是顺乎天理,合乎人情的。
5)现代画家徐悲鸿笔下的马,正如有的评论家所说的那样,“形神兼备,充满生机”。
6)唐朝的张嘉贞说它“制造奇特,人不知其所为”。
三、一段接着一段地直接引用时,中间段落只在段首用起引号,该段段尾却不用引回号。但是正统文学不在考虑范围内。
四、引号里面又要用引号时,外面一层用双引号,里面一层用单引号。暂时不需要考虑
五、反语和强调,周围没有断句符号。
*/
// 句子结尾的标点。因为引号可能存在误判,不包含引号。
private const val MARK_SENTENCES_END = "?。!?!~"
private const val MARK_SENTENCES_END_P = ".?。!?!~"
// 句中标点,由于某些网站常把“,”写为".",故英文句点按照句中标点判断
private const val MARK_SENTENCES_MID = ".,、,—…"
private const val MARK_SENTENCES_SAY = "问说喊唱叫骂道着答"
// XXX说:“”的冒号
private const val MARK_QUOTATION_BEFORE = ",:,:"
// 引号
private const val MARK_QUOTATION = "\"“”"
private val PARAGRAPH_DIAGLOG = "^[\"”“][^\"”“]+[\"”“]$".toRegex()
// 限制字典的长度
private const val WORD_MAX_LENGTH = 16
private fun match(rule: String, chr: Char): Boolean {
return rule.indexOf(chr) != -1
}
}
@@ -0,0 +1,206 @@
package io.legato.kazusa.help.book
import android.os.Build
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern.spaceRegex
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.data.entities.ReplaceRule
import io.legato.kazusa.exception.RegexTimeoutException
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.config.ReadBookConfig
import io.legato.kazusa.utils.ChineseUtils
import io.legato.kazusa.utils.escapeRegex
import io.legato.kazusa.utils.replace
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CancellationException
import splitties.init.appCtx
import java.lang.ref.WeakReference
import java.util.concurrent.CopyOnWriteArrayList
import java.util.regex.Pattern
class ContentProcessor private constructor(
private val bookName: String,
private val bookOrigin: String
) {
companion object {
private val processors = hashMapOf<String, WeakReference<ContentProcessor>>()
private val isAndroid8 = Build.VERSION.SDK_INT in 26..27
fun get(book: Book) = get(book.name, book.origin)
fun get(bookName: String, bookOrigin: String): ContentProcessor {
val processorWr = processors[bookName + bookOrigin]
var processor: ContentProcessor? = processorWr?.get()
if (processor == null) {
processor = ContentProcessor(bookName, bookOrigin)
processors[bookName + bookOrigin] = WeakReference(processor)
}
return processor
}
fun upReplaceRules() {
processors.forEach {
it.value.get()?.upReplaceRules()
}
}
}
private val titleReplaceRules = CopyOnWriteArrayList<ReplaceRule>()
private val contentReplaceRules = CopyOnWriteArrayList<ReplaceRule>()
val removeSameTitleCache = hashSetOf<String>()
init {
upReplaceRules()
upRemoveSameTitle()
}
fun upReplaceRules() {
titleReplaceRules.run {
clear()
addAll(appDb.replaceRuleDao.findEnabledByTitleScope(bookName, bookOrigin))
}
contentReplaceRules.run {
clear()
addAll(appDb.replaceRuleDao.findEnabledByContentScope(bookName, bookOrigin))
}
}
private fun upRemoveSameTitle() {
val book = appDb.bookDao.getBookByOrigin(bookName, bookOrigin) ?: return
removeSameTitleCache.clear()
val files = BookHelp.getChapterFiles(book).filter {
it.endsWith("nr")
}
removeSameTitleCache.addAll(files)
}
fun getTitleReplaceRules(): List<ReplaceRule> {
return titleReplaceRules
}
@Suppress("MemberVisibilityCanBePrivate")
fun getContentReplaceRules(): List<ReplaceRule> {
return contentReplaceRules
}
fun getContent(
book: Book,
chapter: BookChapter,
content: String,
includeTitle: Boolean = true,
useReplace: Boolean = true,
chineseConvert: Boolean = true,
reSegment: Boolean = true
): BookContent {
var mContent = content
var sameTitleRemoved = false
var effectiveReplaceRules: ArrayList<ReplaceRule>? = null
if (content != "null") {
//去除重复标题
val fileName = chapter.getFileName("nr")
if (!removeSameTitleCache.contains(fileName)) try {
val name = Pattern.quote(book.name)
var title = chapter.title.escapeRegex().replace(spaceRegex, "\\\\s*")
var matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
.matcher(mContent)
if (matcher.find()) {
mContent = mContent.substring(matcher.end())
sameTitleRemoved = true
} else if (useReplace && book.getUseReplaceRule()) {
title = Pattern.quote(
chapter.getDisplayTitle(
contentReplaceRules,
chineseConvert = false
)
)
matcher = Pattern.compile("^(\\s|\\p{P}|${name})*${title}(\\s)*")
.matcher(mContent)
if (matcher.find()) {
mContent = mContent.substring(matcher.end())
sameTitleRemoved = true
}
}
} catch (e: Exception) {
AppLog.put("去除重复标题出错\n${e.localizedMessage}", e)
}
if (reSegment && book.getReSegment()) {
//重新分段
mContent = ContentHelp.reSegment(mContent, chapter.title)
}
if (chineseConvert) {
//简繁转换
try {
when (AppConfig.chineseConverterType) {
1 -> mContent = ChineseUtils.t2s(mContent)
2 -> mContent = ChineseUtils.s2t(mContent)
}
} catch (e: Exception) {
appCtx.toastOnUi("简繁转换出错")
}
}
if (useReplace && book.getUseReplaceRule()) {
//替换
effectiveReplaceRules = arrayListOf()
mContent = mContent.lines().joinToString("\n") { it.trim() }
getContentReplaceRules().forEach { item ->
if (item.pattern.isEmpty()) {
return@forEach
}
try {
val tmp = if (item.isRegex) {
mContent.replace(
item.regex,
item.replacement,
item.getValidTimeoutMillisecond()
)
} else {
mContent.replace(item.pattern, item.replacement)
}
if (mContent != tmp) {
effectiveReplaceRules.add(item)
mContent = tmp
}
} catch (e: RegexTimeoutException) {
item.isEnabled = false
appDb.replaceRuleDao.update(item)
mContent = item.name + e.stackTraceStr
} catch (_: CancellationException) {
} catch (e: Exception) {
AppLog.put("替换净化: 规则 ${item.name}替换出错.\n${mContent}", e)
appCtx.toastOnUi("替换净化: 规则 ${item.name}替换出错")
}
}
}
}
if (includeTitle) {
//重新添加标题
mContent = chapter.getDisplayTitle(
getTitleReplaceRules(),
useReplace = useReplace && book.getUseReplaceRule()
) + "\n" + mContent
}
if (isAndroid8) {
mContent = mContent.replace('\u00A0', ' ')
}
val contents = arrayListOf<String>()
mContent.split("\n").forEach { str ->
val paragraph = str.trim {
it.code <= 0x20 || it == ' '
}
if (paragraph.isNotEmpty()) {
if (contents.isEmpty() && includeTitle) {
contents.add(paragraph)
} else {
contents.add("${ReadBookConfig.paragraphIndent}$paragraph")
}
}
}
return BookContent(sameTitleRemoved, contents, effectiveReplaceRules)
}
}