Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import UIKit
|
||||
|
||||
struct EPUBTOCDisplayItem {
|
||||
var title: String
|
||||
var href: String
|
||||
var depth: Int
|
||||
var pageNumber: Int?
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextPage {
|
||||
var absolutePageIndex: Int
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var chapterTitle: String
|
||||
var pageIndexInChapter: Int
|
||||
var totalPagesInChapter: Int
|
||||
var content: NSAttributedString
|
||||
var contentRange: NSRange
|
||||
var pageStartOffset: Int
|
||||
var pageEndOffset: Int
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextChapter {
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var title: String
|
||||
var attributedContent: NSAttributedString
|
||||
var fragmentOffsets: [String: Int]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
|
||||
func page(at pageNumber: Int) -> LegacyRDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class LegacyRDEPUBTextBookBuilder {
|
||||
static func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter] = []
|
||||
var flatPages: [LegacyRDEPUBTextPage] = []
|
||||
|
||||
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 markedHTML = injectFragmentMarkers(into: normalizedHTML)
|
||||
guard let attributedContent = attributedHTML(
|
||||
html: markedHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let mutableContent = NSMutableAttributedString(attributedString: attributedContent)
|
||||
let fragmentOffsets = extractFragmentOffsets(from: mutableContent)
|
||||
normalizeReadingAttributes(in: mutableContent, font: font, lineSpacing: lineSpacing)
|
||||
|
||||
let plainText = mutableContent.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldSkipChapter(item: item, text: plainText) {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let pageRanges = mutableContent.length > 0 ? mutableContent.pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && mutableContent.length > 0
|
||||
? [NSRange(location: 0, length: mutableContent.length)]
|
||||
: pageRanges
|
||||
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
LegacyRDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: mutableContent.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = LegacyRDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: mutableContent.copy() as! NSAttributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
pages: pages
|
||||
)
|
||||
chapters.append(chapter)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return LegacyRDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private static 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 static func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private static 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 static 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
|
||||
}
|
||||
|
||||
private 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"
|
||||
)
|
||||
}
|
||||
|
||||
private static func attributedHTML(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> NSAttributedString? {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue,
|
||||
NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption"): baseURL as Any
|
||||
]
|
||||
|
||||
if let attributed = try? NSMutableAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributed
|
||||
}
|
||||
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: lineSpacing)
|
||||
]
|
||||
return NSAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
|
||||
private static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, font: UIFont, lineSpacing: CGFloat) {
|
||||
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: lineSpacing)
|
||||
paragraph.lineSpacing = lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: font)
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user