ReadViewSDK/Sources/RDReaderView/LegacyRDReaderController/RDEPUBTextPaging.swift
2026-05-21 19:40:51 +08:00

305 lines
12 KiB
Swift

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
}
}