diff --git a/Doc/index.md b/Doc/index.md index 15137c1..c1d4288 100644 --- a/Doc/index.md +++ b/Doc/index.md @@ -21,7 +21,7 @@ | 文档 | 说明 | |------|------| -| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书反编译的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) | +| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) | ## 目录约定 diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj index 0d517cc..1a1aebb 100644 --- a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj @@ -185,10 +185,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n"; diff --git a/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate b/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate index 447cab9..add3653 100644 Binary files a/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate and b/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift index 24310b2..35cecb5 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift @@ -104,7 +104,8 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { guard let element else { return } RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering( element, - style: request.style + style: request.style, + maxImageSize: resolvedMaxImageSize(for: request) ) } return builder?.generatedAttributedString() @@ -116,7 +117,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { /// 确保最终行距与用户设置的 style.lineSpacing 一致。 private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] { let style = request.style - let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28) + let maxImageSize = resolvedMaxImageSize(for: request) var options: [AnyHashable: Any] = [ NSTextSizeMultiplierDocumentOption: 1.0, DTDefaultFontFamily: style.font.familyName, @@ -124,7 +125,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { DTDefaultFontSize: style.font.pointSize, DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1), DTUseiOS6Attributes: true, - DTMaxImageSize: NSValue(cgSize: screenBounds.size) + DTMaxImageSize: NSValue(cgSize: maxImageSize) ] if let baseURL = request.context.baseURL { @@ -136,5 +137,18 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { return options } + + private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize { + if let pageSize = request.pageSize { + let layoutConfig = request.layoutConfig ?? .default + let contentRect = layoutConfig.contentRect(fallback: pageSize) + let maxWidth = max(round(contentRect.width), 1) + let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1) + return CGSize(width: maxWidth, height: maxHeight) + } + + let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28) + return CGSize(width: max(round(screenBounds.width), 1), height: max(round(screenBounds.height * 0.85), 1)) + } #endif } diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift index 2d9ad0e..f0f7090 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @@ -293,7 +293,9 @@ public final class RDEPUBTextBookBuilder { baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(), style: style, resourceResolver: publication.resourceResolver, - contentLanguageCode: publication.metadata.language + contentLanguageCode: publication.metadata.language, + pageSize: pageSize, + layoutConfig: layoutConfig ) // 渲染 HTML → NSAttributedString diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift index 2d57b6c..3c9d021 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift @@ -123,7 +123,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding { public final class RDEPUBTextBookCache { /// 缓存模式版本号,变更时旧缓存自动失效 - public var schemaVersion: Int = 1 + public var schemaVersion: Int = 4 /// 串行队列,保证缓存读写的线程安全 private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift index dfaaf41..1930713 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift @@ -155,27 +155,31 @@ struct RDEPUBTextLayouter { totalLength: attributedString.length, lineRanges: lineRanges ) + let verifiedRange = verifiedDisplayRange(for: adjusted.range) let trailingFragmentID = nearestTrailingFragmentID( - endingAt: adjusted.range.location + adjusted.range.length, + endingAt: verifiedRange.location + verifiedRange.length, fragmentOffsets: fragmentOffsets ) + let diagnostics = verifiedRange == adjusted.range + ? adjusted.diagnostics + : adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"] frames.append( RDEPUBTextLayoutFrame( - contentRange: adjusted.range, + contentRange: verifiedRange, breakReason: adjusted.breakReason, - blockRange: adjusted.blockRange, - attachmentRanges: adjusted.attachmentRanges, - attachmentKinds: adjusted.attachmentKinds, - blockKinds: adjusted.blockKinds, - semanticHints: adjusted.semanticHints, - attachmentPlacements: adjusted.attachmentPlacements, + blockRange: blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)), + attachmentRanges: attachmentRanges(in: verifiedRange), + attachmentKinds: attachmentKinds(in: verifiedRange), + blockKinds: blockKinds(in: verifiedRange), + semanticHints: semanticHints(in: verifiedRange), + attachmentPlacements: attachmentPlacements(in: verifiedRange), trailingFragmentID: trailingFragmentID, - diagnostics: adjusted.diagnostics + diagnostics: diagnostics ) ) - let nextLocation = adjusted.range.location + adjusted.range.length + let nextLocation = verifiedRange.location + verifiedRange.length guard nextLocation > location else { location += max(visibleRange.length, 1) continue @@ -185,6 +189,30 @@ struct RDEPUBTextLayouter { return frames } + + private func verifiedDisplayRange(for range: NSRange) -> NSRange { + guard range.length > 0, + !attachmentRanges(in: range).isEmpty else { + return range + } + + let pageContent = attributedString.attributedSubstring(from: range) + guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else { + return range + } + + layouter.shouldCacheLayoutFrames = false + guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else { + return range + } + + let visibleRange = layoutFrame.visibleStringRange() + guard visibleRange.length > 0, visibleRange.length < range.length else { + return range + } + + return NSRange(location: range.location, length: visibleRange.length) + } #endif private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath { diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift index 683cc0b..33d96c5 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift @@ -286,10 +286,21 @@ public struct RDEPUBTextChapterContext: Equatable { public struct RDEPUBTextChapterRenderRequest { public var context: RDEPUBTextChapterContext public var style: RDEPUBTextRenderStyle + /// 当前章节将被分页到的页面尺寸;用于让附件缩放贴近真实 page size。 + public var pageSize: CGSize? + /// 当前分页布局配置;用于生成与 WXRead 更接近的内容区尺寸。 + public var layoutConfig: RDEPUBTextLayoutConfig? - public init(context: RDEPUBTextChapterContext, style: RDEPUBTextRenderStyle) { + public init( + context: RDEPUBTextChapterContext, + style: RDEPUBTextRenderStyle, + pageSize: CGSize? = nil, + layoutConfig: RDEPUBTextLayoutConfig? = nil + ) { self.context = context self.style = style + self.pageSize = pageSize + self.layoutConfig = layoutConfig } } diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift index 84e9af7..b086463 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift @@ -197,7 +197,9 @@ enum RDEPUBTextRendererSupport { baseURL: URL?, style: RDEPUBTextRenderStyle, resourceResolver: RDEPUBResourceResolver?, - contentLanguageCode: String? = nil + contentLanguageCode: String? = nil, + pageSize: CGSize? = nil, + layoutConfig: RDEPUBTextLayoutConfig? = nil ) -> RDEPUBTextChapterRenderRequest { let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML)) let stylesheetHrefReplacements = inlineLinkedStyleSheets( @@ -255,7 +257,12 @@ enum RDEPUBTextRendererSupport { stylesheet: RDEPUBTextStyleSheetPackage(layers: layers), resourceDiagnostics: resourceDiagnostics ) - return RDEPUBTextChapterRenderRequest(context: context, style: style) + return RDEPUBTextChapterRenderRequest( + context: context, + style: style, + pageSize: pageSize, + layoutConfig: layoutConfig + ) } /// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记 @@ -285,15 +292,20 @@ enum RDEPUBTextRendererSupport { #if canImport(DTCoreText) static func prepareHTMLElementForReaderRendering( _ element: DTHTMLElement, - style: RDEPUBTextRenderStyle + style: RDEPUBTextRenderStyle, + maxImageSize: CGSize? = nil ) { guard let attachment = element.textAttachment else { return } let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize) - let maxSize = CGSize( + let fallbackSize = CGSize( width: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).width), height: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height * 0.85) ) - normalizeAttachmentLayoutForWXRead(attachment, fontPointSize: pointSize, maxImageSize: maxSize) + normalizeAttachmentLayoutForWXRead( + attachment, + fontPointSize: pointSize, + maxImageSize: maxImageSize ?? fallbackSize + ) if isFootnoteAttachment(attachment) { element.displayStyle = .inline } else if isCoverAttachment(attachment) { @@ -390,6 +402,38 @@ enum RDEPUBTextRendererSupport { private static func normalizeAttachmentHTMLMarkers(in html: String) -> String { var normalized = html + if let bodyPicContainerRegex = try? NSRegularExpression( + pattern: #"