feat(epub): align text rendering with WXRead
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user