diff --git a/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift b/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift index f695d41..03e0e34 100644 --- a/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift +++ b/Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift @@ -30,16 +30,27 @@ extension RDEPUBParser { return true } - let interactivePattern = #"<(script|iframe|video|audio|canvas|svg|form)\b|\bon(load|click|touchstart|touchend|mouseover)=|hype_generated_script|swiper|webview"# for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) { guard let html = htmlString(forRelativePath: item.href) else { continue } - if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil { + if containsInteractiveMarkup(html) { return true } } return false } + + /// 检测 HTML 是否包含需要走 Web 交互渲染的特征。 + /// 注意:普通小说 EPUB 常用静态 SVG 包封面图,不能仅因 `` 就误判为 interactive。 + private func containsInteractiveMarkup(_ html: String) -> Bool { + let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"# + if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + + let dynamicSVGPattern = #"]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"# + return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil + } } diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift index 7b36b94..e1a96b9 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift @@ -94,173 +94,20 @@ public final class RDEPUBTextBookBuilder { let cachedPagination = cacheCoordinator.load(key: cacheKey) 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 request = RDEPUBTextTypesetterPipeline().makeRequest( - from: RDEPUBTypesettingInput( - href: item.href, - title: chapterTitle, - rawHTML: rawHTML, - baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), - style: style, - resourceResolver: publication.resourceResolver, - contentLanguageCode: publication.metadata.language, - pageSize: pageSize, - layoutConfig: layoutConfig - ) - ).request - - // 渲染 HTML → NSAttributedString - let renderStart = CFAbsoluteTimeGetCurrent() - let rendered = try renderPipeline.render(request) - let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart - - lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics) - - let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) - if item.href.lowercased().contains("cover") { - print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))") - } - // 跳过空白的封面/扉页章节 - if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) { - if item.href.lowercased().contains("cover") { - print("[EPUB][Cover] skipped href=\(item.href)") - } - continue - } - - let chapterIndex = chapters.count - let content = NSMutableAttributedString(attributedString: rendered.attributedString) - - // 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎 - let paginateStart = CFAbsoluteTimeGetCurrent() - let layoutFrames: [RDEPUBTextLayoutFrame] - let isCacheHit: Bool - if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) { - // 纯图片封面:整个内容作为一页 - layoutFrames = [ - RDEPUBTextLayoutFrame( - contentRange: NSRange(location: 0, length: content.length), - breakReason: .chapterEnd, - blockRange: nil, - attachmentRanges: attachmentRanges(in: content), - attachmentKinds: [], - blockKinds: [], - semanticHints: [], - attachmentPlacements: [], - trailingFragmentID: nil, - diagnostics: [ - "page break: chapterEnd", - "cover fallback: single attachment page", - "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" - ] - ) - ] - isCacheHit = false - } else if let cached = cachedPagination?[item.href] { - // 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页 - layoutFrames = cached.pageRanges.enumerated().map { idx, range in - let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit - return RDEPUBTextLayoutFrame( - contentRange: range, - breakReason: breakReason, - blockRange: nil, - attachmentRanges: [], - attachmentKinds: [], - blockKinds: [], - semanticHints: cached.semanticHints, - attachmentPlacements: [], - trailingFragmentID: nil, - diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"] - ) - } - isCacheHit = true - } else { - // 缓存未命中:调用 CoreText 分页引擎 - layoutFrames = content.length > 0 - ? paginationPipeline.frames( - for: content, - pageSize: pageSize, - config: layoutConfig, - fragmentOffsets: rendered.fragmentOffsets - ) - : [] - isCacheHit = false - } - let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart - - // 尾页规范化:丢弃纯空白尾页、合并过短尾页 - let normalizedFrames = tailNormalizer.normalize( - layoutFrames, - content: content, - href: item.href - ) - let effectiveFrames = normalizedFrames.isEmpty && content.length > 0 - ? [ - RDEPUBTextLayoutFrame( - contentRange: NSRange(location: 0, length: content.length), - breakReason: .chapterEnd, - blockRange: nil, - attachmentRanges: [], - attachmentKinds: [], - blockKinds: [], - semanticHints: [], - attachmentPlacements: [], - trailingFragmentID: nil, - diagnostics: [ - "page break: chapterEnd", - "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" - ] - ) - ] - : normalizedFrames - if item.href.lowercased().contains("cover") { - print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") - } - - // 记录性能采样 - sampler.record(RDEPUBTextPerformanceSample( - chapterHref: item.href, - renderDuration: renderDuration, - paginateDuration: paginateDuration, - pageCount: effectiveFrames.count, - attributedStringLength: content.length, - cacheHit: isCacheHit - )) - if isCacheHit { - lastBuildCacheStats.hits += 1 - } else { - lastBuildCacheStats.misses += 1 - } - - // 构建页面和章节模型 - let chapterAttributedContent = content.copy() as! NSAttributedString - let pages = effectiveFrames.enumerated().map { localPageIndex, frame in - let range = frame.contentRange - return RDEPUBTextPage( - absolutePageIndex: flatPages.count + localPageIndex, - chapterIndex: chapterIndex, - spineIndex: spineIndex, - href: item.href, - chapterTitle: chapterTitle, - pageIndexInChapter: localPageIndex, - totalPagesInChapter: effectiveFrames.count, - chapterContent: chapterAttributedContent, - content: content.attributedSubstring(from: range), - contentRange: range, - pageStartOffset: range.location, - pageEndOffset: range.location + max(range.length - 1, 0), - metadata: frame.metadata - ) - } + guard let result = try buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: style, + chapterIndex: chapters.count, + absolutePageStartIndex: flatPages.count, + cachedPagination: cachedPagination + ) else { continue } if isPaginationDebugEnabled, item.href.contains("Chapter_3.xhtml") { + let pages = result.chapter.pages print("[PaginationDebug] href=\(item.href) pages=\(pages.count)") for page in pages { let preview = debugPreview(for: page.content, limit: 36) @@ -271,26 +118,16 @@ public final class RDEPUBTextBookBuilder { } } - chapters.append( - RDEPUBTextChapter( - chapterIndex: chapterIndex, - spineIndex: spineIndex, - href: item.href, - title: chapterTitle, - attributedContent: chapterAttributedContent, - fragmentOffsets: rendered.fragmentOffsets, - pageBreakReasons: pages.map(\.metadata.breakReason), - pages: pages - ) - ) - lastBuildPaginationDiagnostics.append( - diagnosticsReporter.chapterDiagnostic( - href: item.href, - title: chapterTitle, - pages: pages - ) - ) - flatPages.append(contentsOf: pages) + chapters.append(result.chapter) + flatPages.append(contentsOf: result.chapter.pages) + lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics) + lastBuildPaginationDiagnostics.append(result.paginationDiagnostic) + sampler.record(result.performanceSample) + if result.cacheHit { + lastBuildCacheStats.hits += 1 + } else { + lastBuildCacheStats.misses += 1 + } } let book = RDEPUBTextBook(chapters: chapters, pages: flatPages) @@ -306,6 +143,220 @@ public final class RDEPUBTextBookBuilder { return book } + /// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。 + public func buildChapter( + parser: RDEPUBParser, + publication: RDEPUBPublication, + spineIndex: Int, + pageSize: CGSize, + style: RDEPUBTextRenderStyle, + chapterIndex: Int = 0, + absolutePageStartIndex: Int = 0 + ) throws -> RDEPUBTextChapterBuildResult? { + let bookID = publication.metadata.identifier ?? publication.metadata.title + let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style) + let cachedPagination = cacheCoordinator.load(key: cacheKey) + return try buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: style, + chapterIndex: chapterIndex, + absolutePageStartIndex: absolutePageStartIndex, + cachedPagination: cachedPagination + ) + } + + private func buildChapter( + parser: RDEPUBParser, + publication: RDEPUBPublication, + spineIndex: Int, + pageSize: CGSize, + style: RDEPUBTextRenderStyle, + chapterIndex: Int, + absolutePageStartIndex: Int, + cachedPagination: [String: RDEPUBTextChapterPaginationCache]? + ) throws -> RDEPUBTextChapterBuildResult? { + guard publication.spine.indices.contains(spineIndex) else { return nil } + let item = publication.spine[spineIndex] + guard item.linear, + item.mediaType.contains("html") || item.mediaType.contains("xhtml"), + let rawHTML = parser.htmlString(forRelativePath: item.href) else { + return nil + } + + let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents) + let request = RDEPUBTextTypesetterPipeline().makeRequest( + from: RDEPUBTypesettingInput( + href: item.href, + title: chapterTitle, + rawHTML: rawHTML, + baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), + style: style, + resourceResolver: publication.resourceResolver, + contentLanguageCode: publication.metadata.language, + pageSize: pageSize, + layoutConfig: layoutConfig + ) + ).request + + let renderStart = CFAbsoluteTimeGetCurrent() + let rendered = try renderPipeline.render(request) + let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart + + let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) + if item.href.lowercased().contains("cover") { + print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))") + } + if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) { + if item.href.lowercased().contains("cover") { + print("[EPUB][Cover] skipped href=\(item.href)") + } + return nil + } + + let content = NSMutableAttributedString(attributedString: rendered.attributedString) + let paginateStart = CFAbsoluteTimeGetCurrent() + let layoutFrames: [RDEPUBTextLayoutFrame] + let isCacheHit: Bool + + if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) { + layoutFrames = [ + RDEPUBTextLayoutFrame( + contentRange: NSRange(location: 0, length: content.length), + breakReason: .chapterEnd, + blockRange: nil, + attachmentRanges: attachmentRanges(in: content), + attachmentKinds: [], + blockKinds: [], + semanticHints: [], + attachmentPlacements: [], + trailingFragmentID: nil, + diagnostics: [ + "page break: chapterEnd", + "cover fallback: single attachment page", + "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" + ] + ) + ] + isCacheHit = false + } else if let cached = cachedPagination?[item.href] { + layoutFrames = cached.pageRanges.enumerated().map { idx, range in + let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit + return RDEPUBTextLayoutFrame( + contentRange: range, + breakReason: breakReason, + blockRange: nil, + attachmentRanges: [], + attachmentKinds: [], + blockKinds: [], + semanticHints: cached.semanticHints, + attachmentPlacements: [], + trailingFragmentID: nil, + diagnostics: [ + "page break: \(breakReason.rawValue)", + "page range: \(NSStringFromRange(range))", + "source: cache hit" + ] + ) + } + isCacheHit = true + } else { + layoutFrames = content.length > 0 + ? paginationPipeline.frames( + for: content, + pageSize: pageSize, + config: layoutConfig, + fragmentOffsets: rendered.fragmentOffsets + ) + : [] + isCacheHit = false + } + + let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart + let normalizedFrames = tailNormalizer.normalize( + layoutFrames, + content: content, + href: item.href + ) + let effectiveFrames = normalizedFrames.isEmpty && content.length > 0 + ? [ + RDEPUBTextLayoutFrame( + contentRange: NSRange(location: 0, length: content.length), + breakReason: .chapterEnd, + blockRange: nil, + attachmentRanges: [], + attachmentKinds: [], + blockKinds: [], + semanticHints: [], + attachmentPlacements: [], + trailingFragmentID: nil, + diagnostics: [ + "page break: chapterEnd", + "page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))" + ] + ) + ] + : normalizedFrames + + if item.href.lowercased().contains("cover") { + print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")") + } + + let chapterAttributedContent = content.copy() as! NSAttributedString + let pages = effectiveFrames.enumerated().map { localPageIndex, frame in + let range = frame.contentRange + return RDEPUBTextPage( + absolutePageIndex: absolutePageStartIndex + localPageIndex, + chapterIndex: chapterIndex, + spineIndex: spineIndex, + href: item.href, + chapterTitle: chapterTitle, + pageIndexInChapter: localPageIndex, + totalPagesInChapter: effectiveFrames.count, + chapterContent: chapterAttributedContent, + content: content.attributedSubstring(from: range), + contentRange: range, + pageStartOffset: range.location, + pageEndOffset: range.location + max(range.length - 1, 0), + metadata: frame.metadata + ) + } + + let chapter = RDEPUBTextChapter( + chapterIndex: chapterIndex, + spineIndex: spineIndex, + href: item.href, + title: chapterTitle, + attributedContent: chapterAttributedContent, + fragmentOffsets: rendered.fragmentOffsets, + pageBreakReasons: pages.map(\.metadata.breakReason), + pages: pages + ) + let performanceSample = RDEPUBTextPerformanceSample( + chapterHref: item.href, + renderDuration: renderDuration, + paginateDuration: paginateDuration, + pageCount: effectiveFrames.count, + attributedStringLength: content.length, + cacheHit: isCacheHit + ) + let diagnostic = diagnosticsReporter.chapterDiagnostic( + href: item.href, + title: chapterTitle, + pages: pages + ) + + return RDEPUBTextChapterBuildResult( + chapter: chapter, + resourceDiagnostics: rendered.resourceDiagnostics, + paginationDiagnostic: diagnostic, + performanceSample: performanceSample, + cacheHit: isCacheHit + ) + } + // MARK: - 章节标题解析 /// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href diff --git a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift index f5f5fdb..d70e724 100644 --- a/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift +++ b/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift @@ -21,6 +21,17 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable { public var sampleNotes: [String] } +// MARK: - 章节构建结果 + +/// 单章构建结果,用于大书快速进入阅读器和后台增量补齐。 +public struct RDEPUBTextChapterBuildResult { + public var chapter: RDEPUBTextChapter + public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] + public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic + public var performanceSample: RDEPUBTextPerformanceSample + public var cacheHit: Bool +} + // MARK: - 页面数据模型 /// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。 @@ -180,4 +191,3 @@ public struct RDEPUBTextBook { return chapterData.location(forPage: page, bookIdentifier: bookIdentifier) } } - diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift index 07aa242..d320d4b 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift @@ -20,9 +20,8 @@ struct RDEPUBChapterPageCounter { private let pageSize: CGSize private let config: RDEPUBTextLayoutConfig private let pageBreakPolicy: RDEPUBPageBreakPolicy - - /// CoreText 帧设置器 private let framesetter: CTFramesetter + /// DTCoreText 路径可直接消费的单矩形布局区域 private let dtLayoutRect: CGRect @@ -59,27 +58,26 @@ struct RDEPUBChapterPageCounter { var frames: [RDEPUBTextLayoutFrame] = [] var location = 0 let resolvedSize = config.resolvedFrameSize(fallback: pageSize) - let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right - let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom + let contentRect = config.contentRect(fallback: resolvedSize) - guard usableWidth > 0, usableHeight > 0 else { + guard contentRect.width > 0, contentRect.height > 0 else { return [] } while location < attributedString.length { - let framePath = CGMutablePath() - let pageRect = CGRect( - x: config.edgeInsets.left, - y: config.edgeInsets.bottom, - width: usableWidth, - height: usableHeight + let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath( + pageSize: resolvedSize, + config: config ) - framePath.addRect(pageRect) - let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil) - let proposedRange = proposedRangeUsingWXReadPageCount( + let frame = CTFramesetterCreateFrame( + framesetter, + CFRangeMake(location, 0), + framePath, + nil + ) + let proposedRange = proposedVisibleRange( from: frame, start: location, - usableHeight: usableHeight, totalLength: attributedString.length ) guard proposedRange.length > 0 else { @@ -87,12 +85,18 @@ struct RDEPUBChapterPageCounter { } let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) + let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame) + let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl( + proposed: keepWithNextAdjusted, + lineRanges: lineRanges + ) + let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted) let adjusted = pageBreakPolicy.adjustedRange( - from: avoidAdjusted, + from: widowOrphanAdjusted, totalLength: attributedString.length, - lineRanges: lineRanges, + lineRanges: effectiveLineRanges, factory: factory ) let trailingFragmentID = factory.nearestTrailingFragmentID( @@ -157,10 +161,15 @@ struct RDEPUBChapterPageCounter { let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange) let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted) let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame) + let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl( + proposed: lineAdjusted, + lineRanges: lineRanges + ) + let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted) let adjusted = pageBreakPolicy.adjustedRange( - from: lineAdjusted, + from: widowOrphanAdjusted, totalLength: attributedString.length, - lineRanges: lineRanges, + lineRanges: effectiveLineRanges, factory: factory ) let verifiedRange: NSRange @@ -231,40 +240,29 @@ struct RDEPUBChapterPageCounter { // MARK: - WXRead 分页对齐 - /// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环。 - private func proposedRangeUsingWXReadPageCount( + /// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。 + private func proposedVisibleRange( from frame: CTFrame, start location: Int, - usableHeight: CGFloat, totalLength: Int ) -> NSRange { - let lines = CTFrameGetLines(frame) as! [CTLine] - guard !lines.isEmpty else { + let visibleRange = CTFrameGetVisibleStringRange(frame) + guard visibleRange.length > 0 else { return NSRange(location: location, length: 0) } - var origins = [CGPoint](repeating: .zero, count: lines.count) - CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) - - var pageCharCount = 0 - for (index, line) in lines.enumerated() { - let lineRange = CTLineGetStringRange(line) - let lineY = origins[index].y - var ascent: CGFloat = 0 - var descent: CGFloat = 0 - CTLineGetTypographicBounds(line, &ascent, &descent, nil) - - if lineY - ascent > usableHeight { - break - } - - pageCharCount += lineRange.length + let resolvedLocation = max(location, visibleRange.location) + let visibleEnd = visibleRange.location + visibleRange.length + let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation) + guard length > 0 else { + return NSRange(location: resolvedLocation, length: 0) } + return NSRange(location: resolvedLocation, length: length) + } - if pageCharCount == 0 { - pageCharCount = 1 + private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] { + lineRanges.filter { lineRange in + lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range) } - - return NSRange(location: location, length: min(pageCharCount, totalLength - location)) } } diff --git a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift index 7ba8244..e1cfa3f 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift @@ -183,6 +183,38 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { return NSRange(location: proposed.location, length: adjustedLength) } + /// 根据 avoidWidows / avoidOrphans 修正页尾断点,避免段首孤悬页尾或段末独悬下一页。 + func trimmedRangeForWidowAndOrphanControl( + proposed: NSRange, + lineRanges: [NSRange] + ) -> NSRange { + guard !lineRanges.isEmpty else { return proposed } + guard config.avoidWidows || config.avoidOrphans else { return proposed } + + var adjusted = proposed + var visibleLines = lineRanges + + if config.avoidWidows, + let widowAdjusted = trimmedRangeAvoidingWidow( + proposed: adjusted, + lineRanges: visibleLines + ), + widowAdjusted != adjusted { + adjusted = widowAdjusted + visibleLines = Array(visibleLines.dropLast()) + } + + if config.avoidOrphans, + let orphanAdjusted = trimmedRangeAvoidingOrphan( + proposed: adjusted, + lineRanges: visibleLines + ) { + adjusted = orphanAdjusted + } + + return adjusted + } + // MARK: - 行信息提取 /// 获取 CTFrame 中所有行的字符范围 @@ -263,6 +295,101 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding { .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } } + // MARK: - Widow / Orphan 控制 + + private func trimmedRangeAvoidingWidow( + proposed: NSRange, + lineRanges: [NSRange] + ) -> NSRange? { + guard lineRanges.count >= 2, + let lastLine = lineRanges.last else { + return nil + } + + let paragraphRange = paragraphRange(containing: lastLine.location) + let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange) + let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange) + + guard trailingLines == 1, paragraphContinuesOnNextPage else { + return nil + } + + let keptLine = lineRanges[lineRanges.count - 2] + let adjustedLength = NSMaxRange(keptLine) - proposed.location + guard adjustedLength > 0 else { return nil } + return NSRange(location: proposed.location, length: adjustedLength) + } + + private func trimmedRangeAvoidingOrphan( + proposed: NSRange, + lineRanges: [NSRange] + ) -> NSRange? { + guard lineRanges.count >= 2, + let lastLine = lineRanges.last else { + return nil + } + + let paragraphRange = paragraphRange(containing: lastLine.location) + let pageEnd = NSMaxRange(proposed) + let paragraphEnd = NSMaxRange(paragraphRange) + guard pageEnd < paragraphEnd else { return nil } + + let remainingLineCount = estimatedRemainingLineCount( + in: paragraphRange, + startingAt: pageEnd + ) + let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange) + guard remainingLineCount == 1, + trailingLines >= 2 else { + return nil + } + + let keptLine = lineRanges[lineRanges.count - 2] + let adjustedLength = NSMaxRange(keptLine) - proposed.location + guard adjustedLength > 0 else { return nil } + return NSRange(location: proposed.location, length: adjustedLength) + } + + private func trailingLineCount( + in lineRanges: [NSRange], + paragraphRange: NSRange + ) -> Int { + var count = 0 + for lineRange in lineRanges.reversed() { + if NSIntersectionRange(lineRange, paragraphRange).length > 0 { + count += 1 + } else { + break + } + } + return count + } + + private func estimatedRemainingLineCount( + in paragraphRange: NSRange, + startingAt location: Int + ) -> Int { + let paragraphEnd = NSMaxRange(paragraphRange) + var currentLocation = max(location, paragraphRange.location) + guard currentLocation < paragraphEnd else { return 0 } + + let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1) + let typesetter = CTTypesetterCreateWithAttributedString(attributedString) + var lineCount = 0 + + while currentLocation < paragraphEnd { + let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width)) + let lineLength = max(suggestedCount, 1) + currentLocation += lineLength + lineCount += 1 + if lineCount > 2 { + break + } + } + + return lineCount + } + /// 获取指定范围内的附件类型列表(去重) func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] { guard let safeRange = clampedRange(range), safeRange.length > 0 else { diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift index 67c905d..420ea0a 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift @@ -41,7 +41,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { let attributedString = NSMutableAttributedString(attributedString: rendered) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString) - RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style) + RDEPUBTextRendererSupport.normalizeReadingAttributes( + in: attributedString, + style: request.style, + layoutConfig: request.layoutConfig ?? .default + ) return RDEPUBRenderedChapterContent( attributedString: attributedString, fragmentOffsets: fragmentOffsets, @@ -76,7 +80,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style) RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString) let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString) - RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style) + RDEPUBTextRendererSupport.normalizeReadingAttributes( + in: attributedString, + style: request.style, + layoutConfig: request.layoutConfig ?? .default + ) return RDEPUBRenderedChapterContent( attributedString: attributedString, fragmentOffsets: fragmentOffsets, diff --git a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift index b97be20..519788a 100644 --- a/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift +++ b/Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBTextRendererSupport.swift @@ -104,7 +104,11 @@ enum RDEPUBTextRendererSupport { // MARK: - 后渲染归一化(由 RDEPUBDTCoreTextRenderer 调用) /// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。 - static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) { + static func normalizeReadingAttributes( + in attributedString: NSMutableAttributedString, + style: RDEPUBTextRenderStyle, + layoutConfig: RDEPUBTextLayoutConfig = .default + ) { let fullRange = NSRange(location: 0, length: attributedString.length) var blockIndex = 0 let sourceText = attributedString.string as NSString @@ -114,6 +118,7 @@ enum RDEPUBTextRendererSupport { 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) + paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0 var updatedAttributes = attributes updatedAttributes[.font] = normalizedFont diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift index f451d87..f0eceb7 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderChapterListController.swift @@ -30,12 +30,15 @@ final class RDEPUBReaderChapterListController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() + view.accessibilityIdentifier = "epub.reader.toc.panel" + tableView.accessibilityIdentifier = "epub.reader.toc.table" tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") tableView.tableFooterView = UIView(frame: .zero) tableView.backgroundColor = theme.contentBackgroundColor navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] + navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar" } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -62,4 +65,4 @@ final class RDEPUBReaderChapterListController: UITableViewController { guard let currentItem else { return false } return currentItem.href == item.href } -} \ No newline at end of file +} diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift index b133d3c..ea173ef 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift @@ -91,6 +91,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { /// 页码变化回调:清除选区、同步阅读状态、检测到达末尾 public func pageNum(readerView: RDReaderView, pageNum: Int) { + readerContext.markUserNavigationActivity() updateCurrentSelection(nil) reconcileTextPaginationSizeIfNeeded(for: pageNum) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift index 70dd6eb..9d1a0cf 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderHighlightsViewController.swift @@ -247,6 +247,8 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() + view.accessibilityIdentifier = "epub.reader.bookmarks.panel" + tableView.accessibilityIdentifier = "epub.reader.bookmarks.table" tableView.tableFooterView = UIView(frame: .zero) tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) applyTheme() @@ -286,6 +288,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController { navigationController?.navigationBar.tintColor = theme.toolControlTextColor navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor] + navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar" } private func updateEmptyState() { diff --git a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift index fc0fa23..19a4e82 100644 --- a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift +++ b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift @@ -25,6 +25,12 @@ import UIKit /// navigationController?.pushViewController(controller, animated: true) /// ``` public final class RDURLReaderController: UIViewController { + private struct PendingDemoPageRequest { + let pageNumber: Int + let animated: Bool + var attemptCount: Int + } + /// 书籍文件 URL private let bookURL: URL /// EPUB 阅读器配置(字体、行距、主题等) @@ -41,6 +47,10 @@ public final class RDURLReaderController: UIViewController { label.text = "reader=opening" return label }() + private var pendingDemoPageRequest: PendingDemoPageRequest? + private var isRetryingPendingDemoPage = false + private let maxPendingDemoPageAttempts = 24 + private let pendingDemoPageRetryDelay: TimeInterval = 0.25 /// 初始化方法 /// - Parameters: @@ -88,9 +98,12 @@ public final class RDURLReaderController: UIViewController { /// - Returns: 是否成功跳转 @discardableResult public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool { - let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false + let moved = performDemoPageNavigation(pageNumber, animated: animated) if moved { - emitDemoState(prefix: "page=\(pageNumber)") + pendingDemoPageRequest = nil + isRetryingPendingDemoPage = false + } else { + queuePendingDemoPageNavigation(pageNumber, animated: animated) } return moved } @@ -187,6 +200,73 @@ public final class RDURLReaderController: UIViewController { embeddedController as? RDEPUBReaderController } + @discardableResult + private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool { + guard let readerController else { return false } + guard pageNumber > 0 else { return false } + + let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count + let numPages = readerController.readerView.numberOfPages() + print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)") + guard pageNumber <= totalPages else { return false } + + readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) + print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)") + emitDemoState(prefix: "page=\(pageNumber)") + return true + } + + private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) { + if let pendingDemoPageRequest, + pendingDemoPageRequest.pageNumber == pageNumber, + pendingDemoPageRequest.animated == animated { + return + } + + pendingDemoPageRequest = PendingDemoPageRequest( + pageNumber: pageNumber, + animated: animated, + attemptCount: 0 + ) + retryPendingDemoPageIfNeeded() + } + + private func retryPendingDemoPageIfNeeded() { + guard !isRetryingPendingDemoPage else { return } + guard pendingDemoPageRequest != nil else { return } + + isRetryingPendingDemoPage = true + DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in + self?.attemptPendingDemoPageNavigation() + } + } + + private func attemptPendingDemoPageNavigation() { + guard var pendingDemoPageRequest else { + isRetryingPendingDemoPage = false + return + } + + if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) { + self.pendingDemoPageRequest = nil + isRetryingPendingDemoPage = false + return + } + + pendingDemoPageRequest.attemptCount += 1 + if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts { + self.pendingDemoPageRequest = nil + isRetryingPendingDemoPage = false + print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts") + return + } + + self.pendingDemoPageRequest = pendingDemoPageRequest + DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in + self?.attemptPendingDemoPageNavigation() + } + } + /// 输出当前阅读器状态日志(Demo 调试用) private func logDemoState(prefix: String) { guard let readerController else { return } @@ -259,7 +339,13 @@ public final class RDURLReaderController: UIViewController { } extension RDURLReaderController: RDEPUBReaderDelegate { + public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) { + retryPendingDemoPageIfNeeded() + emitDemoState() + } + public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) { + retryPendingDemoPageIfNeeded() emitDemoState() } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift index d30d6c4..b8f92a8 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAssemblyCoordinator.swift @@ -23,11 +23,13 @@ final class RDEPUBReaderAssemblyCoordinator { setupLoadingIndicator(controller.loadingIndicator, in: controller.view) setupErrorLabel(controller.errorLabel, in: controller.view) controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView) + print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())") } /// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。 func finishExternalTextBookLaunchIfNeeded() { guard let runtime = context.runtime, + let controller = context.controller, context.isExternalTextBook else { return } @@ -37,7 +39,14 @@ final class RDEPUBReaderAssemblyCoordinator { context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? [] context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? [] } - runtime.finishPagination(restoreLocation: restoreLocation) + + if let textBook = controller.textBook { + print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages") + runtime.applyTextBook(textBook, restoreLocation: restoreLocation) + print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)") + } else { + runtime.finishPagination(restoreLocation: restoreLocation) + } } private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift index c114e1d..c451871 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -9,6 +9,9 @@ import UIKit /// - 便捷方法(renderStyle、layoutConfig 等) /// - 弱引用 controller(仅用于 UIKit 呈现操作) final class RDEPUBReaderContext { + private let activityLock = NSLock() + private var lastUserNavigationTimestamp: CFAbsoluteTime = 0 + // MARK: - 引用 /// 弱引用阅读器控制器,用于 UIKit 呈现操作。 @@ -205,6 +208,22 @@ final class RDEPUBReaderContext { persistence?.saveLocation(location, for: currentBookIdentifier) } + /// 记录最近一次用户翻页/跳转行为,用于后台分页让路。 + func markUserNavigationActivity() { + activityLock.lock() + lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent() + activityLock.unlock() + } + + /// 距离最近一次用户翻页/跳转已经过去的时间。 + func secondsSinceLastUserNavigation() -> CFAbsoluteTime { + activityLock.lock() + let timestamp = lastUserNavigationTimestamp + activityLock.unlock() + guard timestamp > 0 else { return .greatestFiniteMagnitude } + return CFAbsoluteTimeGetCurrent() - timestamp + } + /// 根据规范化 href 获取文本章节数据。 func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { guard let textBook, let publication else { return nil } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift index 167e6eb..8f801fc 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift @@ -9,6 +9,52 @@ import Foundation /// - 刷新可见内容并保持位置 /// - 重建外部纯文本图书 final class RDEPUBReaderPaginationCoordinator { + private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 + private let backgroundChapterPause: TimeInterval = 0.04 + private let incrementalMergeChapterThreshold = 20 + private let stagedIncrementalApplyDelay: TimeInterval = 1.0 + private var stagedIncrementalApplyWorkItem: DispatchWorkItem? + private let stagedBookRequestLock = NSLock() + private var stagedBookRequest: StagedBookRequest? + + private struct QuickBuildState { + var quickWindow: [Int] + var chapterStore: IncrementalChapterStore + var book: RDEPUBTextBook + } + + private struct StagedBookRequest { + var chapterStore: IncrementalChapterStore + var orderedSpineIndices: [Int] + var restoreLocation: RDEPUBLocation? + var isComplete: Bool + } + + private final class IncrementalChapterStore { + private let lock = NSLock() + private var builtChapters: [Int: RDEPUBTextChapter] = [:] + + func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) { + lock.lock() + builtChapters[spineIndex] = chapter + lock.unlock() + } + + func contains(_ spineIndex: Int) -> Bool { + lock.lock() + let contains = builtChapters[spineIndex] != nil + lock.unlock() + return contains + } + + func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] { + lock.lock() + let chapters = builtChapters + lock.unlock() + return chapters + } + } + private unowned let context: RDEPUBReaderContext init(context: RDEPUBReaderContext) { @@ -29,38 +75,22 @@ final class RDEPUBReaderPaginationCoordinator { controller.showLoading() let token = UUID() context.paginationToken = token + print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)") if publication.readingProfile == .textReflowable { - let pageSize = controller.currentTextPageSize() - context.lastTextPaginationPageSize = pageSize - let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize) - let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) - let renderStyle = controller.currentTextRenderStyle() - - DispatchQueue.global(qos: .userInitiated).async { [weak controller] in - guard let controller else { return } - do { - let textBook = try builder.build( - parser: parser, - publication: publication, - pageSize: pageSize, - style: renderStyle - ) - DispatchQueue.main.async { - guard self.context.paginationToken == token else { return } - self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation) - } - } catch { - DispatchQueue.main.async { - guard self.context.paginationToken == token else { return } - self.context.handle(error: error) - } - } - } + print("[EPUB][Pagination] path=text-reflowable-fast-entry") + paginateTextPublication( + parser: parser, + publication: publication, + readingSession: readingSession, + restoreLocation: restoreLocation, + token: token + ) return } if publication.layout == .fixed { + print("[EPUB][Pagination] path=fixed-layout") let snapshot = readingSession.makePaginationSnapshot( pageCounts: Array(repeating: 1, count: publication.spine.count), preferences: controller.currentPreferences(), @@ -71,6 +101,7 @@ final class RDEPUBReaderPaginationCoordinator { } let paginator = context.makePaginator() + print("[EPUB][Pagination] path=web-paginator") context.paginator = paginator paginator.calculate( parser: parser, @@ -91,6 +122,9 @@ final class RDEPUBReaderPaginationCoordinator { /// 应用文本图书模型:生成分页快照并完成分页流程。 func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) { guard let controller = context.controller else { return } + stagedIncrementalApplyWorkItem?.cancel() + stagedIncrementalApplyWorkItem = nil + clearStagedBookRequest() context.textBook = textBook let snapshot = controller.nativeTextSnapshot(from: textBook) context.replaceActiveSnapshot(snapshot) @@ -108,7 +142,7 @@ final class RDEPUBReaderPaginationCoordinator { _ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]), restoreLocation: RDEPUBLocation? ) { - guard let controller = context.controller else { return } + guard context.controller != nil else { return } context.textBook = nil context.replaceActiveSnapshot(snapshot) @@ -168,4 +202,351 @@ final class RDEPUBReaderPaginationCoordinator { context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation) } } + + /// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。 + private func paginateTextPublication( + parser: RDEPUBParser, + publication: RDEPUBPublication, + readingSession: RDEPUBReadingSession, + restoreLocation: RDEPUBLocation?, + token: UUID + ) { + guard let controller = context.controller else { return } + + let pageSize = controller.currentTextPageSize() + context.lastTextPaginationPageSize = pageSize + let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize) + let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) + let renderStyle = controller.currentTextRenderStyle() + let quickSpineCandidates = prioritizedBuildableSpineIndices( + publication: publication, + readingSession: readingSession, + restoreLocation: restoreLocation + ) + + DispatchQueue.global(qos: .utility).async { [weak controller] in + guard controller != nil else { return } + var didApplyQuickChapter = false + + do { + self.waitForReadingInteractionToSettle() + let allBuildableIndices = self.allBuildableSpineIndices(in: publication) + let quickBuildState = try self.buildQuickTextBook( + builder: builder, + parser: parser, + publication: publication, + pageSize: pageSize, + style: renderStyle, + prioritizedCandidates: quickSpineCandidates + ) + + if let quickBook = quickBuildState?.book { + DispatchQueue.main.sync { + guard self.context.paginationToken == token else { return } + didApplyQuickChapter = true + self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation) + } + } + + let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore() + var pendingIncrementalCount = 0 + let incrementalBuildOrder = self.incrementalBuildOrder( + allBuildableIndices: allBuildableIndices, + quickWindow: quickBuildState?.quickWindow ?? [] + ) + + for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) { + self.waitForReadingInteractionToSettle() + guard let result = try builder.buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: renderStyle + ) else { + continue + } + + chapterStore.insert(result.chapter, for: spineIndex) + pendingIncrementalCount += 1 + Thread.sleep(forTimeInterval: self.backgroundChapterPause) + + if pendingIncrementalCount >= self.incrementalMergeChapterThreshold { + pendingIncrementalCount = 0 + self.stageBookRequest( + chapterStore: chapterStore, + orderedSpineIndices: allBuildableIndices, + restoreLocation: restoreLocation, + isComplete: false + ) + DispatchQueue.main.async { + guard self.context.paginationToken == token else { return } + self.scheduleStagedIncrementalTextBookApplication() + } + } + } + + self.stageBookRequest( + chapterStore: chapterStore, + orderedSpineIndices: allBuildableIndices, + restoreLocation: restoreLocation, + isComplete: true + ) + DispatchQueue.main.async { + guard self.context.paginationToken == token else { return } + self.scheduleStagedIncrementalTextBookApplication() + } + } catch { + DispatchQueue.main.async { + guard self.context.paginationToken == token else { return } + if didApplyQuickChapter { + self.context.isRepaginating = false + self.context.hideLoading() + } else { + self.context.handle(error: error) + } + } + } + } + } + + private func buildQuickTextBook( + builder: RDEPUBTextBookBuilder, + parser: RDEPUBParser, + publication: RDEPUBPublication, + pageSize: CGSize, + style: RDEPUBTextRenderStyle, + prioritizedCandidates: [Int] + ) throws -> QuickBuildState? { + var anchorResult: RDEPUBTextChapterBuildResult? + for spineIndex in prioritizedCandidates { + guard let result = try builder.buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: style + ), !result.chapter.pages.isEmpty else { + continue + } + anchorResult = result + break + } + + guard let anchorResult else { + return nil + } + + let quickWindow = quickWindowSpineIndices( + around: anchorResult.chapter.spineIndex, + in: publication + ) + + let chapterStore = IncrementalChapterStore() + for spineIndex in quickWindow { + let result: RDEPUBTextChapterBuildResult? + if spineIndex == anchorResult.chapter.spineIndex { + result = anchorResult + } else { + result = try builder.buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: style + ) + } + + guard let chapter = result?.chapter, + !chapter.pages.isEmpty else { + continue + } + chapterStore.insert(chapter, for: spineIndex) + } + + guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else { + return nil + } + + print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)") + return QuickBuildState( + quickWindow: quickWindow, + chapterStore: chapterStore, + book: book + ) + } + + private func prioritizedBuildableSpineIndices( + publication: RDEPUBPublication, + readingSession: RDEPUBReadingSession, + restoreLocation: RDEPUBLocation? + ) -> [Int] { + let preferred = readingSession.initialSpineIndex(for: restoreLocation) + return publication.spine.indices + .filter { isBuildableTextSpine(at: $0, in: publication) } + .sorted { lhs, rhs in + abs(lhs - preferred) < abs(rhs - preferred) + } + } + + private func quickWindowSpineIndices( + around anchorSpineIndex: Int, + in publication: RDEPUBPublication, + maxChapterCount: Int = 3 + ) -> [Int] { + let buildableIndices = allBuildableSpineIndices(in: publication) + guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else { + return [anchorSpineIndex] + } + + var selected: [Int] = [anchorSpineIndex] + var nextPosition = anchorPosition + 1 + var previousPosition = anchorPosition - 1 + + while selected.count < maxChapterCount, + nextPosition < buildableIndices.count || previousPosition >= 0 { + if nextPosition < buildableIndices.count { + selected.append(buildableIndices[nextPosition]) + nextPosition += 1 + if selected.count == maxChapterCount { + break + } + } + + if previousPosition >= 0 { + selected.insert(buildableIndices[previousPosition], at: 0) + previousPosition -= 1 + } + } + + return selected + } + + private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] { + publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } + } + + func scheduleStagedIncrementalTextBookApplication() { + stagedIncrementalApplyWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.applyStagedIncrementalTextBookIfPossible() + } + stagedIncrementalApplyWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem) + } + + private func applyStagedIncrementalTextBookIfPossible() { + guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown, + !context.isRepaginating, + context.readingSession?.navigatorState == .idle else { + scheduleStagedIncrementalTextBookApplication() + return + } + + guard let staged = consumeStagedBookRequest() else { + stagedIncrementalApplyWorkItem = nil + return + } + + stagedIncrementalApplyWorkItem = nil + guard let stagedBook = textBook( + from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices), + orderedBy: staged.orderedSpineIndices + ) else { + return + } + let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation + let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book" + print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)") + context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation) + } + + private func waitForReadingInteractionToSettle() { + while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown { + Thread.sleep(forTimeInterval: 0.08) + } + } + + private func incrementalBuildOrder( + allBuildableIndices: [Int], + quickWindow: [Int] + ) -> [Int] { + guard let firstWindowIndex = quickWindow.first, + let lastWindowIndex = quickWindow.last else { + return allBuildableIndices + } + + let forward = allBuildableIndices.filter { $0 > lastWindowIndex } + let backward = allBuildableIndices.filter { $0 < firstWindowIndex } + return quickWindow + forward + backward + } + + private func textBook( + from builtChapters: [Int: RDEPUBTextChapter], + orderedBy spineIndices: [Int] + ) -> RDEPUBTextBook? { + var chapters: [RDEPUBTextChapter] = [] + var pages: [RDEPUBTextPage] = [] + + for spineIndex in spineIndices { + guard var chapter = builtChapters[spineIndex], + !chapter.pages.isEmpty else { + continue + } + + chapter.chapterIndex = chapters.count + let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in + var page = page + page.absolutePageIndex = pages.count + localPageIndex + page.chapterIndex = chapters.count + page.pageIndexInChapter = localPageIndex + page.totalPagesInChapter = chapter.pages.count + return page + } + chapter.pages = normalizedPages + chapters.append(chapter) + pages.append(contentsOf: normalizedPages) + } + + guard !pages.isEmpty else { + return nil + } + return RDEPUBTextBook(chapters: chapters, pages: pages) + } + + private func stageBookRequest( + chapterStore: IncrementalChapterStore, + orderedSpineIndices: [Int], + restoreLocation: RDEPUBLocation?, + isComplete: Bool + ) { + stagedBookRequestLock.lock() + stagedBookRequest = StagedBookRequest( + chapterStore: chapterStore, + orderedSpineIndices: orderedSpineIndices, + restoreLocation: restoreLocation, + isComplete: isComplete + ) + stagedBookRequestLock.unlock() + } + + private func consumeStagedBookRequest() -> StagedBookRequest? { + stagedBookRequestLock.lock() + defer { stagedBookRequestLock.unlock() } + let request = stagedBookRequest + stagedBookRequest = nil + return request + } + + private func clearStagedBookRequest() { + stagedBookRequestLock.lock() + stagedBookRequest = nil + stagedBookRequestLock.unlock() + } + + private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool { + guard publication.spine.indices.contains(index) else { return false } + let item = publication.spine[index] + return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) + } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift index 64bef98..9003576 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -73,11 +73,15 @@ final class RDEPUBReaderRuntime { return false } - if context.textBook != nil { - guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else { + if let textBook = context.textBook { + guard textBook.page(at: pageNumber) != nil else { return false } - return locationCoordinator.restoreReadingLocation(location, animated: animated) + readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated) + if let location = locationCoordinator.currentVisibleLocation() { + context.persist(location: location) + } + return true } guard context.activePages.indices.contains(pageNumber - 1) else {