diff --git a/ReadViewDemo/Pods/Pods.xcodeproj/xcuserdata/shen.xcuserdatad/xcschemes/xcschememanagement.plist b/ReadViewDemo/Pods/Pods.xcodeproj/xcuserdata/shen.xcuserdatad/xcschemes/xcschememanagement.plist index 31f7503..4e445b7 100644 --- a/ReadViewDemo/Pods/Pods.xcodeproj/xcuserdata/shen.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ReadViewDemo/Pods/Pods.xcodeproj/xcuserdata/shen.xcuserdatad/xcschemes/xcschememanagement.plist @@ -8,56 +8,78 @@ isShown + orderHint + 1 DTCoreText.xcscheme isShown + orderHint + 0 DTFoundation.xcscheme isShown + orderHint + 2 Pods-ReadViewDemo.xcscheme isShown + orderHint + 3 RDReaderView-RDReaderViewAssets.xcscheme isShown + orderHint + 5 RDReaderView.xcscheme isShown + orderHint + 4 SSAlertSwift.xcscheme isShown + orderHint + 8 SnapKit-SnapKit_Privacy.xcscheme isShown + orderHint + 7 SnapKit.xcscheme isShown + orderHint + 6 ZIPFoundation-ZIPFoundation_Privacy.xcscheme isShown + orderHint + 10 ZIPFoundation.xcscheme isShown + orderHint + 9 SuppressBuildableAutocreation 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 601a06f..9af9d20 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 c8841c9..bcfff2d 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift @@ -77,18 +77,27 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer { options: dtOptions(request: request), documentAttributes: nil ) + builder?.willFlushCallback = { element in + guard let element else { return } + RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering( + element, + style: request.style + ) + } return builder?.generatedAttributedString() } private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] { let style = request.style + let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28) var options: [AnyHashable: Any] = [ NSTextSizeMultiplierDocumentOption: 1.0, DTDefaultFontFamily: style.font.familyName, DTDefaultFontName: style.font.fontName, DTDefaultFontSize: style.font.pointSize, DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1), - DTUseiOS6Attributes: true + DTUseiOS6Attributes: true, + DTMaxImageSize: NSValue(cgSize: screenBounds.size) ] if let baseURL = request.context.baseURL { diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift index 2263fa2..b8027d3 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @@ -159,15 +159,43 @@ public final class RDEPUBTextBookBuilder { lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics) let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) - if shouldSkipChapter(item: item, text: plainText) { + 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 layoutFrames = content.length > 0 - ? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets) - : [] + let layoutFrames: [RDEPUBTextLayoutFrame] + 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)))" + ] + ) + ] + } else { + layoutFrames = content.length > 0 + ? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets) + : [] + } let effectiveFrames = layoutFrames.isEmpty && content.length > 0 ? [ RDEPUBTextLayoutFrame( @@ -187,6 +215,9 @@ public final class RDEPUBTextBookBuilder { ) ] : layoutFrames + 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 pages = effectiveFrames.enumerated().map { localPageIndex, frame in let range = frame.contentRange @@ -258,14 +289,55 @@ public final class RDEPUBTextBookBuilder { } } - private func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool { + private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool { let lowercasedHref = item.href.lowercased() - if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) { + var hasAttachment = false + if content.length > 0 { + content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in + guard value != nil else { return } + hasAttachment = true + stop.pointee = true + } + } + if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) { return true } return false } + private func attachmentCount(in content: NSAttributedString) -> Int { + guard content.length > 0 else { return 0 } + var count = 0 + content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in + if value != nil { + count += 1 + } + } + return count + } + + private func attachmentRanges(in content: NSAttributedString) -> [NSRange] { + guard content.length > 0 else { return [] } + var ranges: [NSRange] = [] + content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in + if value != nil { + ranges.append(range) + } + } + return ranges + } + + private func isAttachmentOnlyCoverChapter( + item: RDEPUBSpineItem, + content: NSAttributedString, + plainText: String + ) -> Bool { + let lowercasedHref = item.href.lowercased() + guard lowercasedHref.contains("cover") else { return false } + let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines) + return attachmentCount(in: content) > 0 && trimmed.count <= 1 + } + private func uniqueValues(from values: [T]) -> [T] { values.reduce(into: [T]()) { result, value in if !result.contains(value) { diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift index e19b7f7..e079eca 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRendererSupport.swift @@ -1,9 +1,15 @@ import UIKit +#if canImport(DTCoreText) +import DTCoreText +#endif + enum RDEPUBTextRendererSupport { private static let stylesheetLinkPattern = #"]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"# private static let imageSourcePattern = #"]*src\s*=\s*["']([^"']+)["'][^>]*>"# private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"# + private static var didLogFootnoteAttachment = false + private static var didLogCoverAttachment = false static func injectFragmentMarkers(into html: String) -> String { guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else { @@ -84,16 +90,18 @@ enum RDEPUBTextRendererSupport { var blockIndex = 0 attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in let sourceFont = attributes[.font] as? UIFont + let normalizedFont = normalizedFont(from: sourceFont, baseFont: style.font) 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) var updatedAttributes = attributes - updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: style.font) + updatedAttributes[.font] = normalizedFont updatedAttributes[.paragraphStyle] = paragraph if let textColor = style.textColor { updatedAttributes[.foregroundColor] = textColor } + normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont) updatedAttributes[.rdPageBlockRange] = NSStringFromRange(range) updatedAttributes[.rdPageBlockIndex] = blockIndex if let attachmentKind = attachmentKind(for: attributes) { @@ -201,9 +209,59 @@ enum RDEPUBTextRendererSupport { } } + cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML) return cleanedHTML } +#if canImport(DTCoreText) + static func prepareHTMLElementForReaderRendering( + _ element: DTHTMLElement, + style: RDEPUBTextRenderStyle + ) { + guard let attachment = element.textAttachment else { return } + + let lowercasedClasses = (((attachment.attributes["class"] as? String) ?? (element.attributes["class"] as? String) ?? "")).lowercased() + let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased() + ?? ((attachment.attributes["src"] as? String) ?? (element.attributes["src"] as? String) ?? "").lowercased() + let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize) + + if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" { + let targetHeight = max(round(pointSize * 0.54), 1) + let originalSize = attachment.originalSize + let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1 + let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1) + attachment.displaySize = CGSize(width: targetWidth, height: targetHeight) + attachment.verticalAlignment = .center + element.displayStyle = .inline + if !didLogFootnoteAttachment { + didLogFootnoteAttachment = true + print("[EPUB][Attachment] footnote classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize)) font=\(pointSize)") + } + return + } + + if lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg" { + let maxSize = UIScreen.main.bounds.insetBy(dx: 20, dy: 28).size + let originalSize = attachment.originalSize + if originalSize.width > 0, originalSize.height > 0 { + let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height) + attachment.displaySize = CGSize( + width: round(originalSize.width * scale), + height: round(originalSize.height * scale) + ) + } else { + attachment.displaySize = CGSize(width: round(maxSize.width), height: round(maxSize.height)) + } + attachment.verticalAlignment = .baseline + element.displayStyle = .block + if !didLogCoverAttachment { + didLogCoverAttachment = true + print("[EPUB][Attachment] cover classes=\(lowercasedClasses) path=\(lowercasedPath) original=\(string(from: originalSize)) display=\(string(from: attachment.displaySize))") + } + } + } +#endif + private static func makeStyleSheetLayers( style: RDEPUBTextRenderStyle, epubCSS: String @@ -244,10 +302,32 @@ enum RDEPUBTextRendererSupport { private static func replaceCSS() -> String { """ img, svg, video, canvas { - display: block; max-width: 100%; height: auto; } + .frontCover, + .bodyPic, + .qrbodyPic, + figure { + text-align: center; + text-indent: 0; + } + .frontCover img, + .rd-front-cover-image, + .bodyPic img, + .qrbodyPic img, + figure img { + display: block; + margin-left: auto; + margin-right: auto; + } + .qqreader-footnote, + .s-pic, + .h-pic, + .g-pic { + display: inline; + vertical-align: middle; + } h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; @@ -262,6 +342,168 @@ enum RDEPUBTextRendererSupport { """ } + private static func normalizeAttachmentHTMLMarkers(in html: String) -> String { + var normalized = html + + if let footnoteRegex = try? NSRegularExpression( + pattern: #"]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#, + options: [.caseInsensitive] + ) { + normalized = replaceMatches( + using: footnoteRegex, + in: normalized + ) { tag in + mergeHTMLAttributes( + into: tag, + requiredClass: nil, + styleFragments: [ + "width:1em", + "height:1em", + "vertical-align:middle", + "display:inline-block" + ] + ) + } + } + + if let coverRegex = try? NSRegularExpression( + pattern: #"]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(]*>)\s*"#, + options: [.caseInsensitive] + ) { + normalized = replaceMatches( + using: coverRegex, + in: normalized + ) { tag in + guard let imageTagRegex = try? NSRegularExpression(pattern: #"]*>"#, options: [.caseInsensitive]), + let imageMatch = imageTagRegex.firstMatch( + in: tag, + options: [], + range: NSRange(location: 0, length: (tag as NSString).length) + ), + let imageRange = Range(imageMatch.range, in: tag) else { + return tag + } + + let imageTag = String(tag[imageRange]) + let normalizedImageTag = mergeHTMLAttributes( + into: imageTag, + requiredClass: "rd-front-cover-image", + styleFragments: [ + "display:block", + "width:100%", + "height:auto", + "margin-left:auto", + "margin-right:auto" + ] + ) + return tag.replacingCharacters(in: imageRange, with: normalizedImageTag) + } + } + + return normalized + } + + private static func replaceMatches( + using regex: NSRegularExpression, + in source: String, + transform: (String) -> String + ) -> String { + let nsSource = source as NSString + let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length)) + guard !matches.isEmpty else { return source } + + var rewritten = source + for match in matches.reversed() { + guard let range = Range(match.range, in: rewritten) else { continue } + let original = String(rewritten[range]) + rewritten.replaceSubrange(range, with: transform(original)) + } + return rewritten + } + + private static func string(from size: CGSize) -> String { + "{\(Int(round(size.width))), \(Int(round(size.height)))}" + } + + private static func mergeHTMLAttributes( + into tag: String, + requiredClass: String?, + styleFragments: [String] + ) -> String { + var rewritten = tag + if let requiredClass { + if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]), + let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)), + match.numberOfRanges > 1 { + let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1)) + if !existingClasses.localizedCaseInsensitiveContains(requiredClass) { + let replacement = #"class="\#(existingClasses) \#(requiredClass)""# + if let range = Range(match.range, in: rewritten) { + rewritten.replaceSubrange(range, with: replacement) + } + } + } else if let closing = rewritten.lastIndex(of: ">") { + rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing) + } + } + + let styleValue = styleFragments.joined(separator: ";") + ";" + if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]), + let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)), + match.numberOfRanges > 1 { + let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines) + let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue + let replacement = #"style="\#(merged)""# + if let range = Range(match.range, in: rewritten) { + rewritten.replaceSubrange(range, with: replacement) + } + } else if let closing = rewritten.lastIndex(of: ">") { + rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing) + } + + return rewritten + } + + private static func normalizeAttachmentDisplayIfNeeded( + in attributes: inout [NSAttributedString.Key: Any], + font: UIFont + ) { + guard let attachment = attributes[.attachment] else { return } + +#if canImport(DTCoreText) + if let textAttachment = attachment as? DTTextAttachment { + let lowercasedClasses = ((textAttachment.attributes["class"] as? String) ?? "").lowercased() + let lowercasedPath = textAttachment.contentURL?.lastPathComponent.lowercased() ?? "" + if lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png" { + let targetHeight = max(round(font.pointSize * 0.14), 1) + let originalSize = textAttachment.originalSize + let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1 + let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1) + textAttachment.displaySize = CGSize(width: targetWidth, height: round(targetHeight)) + textAttachment.verticalAlignment = .center + } else if lowercasedClasses.contains("rd-front-cover-image") { + let originalSize = textAttachment.originalSize + let maxWidth = max(UIScreen.main.bounds.width - 48, font.lineHeight * 8) + if originalSize.width > 0, originalSize.height > 0 { + let scaledHeight = round(originalSize.height * (maxWidth / originalSize.width)) + textAttachment.displaySize = CGSize(width: round(maxWidth), height: scaledHeight) + } else { + textAttachment.displaySize = CGSize(width: round(maxWidth), height: round(maxWidth * 1.4)) + } + textAttachment.verticalAlignment = .baseline + } + attributes[.attachment] = textAttachment + return + } +#endif + + if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 { + let targetHeight = max(round(font.pointSize * 0.86), 1) + textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight) + attributes[.attachment] = textAttachment + } + } + private static func darkCSS(style: RDEPUBTextRenderStyle) -> String { let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)" let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)" diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift index c95e027..618e529 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift @@ -1,6 +1,10 @@ import UIKit import Foundation +#if canImport(DTCoreText) +import DTCoreText +#endif + protocol RDEPUBTextContentViewDelegate: AnyObject { func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) @@ -50,6 +54,13 @@ final class RDEPUBTextContentView: UIView { return view }() + private let coverImageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFit + view.isHidden = true + return view + }() + private let pageNumberLabel: UILabel = { let label = UILabel() label.font = UIFont.systemFont(ofSize: 13) @@ -58,6 +69,7 @@ final class RDEPUBTextContentView: UIView { override init(frame: CGRect) { super.init(frame: frame) + addSubview(coverImageView) addSubview(textView) addSubview(pageNumberLabel) textView.delegate = self @@ -80,6 +92,7 @@ final class RDEPUBTextContentView: UIView { super.layoutSubviews() textView.frame = bounds.inset(by: contentInsets) + coverImageView.frame = bounds.inset(by: contentInsets) let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) pageNumberLabel.frame = CGRect( @@ -105,6 +118,17 @@ final class RDEPUBTextContentView: UIView { pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.text = "\(pageNumber) / \(totalPages)" + if configureCoverIfNeeded(for: page) { + textView.attributedText = nil + textView.selectedRange = NSRange(location: 0, length: 0) + delegate?.textContentView(self, didChangeSelection: nil) + setNeedsLayout() + return + } + + coverImageView.isHidden = true + coverImageView.image = nil + let displayContent = NSMutableAttributedString(attributedString: page.content) let fullRange = NSRange(location: 0, length: displayContent.length) displayContent.addAttribute( @@ -112,6 +136,7 @@ final class RDEPUBTextContentView: UIView { value: configuration.theme.contentTextColor, range: fullRange ) + normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize) applyHighlights(to: displayContent, page: page) applySearchHighlights(to: displayContent, page: page, searchState: searchState) textView.tintColor = configuration.theme.toolControlTextColor @@ -189,6 +214,73 @@ final class RDEPUBTextContentView: UIView { return lowerBound.. Bool { + guard page.pageIndexInChapter == 0, + page.href.lowercased().contains("cover"), + let image = coverImage(from: page.content) else { + return false + } + + coverImageView.image = image + coverImageView.isHidden = false + textView.attributedText = nil + return true + } + + private func coverImage(from content: NSAttributedString) -> UIImage? { + guard content.length > 0 else { return nil } + var resolvedImage: UIImage? + content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in + guard let image = image(from: value) else { return } + resolvedImage = image + stop.pointee = true + } + return resolvedImage + } + + private func image(from attachmentValue: Any?) -> UIImage? { +#if canImport(DTCoreText) + if let attachment = attachmentValue as? DTTextAttachment, + let url = attachment.contentURL { + return UIImage(contentsOfFile: url.path) + } +#endif + if let attachment = attachmentValue as? NSTextAttachment { + if let image = attachment.image { + return image + } + if let data = attachment.contents { + return UIImage(data: data) + } + if let fileWrapper = attachment.fileWrapper, + let data = fileWrapper.regularFileContents { + return UIImage(data: data) + } + } + return nil + } + + private func normalizeInlineAttachments(in content: NSMutableAttributedString, basePointSize: CGFloat) { + guard content.length > 0 else { return } + content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in +#if canImport(DTCoreText) + if let attachment = value as? DTTextAttachment { + let classes = ((attachment.attributes["class"] as? String) ?? "").lowercased() + let source = (attachment.contentURL?.lastPathComponent ?? (attachment.attributes["src"] as? String) ?? "").lowercased() + guard classes.contains("qqreader-footnote") || source.contains("note.png") else { return } + + let pointSize = max(basePointSize, 1) + let targetHeight = max(round(pointSize * 0.14), 1) + let aspectRatio = attachment.originalSize.height > 0 ? attachment.originalSize.width / attachment.originalSize.height : 1 + let targetWidth = max(round(targetHeight * max(aspectRatio, 0.1)), 1) + attachment.displaySize = CGSize(width: targetWidth, height: targetHeight) + attachment.verticalAlignment = .center + content.addAttribute(.attachment, value: attachment, range: range) + } +#endif + } + } + } extension RDEPUBTextContentView: UITextViewDelegate {