205 lines
7.8 KiB
Swift
205 lines
7.8 KiB
Swift
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
|
|
}
|
|
} |