Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
#if canImport(DTCoreText)
|
||||
let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)
|
||||
guard let data = markedHTML.data(using: .utf8) else {
|
||||
throw RDEPUBTextRenderingError.htmlEncodingFailed
|
||||
}
|
||||
|
||||
guard let rendered = makeAttributedString(from: data, baseURL: baseURL, style: style) else {
|
||||
return fallbackRenderedContent(for: markedHTML, style: style)
|
||||
}
|
||||
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style)
|
||||
return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets)
|
||||
#else
|
||||
let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)
|
||||
return fallbackRenderedContent(for: markedHTML, style: style)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func fallbackRenderedContent(for html: String, style: RDEPUBTextRenderStyle) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: html, style: style)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style)
|
||||
return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func makeAttributedString(from data: Data, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
options: dtOptions(baseURL: baseURL, style: style),
|
||||
documentAttributes: nil
|
||||
)
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
private func dtOptions(baseURL: URL?, style: RDEPUBTextRenderStyle) -> [AnyHashable: Any] {
|
||||
var options: [AnyHashable: Any] = [
|
||||
NSTextSizeMultiplierDocumentOption: 1.0,
|
||||
DTDefaultFontFamily: style.font.familyName,
|
||||
DTDefaultFontName: style.font.fontName,
|
||||
DTDefaultFontSize: style.font.pointSize,
|
||||
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
||||
DTUseiOS6Attributes: true
|
||||
]
|
||||
|
||||
if let baseURL {
|
||||
options[NSBaseURLDocumentOption] = baseURL
|
||||
}
|
||||
if let textColor = style.textColor {
|
||||
options[DTDefaultTextColor] = textColor
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
public var absolutePageIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var chapterTitle: String
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
public var content: NSAttributedString
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
public var pageEndOffset: Int
|
||||
}
|
||||
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var attributedContent: NSAttributedString
|
||||
public var fragmentOffsets: [String: Int]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
public struct RDEPUBTextBook: Equatable {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
self.pages = pages
|
||||
}
|
||||
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
public init(renderer: RDEPUBTextRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let normalizedHTML = normalizeHTML(rawHTML)
|
||||
let rendered = try renderer.renderChapter(
|
||||
html: normalizedHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style
|
||||
)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldSkipChapter(item: item, text: plainText) {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && content.length > 0
|
||||
? [NSRange(location: 0, length: content.length)]
|
||||
: pageRanges
|
||||
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedHTML
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
extension NSAttributedString {
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
var ranges: [NSRange] = []
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(self)
|
||||
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
|
||||
var visibleRange = CFRangeMake(0, 0)
|
||||
var location = 0
|
||||
|
||||
while visibleRange.location + visibleRange.length < length {
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
|
||||
visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
ranges.append(NSRange(location: location, length: visibleRange.length))
|
||||
location += visibleRange.length
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
var ss_cssString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
public var font: UIFont
|
||||
public var lineSpacing: CGFloat
|
||||
public var textColor: UIColor?
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
self.font = font
|
||||
self.lineSpacing = lineSpacing
|
||||
self.textColor = textColor
|
||||
self.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
public var attributedString: NSAttributedString
|
||||
public var fragmentOffsets: [String: Int]
|
||||
|
||||
public init(attributedString: NSAttributedString, fragmentOffsets: [String: Int]) {
|
||||
self.attributedString = attributedString
|
||||
self.fragmentOffsets = fragmentOffsets
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .htmlEncodingFailed:
|
||||
return "HTML 编码失败"
|
||||
case .htmlImportFailed:
|
||||
return "HTML 富文本导入失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import UIKit
|
||||
|
||||
enum RDEPUBTextRendererSupport {
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var fragmentOffsets: [String: Int] = [:]
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var offsetAdjustment = 0
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let fullMatch = mutableString.substring(with: match.range) as NSString
|
||||
let fragmentID = fullMatch
|
||||
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
|
||||
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
|
||||
|
||||
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
|
||||
fragmentOffsets[fragmentID] = adjustedLocation
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
offsetAdjustment -= match.range.length
|
||||
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: style.font)
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
if let textColor = style.textColor {
|
||||
updatedAttributes[.foregroundColor] = textColor
|
||||
}
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
|
||||
.foregroundColor: style.textColor ?? UIColor.black
|
||||
]
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
guard let sourceFont else {
|
||||
return baseFont
|
||||
}
|
||||
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
|
||||
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
|
||||
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
|
||||
}
|
||||
return baseFont
|
||||
}
|
||||
|
||||
private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) {
|
||||
self.textBook = textBook
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for chapter in textBook.chapters {
|
||||
let source = chapter.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import UIKit
|
||||
|
||||
/// 将纯文本文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线
|
||||
public final class RDPlainTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
public init(renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer()) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = rd_decodeTextFile(url: textFileURL)
|
||||
let chapterSpecs = splitChapters(from: rawText)
|
||||
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
|
||||
for (index, spec) in chapterSpecs.enumerated() {
|
||||
let html = wrapTextAsHTML(spec.content)
|
||||
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && content.length > 0
|
||||
? [NSRange(location: 0, length: content.length)]
|
||||
: pageRanges
|
||||
|
||||
let href = "chapter_\(index).xhtml"
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: index,
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
chapterTitle: spec.title ?? "第 \(index + 1) 章",
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: index,
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
fragmentOffsets: [:],
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
// MARK: - 章节拆分
|
||||
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 按中文章节正则拆分文本。无匹配时整体作为一章。
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
||||
guard !matches.isEmpty else {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
|
||||
var specs: [ChapterSpec] = []
|
||||
for (i, match) in matches.enumerated() {
|
||||
let titleRange = match.range(at: 1)
|
||||
let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let contentStart = titleRange.location + titleRange.length
|
||||
let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length
|
||||
let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
|
||||
let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !content.isEmpty {
|
||||
specs.append(ChapterSpec(title: title, content: content))
|
||||
}
|
||||
}
|
||||
|
||||
// 拆分结果为空(所有章节内容为空),整体作为一章
|
||||
if specs.isEmpty {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// MARK: - HTML 包装
|
||||
|
||||
/// 将纯文本包装为 HTML 段落
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
return "<html><body>\(body)</body></html>"
|
||||
}
|
||||
|
||||
// MARK: - 文本解码
|
||||
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user