feat(epub): complete wxread parity scope

This commit is contained in:
shen
2026-05-26 09:19:37 +08:00
parent 23182e8b5a
commit 0e7d952fe5
31 changed files with 2619 additions and 1533 deletions
@@ -64,6 +64,15 @@ struct RDEPUBPageLayoutSnapshot {
let layoutFrame: DTCoreTextLayoutFrame
#endif
var contentBounds: CGRect {
let rects = lines.map(\.frame) + attachments.map(\.frame)
guard var bounds = rects.first else { return .zero }
for rect in rects.dropFirst() {
bounds = bounds.union(rect)
}
return bounds
}
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
}
@@ -80,12 +89,44 @@ struct RDEPUBPageLayoutSnapshot {
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
}
func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
lines.first { line in
let lineRect = CGRect(
x: line.frame.minX,
y: line.baselineOrigin.y - line.ascent,
width: max(line.frame.width, 1),
height: line.ascent + line.descent + line.leading
)
return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
}
}
func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
}
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
decorations.filter { decoration in
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
}
}
func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
if let attachment = attachment(at: point) {
return attachment.stringRange
}
if let run = run(at: point) {
return run.stringRange
}
let hitDecorations = rects(containing: point, in: decorations)
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
lhs.absoluteRange.length < rhs.absoluteRange.length
}) {
return mostSpecific.absoluteRange
}
return line(at: point)?.stringRange
}
#if canImport(DTCoreText)
static func build(
from layoutFrame: DTCoreTextLayoutFrame,
@@ -21,6 +21,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
public var fontSize: CGFloat
/// 1.6 1.6
public var lineHeightMultiple: CGFloat
///
public var numberOfColumns: Int
/// 20pt
public var columnGap: CGFloat
/// .pageCurl仿 .scroll
public var displayType: RDReaderView.DisplayType
///
@@ -73,6 +77,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
public init(
fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6,
numberOfColumns: Int = 1,
columnGap: CGFloat = 20,
displayType: RDReaderView.DisplayType = .pageCurl,
landscapeDualPageEnabled: Bool = true,
showsTableOfContents: Bool = true,
@@ -87,6 +93,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
) {
self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple
self.numberOfColumns = max(1, numberOfColumns)
self.columnGap = max(0, columnGap)
self.displayType = displayType
self.landscapeDualPageEnabled = landscapeDualPageEnabled
self.showsTableOfContents = showsTableOfContents
@@ -115,6 +123,8 @@ extension RDEPUBReaderConfiguration {
lineHeightMultiple: lineHeightMultiple,
reflowableContentInsets: reflowableContentInsets,
fixedContentInset: fixedContentInset,
numberOfColumns: numberOfColumns,
columnGap: columnGap,
themeBackgroundColor: theme.themeBackgroundColorCSS,
themeTextColor: theme.themeTextColorCSS,
fixedBackgroundColor: theme.themeBackgroundColorCSS,
@@ -122,4 +132,4 @@ extension RDEPUBReaderConfiguration {
fixedLayoutSpreadMode: fixedLayoutSpreadMode
)
}
}
}
@@ -106,6 +106,12 @@ public final class RDEPUBReaderController: UIViewController {
activeBookmarks
}
/// WXRead WRBookmark
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
@@ -164,6 +170,7 @@ public final class RDEPUBReaderController: UIViewController {
private var searchState: RDEPUBSearchState?
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
private var pendingPresentationRestoreLocation: RDEPUBLocation?
private var isWaitingForViewportTransitionCompletion = false
/// TextBook EPUB
private var isExternalTextBook = false
@@ -289,6 +296,7 @@ public final class RDEPUBReaderController: UIViewController {
super.viewWillTransition(to: size, with: coordinator)
guard didStartInitialLoad else { return }
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
isWaitingForViewportTransitionCompletion = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
@@ -315,6 +323,7 @@ public final class RDEPUBReaderController: UIViewController {
searchState = nil
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
pendingPresentationRestoreLocation = nil
isWaitingForViewportTransitionCompletion = false
updateCurrentSelection(nil)
readerView.reloadData()
@@ -587,17 +596,23 @@ public final class RDEPUBReaderController: UIViewController {
}
private func applyReaderViewConfiguration() {
let displayTypeDidChange = readerView.currentDisplayType != configuration.displayType
let preservedLocation = displayTypeDidChange ? currentVisibleLocation() : nil
let resolvedDirection = resolvedPageDirection()
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|| readerView.pageDirection != resolvedDirection
let preservedLocation = presentationDidChange
? (pendingPresentationRestoreLocation ?? currentVisibleLocation() ?? persistenceLocation())
: nil
view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
readerView.pageDirection = resolvedPageDirection()
readerView.pageDirection = resolvedDirection
updateReaderChrome()
if displayTypeDidChange {
if presentationDidChange {
readerView.switchReaderDisplayType(configuration.displayType)
if let preservedLocation {
_ = restoreReadingLocation(preservedLocation)
}
pendingPresentationRestoreLocation = nil
}
}
@@ -789,7 +804,8 @@ public final class RDEPUBReaderController: UIViewController {
private func repaginatePreservingCurrentLocation() {
guard publication != nil else { return }
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
let restoreLocation = pendingPresentationRestoreLocation ?? currentVisibleLocation() ?? persistenceLocation()
pendingPresentationRestoreLocation = nil
paginatePublication(restoreLocation: restoreLocation)
}
@@ -1042,6 +1058,9 @@ public final class RDEPUBReaderController: UIViewController {
settingsController.onLineHeightChange = { [weak self] lineHeightMultiple in
self?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
}
settingsController.onColumnCountChange = { [weak self] numberOfColumns in
self?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak self] displayType in
self?.updateConfiguration { $0.displayType = displayType }
}
@@ -1097,6 +1116,8 @@ public final class RDEPUBReaderController: UIViewController {
) -> Bool {
oldConfiguration.fontSize != newConfiguration.fontSize ||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
oldConfiguration.columnGap != newConfiguration.columnGap ||
oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
@@ -1160,6 +1181,24 @@ public final class RDEPUBReaderController: UIViewController {
return nil
}
if let textBook,
let chapterData = textBook.chapterData(
for: currentLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
),
let tocItem = chapterData.primaryTableOfContentsItem(
from: publication.tableOfContents,
normalizer: { publication.resourceResolver.normalizedHref($0) }
) {
return items.last { $0.href == tocItem.href } ?? items.last {
guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
return items.last { item in
guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
return false
@@ -1175,12 +1214,18 @@ public final class RDEPUBReaderController: UIViewController {
items.flatMap { item in
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
let pageNumber: Int?
if let textBook, let publication {
pageNumber = textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
if let textBook, let publication,
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
)
) ?? location
pageNumber = chapterData.pageNumber(for: normalizedLocation)
?? textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
} else if let readingSession {
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
} else {
@@ -1242,8 +1287,8 @@ public final class RDEPUBReaderController: UIViewController {
frameWidth: derivedFrameSize.width,
frameHeight: derivedFrameSize.height,
edgeInsets: .zero,
numberOfColumns: 1,
columnGap: 20,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
@@ -1646,22 +1691,12 @@ extension RDEPUBReaderController {
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
if let chapterData = textChapterData(forNormalizedHref: searchMatch.href) {
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: chapterData.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
)
if let pageNumber = chapterData.pageNumber(for: location) {
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber
}
if let rangeLocation = searchMatch.rangeLocation,
let page = chapterData.pages.first(where: {
rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset
}) {
let page = chapterData.page(containing: rangeLocation) {
return page.absolutePageIndex + 1
}
}
@@ -1822,6 +1857,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
_ = isLandscape
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
}
}
@@ -1958,7 +1994,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return nil
}
let location = textBook.chapterData(for: page.href)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
@@ -1986,13 +2022,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
private func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> NativeTextSnapshot {
let chapters = textBook.chapters.map {
EPUBChapterInfo(
spineIndex: $0.spineIndex,
title: $0.title,
pageCount: $0.pages.count
)
}
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
@@ -112,6 +112,8 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
public var fontSize: CGFloat?
/// nil 使
public var lineHeightMultiple: CGFloat?
/// nil 使
public var numberOfColumns: Int?
/// nil 使
public var displayMode: RDEPUBReaderDisplayMode?
/// nil 使
@@ -122,12 +124,14 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
brightness: CGFloat? = nil,
fontSize: CGFloat? = nil,
lineHeightMultiple: CGFloat? = nil,
numberOfColumns: Int? = nil,
displayMode: RDEPUBReaderDisplayMode? = nil,
themePreset: RDEPUBReaderThemePreset? = nil
) {
self.brightness = brightness
self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple
self.numberOfColumns = numberOfColumns
self.displayMode = displayMode
self.themePreset = themePreset
}
@@ -145,6 +149,9 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
if let lineHeightMultiple {
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
}
if let numberOfColumns {
resolvedConfiguration.numberOfColumns = max(1, numberOfColumns)
}
if let displayMode {
resolvedConfiguration.displayType = displayMode.displayType
}
@@ -169,8 +176,9 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
brightness: max(0, min(1, brightness)),
fontSize: configuration.fontSize,
lineHeightMultiple: configuration.lineHeightMultiple,
numberOfColumns: configuration.numberOfColumns,
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
)
}
}
}
@@ -14,6 +14,8 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
var onFontSizeChange: ((CGFloat) -> Void)?
///
var onLineHeightChange: ((CGFloat) -> Void)?
///
var onColumnCountChange: ((Int) -> Void)?
///
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
///
@@ -65,6 +67,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
private let decreaseFontButton = UIButton(type: .system)
private let increaseFontButton = UIButton(type: .system)
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
private let themeStackView: UIStackView = {
let stackView = UIStackView()
@@ -136,6 +139,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
ThemePreset.allCases.forEach { preset in
@@ -156,6 +160,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
}
@@ -203,6 +208,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
lineHeightControl.selectedSegmentIndex = lineHeightIndex
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
switch currentConfiguration.displayType {
case .pageCurl:
@@ -233,7 +239,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
button.backgroundColor = theme.toolBackgroundColor
}
[lineHeightControl, displayTypeControl].forEach { control in
[lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
control.backgroundColor = theme.toolBackgroundColor
if #available(iOS 13.0, *) {
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
@@ -289,6 +295,12 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
onLineHeightChange?(value)
}
@objc private func columnCountChanged(_ control: UISegmentedControl) {
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
currentConfiguration.numberOfColumns = numberOfColumns
onColumnCountChange?(numberOfColumns)
}
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
let displayType: RDReaderView.DisplayType
switch control.selectedSegmentIndex {
@@ -309,4 +321,4 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
applyTheme(preset.theme)
onThemeChange?(preset.theme)
}
}
}
@@ -102,17 +102,8 @@ final class RDEPUBSelectionOverlayView: UIView {
}
if let snapshot,
let attachment = snapshot.attachment(at: point) {
return attachment.stringRange
}
if let snapshot {
let hitDecorations = snapshot.rects(containing: point, in: resolvedDecorations)
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
lhs.absoluteRange.length < rhs.absoluteRange.length
}) {
return mostSpecific.absoluteRange
}
let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
return absoluteRange
}
for decoration in resolvedDecorations {
@@ -383,13 +383,14 @@ final class RDEPUBTextContentView: UIView {
return
}
let totalLength = max(page.content.length - 1, 1)
let relativeLocation = range.location - page.pageStartOffset
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(range.location, 0)
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(max(relativeLocation, 0)) / Double(totalLength),
lastProgression: Double(max(relativeLocation + range.length - 1, 0)) / Double(totalLength),
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
fragment: nil
),
text: selectedText,
@@ -698,12 +699,12 @@ extension RDEPUBTextContentView: UITextViewDelegate {
let globalStart = page.pageStartOffset + selectedRange.location
let globalEnd = globalStart + selectedRange.length
let totalLength = max(page.content.length - 1, 1)
let totalLength = max(page.chapterContent.length - 1, 1)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(selectedRange.location) / Double(totalLength),
lastProgression: Double(max(selectedRange.location + selectedRange.length - 1, 0)) / Double(totalLength),
progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil
),
text: selectedText,