本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。 主要改动: 1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。 2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。 3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。 4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。 5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。 6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
481 lines
16 KiB
Swift
481 lines
16 KiB
Swift
import UIKit
|
|
|
|
public final class RDEPUBReaderController: UIViewController {
|
|
|
|
public weak var delegate: RDEPUBReaderDelegate?
|
|
|
|
public var configuration: RDEPUBReaderConfiguration {
|
|
didSet {
|
|
// M-09: Apply all configuration side effects even before view is loaded,
|
|
// but UI-dependent actions only after view is loaded.
|
|
readerContext.configuration = configuration
|
|
guard isViewLoaded else { return }
|
|
applyWebViewDebugPolicy()
|
|
persistReaderSettingsIfNeeded()
|
|
let oldConfiguration = oldValue
|
|
applyReaderViewConfiguration()
|
|
|
|
if isExternalTextBook {
|
|
if requiresRepagination(from: oldConfiguration, to: configuration) {
|
|
rebuildExternalTextBook()
|
|
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
|
|
refreshVisibleContentPreservingLocation()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard publication != nil else { return }
|
|
if requiresRepagination(from: oldConfiguration, to: configuration) {
|
|
repaginatePreservingCurrentLocation()
|
|
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
|
|
refreshVisibleContentPreservingLocation()
|
|
}
|
|
}
|
|
}
|
|
|
|
public var currentLocation: RDEPUBLocation? {
|
|
currentVisibleLocation()
|
|
}
|
|
|
|
public var currentPageNumber: Int? {
|
|
guard readerView.currentPage >= 0 else { return nil }
|
|
return readerView.currentPage + 1
|
|
}
|
|
|
|
public internal(set) var currentSelection: RDEPUBSelection? {
|
|
get { readerContext.currentSelection }
|
|
set { readerContext.currentSelection = newValue }
|
|
}
|
|
|
|
public var highlights: [RDEPUBHighlight] {
|
|
activeHighlights
|
|
}
|
|
|
|
public var bookmarks: [RDEPUBBookmark] {
|
|
activeBookmarks
|
|
}
|
|
|
|
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 ?? []
|
|
}
|
|
|
|
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
|
|
guard let publication else { return [] }
|
|
return flattenedTableOfContentsItems(from: publication.tableOfContents)
|
|
}
|
|
|
|
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
|
|
resolvedCurrentTableOfContentsItem()
|
|
}
|
|
|
|
let epubURL: URL
|
|
|
|
let persistence: RDEPUBReaderPersistence?
|
|
|
|
let dependencies: RDEPUBReaderDependencies
|
|
|
|
let readerView = RDReaderView()
|
|
|
|
let loadingIndicator: UIActivityIndicatorView = {
|
|
UIActivityIndicatorView(style: .large)
|
|
}()
|
|
|
|
let errorLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.numberOfLines = 0
|
|
label.textAlignment = .center
|
|
label.textColor = .darkGray
|
|
label.isHidden = true
|
|
return label
|
|
}()
|
|
|
|
let paginationHostView = UIView()
|
|
|
|
lazy var readerContext = RDEPUBReaderContext(controller: self)
|
|
|
|
var parser: RDEPUBParser? {
|
|
get { readerContext.parser }
|
|
set { readerContext.parser = newValue }
|
|
}
|
|
|
|
var publication: RDEPUBPublication? {
|
|
get { readerContext.publication }
|
|
set { readerContext.publication = newValue }
|
|
}
|
|
|
|
var readingSession: RDEPUBReadingSession? {
|
|
get { readerContext.readingSession }
|
|
set { readerContext.readingSession = newValue }
|
|
}
|
|
|
|
var textBook: RDEPUBTextBook? {
|
|
get { readerContext.textBook }
|
|
set { readerContext.textBook = newValue }
|
|
}
|
|
|
|
var activePages: [EPUBPage] {
|
|
readerContext.activePages
|
|
}
|
|
|
|
var activeChapters: [EPUBChapterInfo] {
|
|
readerContext.activeChapters
|
|
}
|
|
|
|
var activeBookmarks: [RDEPUBBookmark] {
|
|
get { readerContext.activeBookmarks }
|
|
set { readerContext.activeBookmarks = newValue }
|
|
}
|
|
|
|
var activeHighlights: [RDEPUBHighlight] {
|
|
get { readerContext.activeHighlights }
|
|
set { readerContext.activeHighlights = newValue }
|
|
}
|
|
|
|
lazy var topToolView = runtime.makeTopToolView()
|
|
|
|
lazy var bottomToolView = runtime.makeBottomToolView()
|
|
|
|
lazy var searchBarView = RDEPUBReaderSearchBarView()
|
|
|
|
private(set) var isSearchBarVisible = false
|
|
|
|
var currentBookIdentifier: String? {
|
|
get { readerContext.currentBookIdentifier }
|
|
set { readerContext.currentBookIdentifier = newValue }
|
|
}
|
|
|
|
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
|
|
|
|
var currentBrightness: CGFloat {
|
|
get { readerContext.currentBrightness }
|
|
set { readerContext.currentBrightness = newValue }
|
|
}
|
|
|
|
var didStartInitialLoad: Bool {
|
|
get { readerContext.didStartInitialLoad }
|
|
set { readerContext.didStartInitialLoad = newValue }
|
|
}
|
|
|
|
var isRepaginating: Bool {
|
|
get { readerContext.isRepaginating }
|
|
set { readerContext.isRepaginating = newValue }
|
|
}
|
|
|
|
var lastTextPaginationPageSize: CGSize? {
|
|
get { readerContext.lastTextPaginationPageSize }
|
|
set { readerContext.lastTextPaginationPageSize = newValue }
|
|
}
|
|
|
|
var isReconcilingTextPaginationSize = false
|
|
|
|
var paginationToken: UUID {
|
|
get { readerContext.paginationToken }
|
|
set { readerContext.paginationToken = newValue }
|
|
}
|
|
|
|
var searchState: RDEPUBSearchState? {
|
|
get { readerContext.searchState }
|
|
set { readerContext.searchState = newValue }
|
|
}
|
|
|
|
var isExternalTextBook: Bool {
|
|
get { readerContext.isExternalTextBook }
|
|
set { readerContext.isExternalTextBook = newValue }
|
|
}
|
|
|
|
var textFileURL: URL? {
|
|
get { readerContext.textFileURL }
|
|
set { readerContext.textFileURL = newValue }
|
|
}
|
|
|
|
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
|
|
|
|
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
|
|
|
|
public init(
|
|
epubURL: URL,
|
|
configuration: RDEPUBReaderConfiguration = .default,
|
|
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
|
|
dependencies: RDEPUBReaderDependencies = .live
|
|
) {
|
|
let persistedSettings = persistence?.loadReaderSettings()
|
|
self.epubURL = epubURL
|
|
self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
|
|
self.persistence = persistence
|
|
self.dependencies = dependencies
|
|
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
readerContext.dependencies = dependencies
|
|
readerContext.configuration = self.configuration
|
|
readerContext.epubURL = epubURL
|
|
readerContext.persistence = persistence
|
|
self.currentBrightness = brightness
|
|
applyWebViewDebugPolicy()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func applyWebViewDebugPolicy() {
|
|
RDEPUBWebViewDebug.applyDebugPolicy(
|
|
inspectableEnabled: configuration.allowsInspectableWebViews,
|
|
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
|
|
)
|
|
}
|
|
|
|
public convenience init(
|
|
textBook: RDEPUBTextBook,
|
|
bookIdentifier: String,
|
|
title: String,
|
|
textFileURL: URL? = nil,
|
|
configuration: RDEPUBReaderConfiguration = .default,
|
|
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
|
|
dependencies: RDEPUBReaderDependencies = .live
|
|
) {
|
|
let persistedSettings = persistence?.loadReaderSettings()
|
|
let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
|
|
|
|
self.init(
|
|
epubURL: URL(string: "about:blank")!,
|
|
configuration: resolvedConfig,
|
|
persistence: persistence,
|
|
dependencies: dependencies
|
|
)
|
|
self.isExternalTextBook = true
|
|
self.textBook = textBook
|
|
self.textFileURL = textFileURL
|
|
self.currentBookIdentifier = bookIdentifier
|
|
self.title = title
|
|
self.didStartInitialLoad = true
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
currentBrightness = currentBrightness
|
|
readerAssemblyCoordinator.assembleInterface()
|
|
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
|
|
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
|
|
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
|
|
}
|
|
}
|
|
|
|
public override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
navigationController?.setNavigationBarHidden(true, animated: animated)
|
|
navigationController?.interactivePopGestureRecognizer?.delegate = self
|
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
|
|
}
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
startInitialLoadIfNeeded()
|
|
RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self)
|
|
}
|
|
|
|
public override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
runtime.viewportMonitor.viewDidLayoutSubviews()
|
|
}
|
|
|
|
public override func didReceiveMemoryWarning() {
|
|
super.didReceiveMemoryWarning()
|
|
runtime.handleMemoryWarning()
|
|
}
|
|
|
|
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
|
}
|
|
|
|
func showSearchBar() {
|
|
guard !isSearchBarVisible else { return }
|
|
isSearchBarVisible = true
|
|
searchBarView.apply(theme: configuration.theme)
|
|
|
|
searchBarView.onSearchSubmit = { [weak self] keyword in
|
|
self?.searchBarView.showSearching()
|
|
self?.runtime.search(keyword: keyword)
|
|
self?.updateSearchCount()
|
|
}
|
|
searchBarView.onSearchTextChanged = { [weak self] keyword in
|
|
guard let self else { return }
|
|
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if normalizedKeyword.isEmpty {
|
|
self.runtime.clearSearch()
|
|
} else {
|
|
self.searchBarView.showSearching()
|
|
self.runtime.search(keyword: normalizedKeyword)
|
|
}
|
|
self.updateSearchCount()
|
|
}
|
|
searchBarView.onSearchPrevious = { [weak self] in
|
|
_ = self?.runtime.searchPrevious()
|
|
self?.updateSearchCount()
|
|
}
|
|
searchBarView.onSearchNext = { [weak self] in
|
|
_ = self?.runtime.searchNext()
|
|
self?.updateSearchCount()
|
|
}
|
|
searchBarView.onSelectMatch = { [weak self] matchIndex in
|
|
guard let self else { return }
|
|
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
|
|
self.updateSearchCount()
|
|
if didNavigate {
|
|
self.hideSearchBar(clearSearch: false)
|
|
}
|
|
}
|
|
searchBarView.onClose = { [weak self] in
|
|
self?.hideSearchBar(clearSearch: true)
|
|
}
|
|
|
|
if readerView.isShowToolView {
|
|
installSearchBarView()
|
|
}
|
|
}
|
|
|
|
private func installSearchBarView() {
|
|
guard searchBarView.superview == nil else { return }
|
|
searchBarView.apply(theme: configuration.theme)
|
|
|
|
readerView.addSubview(searchBarView)
|
|
readerView.searchBarView = searchBarView
|
|
let bottomAnchor = bottomToolView.superview == nil
|
|
? readerView.bottomAnchor
|
|
: bottomToolView.topAnchor
|
|
NSLayoutConstraint.activate([
|
|
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
|
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
|
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
|
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
])
|
|
|
|
searchBarView.alpha = 0
|
|
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
|
UIView.animate(withDuration: 0.28) {
|
|
self.searchBarView.alpha = 1
|
|
self.searchBarView.presentedView.transform = .identity
|
|
}
|
|
|
|
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
|
searchBarView.restoreKeyword(keyword)
|
|
updateSearchCount()
|
|
} else {
|
|
searchBarView.showNoResults()
|
|
}
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.searchBarView.textField.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
func hideSearchBar(clearSearch: Bool = false) {
|
|
guard isSearchBarVisible else { return }
|
|
isSearchBarVisible = false
|
|
|
|
searchBarView.textField.resignFirstResponder()
|
|
UIView.animate(withDuration: 0.25, animations: {
|
|
self.searchBarView.alpha = 0
|
|
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
|
}) { _ in
|
|
self.searchBarView.removeFromSuperview()
|
|
self.searchBarView.alpha = 1
|
|
self.searchBarView.presentedView.transform = .identity
|
|
self.readerView.searchBarView = nil
|
|
}
|
|
|
|
if clearSearch {
|
|
runtime.clearSearch()
|
|
}
|
|
}
|
|
|
|
func updateSearchCount() {
|
|
guard let searchState else {
|
|
searchBarView.showNoResults()
|
|
return
|
|
}
|
|
searchBarView.updateResults(
|
|
sections: searchResultSections(for: searchState),
|
|
keyword: searchState.keyword,
|
|
currentMatchIndex: searchState.currentMatchIndex
|
|
)
|
|
}
|
|
|
|
func handleToolViewVisibilityChanged(isVisible: Bool) {
|
|
if isVisible {
|
|
if isSearchBarVisible {
|
|
installSearchBarView()
|
|
} else if searchState != nil {
|
|
showSearchBar()
|
|
}
|
|
} else {
|
|
if isSearchBarVisible {
|
|
hideSearchBar(clearSearch: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private extension RDEPUBReaderController {
|
|
|
|
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
|
|
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
|
|
searchSectionTitle(for: entry.element)
|
|
})
|
|
|
|
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
|
|
let title = searchSectionTitle(for: match)
|
|
if titles.last != title, titles.contains(title) == false {
|
|
titles.append(title)
|
|
}
|
|
}
|
|
|
|
return orderedTitles.compactMap { title in
|
|
guard let matches = groupedMatches[title] else { return nil }
|
|
let items = matches.map { offset, match in
|
|
RDEPUBReaderSearchSection.Item(
|
|
matchIndex: offset,
|
|
previewText: match.previewText,
|
|
isCurrent: offset == searchState.currentMatchIndex
|
|
)
|
|
}
|
|
return RDEPUBReaderSearchSection(title: title, items: items)
|
|
}
|
|
}
|
|
|
|
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
|
|
guard let publication else {
|
|
return match.href
|
|
}
|
|
|
|
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
|
|
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
|
|
if let tocItem = tocItems.last(where: {
|
|
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
|
|
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
|
|
return normalizedItemHref == normalizedMatchHref
|
|
}) {
|
|
return tocItem.title
|
|
}
|
|
|
|
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
|
|
publication.spine.indices.contains(spineIndex) {
|
|
return publication.spine[spineIndex].title
|
|
}
|
|
|
|
return normalizedMatchHref
|
|
}
|
|
}
|