import UIKit /// Captures layout parameters on the main thread for safe use on background queues. /// Create via `RDEPUBReaderContext.makeLayoutSnapshot()` before dispatching work off the main thread. struct RDEPUBLayoutSnapshot { let pageSize: CGSize let style: RDEPUBTextRenderStyle let layoutConfig: RDEPUBTextLayoutConfig let renderSignature: String } final class RDEPUBReaderContext { private let activityLock = NSLock() private var lastUserNavigationTimestamp: CFAbsoluteTime = 0 weak var controller: RDEPUBReaderController? weak var readerView: RDEpubReaderView? let state: RDEPUBReaderState let environment: RDEPUBReaderEnvironment let services: RDEPUBReaderServices var dependencies: RDEPUBReaderDependencies { get { services.dependencies } set { services.dependencies = newValue environment.displayEnvironment = newValue.environment } } var runtime: RDEPUBReaderRuntime? { controller?.runtime } var parser: RDEPUBParser? { get { state.parser } set { state.parser = newValue } } var publication: RDEPUBPublication? { get { state.publication } set { state.publication = newValue } } var readingSession: RDEPUBReadingSession? { get { state.readingSession } set { state.readingSession = newValue } } var textBook: RDEPUBTextBook? { get { state.textBook } set { state.textBook = newValue } } var bookPageMap: RDEPUBBookPageMap? { get { state.bookPageMap } set { state.bookPageMap = newValue } } var activeBookmarks: [RDEPUBBookmark] { get { state.activeBookmarks } set { state.activeBookmarks = newValue } } var activeHighlights: [RDEPUBHighlight] { get { state.activeHighlights } set { state.activeHighlights = newValue } } var currentBookIdentifier: String? { get { state.currentBookIdentifier } set { state.currentBookIdentifier = newValue } } var paginationToken: UUID { get { state.paginationToken } set { state.paginationToken = newValue } } var searchState: RDEPUBSearchState? { get { state.searchState } set { state.searchState = newValue } } var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] { get { state.pendingPageMapUpdates } set { state.pendingPageMapUpdates = newValue } } var lastTextPaginationPageSize: CGSize? { get { state.lastTextPaginationPageSize } set { state.lastTextPaginationPageSize = newValue } } var lastMetadataParseWallClockMs: Int { get { state.lastMetadataParseWallClockMs } set { state.lastMetadataParseWallClockMs = newValue } } var lastMetadataParseConcurrency: Int { get { state.lastMetadataParseConcurrency } set { state.lastMetadataParseConcurrency = newValue } } var currentSelection: RDEPUBSelection? { get { state.currentSelection } set { state.currentSelection = newValue } } var selectionState: RDEPUBSelectionState { get { state.selectionState } set { state.selectionState = newValue } } var configuration: RDEPUBReaderConfiguration = .default var persistence: RDEPUBReaderPersistence? var epubURL: URL = URL(string: "about:blank")! var isRepaginating: Bool { get { state.isRepaginating } set { state.isRepaginating = newValue } } var didStartInitialLoad: Bool { get { state.didStartInitialLoad } set { state.didStartInitialLoad = newValue } } var isExternalTextBook: Bool { get { state.isExternalTextBook } set { state.isExternalTextBook = newValue } } var textFileURL: URL? { get { state.textFileURL } set { state.textFileURL = newValue } } var textBookCache: RDEPUBTextBookCache { state.textBookCache } init(controller: RDEPUBReaderController) { self.controller = controller self.readerView = controller.readerView let state = RDEPUBReaderState() self.state = state self.environment = RDEPUBReaderEnvironment( controller: controller, readerView: controller.readerView, displayEnvironment: RDEPUBUIScreenEnvironment() ) self.services = RDEPUBReaderServices(dependencies: .live) } func currentLayoutContext() -> RDEPUBNavigatorLayoutContext { environment.currentLayoutContext(configuration: configuration) } func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences { var preferences = environment.currentPreferences(configuration: configuration, pageIndex: pageIndex) if publication?.requiresSinglePagePresentation == true { preferences.fixedLayoutSpreadMode = .never } return preferences } func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets { currentPreferences(pageIndex: pageIndex).reflowableContentInsets } /// Captures all layout parameters needed for background chapter loading. /// Must be called on the main thread. The returned snapshot is safe to use on any thread. func makeLayoutSnapshot() -> RDEPUBLayoutSnapshot { dispatchPrecondition(condition: .onQueue(.main)) let pageSize = currentTextPageSize() let style = currentTextRenderStyle() let layoutConfig = currentTextLayoutConfig(pageSize: pageSize) let renderSignature = renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig) return RDEPUBLayoutSnapshot( pageSize: pageSize, style: style, layoutConfig: layoutConfig, renderSignature: renderSignature ) } func currentTextPageSize() -> CGSize { // M-04: Use dispatchPrecondition instead of assert so it's enforced in Release builds too. dispatchPrecondition(condition: .onQueue(.main)) let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil if let readerView, let pageNum { let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum) if resolvedSize.width > 0, resolvedSize.height > 0 { return resolvedSize } } let viewportSize = currentLayoutContext().viewportSize if viewportSize.width > 0, viewportSize.height > 0 { return viewportSize } if let lastTextPaginationPageSize, lastTextPaginationPageSize.width > 0, lastTextPaginationPageSize.height > 0 { return lastTextPaginationPageSize } return environment.fallbackViewportSize } func currentTextRenderStyle() -> RDEPUBTextRenderStyle { dispatchPrecondition(condition: .onQueue(.main)) return environment.currentTextRenderStyle(configuration: configuration) } func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig { dispatchPrecondition(condition: .onQueue(.main)) return environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize) } func resolvedTextRenderer() -> RDEPUBTextRenderer { services.resolvedTextRenderer(configuration: configuration) } var activePages: [EPUBPage] { state.activePages } var activeChapters: [EPUBChapterInfo] { state.activeChapters } var currentBrightness: CGFloat { get { environment.currentBrightness } set { environment.currentBrightness = newValue } } func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) { state.replaceActiveSnapshot(snapshot) } func clearActiveSnapshot() { state.clearActiveSnapshot() } func makeParser() -> RDEPUBParser { services.makeParser() } func makePaginator() -> RDEPUBPaginator { services.makePaginator() } func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder { services.makeTextBookBuilder( configuration: configuration, cache: textBookCache, layoutConfig: layoutConfig ) } func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache { services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier) } func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey { let contentHash: String if let parser, let publication, publication.spine.indices.contains(spineIndex) { let href = publication.spine[spineIndex].href contentHash = parser.htmlString(forRelativePath: href)?.rd_sha256Hex ?? "" } else { contentHash = "" } return chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: contentHash, renderSignature: currentRenderSignature() ) } func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey { chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: precomputedContentHash, renderSignature: currentRenderSignature() ) } func chapterCacheKey( forSpineIndex spineIndex: Int, precomputedContentHash: String, renderSignature: String ) -> RDEPUBChapterCacheKey { RDEPUBChapterCacheKey( bookID: currentBookIdentifier ?? "", spineIndex: spineIndex, renderSignature: renderSignature, chapterContentHash: precomputedContentHash ) } func currentRenderSignature() -> String { dispatchPrecondition(condition: .onQueue(.main)) let style = currentTextRenderStyle() let pageSize = currentTextPageSize() let layoutConfig = currentTextLayoutConfig(pageSize: pageSize) return renderSignature(style: style, pageSize: pageSize, layoutConfig: layoutConfig) } private func renderSignature( style: RDEPUBTextRenderStyle, pageSize: CGSize, layoutConfig: RDEPUBTextLayoutConfig ) -> String { [ style.font.fontName, "\(style.font.pointSize)", "\(configuration.lineHeightMultiple)", "\(style.lineSpacing)", layoutConfig.cacheSignature, "\(RDEPUBChapterSummary.currentSchemaVersion)" ].joined(separator: "|") } func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? { runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex)) } func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? { guard let publication else { return nil } let normalizedLocation = publication.resourceResolver.normalizedLocation( location, relativeToSpineIndex: nil, bookIdentifier: currentBookIdentifier ) ?? location guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else { return nil } return publication.spine.firstIndex { publication.resourceResolver.normalizedHref($0.href) == normalizedHref } } func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder { services.makePlainTextBookBuilder( configuration: configuration, layoutConfig: layoutConfig ) } func currentVisibleLocation() -> RDEPUBLocation? { controller?.currentVisibleLocation() } func persistenceLocation() -> RDEPUBLocation? { guard let currentBookIdentifier else { return nil } return persistence?.loadLocation(for: currentBookIdentifier) } func persist(location: RDEPUBLocation) { guard let currentBookIdentifier else { return } persistence?.saveLocation(location, for: currentBookIdentifier) } func markUserNavigationActivity() { activityLock.lock() lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent() activityLock.unlock() } func secondsSinceLastUserNavigation() -> CFAbsoluteTime { activityLock.lock() let timestamp = lastUserNavigationTimestamp activityLock.unlock() guard timestamp > 0 else { return .greatestFiniteMagnitude } return CFAbsoluteTimeGetCurrent() - timestamp } func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? { guard let textBook else { return nil } // External text books (e.g. plain .txt) have no publication; their // chapter hrefs are matched verbatim. let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href return textBook.chapters.lazy .first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref }) .flatMap { textBook.chapterData(for: $0.href) } } func showLoading() { controller?.showLoading() } func hideLoading() { controller?.hideLoading() } func handle(error: Error) { controller?.handle(error: error) } func updateReaderChrome() { controller?.updateReaderChrome() } func refreshVisibleContentPreservingLocation() { controller?.refreshVisibleContentPreservingLocation() } func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool { controller?.restoreReadingLocation(location, animated: animated) ?? false } func repaginatePreservingCurrentLocation() { controller?.repaginatePreservingCurrentLocation() } func applyReaderViewConfiguration() { controller?.applyReaderViewConfiguration() } }