Files
legado-ios/Sources/LegadoCore/LocalBook/LocalBookService.swift
T

768 lines
32 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreFoundation
import CryptoKit
import Foundation
public enum LocalBookError: Error, LocalizedError, Sendable {
case unsupportedFormat
case oversized
case unreadableEncoding
case missingFile
public var errorDescription: String? {
switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书"
case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失"
}
}
}
public struct LocalBookImportReport: Sendable {
public var book: Book
public var chapters: [BookChapter]
public init(book: Book, chapters: [BookChapter]) {
self.book = book
self.chapters = chapters
}
}
public enum TxtTocRuleSelector {
public static let separator = "🫅🈳🏻"
public static func select(
content: String,
rules: [TxtTocRule],
transformTitle: (TxtTocRule, String, Int, String?, Int) -> String = { _, value, _, _, _ in value }
) -> TxtTocRule? {
let source = content as NSString
var maximum = -1
var selected: TxtTocRule?
for rule in rules.filter(\.enable).sorted(by: { $0.serialNumber < $1.serialNumber }) {
guard !rule.rule.isEmpty else { continue }
guard let expression = try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines) else { continue }
var start = 0
var validCount = 0
var shortGapCount = 0
var lastTitle: String?
for match in expression.matches(in: content, range: NSRange(location: 0, length: source.length)) {
let length = match.range.location - start
if start == 0 || length > 1_000 {
let rawTitle = source.substring(with: match.range)
let title = transformTitle(rule, rawTitle, validCount, lastTitle, length)
if !title.isEmpty {
lastTitle = title
validCount += 1
}
start = NSMaxRange(match.range)
} else if length < 100 {
shortGapCount += 1
}
}
if validCount >= shortGapCount * 3, validCount > maximum + 2 {
maximum = validCount
selected = rule
if maximum > 70 { break }
}
}
return selected
}
}
public enum TxtTocRuleDefaults {
public static func load(bundle: Bundle = .main) -> [TxtTocRule] {
guard let url = bundle.url(forResource: "txtTocRule", withExtension: "json"),
let data = try? Data(contentsOf: url),
let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) else { return [] }
return values.sorted { $0.serialNumber < $1.serialNumber }
}
}
private final class TxtVolumeState {
var lastTitle = ""
}
private struct TxtHeadingMatch {
var title: String
var start: Int64
var end: Int64
var previousLength: Int
}
public struct LocalBookService: Sendable {
public var maximumFileSize: Int64
public var tocRules: [TxtTocRule]
private var loadsLiveTocRules = false
public init(maximumFileSize: Int64 = .max, tocRules: [TxtTocRule] = []) {
self.maximumFileSize = maximumFileSize
self.tocRules = tocRules.filter(\.enable).sorted { $0.serialNumber < $1.serialNumber }
}
public static func live() -> LocalBookService {
LocalBookService(tocRules: TxtTocRuleDefaults.load())
}
public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
if Int64(values.fileSize ?? 0) > maximumFileSize { throw LocalBookError.oversized }
let data = try Data(contentsOf: sourceURL, options: .mappedIfSafe)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent("\(UUID().uuidString).txt")
try data.write(to: destination, options: .atomic)
do {
return try textFileReport(data: data, sourceURL: sourceURL, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func openTextFile(at url: URL) throws -> LocalBookImportReport {
guard url.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
return try textFileReport(data: Data(contentsOf: url, options: .mappedIfSafe), sourceURL: url, destination: url)
}
private func textFileReport(data: Data, sourceURL: URL, destination: URL) throws -> LocalBookImportReport {
let decoded = try decode(Data(data.prefix(512_000)))
let name = sourceURL.deletingPathExtension().lastPathComponent
var book = Book(bookUrl: destination.absoluteString, name: name, origin: "local")
book.originName = sourceURL.lastPathComponent
book.charset = decoded.charset
book.canUpdate = false
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(data, encoding: decoded.encoding)
let selectedRule = TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
rule, title, index, previous, length in
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
}
if let selectedRule {
book.tocUrl = selectedRule.rule + TxtTocRuleSelector.separator + selectedRule.replacement
}
let fullBOMLength = bomLength(in: data, encoding: decoded.encoding)
let hasTOC = !headingMatches(
data: data,
encoding: decoded.encoding,
bomLength: fullBOMLength,
pattern: selectedRule?.rule ?? fallbackTocPattern,
usesFirstCapture: selectedRule == nil
).isEmpty
var chapters = chapters(
data: data,
encoding: decoded.encoding,
bomLength: fullBOMLength,
book: book,
fallbackTitle: name,
selectedRule: selectedRule
)
let totalWordCount = applyWordCounts(&chapters, data: data, encoding: decoded.encoding)
book.wordCount = wordCountFormat(totalWordCount)
if hasTOC,
let first = chapters.first(where: { !$0.isVolume && $0.start == Int64(fullBOMLength) }),
first.start == Int64(fullBOMLength),
let endValue = first.end,
endValue > Int64(fullBOMLength),
let preface = String(
data: data.subdata(in: fullBOMLength..<min(Int(endValue), data.count)),
encoding: decoded.encoding
) {
book.intro = preface.utf16.count > 600 ? String(preface.prefix(600)) : preface
}
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
public func loadChapters(book: Book) throws -> ChapterListReport {
let decoded = try read(book)
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
let selectedRule = rule(storedIn: book.tocUrl) ?? TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
rule, title, index, previous, length in
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
}
var chapters = chapters(
data: decoded.data,
encoding: decoded.encoding,
bomLength: decoded.bomLength,
book: book,
fallbackTitle: book.name,
selectedRule: selectedRule
)
_ = applyWordCounts(&chapters, data: decoded.data, encoding: decoded.encoding)
return .init(chapters: chapters)
}
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
let start = max(Int(chapter.start ?? 0), 0)
let end = min(Int(chapter.end ?? Int64(fileSize)), fileSize)
guard start <= end else {
return .init(title: chapter.title, diagnostics: [
.init(severity: .error, code: "local.range", message: "本地章节范围无效", rule: chapter.url)
])
}
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(start))
let contentData = try handle.read(upToCount: end - start) ?? Data()
let encoding: String.Encoding
if let charset = book.charset, let stored = self.encoding(named: charset) {
encoding = stored
} else {
try handle.seek(toOffset: 0)
let probe = try handle.read(upToCount: 512_000) ?? Data()
encoding = try decode(probe).encoding
}
let content = (String(data: contentData, encoding: encoding) ?? "")
.replacingOccurrences(of: #"^[\n\s]+"#, with: "  ", options: .regularExpression)
return .init(title: chapter.title, content: content, pagesVisited: 1)
}
private func read(_ book: Book) throws -> (charset: String, encoding: String.Encoding, bomLength: Int, data: Data) {
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let data = try Data(contentsOf: url, options: .mappedIfSafe)
if let charset = book.charset, let encoding = encoding(named: charset) {
return (charset, encoding, bomLength(in: data, encoding: encoding), data)
}
let decoded = try decode(Data(data.prefix(512_000)))
return (decoded.charset, decoded.encoding, bomLength(in: data, encoding: decoded.encoding), data)
}
private func decode(_ data: Data, preferredCharset: String? = nil) throws -> (text: String, charset: String, encoding: String.Encoding, bomLength: Int) {
if let preferredCharset, let encoding = encoding(named: preferredCharset),
let value = String(data: data, encoding: encoding) {
return (stripBOM(value), preferredCharset, encoding, bomLength(in: data, encoding: encoding))
}
if data.starts(with: [0xFF, 0xFE]),
let value = String(data: data, encoding: .utf16LittleEndian) {
return (stripBOM(value), "UTF-16LE", .utf16LittleEndian, 2)
}
if data.starts(with: [0xFE, 0xFF]),
let value = String(data: data, encoding: .utf16BigEndian) {
return (stripBOM(value), "UTF-16BE", .utf16BigEndian, 2)
}
let candidates: [(String, String.Encoding)] = [
("UTF-8", .utf8),
("UTF-16", .utf16),
("GB18030", .gb18030),
("Big5", .big5)
]
for (name, encoding) in candidates {
if let value = String(data: data, encoding: encoding), isPlausible(value) {
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
}
// A bounded probe can end halfway through a UTF-8 scalar. Keep
// UTF-8 as the selected charset instead of misclassifying the
// truncated probe as UTF-16 and producing unreadable chapters.
if encoding == .utf8 {
let value = String(decoding: data, as: UTF8.self)
let replacementCount = value.reduce(into: 0) { count, character in
if character == "\u{FFFD}" { count += 1 }
}
if replacementCount <= 2, isPlausible(value) {
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
}
}
}
throw LocalBookError.unreadableEncoding
}
private func chapters(
data: Data,
encoding: String.Encoding,
bomLength: Int,
book: Book,
fallbackTitle: String,
selectedRule: TxtTocRule?
) -> [BookChapter] {
let pattern = selectedRule?.rule ?? fallbackTocPattern
let matches = headingMatches(
data: data,
encoding: encoding,
bomLength: bomLength,
pattern: pattern,
usesFirstCapture: selectedRule == nil
)
guard !matches.isEmpty else {
if data.count - bomLength > 10 * 1_024 {
return chaptersWithoutTOC(data, encoding: encoding, bomLength: bomLength, book: book)
}
var chapter = BookChapter(url: "local://chapter/0", title: fallbackTitle, bookUrl: book.bookUrl, index: 0)
chapter.start = Int64(bomLength)
chapter.end = Int64(data.count)
chapter.baseUrl = book.bookUrl
return finalized([chapter], book: book)
}
var previousTitle: String?
var chapters: [BookChapter] = []
let volumeState = TxtVolumeState()
if let first = matches.first,
first.start > Int64(bomLength),
let preface = String(
data: data.subdata(in: bomLength..<min(Int(first.start), data.count)),
encoding: encoding
),
!preface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
var insertedVolumes: [String] = []
let title = transformedTitle(
"前言",
script: selectedRule?.replacement,
index: chapters.count,
book: book,
volumeState: volumeState,
onVolume: { insertedVolumes.append($0) }
)
for volumeTitle in insertedVolumes {
var volume = BookChapter(url: "", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
volume.start = Int64(bomLength)
volume.end = Int64(bomLength)
volume.baseUrl = book.bookUrl
volume.isVolume = true
chapters.append(volume)
}
if !title.isEmpty {
var chapter = BookChapter(url: "", title: title, bookUrl: book.bookUrl, index: chapters.count)
chapter.start = Int64(bomLength)
chapter.end = first.start
chapter.baseUrl = book.bookUrl
chapters.append(chapter)
}
}
for (matchIndex, match) in matches.enumerated() {
splitLastLongChapterIfNeeded(
&chapters,
data: data,
encoding: encoding,
book: book,
volumeState: volumeState,
enabled: selectedRule != nil
)
var insertedVolumes: [String] = []
let title = transformedTitle(
match.title.isEmpty ? "第 \(matchIndex + 1) 章" : match.title,
script: selectedRule?.replacement,
index: chapters.count,
previousTitle: previousTitle,
previousLength: match.previousLength,
book: book,
volumeState: volumeState,
onVolume: { insertedVolumes.append($0) }
)
previousTitle = title
let start = match.end
let end = matchIndex + 1 < matches.count ? matches[matchIndex + 1].start : Int64(data.count)
for volumeTitle in insertedVolumes {
let offset = chapters.last?.end ?? 0
var volume = BookChapter(url: "local://chapter/\(chapters.count)", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
volume.start = offset
volume.end = offset
volume.baseUrl = book.bookUrl
volume.isVolume = true
chapters.append(volume)
}
var chapter = BookChapter(url: "local://chapter/\(chapters.count)", title: title, bookUrl: book.bookUrl, index: chapters.count)
chapter.start = start
chapter.end = max(end, start)
chapter.baseUrl = book.bookUrl
chapters.append(chapter)
}
splitLastLongChapterIfNeeded(
&chapters,
data: data,
encoding: encoding,
book: book,
volumeState: volumeState,
enabled: selectedRule != nil
)
return finalized(chapters, book: book)
}
private var fallbackTocPattern: String {
#"(?mi)^[\t ]*((?:第[0-9零〇一二三四五六七八九十百千万两]+[章节卷回部篇集][^\r\n]{0,50})|(?:chapter\s+\d+[^\r\n]{0,50}))[\t ]*$"#
}
private func splitLastLongChapterIfNeeded(
_ chapters: inout [BookChapter],
data: Data,
encoding: String.Encoding,
book: Book,
volumeState: TxtVolumeState,
enabled: Bool
) {
guard enabled, book.readConfig?.splitLongChapter ?? true,
var chapter = chapters.last,
!chapter.isVolume,
let startValue = chapter.start,
let endValue = chapter.end,
endValue - startValue > 102_400 else { return }
let start = max(Int(startValue), 0)
let end = min(max(Int(endValue), start), data.count)
chapters.removeLast()
chapter.end = chapter.start
chapter.isVolume = true
chapter.tag = nil
chapters.append(chapter)
volumeState.lastTitle = chapter.title
chapters.append(contentsOf: splitByteRange(
data,
start: start,
end: end,
encoding: encoding,
book: book,
title: { "\(chapter.title)(\($0 + 1))" }
))
}
private func splitByteRange(
_ data: Data,
start initialStart: Int,
end rangeEnd: Int,
encoding: String.Encoding,
book: Book,
title: (Int) -> String
) -> [BookChapter] {
splitHeadinglessRange(data, start: initialStart, end: rangeEnd, encoding: encoding, book: book) { _, _, index in title(index) }
}
private func headingMatches(
data: Data,
encoding: String.Encoding,
bomLength: Int,
pattern: String,
usesFirstCapture: Bool
) -> [TxtHeadingMatch] {
guard let expression = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { return [] }
if data.count <= 2 * 1_024 * 1_024,
bomLength <= data.count,
let text = String(data: data.subdata(in: bomLength..<data.count), encoding: encoding) {
let source = text as NSString
let offsets = byteOffsetMap(text, encoding: encoding, initialOffset: bomLength)
return expression.matches(in: text, range: NSRange(location: 0, length: source.length)).map { match in
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
let start = min(match.range.location, offsets.count - 1)
let end = min(NSMaxRange(match.range), offsets.count - 1)
return .init(title: title, start: offsets[start], end: offsets[end], previousLength: match.range.location)
}
}
let blockSize = 512_000
var position = bomLength
var output: [TxtHeadingMatch] = []
while position < data.count {
let limit = min(position + blockSize, data.count)
var end = limit
if limit < data.count,
let newline = newlineIndex(in: data, range: position..<limit, encoding: encoding, fromEnd: true),
newline > position {
end = newline
}
if end <= position { end = limit }
let blockData = data.subdata(in: position..<end)
guard let block = String(data: blockData, encoding: encoding) else {
position = end
continue
}
let source = block as NSString
let offsets = byteOffsetMap(block, encoding: encoding, initialOffset: position)
var previousEnd = 0
for match in expression.matches(in: block, range: NSRange(location: 0, length: source.length)) {
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
let startIndex = min(match.range.location, offsets.count - 1)
let endIndex = min(NSMaxRange(match.range), offsets.count - 1)
output.append(.init(
title: title,
start: offsets[startIndex],
end: offsets[endIndex],
previousLength: max(match.range.location - previousEnd, 0)
))
previousEnd = NSMaxRange(match.range)
}
position = end
}
return output
}
private func chaptersWithoutTOC(_ data: Data, encoding: String.Encoding, bomLength: Int, book: Book) -> [BookChapter] {
let chapters = splitHeadinglessRange(data, start: bomLength, end: data.count, encoding: encoding, book: book) {
block, chapter, _ in "第\(block)章(\(chapter))"
}
return finalized(chapters, book: book)
}
private func splitHeadinglessRange(
_ data: Data,
start rangeStart: Int,
end rangeEnd: Int,
encoding: String.Encoding,
book: Book,
title: (Int, Int, Int) -> String
) -> [BookChapter] {
let bufferSize = 512_000
let maximumLength = 10 * 1_024
var cursor = rangeStart
var block = 0
var output: [BookChapter] = []
var finalChapterNumber = 0
while cursor < rangeEnd {
block += 1
let blockEnd = min(cursor + bufferSize, rangeEnd)
var chapterInBlock = 0
while blockEnd - cursor > maximumLength {
chapterInBlock += 1
let searchStart = cursor + maximumLength
let newline = newlineIndex(
in: data,
range: searchStart..<blockEnd,
encoding: encoding,
fromEnd: false
)
let end = newline ?? blockEnd
var chapter = BookChapter(
url: "",
title: title(block, chapterInBlock, output.count),
bookUrl: book.bookUrl,
index: output.count
)
chapter.start = Int64(cursor)
chapter.end = Int64(end)
chapter.baseUrl = book.bookUrl
output.append(chapter)
cursor = end
}
finalChapterNumber = chapterInBlock + 1
if blockEnd == rangeEnd { break }
}
if cursor < rangeEnd {
if rangeEnd - cursor <= 100, !output.isEmpty {
output[output.count - 1].end = Int64(rangeEnd)
} else {
var chapter = BookChapter(
url: "",
title: title(block, finalChapterNumber, output.count),
bookUrl: book.bookUrl,
index: output.count
)
chapter.start = Int64(cursor)
chapter.end = Int64(rangeEnd)
chapter.baseUrl = book.bookUrl
output.append(chapter)
}
}
return output
}
@discardableResult
private func applyWordCounts(
_ chapters: inout [BookChapter],
data: Data,
encoding: String.Encoding
) -> Int {
var total = 0
for index in chapters.indices where !chapters[index].isVolume {
let start = max(Int(chapters[index].start ?? 0), 0)
let end = min(max(Int(chapters[index].end ?? Int64(start)), start), data.count)
guard start < end else {
chapters[index].wordCount = ""
continue
}
let text = String(data: data.subdata(in: start..<end), encoding: encoding) ?? ""
let count = text.utf16.count
chapters[index].wordCount = wordCountFormat(count)
total += count
}
return total
}
private func wordCountFormat(_ words: Int) -> String {
guard words > 0 else { return "" }
guard words > 10_000 else { return "\(words)字" }
let value = Double(words) / 10_000
let rounded = (value * 10).rounded() / 10
if rounded.rounded() == rounded { return "\(Int(rounded))万字" }
return String(format: "%.1f万字", rounded)
}
private func finalized(_ chapters: [BookChapter], book: Book) -> [BookChapter] {
chapters.enumerated().map { index, value in
var chapter = value
chapter.index = index
chapter.bookUrl = book.bookUrl
let seed = book.originName + String(index) + chapter.title
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
chapter.url = String(digest.dropFirst(8).prefix(16))
return chapter
}
}
private func rule(storedIn value: String) -> TxtTocRule? {
guard !value.isEmpty else { return nil }
let parts = value.components(separatedBy: TxtTocRuleSelector.separator)
return .init(name: "本书目录规则", rule: parts[0], replacement: parts.dropFirst().first ?? "")
}
private func effectiveTocRules() -> [TxtTocRule] {
tocRules
}
private func transformedTitle(
_ value: String,
script: String?,
index: Int,
previousTitle: String? = nil,
previousLength: Int = -1,
book: Book,
volumeState: TxtVolumeState = .init(),
onVolume: ((String) -> Void)? = nil
) -> String {
guard let script = script?.trimmingCharacters(in: .whitespacesAndNewlines), !script.isEmpty else { return value }
let previous: String
if let previousTitle,
let data = try? JSONSerialization.data(withJSONObject: [previousTitle]),
let encoded = String(data: data, encoding: .utf8) {
previous = String(encoded.dropFirst().dropLast())
} else {
previous = "null"
}
let volumeLiteral = (try? JSONSerialization.data(withJSONObject: [volumeState.lastTitle]))
.flatMap { String(data: $0, encoding: .utf8) }
.map { String($0.dropFirst().dropLast()) } ?? "\"\""
let wrapped = """
var index = \(index + 1); var prevTitle = \(previous); var prevLength = \(previousLength);
var lastVolumeTitle = \(volumeLiteral);
\(script)
"""
guard let evaluated = try? JavaScriptRuleEvaluator().evaluate(
script: wrapped,
result: .string(value),
executionContext: .init(page: index + 1, book: book)
) else { return value }
let title = evaluated.value.stringValue
let volumes = evaluated.variables["__legadoVolumes"]
.flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } ?? []
for volume in volumes {
volumeState.lastTitle = volume
onVolume?(volume)
}
return title
}
private func encoding(named name: String) -> String.Encoding? {
switch name.uppercased() {
case "UTF-8", "UTF8": .utf8
case "UTF-16", "UTF16": .utf16
case "UTF-16LE", "UTF16LE": .utf16LittleEndian
case "UTF-16BE", "UTF16BE": .utf16BigEndian
case "GB18030", "GBK", "GB2312": .gb18030
case "BIG5": .big5
default: nil
}
}
private func bomLength(in data: Data, encoding: String.Encoding) -> Int {
if data.starts(with: [0xEF, 0xBB, 0xBF]) { return 3 }
if isUTF16(encoding),
data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) { return 2 }
return 0
}
private func isUTF16(_ encoding: String.Encoding) -> Bool {
encoding == .utf16 || encoding == .utf16LittleEndian || encoding == .utf16BigEndian
}
private func newlineIndex(
in data: Data,
range: Range<Int>,
encoding: String.Encoding,
fromEnd: Bool
) -> Int? {
guard !range.isEmpty else { return nil }
let bytePair: (UInt8, UInt8)?
if encoding == .utf16LittleEndian || (encoding == .utf16 && data.starts(with: [0xFF, 0xFE])) {
bytePair = (0x0A, 0x00)
} else if encoding == .utf16BigEndian || (encoding == .utf16 && data.starts(with: [0xFE, 0xFF])) {
bytePair = (0x00, 0x0A)
} else {
bytePair = nil
}
guard let bytePair else {
return fromEnd ? data[range].lastIndex(of: 0x0A) : data[range].firstIndex(of: 0x0A)
}
var index = range.lowerBound + (range.lowerBound.isMultiple(of: 2) ? 0 : 1)
guard index + 1 < range.upperBound else { return nil }
if fromEnd {
index = range.upperBound - 2
if !index.isMultiple(of: 2) { index -= 1 }
while index >= range.lowerBound {
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
index -= 2
}
} else {
while index + 1 < range.upperBound {
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
index += 2
}
}
return nil
}
private func byteOffsetMap(_ content: String, encoding: String.Encoding, initialOffset: Int) -> [Int64] {
var offsets = [Int64](repeating: Int64(initialOffset), count: content.utf16.count + 1)
var utf16Index = 0
var byteOffset = initialOffset
for scalar in content.unicodeScalars {
let scalarText = String(scalar)
let unitCount = scalarText.utf16.count
let byteCount = isUTF16(encoding)
? unitCount * 2
: (scalarText.data(using: encoding, allowLossyConversion: true)?.count ?? 0)
if unitCount > 1 {
for step in 1..<unitCount { offsets[utf16Index + step] = Int64(byteOffset) }
}
utf16Index += unitCount
byteOffset += byteCount
offsets[utf16Index] = Int64(byteOffset)
}
return offsets
}
private func textProbe(_ data: Data, encoding: String.Encoding) -> String {
let prefix = Data(data.prefix(512_000))
for removed in 0...8 where prefix.count >= removed {
let candidate = prefix.dropLast(removed)
if let value = String(data: Data(candidate), encoding: encoding) { return stripBOM(value) }
}
return ""
}
private func stripBOM(_ value: String) -> String {
value.hasPrefix("\u{feff}") ? String(value.dropFirst()) : value
}
private func isPlausible(_ value: String) -> Bool {
guard !value.isEmpty else { return true }
let controls = value.unicodeScalars.filter { $0.value < 32 && !CharacterSet.whitespacesAndNewlines.contains($0) }.count
return controls * 100 < max(value.unicodeScalars.count, 1)
}
}
private extension String.Encoding {
static let gb18030 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)
))
static let big5 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.big5.rawValue)
))
}