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)] = [ (#"分页符"#, ""), (#"\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 } }