feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -0,0 +1,226 @@
import Foundation
import Testing
@testable import LegadoCore
struct LocalBookServiceTests {
@Test func decodesDefaultTocRuleWithMissingReplacement() throws {
let data = Data(#"{"id":-1,"name":"目录","rule":"^第.+章$","serialNumber":0,"enable":true}"#.utf8)
let value = try JSONDecoder().decode(TxtTocRule.self, from: data)
#expect(value.replacement == "")
#expect(value.example == nil)
#expect(value.enable)
}
@Test func importsUTF8TextSplitsChaptersAndReadsContent() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "测试书.txt")
let sourceData = Data("序言\n第一章 相遇\n这是第一章正文\n第二章 远行\n这是第二章正文".utf8)
try sourceData.write(to: source)
let service = LocalBookService()
let report = try service.importTextFile(at: source, into: root.appending(path: "library"))
#expect(report.book.origin == "local")
#expect(report.book.charset == "UTF-8")
#expect(report.chapters.map(\.title) == ["前言", "第一章 相遇", "第二章 远行"])
#expect(report.chapters[1].url == "82e56047094237f3")
let firstTitle = try #require(sourceData.range(of: Data("第一章 相遇".utf8)))
let secondTitle = try #require(sourceData.range(of: Data("第二章 远行".utf8)))
#expect(report.chapters[1].start == Int64(firstTitle.upperBound))
#expect(report.chapters[1].end == Int64(secondTitle.lowerBound))
#expect(report.chapters.map(\.wordCount) == ["3字", "9字", "8字"])
#expect(report.book.wordCount == "20字")
#expect(report.book.intro == "序言\n")
let content = try service.loadContent(book: report.book, chapter: report.chapters[1])
#expect(content.content == "  这是第一章正文\n")
#expect(FileManager.default.fileExists(atPath: try #require(URL(string: report.book.bookUrl)).path))
}
@Test func createsSingleChapterWhenNoHeadingExists() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "短篇.txt")
try Data("这是一个没有章节标题的短篇。".utf8).write(to: source)
let report = try LocalBookService().importTextFile(at: source, into: root.appending(path: "library"))
#expect(report.chapters.count == 1)
#expect(report.chapters[0].title == "短篇")
#expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[0]).content == "这是一个没有章节标题的短篇。")
}
@Test func splitsLongHeadinglessTextAtTenKiBThreshold() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "无目录长文.txt")
let paragraph = String(repeating: "a", count: 10_500)
try Data("\(paragraph)\n\(paragraph)\n尾声".utf8).write(to: source)
let report = try LocalBookService(tocRules: []).importTextFile(
at: source,
into: root.appending(path: "library")
)
#expect(report.chapters.count == 2)
#expect(report.chapters.map(\.title) == ["第1章(1)", "第1章(2)"])
#expect(report.chapters[0].end == report.chapters[1].start)
#expect(report.book.wordCount == "2.1万字")
#expect(report.book.intro == nil)
}
@Test func numbersHeadinglessChaptersBy512KBBlock() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "多块无目录.txt")
let paragraph = String(repeating: "a", count: 10_500) + "\n"
try Data(String(repeating: paragraph, count: 55).utf8).write(to: source)
let report = try LocalBookService(tocRules: []).importTextFile(
at: source,
into: root.appending(path: "library")
)
#expect(report.chapters.first?.title == "第1章(1)")
#expect(report.chapters.contains { $0.title.hasPrefix("第2章(") })
for index in 1..<report.chapters.count {
#expect(report.chapters[index - 1].end == report.chapters[index].start)
}
}
@Test func selectsCustomTocRuleAndRunsTitleScript() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "自定义目录.txt")
let body = String(repeating: "正文", count: 650)
try Data("卷1 开始\n\(body)\n卷2 继续\n\(body)\n卷3 结束\n\(body)".utf8).write(to: source)
let rule = TxtTocRule(
name: "卷标题",
rule: #"(?m)^卷\d+[^\r\n]*$"#,
replacement: "result.replace('卷', '第')",
serialNumber: 1
)
let report = try LocalBookService(tocRules: [rule]).importTextFile(
at: source,
into: root.appending(path: "library")
)
#expect(report.book.tocUrl == rule.rule + TxtTocRuleSelector.separator + rule.replacement)
#expect(report.chapters.map(\.title) == ["第1 开始", "第2 继续", "第3 结束"])
}
@Test func tocRuleSelectorKeepsEarlierRuleUnlessLaterWinsByMoreThanTwo() {
let body = String(repeating: "字", count: 1_100)
let content = "A1\n\(body)\nA2\n\(body)\nA3\n\(body)\nB1\n\(body)\nB2\n\(body)\nB3\n\(body)\nB4"
let first = TxtTocRule(id: 1, name: "A", rule: #"(?m)^A\d$"#, serialNumber: 1)
let second = TxtTocRule(id: 2, name: "B", rule: #"(?m)^B\d$"#, serialNumber: 2)
#expect(TxtTocRuleSelector.select(content: content, rules: [first, second])?.id == first.id)
}
@Test func titleScriptPutVolumeInsertsVolumeChapter() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "分卷.txt")
let body = String(repeating: "正文", count: 650)
try Data("章1\n\(body)\n章2\n\(body)\n章3\n\(body)".utf8).write(to: source)
let rule = TxtTocRule(
name: "脚本分卷",
rule: #"(?m)^章\d$"#,
replacement: "if (index === 2) java.putVolume('第二卷'); result;",
serialNumber: 1
)
let report = try LocalBookService(tocRules: [rule]).importTextFile(
at: source,
into: root.appending(path: "library")
)
#expect(report.chapters.map(\.title) == ["章1", "第二卷", "章2", "章3"])
#expect(report.chapters.map(\.isVolume) == [false, true, false, false])
#expect(report.chapters[1].start == report.chapters[1].end)
}
@Test func scansHeadingsAcross512KBBlocksAndReadsByByteRange() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "跨块.txt")
let longBody = String(repeating: "段落内容\n", count: 50_000)
let sourceData = Data("第一章 开始\n\(longBody)第二章 结束\n结尾".utf8)
try sourceData.write(to: source)
let report = try LocalBookService().importTextFile(
at: source,
into: root.appending(path: "library")
)
let secondTitle = try #require(sourceData.range(of: Data("第二章 结束".utf8)))
#expect(report.chapters.map(\.title) == ["第一章 开始", "第二章 结束"])
#expect(report.chapters[0].end == Int64(secondTitle.lowerBound))
#expect(report.chapters[1].start == Int64(secondTitle.upperBound))
#expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[1]).content == "  结尾")
}
@Test func preservesUTF16EndianBoundariesAcross512KBBlocks() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let text = "第一章 开始\n\(String(repeating: "段落内容\n", count: 70_000))第二章 结束\n结尾"
let fixtures: [(String, String.Encoding, [UInt8], String)] = [
("LE", .utf16LittleEndian, [0xFF, 0xFE], "UTF-16LE"),
("BE", .utf16BigEndian, [0xFE, 0xFF], "UTF-16BE")
]
for (suffix, encoding, bom, charset) in fixtures {
let source = root.appending(path: "UTF16\(suffix).txt")
var sourceData = Data(bom)
sourceData.append(try #require(text.data(using: encoding)))
try sourceData.write(to: source)
let report = try LocalBookService().importTextFile(
at: source,
into: root.appending(path: "library-\(suffix)")
)
let titleData = try #require("第二章 结束".data(using: encoding))
let titleRange = try #require(sourceData.range(of: titleData))
let second = try #require(report.chapters.first(where: { $0.title == "第二章 结束" }))
#expect(report.book.charset == charset)
#expect(second.start == Int64(titleRange.upperBound))
#expect(try LocalBookService().loadContent(book: report.book, chapter: second).content == "  结尾")
}
}
@Test func splitsOversizedTocChapterIntoVolumeAndUpdatesFollowingScriptState() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let source = root.appending(path: "超长章节.txt")
let longBody = String(repeating: "正文内容\n", count: 12_000)
try Data("第一章\n\(longBody)第二章\n结尾".utf8).write(to: source)
let rule = TxtTocRule(
name: "长章脚本",
rule: #"(?m)^第[一二]章$"#,
replacement: "result === '第二章' ? lastVolumeTitle + '|' + index + '|' + result : result",
serialNumber: 1
)
let report = try LocalBookService(tocRules: [rule]).importTextFile(
at: source,
into: root.appending(path: "library")
)
#expect(report.chapters.first?.title == "第一章")
#expect(report.chapters.first?.isVolume == true)
#expect(report.chapters.dropFirst().contains { $0.title == "第一章(1)" })
#expect(report.chapters.last?.title.hasPrefix("第一章|") == true)
#expect(report.chapters.last?.title.hasSuffix("|第二章") == true)
}
}