feat(epub): align text rendering with WXRead

This commit is contained in:
shen
2026-05-24 09:56:32 +08:00
parent 5125b8de51
commit a318c0e3d0
13 changed files with 1556 additions and 135 deletions
@@ -111,6 +111,7 @@ public final class RDEPUBReaderController: UIViewController {
fileprivate lazy var topToolView = makeTopToolView()
fileprivate lazy var bottomToolView = makeBottomToolView()
fileprivate var currentBookIdentifier: String?
private let textBookCache = RDEPUBTextBookCache()
private var currentBrightness: CGFloat
private var didStartInitialLoad = false
private var isRepaginating = false
@@ -575,7 +576,7 @@ public final class RDEPUBReaderController: UIViewController {
if publication.readingProfile == .textReflowable {
let renderer = resolvedTextRenderer()
let builder = RDEPUBTextBookBuilder(renderer: renderer)
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle()
@@ -0,0 +1,130 @@
import UIKit
struct RDEPUBTextOverlayDecoration {
enum Kind: String {
case selection
case highlight
case underline
case search
case activeSearch
case locate
}
var kind: Kind
var absoluteRange: NSRange
var rects: [CGRect]
var color: UIColor
}
final class RDEPUBSelectionOverlayView: UIView {
private var page: RDEPUBTextPage?
private var selectionRange: NSRange?
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let selectionVerticalAdjustment: CGFloat = -1
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(page: RDEPUBTextPage, selectionColor: UIColor) {
self.page = page
self.selectionColor = selectionColor
selectionRange = nil
decorations = []
setNeedsDisplay()
}
func updateSelection(absoluteRange: NSRange?) {
selectionRange = absoluteRange
setNeedsDisplay()
}
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations
setNeedsDisplay()
}
func clearSelection() {
updateSelection(absoluteRange: nil)
}
func absoluteRange(at point: CGPoint) -> NSRange? {
return nil
}
func decorationSummary() -> String {
let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
let highlightCount = counts[.highlight, default: 0]
let underlineCount = counts[.underline, default: 0]
let searchCount = counts[.search, default: 0]
let activeSearchCount = counts[.activeSearch, default: 0]
let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
return [
"selection \(selectionLabel)",
"highlight \(highlightCount)",
"underline \(underlineCount)",
"search \(searchCount)",
"activeSearch \(activeSearchCount)",
"locate \(locateLabel)"
].joined(separator: " · ")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for decoration in resolvedDecorations {
switch decoration.kind {
case .underline:
context.setStrokeColor(decoration.color.cgColor)
context.setLineWidth(2)
for underlineRect in decoration.rects {
let y = underlineRect.maxY - 1
context.move(to: CGPoint(x: underlineRect.minX, y: y))
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
context.strokePath()
}
default:
context.setFillColor(decoration.color.cgColor)
for selectionRect in decoration.rects {
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
guard let page else { return [] }
let nonSelection = decorations.filter { !$0.rects.isEmpty }
guard let selectionRange else { return nonSelection }
let selectionRects:[CGRect] = []
guard !selectionRects.isEmpty else { return nonSelection }
return [
RDEPUBTextOverlayDecoration(
kind: .selection,
absoluteRange: selectionRange,
rects: selectionRects,
color: selectionColor
)
]
}
}
@@ -37,12 +37,60 @@ final class RDEPUBSelectableTextView: UITextView {
}
}
#if canImport(DTCoreText)
final class RDEPUBDirectCoreTextPageView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
setNeedsDisplay()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
contentMode = .redraw
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
layoutFrame.draw(in: context, options: drawOptions)
context.restoreGState()
}
}
#endif
final class RDEPUBTextContentView: UIView {
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
private var highlightedRanges: [RDEPUBHighlight] = []
weak var delegate: RDEPUBTextContentViewDelegate?
#if canImport(DTCoreText)
private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
let view = RDEPUBDirectCoreTextPageView()
view.backgroundColor = .clear
view.isOpaque = false
return view
}()
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
#endif
private let textView: RDEPUBSelectableTextView = {
let view = RDEPUBSelectableTextView()
view.isEditable = false
@@ -70,6 +118,9 @@ final class RDEPUBTextContentView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(coreTextContentView)
#endif
addSubview(textView)
addSubview(pageNumberLabel)
textView.delegate = self
@@ -91,6 +142,10 @@ final class RDEPUBTextContentView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
#if canImport(DTCoreText)
coreTextContentView.frame = bounds.inset(by: contentInsets)
updateCoreTextLayoutFrameIfNeeded()
#endif
textView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets)
@@ -119,6 +174,12 @@ final class RDEPUBTextContentView: UIView {
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
if configureCoverIfNeeded(for: page) {
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
@@ -129,7 +190,17 @@ final class RDEPUBTextContentView: UIView {
coverImageView.isHidden = true
coverImageView.image = nil
let displayContent = NSMutableAttributedString(attributedString: page.content)
let selectionContent = NSMutableAttributedString(attributedString: page.content)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
normalizeInlineAttachments(in: selectionContent, basePointSize: configuration.fontSize)
#if canImport(DTCoreText)
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(
.foregroundColor,
@@ -137,10 +208,20 @@ final class RDEPUBTextContentView: UIView {
range: fullRange
)
normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize)
applyHighlights(to: displayContent, page: page)
applySearchHighlights(to: displayContent, page: page, searchState: searchState)
applyHighlights(to: displayContent, page: page, contentBaseOffset: 0)
applySearchHighlights(to: displayContent, page: page, searchState: searchState, contentBaseOffset: 0)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent
coreTextDisplayRange = page.contentRange
updateCoreTextLayoutFrameIfNeeded()
#else
applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
#endif
textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = displayContent
textView.attributedText = selectionProxyContent(from: selectionContent)
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
@@ -152,6 +233,14 @@ final class RDEPUBTextContentView: UIView {
}
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
}
private func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
contentBaseOffset: Int
) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
@@ -163,7 +252,7 @@ final class RDEPUBTextContentView: UIView {
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(
location: overlapStart - pageStart,
location: overlapStart - contentBaseOffset,
length: overlapEnd - overlapStart
)
switch highlight.style {
@@ -186,6 +275,20 @@ final class RDEPUBTextContentView: UIView {
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?
) {
applySearchHighlights(
to: content,
page: page,
searchState: searchState,
contentBaseOffset: page.pageStartOffset
)
}
private func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?,
contentBaseOffset: Int
) {
guard let searchState else { return }
@@ -202,7 +305,7 @@ final class RDEPUBTextContentView: UIView {
let overlapEnd = min(matchEnd, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(location: Int(overlapStart - pageStart), length: Int(overlapEnd - overlapStart))
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
@@ -223,6 +326,12 @@ final class RDEPUBTextContentView: UIView {
coverImageView.image = image
coverImageView.isHidden = false
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
textView.attributedText = nil
return true
}
@@ -265,22 +374,61 @@ final class RDEPUBTextContentView: UIView {
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
RDEPUBTextRendererSupport.normalizeAttachmentLayoutForWXRead(
attachment,
fontPointSize: basePointSize
)
content.addAttribute(.attachment, value: attachment, range: range)
}
#endif
}
}
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length)
proxy.removeAttribute(.backgroundColor, range: fullRange)
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
var attachmentRanges: [NSRange] = []
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard value != nil else { return }
attachmentRanges.append(range)
}
for range in attachmentRanges.reversed() {
let replacement = NSAttributedString(
string: String(repeating: " ", count: max(range.length, 1)),
attributes: [
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
.foregroundColor: UIColor.clear
]
)
proxy.replaceCharacters(in: range, with: replacement)
}
return proxy
}
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden,
let displayContent = coreTextDisplayContent,
let displayRange = coreTextDisplayRange,
coreTextContentView.bounds.width > 0,
coreTextContentView.bounds.height > 0 else {
return
}
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
coreTextContentView.layoutFrame = nil
return
}
layouter.shouldCacheLayoutFrames = false
coreTextContentView.layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
}
#endif
}
extension RDEPUBTextContentView: UITextViewDelegate {